@open-mercato/channel-apns 0.6.8-develop.6985.1.fb93574faa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +32 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js +47 -0
  7. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js.map +7 -0
  8. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js +7 -0
  9. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js.map +7 -0
  10. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js +104 -0
  11. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js.map +7 -0
  12. package/dist/modules/channel_apns/acl.js +10 -0
  13. package/dist/modules/channel_apns/acl.js.map +7 -0
  14. package/dist/modules/channel_apns/di.js +24 -0
  15. package/dist/modules/channel_apns/di.js.map +7 -0
  16. package/dist/modules/channel_apns/index.js +9 -0
  17. package/dist/modules/channel_apns/index.js.map +7 -0
  18. package/dist/modules/channel_apns/integration.js +76 -0
  19. package/dist/modules/channel_apns/integration.js.map +7 -0
  20. package/dist/modules/channel_apns/lib/adapter.js +117 -0
  21. package/dist/modules/channel_apns/lib/adapter.js.map +7 -0
  22. package/dist/modules/channel_apns/lib/credentials.js +42 -0
  23. package/dist/modules/channel_apns/lib/credentials.js.map +7 -0
  24. package/dist/modules/channel_apns/lib/fake-provider.js +30 -0
  25. package/dist/modules/channel_apns/lib/fake-provider.js.map +7 -0
  26. package/dist/modules/channel_apns/lib/health.js +10 -0
  27. package/dist/modules/channel_apns/lib/health.js.map +7 -0
  28. package/dist/modules/channel_apns/setup.js +25 -0
  29. package/dist/modules/channel_apns/setup.js.map +7 -0
  30. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js +225 -0
  31. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js.map +7 -0
  32. package/dist/modules/channel_apns/widgets/injection/connect/widget.js +17 -0
  33. package/dist/modules/channel_apns/widgets/injection/connect/widget.js.map +7 -0
  34. package/dist/modules/channel_apns/widgets/injection-table.js +15 -0
  35. package/dist/modules/channel_apns/widgets/injection-table.js.map +7 -0
  36. package/jest.config.cjs +34 -0
  37. package/package.json +96 -0
  38. package/src/index.ts +1 -0
  39. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.ts +67 -0
  40. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.ts +3 -0
  41. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.ts +145 -0
  42. package/src/modules/channel_apns/acl.ts +6 -0
  43. package/src/modules/channel_apns/di.ts +26 -0
  44. package/src/modules/channel_apns/index.ts +6 -0
  45. package/src/modules/channel_apns/integration.ts +77 -0
  46. package/src/modules/channel_apns/lib/__tests__/adapter.test.ts +158 -0
  47. package/src/modules/channel_apns/lib/__tests__/apnsTestKey.ts +21 -0
  48. package/src/modules/channel_apns/lib/__tests__/credentials.test.ts +98 -0
  49. package/src/modules/channel_apns/lib/__tests__/fake-provider.test.ts +93 -0
  50. package/src/modules/channel_apns/lib/__tests__/message-golden.test.ts +101 -0
  51. package/src/modules/channel_apns/lib/adapter.ts +199 -0
  52. package/src/modules/channel_apns/lib/credentials.ts +88 -0
  53. package/src/modules/channel_apns/lib/fake-provider.ts +54 -0
  54. package/src/modules/channel_apns/lib/health.ts +13 -0
  55. package/src/modules/channel_apns/setup.ts +31 -0
  56. package/src/modules/channel_apns/widgets/injection/connect/widget.client.tsx +251 -0
  57. package/src/modules/channel_apns/widgets/injection/connect/widget.ts +16 -0
  58. package/src/modules/channel_apns/widgets/injection-table.ts +13 -0
  59. package/tsconfig.json +9 -0
  60. package/watch.mjs +7 -0
@@ -0,0 +1,67 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
5
+
6
+ /**
7
+ * TC-CHANNEL-PUSH-003 — APNs is registered AND tenant-scoped.
8
+ *
9
+ * `@open-mercato/channel-apns` registers the `apns` push `ChannelAdapter` at import
10
+ * with `channelScope: 'tenant'`. Push providers have no OAuth/webhook surface of their
11
+ * own, so the credential-connect routes are where hub registration is observable:
12
+ *
13
+ * - the **per-user** route short-circuits tenant-scoped providers with 403
14
+ * (`provider_is_tenant_scoped`) before touching credentials — 403 rather than 404
15
+ * (`no_adapter`) proves the adapter is registered, and it guards against a non-admin
16
+ * minting a tenant-wide channel;
17
+ * - the **tenant** route runs the adapter's own `validateCredentials`, which rejects the
18
+ * empty payload with 422 — proving the adapter is not merely registered but reachable.
19
+ *
20
+ * Neither call creates a channel, so there is nothing to clean up. Real send paths are
21
+ * covered network-free by `lib/__tests__/adapter.test.ts`.
22
+ */
23
+ test.describe('TC-CHANNEL-PUSH-003: APNs provider registration', () => {
24
+ test('POST per-user connect/credentials with providerKey=apns is refused (403, tenant-scoped)', async ({
25
+ request,
26
+ }) => {
27
+ const token = await getAuthToken(request)
28
+ const response = await apiRequest(
29
+ request,
30
+ 'POST',
31
+ '/api/communication_channels/channels/connect/credentials',
32
+ {
33
+ token,
34
+ data: { providerKey: 'apns', displayName: 'APNs — per-user (should be refused)', credentials: {} },
35
+ },
36
+ )
37
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
38
+ expect(response.status(), 'authenticated request should not 401').not.toBe(401)
39
+ expect(response.status()).toBe(403)
40
+ const body = await readJsonSafe<{ code?: string }>(response)
41
+ expect(body?.code).toBe('provider_is_tenant_scoped')
42
+ })
43
+
44
+ test('POST tenant connect/credentials with providerKey=apns reaches validateCredentials (422)', async ({
45
+ request,
46
+ }) => {
47
+ const token = await getAuthToken(request)
48
+ const response = await apiRequest(
49
+ request,
50
+ 'POST',
51
+ '/api/communication_channels/channels/connect/tenant-credentials',
52
+ {
53
+ token,
54
+ // Missing p8Key/keyId/teamId/bundleId → validateCredentials rejects it, so the
55
+ // flow never creates a channel (no side effect / no cleanup needed).
56
+ data: { providerKey: 'apns', displayName: 'APNs — tenant connect', credentials: {} },
57
+ },
58
+ )
59
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
60
+ // Asserting 422 (not merely "not 404") proves the request authenticated AND reached the
61
+ // adapter — a 401 auth misconfig would otherwise let the spec pass vacuously.
62
+ expect(response.status(), 'authenticated request should not 401').not.toBe(401)
63
+ expect(response.status(), 'registered adapter should reject empty credentials with 422').toBe(422)
64
+ const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)
65
+ expect(body?.fieldErrors ?? body?.error).toBeTruthy()
66
+ })
67
+ })
@@ -0,0 +1,3 @@
1
+ export const integrationMeta = {
2
+ requiredEnvVars: ['OM_PUSH_FAKE_PROVIDERS'],
3
+ }
@@ -0,0 +1,145 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import { createNotificationFixture } from '@open-mercato/core/helpers/integration/notificationsFixtures'
5
+ import { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'
6
+ import {
7
+ connectFakePushChannel,
8
+ deleteChannelIfExists,
9
+ deleteDeliveriesForDevice,
10
+ deleteFakePushDevice,
11
+ expectNativeMessage,
12
+ makeFakePushToken,
13
+ readLatestDelivery,
14
+ registerFakePushDevice,
15
+ } from '@open-mercato/core/helpers/integration/pushFake'
16
+
17
+ /**
18
+ * TC-CHANNEL-PUSH-006 — the REAL APNs adapter, end-to-end, without live credentials.
19
+ *
20
+ * Only the network provider is faked (`OM_PUSH_FAKE_PROVIDERS`): the adapter's credential resolution and
21
+ * `buildApnsNotification` run for real, against a real `apn.Notification`. The `.p8` key is never parsed
22
+ * because parsing lives inside the sender factory the fake replaces — the connect route only
23
+ * schema-validates it.
24
+ *
25
+ * The recorded message is therefore the WIRE form node-apn transmits: request `headers` (where
26
+ * `apns-push-type` lives) and the compiled `aps` payload.
27
+ */
28
+ const PROVIDER = 'apns'
29
+
30
+ type NativeApns = {
31
+ headers: Record<string, unknown>
32
+ payload: { aps: Record<string, unknown> } & Record<string, unknown>
33
+ }
34
+
35
+ test.describe('TC-CHANNEL-PUSH-006: real APNs adapter reaches sent with a correct native notification', () => {
36
+ test('maps pushOptions onto the aps payload for a visible notification', async ({ request }) => {
37
+ // Budgeted explicitly rather than via `test.slow()` (which only triples the config's 20s budget).
38
+ // NOTE: this does not fix the current CI failure — `POST /api/notifications` hangs >30s in the
39
+ // channel-package shard, so the test dies on `apiRequest`'s own 30s timeout. See the spec changelog.
40
+ test.setTimeout(120_000)
41
+ const adminToken = await getAuthToken(request, 'admin')
42
+ const { tenantId, userId } = getTokenScope(adminToken)
43
+ const { pushToken, tokenTail } = makeFakePushToken(PROVIDER)
44
+ const startedAt = new Date().toISOString()
45
+
46
+ let channelId: string | null = null
47
+ let userDeviceId: string | null = null
48
+ try {
49
+ channelId = await connectFakePushChannel(request, adminToken, PROVIDER, 'TC-CHANNEL-PUSH-006 APNs')
50
+ userDeviceId = await registerFakePushDevice(
51
+ request,
52
+ adminToken,
53
+ PROVIDER,
54
+ pushToken,
55
+ `qa-tc-channel-push-006-${Date.now()}`,
56
+ )
57
+
58
+ await createNotificationFixture(request, adminToken, {
59
+ recipientUserId: userId,
60
+ type: 'admin.custom_message',
61
+ title: 'Order shipped',
62
+ body: 'Your order is on its way',
63
+ pushOptions: { badge: 5, sound: 'chime.caf' },
64
+ })
65
+
66
+ await drainIntegrationQueue('events')
67
+ await drainIntegrationQueue('push-deliveries')
68
+
69
+ await expect
70
+ .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {
71
+ timeout: 30_000,
72
+ })
73
+ .toBe('sent')
74
+
75
+ const native = (await expectNativeMessage(PROVIDER, tokenTail, startedAt)) as unknown as NativeApns
76
+ expect(native.headers['apns-topic']).toBe('com.openmercato.fake')
77
+ expect(native.payload.aps).toMatchObject({
78
+ alert: { title: 'Order shipped', body: 'Your order is on its way' },
79
+ badge: 5,
80
+ sound: 'chime.caf',
81
+ })
82
+ expect(native.payload.aps['content-available']).toBeUndefined()
83
+ } finally {
84
+ await deleteDeliveriesForDevice(userDeviceId)
85
+ await deleteFakePushDevice(request, adminToken, userDeviceId)
86
+ await deleteChannelIfExists(request, adminToken, channelId)
87
+ }
88
+ })
89
+
90
+ test('a silent type produces a background content-available push with no user-facing copy', async ({ request }) => {
91
+ // Budgeted explicitly rather than via `test.slow()` (which only triples the config's 20s budget).
92
+ // NOTE: this does not fix the current CI failure — `POST /api/notifications` hangs >30s in the
93
+ // channel-package shard, so the test dies on `apiRequest`'s own 30s timeout. See the spec changelog.
94
+ test.setTimeout(120_000)
95
+ const adminToken = await getAuthToken(request, 'admin')
96
+ const { tenantId, userId } = getTokenScope(adminToken)
97
+ const { pushToken, tokenTail } = makeFakePushToken(PROVIDER)
98
+ const startedAt = new Date().toISOString()
99
+
100
+ let channelId: string | null = null
101
+ let userDeviceId: string | null = null
102
+ try {
103
+ channelId = await connectFakePushChannel(request, adminToken, PROVIDER, 'TC-CHANNEL-PUSH-006 APNs silent')
104
+ userDeviceId = await registerFakePushDevice(
105
+ request,
106
+ adminToken,
107
+ PROVIDER,
108
+ pushToken,
109
+ `qa-tc-channel-push-006-silent-${Date.now()}`,
110
+ )
111
+
112
+ // Silent-ness is a property of the registered type, never a per-call flag.
113
+ await createNotificationFixture(request, adminToken, {
114
+ recipientUserId: userId,
115
+ type: 'admin.custom_silent',
116
+ title: 'Should not be delivered as copy',
117
+ body: 'Should not be delivered as copy',
118
+ data: { sync: 'orders' },
119
+ })
120
+
121
+ await drainIntegrationQueue('events')
122
+ await drainIntegrationQueue('push-deliveries')
123
+
124
+ await expect
125
+ .poll(async () => (await readLatestDelivery(tenantId, userDeviceId as string))?.status ?? null, {
126
+ timeout: 30_000,
127
+ })
128
+ .toBe('sent')
129
+
130
+ const row = await readLatestDelivery(tenantId, userDeviceId as string)
131
+ expect(row?.silent).toBe(true)
132
+
133
+ const native = (await expectNativeMessage(PROVIDER, tokenTail, startedAt)) as unknown as NativeApns
134
+ // Apple requires both of these for a background push; sending an alert alongside them is invalid.
135
+ expect(native.headers['apns-push-type']).toBe('background')
136
+ expect(native.headers['apns-priority']).toBe(5)
137
+ expect(native.payload.aps).toEqual({ 'content-available': 1 })
138
+ expect(native.payload.sync).toBe('orders')
139
+ } finally {
140
+ await deleteDeliveriesForDevice(userDeviceId)
141
+ await deleteFakePushDevice(request, adminToken, userDeviceId)
142
+ await deleteChannelIfExists(request, adminToken, channelId)
143
+ }
144
+ })
145
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_apns.view', title: 'View APNs channel configuration', module: 'channel_apns' },
3
+ { id: 'channel_apns.configure', title: 'Configure APNs credentials', module: 'channel_apns' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,26 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ hasChannelAdapter,
5
+ registerChannelAdapter,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
7
+ import { getApnsChannelAdapter } from './lib/adapter'
8
+ import { ensureApnsFakeProviderInstalled } from './lib/fake-provider'
9
+ import { channelApnsHealthCheck } from './lib/health'
10
+
11
+ export function register(container: AppContainer): void {
12
+ // Test-only: swap @parse/node-apn for a network-free fake when `OM_PUSH_FAKE_PROVIDERS` is set
13
+ // (no-op in production), so integration specs exercise this adapter end-to-end. Swaps the SDK
14
+ // client only — the adapter below stays registered. See lib/fake-provider.ts.
15
+ ensureApnsFakeProviderInstalled()
16
+
17
+ if (!hasChannelAdapter('apns')) {
18
+ registerChannelAdapter(getApnsChannelAdapter())
19
+ }
20
+ container.register({
21
+ channelApnsAdapter: asValue(getApnsChannelAdapter()),
22
+ // Registered under the exact service name declared in `integration.ts`
23
+ // (`healthCheck.service`); without it the hub's resolve throws.
24
+ channelApnsHealthCheck: asValue(channelApnsHealthCheck),
25
+ })
26
+ }
@@ -0,0 +1,6 @@
1
+ export const metadata = {
2
+ id: 'channel_apns',
3
+ title: 'Apple Push Notification Channel',
4
+ description:
5
+ 'Deliver mobile push to iOS devices via the Apple Push Notification service (HTTP/2, token-based .p8 auth). Registers a `push` ChannelAdapter (providerKey `apns`) into the Communications Hub.',
6
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ type IntegrationBundle,
3
+ type IntegrationDefinition,
4
+ } from '@open-mercato/shared/modules/integrations/types'
5
+
6
+ export const integration: IntegrationDefinition = {
7
+ id: 'channel_apns',
8
+ title: 'Apple Push Notification service',
9
+ description:
10
+ 'Deliver mobile push to iOS devices via APNs (HTTP/2, token-based .p8 auth). Provider for the push_notifications channel.',
11
+ category: 'communication',
12
+ hub: 'communication_channels',
13
+ providerKey: 'apns',
14
+ icon: 'apple',
15
+ docsUrl: 'https://developer.apple.com/documentation/usernotifications',
16
+ package: '@open-mercato/channel-apns',
17
+ version: '0.1.0',
18
+ author: 'Open Mercato Team',
19
+ company: 'Open Mercato',
20
+ license: 'MIT',
21
+ tags: ['push', 'apns', 'apple', 'ios', 'communication'],
22
+ apiVersions: [
23
+ {
24
+ id: 'v1',
25
+ label: 'APNs HTTP/2 (token auth)',
26
+ status: 'stable',
27
+ default: true,
28
+ changelog: 'Apple Push Notification service over HTTP/2 with token-based .p8 auth (@parse/node-apn).',
29
+ },
30
+ ],
31
+ credentials: {
32
+ fields: [
33
+ {
34
+ key: 'p8Key',
35
+ label: 'APNs Auth Key (.p8)',
36
+ type: 'secret',
37
+ required: true,
38
+ helpText:
39
+ 'Apple Developer -> Keys -> create an APNs key, download the .p8, and paste its contents here. Stored encrypted at rest.',
40
+ },
41
+ {
42
+ key: 'keyId',
43
+ label: 'Key ID',
44
+ type: 'text',
45
+ required: true,
46
+ helpText: 'The 10-character identifier of the APNs key (from the key filename / Apple Developer portal).',
47
+ },
48
+ {
49
+ key: 'teamId',
50
+ label: 'Apple Team ID',
51
+ type: 'text',
52
+ required: true,
53
+ helpText: 'Your 10-character Apple Developer Team ID.',
54
+ },
55
+ {
56
+ key: 'bundleId',
57
+ label: 'App Bundle ID',
58
+ type: 'text',
59
+ required: true,
60
+ placeholder: 'com.example.app',
61
+ helpText: 'The app bundle id, used as the APNs topic.',
62
+ },
63
+ {
64
+ key: 'production',
65
+ label: 'Production (use production APNs host)',
66
+ type: 'boolean',
67
+ required: false,
68
+ helpText: 'Leave off for the sandbox host during development.',
69
+ },
70
+ ],
71
+ },
72
+ healthCheck: { service: 'channelApnsHealthCheck' },
73
+ }
74
+
75
+ export const integrations: IntegrationDefinition[] = [integration]
76
+ export const bundles: IntegrationBundle[] = []
77
+ export const bundle: IntegrationBundle | undefined = undefined
@@ -0,0 +1,158 @@
1
+ import { buildApnsNotification, getApnsChannelAdapter, setApnsSenderFactory, type ApnsSender } from '../adapter'
2
+ import type { SendMessageInput } from '@open-mercato/core/modules/communication_channels/lib/adapter'
3
+ import type { PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
4
+ import { APNS_TEST_P8_KEY } from './apnsTestKey'
5
+
6
+ const credentials = {
7
+ p8Key: APNS_TEST_P8_KEY,
8
+ keyId: 'ABC123KEYI',
9
+ teamId: 'TEAM123456',
10
+ bundleId: 'com.example.app',
11
+ production: false,
12
+ }
13
+
14
+ function buildInput(overrides?: Partial<SendMessageInput>): SendMessageInput {
15
+ return {
16
+ content: {
17
+ text: 'Body text',
18
+ bodyFormat: 'text',
19
+ raw: { title: 'Hello', body: 'Body text', data: { type: 'orders.shipped', notificationId: 'n1' } },
20
+ },
21
+ credentials,
22
+ scope: { tenantId: 't1', organizationId: 'o1' },
23
+ metadata: { pushToken: 'apns-device-token-abc', platform: 'ios' },
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ afterEach(() => setApnsSenderFactory(null))
29
+
30
+ describe('ApnsChannelAdapter', () => {
31
+ it('sends a notification with the bundle id as topic', async () => {
32
+ const send: ApnsSender = jest.fn(async () => ({ ok: true }))
33
+ setApnsSenderFactory(() => send)
34
+
35
+ const result = await getApnsChannelAdapter().sendMessage(buildInput())
36
+
37
+ expect(result.status).toBe('sent')
38
+ expect(send).toHaveBeenCalledTimes(1)
39
+ const [payload, token] = (send as jest.Mock).mock.calls[0]
40
+ expect(token).toBe('apns-device-token-abc')
41
+ expect(payload).toMatchObject({ title: 'Hello', body: 'Body text', topic: 'com.example.app' })
42
+ expect(payload.data).toEqual({ type: 'orders.shipped', notificationId: 'n1' })
43
+ })
44
+
45
+ it('forwards the silent flag and push options to the sender', async () => {
46
+ const send: ApnsSender = jest.fn(async () => ({ ok: true }))
47
+ setApnsSenderFactory(() => send)
48
+
49
+ await getApnsChannelAdapter().sendMessage(
50
+ buildInput({
51
+ content: {
52
+ raw: {
53
+ title: '',
54
+ body: '',
55
+ data: { type: 'sync.data.updated' },
56
+ silent: true,
57
+ options: { badge: 4, priority: 'normal' },
58
+ },
59
+ },
60
+ }),
61
+ )
62
+
63
+ const [payload] = (send as jest.Mock).mock.calls[0]
64
+ expect(payload.silent).toBe(true)
65
+ expect(payload.options).toEqual({ badge: 4, priority: 'normal' })
66
+ expect(payload.topic).toBe('com.example.app')
67
+ })
68
+
69
+ it('fails fast when the push token is missing', async () => {
70
+ const result = await getApnsChannelAdapter().sendMessage(buildInput({ metadata: { platform: 'ios' } }))
71
+ expect(result.status).toBe('failed')
72
+ expect(result.error).toBe('missing_push_token')
73
+ })
74
+
75
+ it('rejects invalid credentials without sending', async () => {
76
+ const send = jest.fn()
77
+ setApnsSenderFactory(() => send as unknown as ApnsSender)
78
+ const result = await getApnsChannelAdapter().sendMessage(buildInput({ credentials: { p8Key: 'x' } }))
79
+ expect(result.status).toBe('failed')
80
+ expect(result.error).toBe('invalid_apns_credentials')
81
+ expect(send).not.toHaveBeenCalled()
82
+ })
83
+
84
+ // Only 410/Unregistered is a permanent dead-token signal. `'410'` is included so a bare-status
85
+ // rejection (node-apn couldn't parse a reason from the body) still prunes the device.
86
+ it.each(['Unregistered', '410'])(
87
+ 'maps the %s rejection to the device_unregistered sentinel',
88
+ async (reason) => {
89
+ setApnsSenderFactory(() => async () => ({ ok: false, reason }))
90
+ const result = await getApnsChannelAdapter().sendMessage(buildInput())
91
+ expect(result.status).toBe('failed')
92
+ expect(result.error).toBe('device_unregistered')
93
+ expect(result.metadata?.unregistered).toBe(true)
94
+ },
95
+ )
96
+
97
+ it('does NOT unregister on BadDeviceToken (valid token, wrong environment)', async () => {
98
+ // Apple returns BadDeviceToken for a valid token sent to the wrong environment, not only for a
99
+ // malformed one. Treating it as permanent would soft-delete every live device on an env-config
100
+ // mistake, so it must be a transient failure that KEEPS the device.
101
+ setApnsSenderFactory(() => async () => ({ ok: false, reason: 'BadDeviceToken' }))
102
+ const result = await getApnsChannelAdapter().sendMessage(buildInput())
103
+ expect(result.status).toBe('failed')
104
+ expect(result.error).toBe('BadDeviceToken')
105
+ expect(result.metadata?.unregistered).toBeUndefined()
106
+ })
107
+
108
+ it('treats other rejections as transient failures', async () => {
109
+ setApnsSenderFactory(() => async () => ({ ok: false, reason: 'TooManyRequests' }))
110
+ const result = await getApnsChannelAdapter().sendMessage(buildInput())
111
+ expect(result.status).toBe('failed')
112
+ expect(result.error).toBe('TooManyRequests')
113
+ expect(result.metadata?.unregistered).toBeUndefined()
114
+ })
115
+
116
+ it('reports transport errors as transient failures', async () => {
117
+ setApnsSenderFactory(() => async () => ({ ok: false, error: 'socket hang up' }))
118
+ const result = await getApnsChannelAdapter().sendMessage(buildInput())
119
+ expect(result.status).toBe('failed')
120
+ expect(result.error).toBe('socket hang up')
121
+ expect(result.metadata?.unregistered).toBeUndefined()
122
+ })
123
+ })
124
+
125
+ function envelope(overrides: Partial<PushEnvelope> = {}): PushEnvelope & { topic: string } {
126
+ return {
127
+ title: 'Hello',
128
+ body: 'Body text',
129
+ data: { type: 'orders.shipped' },
130
+ options: {},
131
+ silent: false,
132
+ topic: 'com.example.app',
133
+ ...overrides,
134
+ }
135
+ }
136
+
137
+ describe('buildApnsNotification', () => {
138
+ it('builds a content-available background note for a silent envelope', () => {
139
+ const note = buildApnsNotification({}, envelope({ title: '', body: '', silent: true }))
140
+ expect(note.contentAvailable).toBe(1)
141
+ expect(note.pushType).toBe('background')
142
+ expect(note.priority).toBe(5)
143
+ expect(note.alert).toBeUndefined()
144
+ expect(note.sound).toBeUndefined()
145
+ expect(note.payload).toEqual({ type: 'orders.shipped' })
146
+ })
147
+
148
+ it('applies alert, sound, badge, body override and priority from options', () => {
149
+ const note = buildApnsNotification(
150
+ {},
151
+ envelope({ options: { sound: 'chime.caf', badge: 7, priority: 'normal', body: 'override body' } }),
152
+ )
153
+ expect(note.alert).toEqual({ title: 'Hello', body: 'override body' })
154
+ expect(note.sound).toBe('chime.caf')
155
+ expect(note.badge).toBe(7)
156
+ expect(note.priority).toBe(5)
157
+ })
158
+ })
@@ -0,0 +1,21 @@
1
+ import { generateKeyPairSync } from 'node:crypto'
2
+
3
+ /**
4
+ * A throwaway ES256 (P-256) private key in PKCS#8 PEM — the curve Apple's `.p8`
5
+ * signing keys use.
6
+ *
7
+ * `apnsCredentialsSchema` structurally parses `p8Key` with `createPrivateKey`,
8
+ * so fixtures need a genuinely parseable key. Generating one per test process
9
+ * keeps a private key out of the repository while still exercising the real
10
+ * validation path.
11
+ */
12
+ export function generateApnsTestP8Key(): string {
13
+ return generateKeyPairSync('ec', {
14
+ namedCurve: 'P-256',
15
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
16
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
17
+ }).privateKey
18
+ }
19
+
20
+ /** Shared instance so a suite's fixtures all agree on one key. */
21
+ export const APNS_TEST_P8_KEY = generateApnsTestP8Key()
@@ -0,0 +1,98 @@
1
+ import { apnsCredentialsSchema, resolveApnsCredentials } from '../credentials'
2
+ import { APNS_TEST_P8_KEY } from './apnsTestKey'
3
+ import {
4
+ PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID,
5
+ PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID,
6
+ PUSH_CREDENTIAL_ERROR_INVALID_P8,
7
+ PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID,
8
+ } from '@open-mercato/core/modules/communication_channels/lib/push-credential-errors'
9
+
10
+ const validCredentials = {
11
+ p8Key: APNS_TEST_P8_KEY,
12
+ keyId: 'ABC123DEFG',
13
+ teamId: 'TEAM123456',
14
+ bundleId: 'com.demo.app',
15
+ }
16
+
17
+ function firstIssueMessage(input: unknown): string | undefined {
18
+ const parsed = apnsCredentialsSchema.safeParse(input)
19
+ return parsed.success ? undefined : parsed.error.issues[0]?.message
20
+ }
21
+
22
+ describe('apnsCredentialsSchema', () => {
23
+ it('accepts well-formed credentials', () => {
24
+ const parsed = apnsCredentialsSchema.safeParse({ ...validCredentials, production: true })
25
+ expect(parsed.success).toBe(true)
26
+ })
27
+
28
+ it('accepts credentials without the optional production flag', () => {
29
+ const parsed = apnsCredentialsSchema.safeParse(validCredentials)
30
+ expect(parsed.success).toBe(true)
31
+ })
32
+
33
+ it('rejects when a required field is missing', () => {
34
+ const { p8Key, ...withoutKey } = validCredentials
35
+ expect(apnsCredentialsSchema.safeParse(withoutKey).success).toBe(false)
36
+ expect(apnsCredentialsSchema.safeParse({ ...validCredentials, keyId: '' }).success).toBe(false)
37
+ expect(apnsCredentialsSchema.safeParse({ ...validCredentials, teamId: '' }).success).toBe(false)
38
+ expect(apnsCredentialsSchema.safeParse({ ...validCredentials, bundleId: '' }).success).toBe(false)
39
+ })
40
+
41
+ it('rejects malformed input', () => {
42
+ expect(apnsCredentialsSchema.safeParse({ p8Key: 123 }).success).toBe(false)
43
+ expect(apnsCredentialsSchema.safeParse('not-an-object').success).toBe(false)
44
+ })
45
+
46
+ // Regression: a non-empty but unparseable `.p8` used to pass validation, so the
47
+ // channel connected and showed as Active while being unable to deliver anything.
48
+ it('rejects a p8Key that is not a parseable private key', () => {
49
+ expect(
50
+ firstIssueMessage({ ...validCredentials, p8Key: 'obviously-not-a-key' }),
51
+ ).toBe(PUSH_CREDENTIAL_ERROR_INVALID_P8)
52
+ expect(
53
+ firstIssueMessage({
54
+ ...validCredentials,
55
+ p8Key: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n',
56
+ }),
57
+ ).toBe(PUSH_CREDENTIAL_ERROR_INVALID_P8)
58
+ })
59
+
60
+ it('rejects Apple identifiers that are not 10 alphanumeric characters', () => {
61
+ expect(firstIssueMessage({ ...validCredentials, keyId: 'SHORT' })).toBe(
62
+ PUSH_CREDENTIAL_ERROR_INVALID_KEY_ID,
63
+ )
64
+ expect(firstIssueMessage({ ...validCredentials, teamId: 'WAY-TOO-LONG-1' })).toBe(
65
+ PUSH_CREDENTIAL_ERROR_INVALID_TEAM_ID,
66
+ )
67
+ })
68
+
69
+ it('rejects a bundleId that is not reverse-DNS', () => {
70
+ expect(firstIssueMessage({ ...validCredentials, bundleId: 'notreversedns' })).toBe(
71
+ PUSH_CREDENTIAL_ERROR_INVALID_BUNDLE_ID,
72
+ )
73
+ })
74
+ })
75
+
76
+ describe('resolveApnsCredentials', () => {
77
+ it('passes through the strongly-typed send config', () => {
78
+ const resolved = resolveApnsCredentials({ ...validCredentials, production: true })
79
+ expect(resolved.p8Key).toBe(validCredentials.p8Key)
80
+ expect(resolved.keyId).toBe(validCredentials.keyId)
81
+ expect(resolved.teamId).toBe(validCredentials.teamId)
82
+ expect(resolved.bundleId).toBe(validCredentials.bundleId)
83
+ })
84
+
85
+ it('keeps boolean production flags verbatim', () => {
86
+ expect(resolveApnsCredentials({ ...validCredentials, production: true }).production).toBe(true)
87
+ expect(resolveApnsCredentials({ ...validCredentials, production: false }).production).toBe(false)
88
+ })
89
+
90
+ it('coerces string production flags via parseBooleanWithDefault', () => {
91
+ expect(resolveApnsCredentials({ ...validCredentials, production: 'true' }).production).toBe(true)
92
+ expect(resolveApnsCredentials({ ...validCredentials, production: 'false' }).production).toBe(false)
93
+ })
94
+
95
+ it('defaults production to false when omitted', () => {
96
+ expect(resolveApnsCredentials(validCredentials).production).toBe(false)
97
+ })
98
+ })