@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +32 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js +47 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js.map +7 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js +104 -0
- package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js.map +7 -0
- package/dist/modules/channel_apns/acl.js +10 -0
- package/dist/modules/channel_apns/acl.js.map +7 -0
- package/dist/modules/channel_apns/di.js +24 -0
- package/dist/modules/channel_apns/di.js.map +7 -0
- package/dist/modules/channel_apns/index.js +9 -0
- package/dist/modules/channel_apns/index.js.map +7 -0
- package/dist/modules/channel_apns/integration.js +76 -0
- package/dist/modules/channel_apns/integration.js.map +7 -0
- package/dist/modules/channel_apns/lib/adapter.js +117 -0
- package/dist/modules/channel_apns/lib/adapter.js.map +7 -0
- package/dist/modules/channel_apns/lib/credentials.js +42 -0
- package/dist/modules/channel_apns/lib/credentials.js.map +7 -0
- package/dist/modules/channel_apns/lib/fake-provider.js +30 -0
- package/dist/modules/channel_apns/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_apns/lib/health.js +10 -0
- package/dist/modules/channel_apns/lib/health.js.map +7 -0
- package/dist/modules/channel_apns/setup.js +25 -0
- package/dist/modules/channel_apns/setup.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js +225 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_apns/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_apns/widgets/injection-table.js +15 -0
- package/dist/modules/channel_apns/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +96 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.ts +67 -0
- package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.ts +3 -0
- package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.ts +145 -0
- package/src/modules/channel_apns/acl.ts +6 -0
- package/src/modules/channel_apns/di.ts +26 -0
- package/src/modules/channel_apns/index.ts +6 -0
- package/src/modules/channel_apns/integration.ts +77 -0
- package/src/modules/channel_apns/lib/__tests__/adapter.test.ts +158 -0
- package/src/modules/channel_apns/lib/__tests__/apnsTestKey.ts +21 -0
- package/src/modules/channel_apns/lib/__tests__/credentials.test.ts +98 -0
- package/src/modules/channel_apns/lib/__tests__/fake-provider.test.ts +93 -0
- package/src/modules/channel_apns/lib/__tests__/message-golden.test.ts +101 -0
- package/src/modules/channel_apns/lib/adapter.ts +199 -0
- package/src/modules/channel_apns/lib/credentials.ts +88 -0
- package/src/modules/channel_apns/lib/fake-provider.ts +54 -0
- package/src/modules/channel_apns/lib/health.ts +13 -0
- package/src/modules/channel_apns/setup.ts +31 -0
- package/src/modules/channel_apns/widgets/injection/connect/widget.client.tsx +251 -0
- package/src/modules/channel_apns/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_apns/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- 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,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,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
|
+
})
|