@openchambery/web 1.18.7-beta.2 → 1.18.7-beta.3
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/dist/assets/AgentAvatar-CVTgW_rx.js +1 -0
- package/dist/assets/AssistantView-CvQ91w2s.js +1 -0
- package/dist/assets/{ChatContainer-BHDNtLuy.js → ChatContainer-CERrtaB-.js} +41 -43
- package/dist/assets/ContextPanel-CLz4r1G-.js +285 -0
- package/dist/assets/DiagramView-DhV4Zthj.js +1 -0
- package/dist/assets/{FadeInOnReveal-BpIMrqet.js → FadeInOnReveal-Cy3sf2M5.js} +1 -1
- package/dist/assets/{Icon-C0G6afNV.js → Icon-BLWVqBee.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-B-SOb7Za.js → MarkdownRendererImpl-B4qwUu4q.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-C7OjF65p.js → MobileDetailNavigation-CuWgEThi.js} +1 -1
- package/dist/assets/{MobileShareBridge-CRRrlX0l.js → MobileShareBridge-C3nay5Sq.js} +2 -2
- package/dist/assets/MobileSurface-D7v_ZkYO.js +2 -0
- package/dist/assets/{MultiRunWindow-Dx4tkVFN.js → MultiRunWindow-Cq8yyiqm.js} +1 -1
- package/dist/assets/{OnboardingScreen-BpxD5W4Z.js → OnboardingScreen-CTNi2zuA.js} +1 -1
- package/dist/assets/{OverlayScrollbar-Bo7MF_jE.js → OverlayScrollbar-GQgJusKL.js} +1 -1
- package/dist/assets/SettingsView-CViKCsxJ.js +25 -0
- package/dist/assets/{SettingsWindow-BlKxv7c3.js → SettingsWindow-C6vwy4hH.js} +1 -1
- package/dist/assets/{TerminalView-xtUbWmdn.js → TerminalView-C1aX22ka.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DUYcTYYW.js → ToolOutputDialog-BrOqj1sB.js} +4 -4
- package/dist/assets/appThemeRegistry-BbqImxSo.js +2 -0
- package/dist/assets/{button-DfF088a8.js → button-CeCkEpvn.js} +1 -1
- package/dist/assets/{clipboard-RoOQVnSl.js → clipboard-BXC1vZwE.js} +1 -1
- package/dist/assets/{collapsible-CVL-5iEB.js → collapsible-B-aagADh.js} +1 -1
- package/dist/assets/{desktopHosts-Dzx3McaD.js → desktopHosts-BrrYPqKq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-B9S0Rp5s.js → desktopRelayRestore-DR60DATT.js} +1 -1
- package/dist/assets/diagram-Cxma_IKI.js +1 -0
- package/dist/assets/dialog-wraJHZQ2.js +1 -0
- package/dist/assets/es-DQqFAaWT.js +15 -0
- package/dist/assets/fileQueries-BJKLdYos.js +1 -0
- package/dist/assets/fr-CNfGRpc1.js +13 -0
- package/dist/assets/{gitApi-DsdwefuR.js → gitApi-B4z4eSZV.js} +1 -1
- package/dist/assets/{input-Ca2tUhgN.js → input-CyG05w-V.js} +1 -1
- package/dist/assets/insertionBoundaries-DCYAK5W1.js +4 -0
- package/dist/assets/ja-CQqehnVn.js +15 -0
- package/dist/assets/ko-kmwi8UJD.js +15 -0
- package/dist/assets/main-A8oe9r34.js +29 -0
- package/dist/assets/{main-CKb-YXE6.js → main-BJBKpmw3.js} +2 -2
- package/dist/assets/{markdown-worker-ZaHiIw5I.js → markdown-worker-DDCUZkz4.js} +1 -1
- package/dist/assets/{markdownTheme-DidS-jC5.js → markdownTheme-H7_7lbuh.js} +1 -1
- package/dist/assets/miniChat-BObOyFSV.js +2 -0
- package/dist/assets/mobile-Ba390Cmc.js +2 -0
- package/dist/assets/multirun-CMDYOrmD.js +4 -0
- package/dist/assets/{persistence-aCOlo6ew.js → persistence-DE9RnVcA.js} +2 -2
- package/dist/assets/pl-Blv5o2QA.js +15 -0
- package/dist/assets/platform-MjT8iqKB.js +2 -0
- package/dist/assets/projectMeta-uWBWFG5A.js +3 -0
- package/dist/assets/pt-BR-BK74t-Bd.js +15 -0
- package/dist/assets/radio-CEXUd4RC.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-Bpgb2E78.js +2 -0
- package/dist/assets/renderMobileApp-BMwRozEC.js +23 -0
- package/dist/assets/runtimeConfig-hHuyauKZ.js +2 -0
- package/dist/assets/runtimeEndpointReset-CmMI7Xp1.js +4 -0
- package/dist/assets/{shikiHighlight-eMLXb99c.js → shikiHighlight-C-kTQvqd.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CDHNwlp9.js → sortable-tabs-strip-BdbKDDAP.js} +1 -1
- package/dist/assets/src-CgFGBE_A.css +2 -0
- package/dist/assets/store-UweBeN2y.js +15 -0
- package/dist/assets/{terminalApi-B36I-z14.js → terminalApi-Bnx0KvaV.js} +1 -1
- package/dist/assets/{tooltip-GC3dg3gO.js → tooltip-B__x-HDJ.js} +1 -1
- package/dist/assets/uk-6I-KXy3R.js +15 -0
- package/dist/assets/{url-BQHM4bC4.js → url-CTuw7Rjf.js} +1 -1
- package/dist/assets/{useAppFontEffects-B9Ozsfua.js → useAppFontEffects-8iAE0Yrj.js} +4 -4
- package/dist/assets/{useAssistantUIStore-DxZsjSPQ.js → useAssistantUIStore-D2K7ksGj.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-QxNEOYzY.js → useDesktopWindowControlsLayout--oz1VAPt.js} +1 -1
- package/dist/assets/useEffectiveDirectory-DmoqXaa4.js +1 -0
- package/dist/assets/{useFontPreferences-D9h4M7x4.js → useFontPreferences-CWZnNRjz.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BxbEX-es.js +412 -0
- package/dist/assets/{useSessionAutoCleanup-CXqhEUbp.js → useSessionAutoCleanup-BwlJlhY7.js} +1 -1
- package/dist/assets/{useUIStore-Wyu09VUF.js → useUIStore-DOxLVeGb.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-2KOHSbaa.js → useWorkerHighlightedLines-B21D9MOD.js} +1 -1
- package/dist/assets/{utils-CJdWZupK.js → utils-C2R0YoC5.js} +1 -1
- package/dist/assets/vendor-legendapp-list-WE32lwNk.js +1 -0
- package/dist/assets/zh-CN-DwlzS4yK.js +15 -0
- package/dist/assets/zh-TW-p0VriyfT.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +3 -2
- package/server/index.js +19 -6
- package/server/lib/notifications/APNS.md +88 -38
- package/server/lib/notifications/DOCUMENTATION.md +11 -4
- package/server/lib/notifications/apns-runtime.js +793 -78
- package/server/lib/notifications/apns-runtime.test.js +922 -0
- package/server/lib/notifications/apns-titles.js +212 -0
- package/server/lib/notifications/apns-titles.test.js +55 -0
- package/server/lib/notifications/routes.js +57 -1
- package/server/lib/notifications/runtime.js +20 -17
- package/server/lib/notifications/runtime.test.js +108 -0
- package/server/lib/opencode/DOCUMENTATION.md +1 -1
- package/server/lib/opencode/bootstrap-runtime.js +6 -0
- package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
- package/server/lib/opencode/routes.js +24 -5
- package/server/lib/opencode/routes.test.js +78 -1
- package/server/lib/relay/DOCUMENTATION.md +2 -2
- package/server/lib/relay/service.js +45 -3
- package/server/lib/relay/service.test.js +117 -0
- package/server/lib/relay/signing-key.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +27 -2
- package/server/lib/scheduled-tasks/runtime.js +271 -3
- package/server/lib/scheduled-tasks/runtime.test.js +308 -0
- package/server/lib/session-goal/runtime.js +2 -2
- package/dist/assets/AgentAvatar-CWn_73qY.js +0 -1
- package/dist/assets/AssistantView-BiOO5An0.js +0 -1
- package/dist/assets/ContextPanel-BbNBZipF.js +0 -288
- package/dist/assets/DiagramView-AMPWVMV0.js +0 -1
- package/dist/assets/MobileSurface-DmMgH78x.js +0 -2
- package/dist/assets/SettingsView-BDaMrSki.js +0 -25
- package/dist/assets/appThemeRegistry-D6qZmouf.js +0 -2
- package/dist/assets/dialog-BbHDXPt6.js +0 -1
- package/dist/assets/es-BkIvCvwj.js +0 -15
- package/dist/assets/fileQueries-_VUJM8AE.js +0 -1
- package/dist/assets/fr-tCwQ3odX.js +0 -13
- package/dist/assets/insertionBoundaries-M7AXIWfg.js +0 -1
- package/dist/assets/ja-C0rD6dwp.js +0 -15
- package/dist/assets/ko-CAbtZ462.js +0 -15
- package/dist/assets/main-QjOL36Zh.js +0 -29
- package/dist/assets/miniChat-C45kGqqH.js +0 -2
- package/dist/assets/mobile-DH8nxp1K.js +0 -2
- package/dist/assets/multirun-BkKb0iZv.js +0 -4
- package/dist/assets/pl-Bb190ba6.js +0 -15
- package/dist/assets/platform-DxNH_0Fj.js +0 -2
- package/dist/assets/projectMeta-BHtBPaxp.js +0 -3
- package/dist/assets/pt-BR-B6p_t3IB.js +0 -15
- package/dist/assets/radio-BacnmuU0.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-Cn7umSw9.js +0 -2
- package/dist/assets/renderMobileApp-DZBR2t3l.js +0 -23
- package/dist/assets/runtimeConfig-CSAVS8gd.js +0 -2
- package/dist/assets/runtimeEndpointReset-BZUX3z0A.js +0 -4
- package/dist/assets/src-DziW4HUU.css +0 -2
- package/dist/assets/store-1FOY4G3z.js +0 -15
- package/dist/assets/uk-Dy8-6ZEj.js +0 -15
- package/dist/assets/useEffectiveDirectory-D3u_oih8.js +0 -1
- package/dist/assets/useMobileNavigationStore-CCPTHVO9.js +0 -475
- package/dist/assets/zh-CN-gRTE0npc.js +0 -15
- package/dist/assets/zh-TW-ClD4BCfW.js +0 -15
|
@@ -2,6 +2,10 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { createApnsRuntime } from './apns-runtime.js';
|
|
5
|
+
import { registerNotificationRoutes } from './routes.js';
|
|
6
|
+
import { resolveEffectiveRelayUrl } from '../relay/service.js';
|
|
7
|
+
import { createPushRelayServer } from '../../../../relay-server/src/push/index.js';
|
|
8
|
+
import { createApnsProvider } from '../../../../relay-server/src/push/apns.js';
|
|
5
9
|
|
|
6
10
|
// A real P-256 key so the ES256 signing path (direct mode) runs for real.
|
|
7
11
|
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
@@ -38,6 +42,7 @@ const makeDeps = (overrides = {}) => {
|
|
|
38
42
|
APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json',
|
|
39
43
|
readSettingsFromDiskMigrated: vi.fn(async () => settings),
|
|
40
44
|
writeSettingsToDisk: vi.fn(async (next) => { settings = next; }),
|
|
45
|
+
resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
|
|
41
46
|
...overrides,
|
|
42
47
|
};
|
|
43
48
|
};
|
|
@@ -69,6 +74,45 @@ afterEach(() => {
|
|
|
69
74
|
vi.unstubAllGlobals();
|
|
70
75
|
delete process.env.OPENCHAMBER_PUSH_RELAY_URL;
|
|
71
76
|
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
|
|
77
|
+
delete process.env.OPENCHAMBER_RELAY_URL;
|
|
78
|
+
delete process.env.OPENCHAMBER_APNS_KEY_ID;
|
|
79
|
+
delete process.env.OPENCHAMBER_APNS_TEAM_ID;
|
|
80
|
+
delete process.env.OPENCHAMBER_APNS_P8;
|
|
81
|
+
delete process.env.OPENCHAMBER_APNS_BUNDLE_ID;
|
|
82
|
+
delete process.env.OPENCHAMBER_APNS_ENVIRONMENT;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('apns runtime bundle ID', () => {
|
|
86
|
+
it('defaults to the current product bundle ID when env and settings omit it', async () => {
|
|
87
|
+
process.env.OPENCHAMBER_APNS_KEY_ID = 'KEY123';
|
|
88
|
+
process.env.OPENCHAMBER_APNS_TEAM_ID = 'TEAM123';
|
|
89
|
+
process.env.OPENCHAMBER_APNS_P8 = P8;
|
|
90
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
91
|
+
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.yee94.openchamber' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('prefers OPENCHAMBER_APNS_BUNDLE_ID over the default and settings', async () => {
|
|
95
|
+
process.env.OPENCHAMBER_APNS_BUNDLE_ID = 'com.example.app';
|
|
96
|
+
const runtime = createApnsRuntime(
|
|
97
|
+
makeDeps({
|
|
98
|
+
readSettingsFromDiskMigrated: vi.fn(async () => ({
|
|
99
|
+
apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
|
|
100
|
+
})),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.app' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('prefers settings.apnsConfig.bundleId over the default', async () => {
|
|
107
|
+
const runtime = createApnsRuntime(
|
|
108
|
+
makeDeps({
|
|
109
|
+
readSettingsFromDiskMigrated: vi.fn(async () => ({
|
|
110
|
+
apnsConfig: { ...APNS_CONFIG, bundleId: 'com.example.settings' },
|
|
111
|
+
})),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ bundleId: 'com.example.settings' });
|
|
115
|
+
});
|
|
72
116
|
});
|
|
73
117
|
|
|
74
118
|
describe('apns runtime relay mode (default)', () => {
|
|
@@ -151,6 +195,36 @@ describe('apns runtime relay mode (default)', () => {
|
|
|
151
195
|
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
152
196
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
153
197
|
});
|
|
198
|
+
|
|
199
|
+
it('omits relay collapseId when CJK/emoji tags exceed 64 UTF-8 bytes', async () => {
|
|
200
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
201
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
202
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
203
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
204
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
205
|
+
|
|
206
|
+
const sendTag = async (tag) => {
|
|
207
|
+
fetchMock.mockClear();
|
|
208
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
|
|
209
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
|
210
|
+
return Object.prototype.hasOwnProperty.call(sent, 'collapseId') ? sent.collapseId : undefined;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const cjkKeep = '会话'.repeat(10); // 60 bytes
|
|
214
|
+
const cjkOmit = '会话'.repeat(11); // 66 bytes
|
|
215
|
+
const emojiKeep = '🎉'.repeat(16); // 64 bytes
|
|
216
|
+
const emojiOmit = '🎉'.repeat(17); // 68 bytes
|
|
217
|
+
expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
|
|
218
|
+
expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
|
|
219
|
+
expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
|
|
220
|
+
expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
|
|
221
|
+
|
|
222
|
+
expect(await sendTag('ready-x')).toBe('ready-x');
|
|
223
|
+
expect(await sendTag(cjkKeep)).toBe(cjkKeep);
|
|
224
|
+
expect(await sendTag(cjkOmit)).toBeUndefined();
|
|
225
|
+
expect(await sendTag(emojiKeep)).toBe(emojiKeep);
|
|
226
|
+
expect(await sendTag(emojiOmit)).toBeUndefined();
|
|
227
|
+
});
|
|
154
228
|
});
|
|
155
229
|
|
|
156
230
|
describe('apns runtime direct fallback (relay disabled)', () => {
|
|
@@ -186,6 +260,57 @@ describe('apns runtime direct fallback (relay disabled)', () => {
|
|
|
186
260
|
expect(targeted).toEqual(['tokenDirect']);
|
|
187
261
|
});
|
|
188
262
|
|
|
263
|
+
it('keeps direct APNs collapse-id within 64 UTF-8 bytes and omits CJK/emoji over the limit', async () => {
|
|
264
|
+
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
|
265
|
+
const collapseIds = [];
|
|
266
|
+
const http2 = {
|
|
267
|
+
connect: () => ({
|
|
268
|
+
on: () => {},
|
|
269
|
+
close: () => {},
|
|
270
|
+
request: (headers) => {
|
|
271
|
+
collapseIds.push(headers['apns-collapse-id']);
|
|
272
|
+
const listeners = {};
|
|
273
|
+
const req = {
|
|
274
|
+
on: (event, cb) => { listeners[event] = cb; return req; },
|
|
275
|
+
setEncoding: () => req,
|
|
276
|
+
end: () => {
|
|
277
|
+
queueMicrotask(() => {
|
|
278
|
+
listeners.response?.({ ':status': '200' });
|
|
279
|
+
listeners.end?.();
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
return req;
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
const runtime = createApnsRuntime(
|
|
288
|
+
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
|
|
289
|
+
);
|
|
290
|
+
await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
|
|
291
|
+
|
|
292
|
+
const sendTag = async (tag) => {
|
|
293
|
+
collapseIds.length = 0;
|
|
294
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag });
|
|
295
|
+
return collapseIds[0];
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const cjkKeep = '会话'.repeat(10); // 60 bytes
|
|
299
|
+
const cjkOmit = '会话'.repeat(11); // 66 bytes
|
|
300
|
+
const emojiKeep = '🎉'.repeat(16); // 64 bytes
|
|
301
|
+
const emojiOmit = '🎉'.repeat(17); // 68 bytes
|
|
302
|
+
expect(Buffer.byteLength(cjkKeep, 'utf8')).toBeLessThanOrEqual(64);
|
|
303
|
+
expect(Buffer.byteLength(cjkOmit, 'utf8')).toBeGreaterThan(64);
|
|
304
|
+
expect(Buffer.byteLength(emojiKeep, 'utf8')).toBe(64);
|
|
305
|
+
expect(Buffer.byteLength(emojiOmit, 'utf8')).toBeGreaterThan(64);
|
|
306
|
+
|
|
307
|
+
expect(await sendTag('ready-x')).toBe('ready-x');
|
|
308
|
+
expect(await sendTag(cjkKeep)).toBe(cjkKeep);
|
|
309
|
+
expect(await sendTag(cjkOmit)).toBeUndefined();
|
|
310
|
+
expect(await sendTag(emojiKeep)).toBe(emojiKeep);
|
|
311
|
+
expect(await sendTag(emojiOmit)).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
189
314
|
it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => {
|
|
190
315
|
const runtime = createApnsRuntime(makeDeps());
|
|
191
316
|
const parts = runtime.signApnsJwt(APNS_CONFIG).split('.');
|
|
@@ -193,4 +318,801 @@ describe('apns runtime direct fallback (relay disabled)', () => {
|
|
|
193
318
|
expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' });
|
|
194
319
|
expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123');
|
|
195
320
|
});
|
|
321
|
+
|
|
322
|
+
it('does not register tokens with the push relay in direct mode', async () => {
|
|
323
|
+
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
|
324
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
325
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
326
|
+
const http2 = {
|
|
327
|
+
connect: () => ({
|
|
328
|
+
on: () => {},
|
|
329
|
+
close: () => {},
|
|
330
|
+
request: (headers) => {
|
|
331
|
+
const listeners = {};
|
|
332
|
+
const req = {
|
|
333
|
+
on: (event, cb) => { listeners[event] = cb; return req; },
|
|
334
|
+
setEncoding: () => req,
|
|
335
|
+
end: () => {
|
|
336
|
+
queueMicrotask(() => {
|
|
337
|
+
listeners.response?.({ ':status': '200' });
|
|
338
|
+
listeners.end?.();
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
return req;
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
};
|
|
346
|
+
const runtime = createApnsRuntime(
|
|
347
|
+
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
|
|
348
|
+
);
|
|
349
|
+
await runtime.addOrUpdateApnsToken('s', 'tokenDirect');
|
|
350
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
351
|
+
await expect(runtime.reRegisterAllTokens()).resolves.toEqual({ attempted: 0, succeeded: 0, failed: 0 });
|
|
352
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('apns runtime push relay derivation and re-register', () => {
|
|
357
|
+
const registerUrls = (fetchMock) =>
|
|
358
|
+
fetchMock.mock.calls.filter(isRegister).map(([url]) => url);
|
|
359
|
+
const sendUrls = (fetchMock) =>
|
|
360
|
+
fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send')).map(([url]) => url);
|
|
361
|
+
|
|
362
|
+
it('prefers OPENCHAMBER_PUSH_RELAY_URL over derived relay URLs', async () => {
|
|
363
|
+
process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
|
|
364
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://explicit.test/v1/push/send';
|
|
365
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
366
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
367
|
+
|
|
368
|
+
let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/ws' } };
|
|
369
|
+
const runtime = createApnsRuntime(makeDeps({
|
|
370
|
+
readSettingsFromDiskMigrated: async () => settings,
|
|
371
|
+
writeSettingsToDisk: async (next) => { settings = next; },
|
|
372
|
+
resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
|
|
373
|
+
}));
|
|
374
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
375
|
+
expect(registerUrls(fetchMock)).toEqual(['https://explicit.test/v1/push/register-token']);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('derives the push send URL from settings then env relay URLs', async () => {
|
|
379
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
380
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
381
|
+
|
|
382
|
+
let settings = { privateRelay: { enabled: true, relayUrl: 'wss://settings.example/custom' } };
|
|
383
|
+
const runtime = createApnsRuntime(makeDeps({
|
|
384
|
+
readSettingsFromDiskMigrated: async () => settings,
|
|
385
|
+
writeSettingsToDisk: async (next) => { settings = next; },
|
|
386
|
+
resolveEffectiveRelayUrl: async () => resolveEffectiveRelayUrl({ settings }),
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
390
|
+
expect(registerUrls(fetchMock)).toEqual(['https://settings.example/v1/push/register-token']);
|
|
391
|
+
|
|
392
|
+
fetchMock.mockClear();
|
|
393
|
+
process.env.OPENCHAMBER_RELAY_URL = 'wss://env.example/ws';
|
|
394
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
395
|
+
expect(registerUrls(fetchMock)).toEqual(['https://env.example/v1/push/register-token']);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('defaults to the hosted relay push endpoint', async () => {
|
|
399
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
400
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
401
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
402
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
403
|
+
expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('re-registers persisted tokens against the current relay', async () => {
|
|
407
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
|
|
408
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
409
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
410
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
411
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
412
|
+
await runtime.addOrUpdateApnsToken('s2', 'tokenB');
|
|
413
|
+
await runtime.addOrUpdateApnsToken('s3', 'tokenA');
|
|
414
|
+
|
|
415
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
|
|
416
|
+
fetchMock.mockClear();
|
|
417
|
+
await expect(runtime.reRegisterAllTokens()).resolves.toEqual({
|
|
418
|
+
attempted: 2,
|
|
419
|
+
succeeded: 2,
|
|
420
|
+
failed: 0,
|
|
421
|
+
});
|
|
422
|
+
expect(registerUrls(fetchMock).every((url) => url === 'https://relay-b.test/v1/push/register-token')).toBe(true);
|
|
423
|
+
expect(registerUrls(fetchMock)).toHaveLength(2);
|
|
424
|
+
expect(sendUrls(fetchMock)).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('registers with the new relay before the first send after a switch', async () => {
|
|
428
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
|
|
429
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
430
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
431
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
432
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
433
|
+
|
|
434
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
|
|
435
|
+
fetchMock.mockClear();
|
|
436
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
437
|
+
|
|
438
|
+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
439
|
+
'https://relay-b.test/v1/push/register-token',
|
|
440
|
+
'https://relay-b.test/v1/push/send',
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('sends only tokens that registered successfully after a partial failure', async () => {
|
|
445
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-a.test/v1/push/send';
|
|
446
|
+
const fetchMock = vi.fn(async (url, init) => {
|
|
447
|
+
if (String(url).endsWith('/register-token')) {
|
|
448
|
+
const body = JSON.parse(init.body);
|
|
449
|
+
if (body.token === 'tokenB') return jsonResponse({ error: true }, 500);
|
|
450
|
+
return jsonResponse({ ok: true });
|
|
451
|
+
}
|
|
452
|
+
return jsonResponse({ results: [] });
|
|
453
|
+
});
|
|
454
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
455
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
456
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
457
|
+
await runtime.addOrUpdateApnsToken('s2', 'tokenB');
|
|
458
|
+
|
|
459
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay-b.test/v1/push/send';
|
|
460
|
+
fetchMock.mockClear();
|
|
461
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
462
|
+
|
|
463
|
+
const registerBodies = fetchMock.mock.calls
|
|
464
|
+
.filter(isRegister)
|
|
465
|
+
.map(([, init]) => JSON.parse(init.body).token)
|
|
466
|
+
.sort();
|
|
467
|
+
expect(registerBodies).toEqual(['tokenA', 'tokenB']);
|
|
468
|
+
expect(sendUrls(fetchMock)).toEqual(['https://relay-b.test/v1/push/send']);
|
|
469
|
+
expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
|
|
470
|
+
|
|
471
|
+
fetchMock.mockClear();
|
|
472
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
473
|
+
const retryRegister = fetchMock.mock.calls.filter(isRegister).map(([, init]) => JSON.parse(init.body).token);
|
|
474
|
+
expect(retryRegister).toEqual(['tokenB']);
|
|
475
|
+
expect(JSON.parse(fetchMock.mock.calls.find(([url]) => String(url).endsWith('/v1/push/send'))[1].body).tokens).toEqual(['tokenA']);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('warns without leaking the URL when the resolver returns a non-WebSocket URL, then falls back to default', async () => {
|
|
479
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
480
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
481
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
482
|
+
const leaked = 'https://evil.example/v1/push/send?token=secret';
|
|
483
|
+
const runtime = createApnsRuntime(makeDeps({
|
|
484
|
+
resolveEffectiveRelayUrl: async () => leaked,
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
489
|
+
expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
|
|
490
|
+
expect(warn).toHaveBeenCalled();
|
|
491
|
+
const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
|
|
492
|
+
expect(messages).toContain('falling back to default');
|
|
493
|
+
expect(messages).not.toContain(leaked);
|
|
494
|
+
expect(messages).not.toContain('evil.example');
|
|
495
|
+
expect(messages).not.toContain('secret');
|
|
496
|
+
expect(messages).not.toContain('token=');
|
|
497
|
+
} finally {
|
|
498
|
+
warn.mockRestore();
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('warns without leaking the error when the resolver throws, then falls back to default', async () => {
|
|
503
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
504
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
505
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
506
|
+
const leaked = 'wss://evil.example/ws?token=secret';
|
|
507
|
+
const runtime = createApnsRuntime(makeDeps({
|
|
508
|
+
resolveEffectiveRelayUrl: async () => {
|
|
509
|
+
throw new Error(leaked);
|
|
510
|
+
},
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
|
515
|
+
expect(registerUrls(fetchMock)).toEqual(['https://relay.openchamber.dev/v1/push/register-token']);
|
|
516
|
+
expect(warn).toHaveBeenCalled();
|
|
517
|
+
const messages = warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
|
|
518
|
+
expect(messages).toContain('falling back to default');
|
|
519
|
+
expect(messages).not.toContain(leaked);
|
|
520
|
+
expect(messages).not.toContain('evil.example');
|
|
521
|
+
expect(messages).not.toContain('secret');
|
|
522
|
+
expect(messages).not.toContain('token=');
|
|
523
|
+
} finally {
|
|
524
|
+
warn.mockRestore();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('sends more than 100 tokens in batches of at most 100, each token exactly once', async () => {
|
|
529
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
530
|
+
const fetchMock = vi.fn(async (url) =>
|
|
531
|
+
String(url).endsWith('/register-token')
|
|
532
|
+
? jsonResponse({ ok: true })
|
|
533
|
+
: jsonResponse({ results: [] }),
|
|
534
|
+
);
|
|
535
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
536
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
537
|
+
const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
|
|
538
|
+
for (const [i, token] of tokens.entries()) {
|
|
539
|
+
await runtime.addOrUpdateApnsToken(`s${i}`, token);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
fetchMock.mockClear();
|
|
543
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
544
|
+
|
|
545
|
+
const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
|
|
546
|
+
expect(sendCalls).toHaveLength(2);
|
|
547
|
+
const batches = sendCalls.map(([, init]) => JSON.parse(init.body).tokens);
|
|
548
|
+
expect(batches.every((batch) => Array.isArray(batch) && batch.length > 0 && batch.length <= 100)).toBe(true);
|
|
549
|
+
const sent = batches.flat();
|
|
550
|
+
expect(sent).toHaveLength(101);
|
|
551
|
+
expect(new Set(sent).size).toBe(101);
|
|
552
|
+
expect(new Set(sent)).toEqual(new Set(tokens));
|
|
553
|
+
for (const [, init] of sendCalls) {
|
|
554
|
+
const sentBody = JSON.parse(init.body);
|
|
555
|
+
const sendMessage = `${sentBody.ts}.${[...sentBody.tokens].sort().join(',')}.${sentBody.title}`;
|
|
556
|
+
expect(await verifyRelaySignature(sentBody.publicKeyJwk, sendMessage, sentBody.sig)).toBe(true);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('continues remaining send batches when one batch fails', async () => {
|
|
561
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
562
|
+
let sendCount = 0;
|
|
563
|
+
const fetchMock = vi.fn(async (url) => {
|
|
564
|
+
if (String(url).endsWith('/register-token')) return jsonResponse({ ok: true });
|
|
565
|
+
sendCount += 1;
|
|
566
|
+
if (sendCount === 1) return jsonResponse({ error: true }, 500);
|
|
567
|
+
return jsonResponse({ results: [{ token: 'token100', ok: false, drop: true }] });
|
|
568
|
+
});
|
|
569
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
570
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
571
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
572
|
+
const tokens = Array.from({ length: 101 }, (_, i) => `token${String(i).padStart(3, '0')}`);
|
|
573
|
+
for (const [i, token] of tokens.entries()) {
|
|
574
|
+
await runtime.addOrUpdateApnsToken(`s${i}`, token);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
fetchMock.mockClear();
|
|
579
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
580
|
+
const sendCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/v1/push/send'));
|
|
581
|
+
expect(sendCalls).toHaveLength(2);
|
|
582
|
+
expect(sendCalls.map(([, init]) => JSON.parse(init.body).tokens).flat()).toHaveLength(101);
|
|
583
|
+
|
|
584
|
+
fetchMock.mockClear();
|
|
585
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
586
|
+
const retryTokens = fetchMock.mock.calls
|
|
587
|
+
.filter(([url]) => String(url).endsWith('/v1/push/send'))
|
|
588
|
+
.flatMap(([, init]) => JSON.parse(init.body).tokens);
|
|
589
|
+
expect(retryTokens).not.toContain('token100');
|
|
590
|
+
expect(retryTokens).toHaveLength(100);
|
|
591
|
+
} finally {
|
|
592
|
+
warn.mockRestore();
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('sends localized scenario titles grouped by stored token locale', async () => {
|
|
597
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
598
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
599
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
600
|
+
|
|
601
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
602
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenEn', undefined, 'ios', 'en');
|
|
603
|
+
await runtime.addOrUpdateApnsToken('s2', 'tokenZh', undefined, 'ios', 'zh-CN');
|
|
604
|
+
await runtime.addOrUpdateApnsToken('s3', 'tokenJa', undefined, 'android', 'ja');
|
|
605
|
+
|
|
606
|
+
fetchMock.mockClear();
|
|
607
|
+
await runtime.sendApnsToAllUiSessions({
|
|
608
|
+
type: 'ready',
|
|
609
|
+
sessionName: 'My session',
|
|
610
|
+
badge: 2,
|
|
611
|
+
tag: 'ready-x',
|
|
612
|
+
data: { sessionId: 'sess1' },
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const sendBodies = fetchMock.mock.calls
|
|
616
|
+
.filter(isSend)
|
|
617
|
+
.map(([, init]) => JSON.parse(init.body));
|
|
618
|
+
expect(sendBodies).toHaveLength(3);
|
|
619
|
+
|
|
620
|
+
const byTitle = Object.fromEntries(sendBodies.map((body) => [body.title, body]));
|
|
621
|
+
expect(byTitle['Agent response is ready']?.tokens).toEqual(['tokenEn']);
|
|
622
|
+
expect(byTitle['智能体回复已就绪']?.tokens).toEqual(['tokenZh']);
|
|
623
|
+
expect(byTitle['エージェントの応答が準備できました']?.tokens).toEqual(['tokenJa']);
|
|
624
|
+
for (const body of sendBodies) {
|
|
625
|
+
expect(body.body).toBe('My session');
|
|
626
|
+
expect(body.badge).toBe(2);
|
|
627
|
+
const sendMessage = `${body.ts}.${[...body.tokens].sort().join(',')}.${body.title}`;
|
|
628
|
+
expect(await verifyRelaySignature(body.publicKeyJwk, sendMessage, body.sig)).toBe(true);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('defaults missing token locale to English titles', async () => {
|
|
633
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
634
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
635
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
636
|
+
|
|
637
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
638
|
+
await runtime.addOrUpdateApnsToken('s1', 'tokenLegacy');
|
|
639
|
+
|
|
640
|
+
fetchMock.mockClear();
|
|
641
|
+
await runtime.sendApnsToAllUiSessions({ type: 'permission', sessionName: 'S' });
|
|
642
|
+
|
|
643
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
|
644
|
+
expect(sent.title).toBe('Agent needs permission');
|
|
645
|
+
expect(sent.body).toBe('S');
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const isLiveActivityRegister = ([url]) => String(url).endsWith('/register-live-activity-token');
|
|
650
|
+
const isLiveActivityUnregister = ([url]) => String(url).endsWith('/unregister-live-activity-token');
|
|
651
|
+
const isLiveActivitySend = ([url]) => String(url).endsWith('/live-activity');
|
|
652
|
+
|
|
653
|
+
const createRouteRegistry = () => {
|
|
654
|
+
const routes = new Map();
|
|
655
|
+
return {
|
|
656
|
+
app: {
|
|
657
|
+
get(routePath, handler) { routes.set(`GET ${routePath}`, handler); },
|
|
658
|
+
post(routePath, handler) { routes.set(`POST ${routePath}`, handler); },
|
|
659
|
+
delete(routePath, handler) { routes.set(`DELETE ${routePath}`, handler); },
|
|
660
|
+
},
|
|
661
|
+
getRoute(method, routePath) { return routes.get(`${method} ${routePath}`); },
|
|
662
|
+
};
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const createMockRes = () => {
|
|
666
|
+
let statusCode = 200;
|
|
667
|
+
let body;
|
|
668
|
+
return {
|
|
669
|
+
status(code) { statusCode = code; return this; },
|
|
670
|
+
json(payload) { body = payload; return this; },
|
|
671
|
+
get statusCode() { return statusCode; },
|
|
672
|
+
get body() { return body; },
|
|
673
|
+
};
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const leakedWarnText = (warn) => warn.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
|
|
677
|
+
|
|
678
|
+
describe('apns live activity token store', () => {
|
|
679
|
+
it('migrates v1 alert-token files without dropping existing device tokens', async () => {
|
|
680
|
+
const fsPromises = createMemoryFs();
|
|
681
|
+
await fsPromises.writeFile('/tmp/apns-tokens.json', JSON.stringify({
|
|
682
|
+
version: 1,
|
|
683
|
+
tokensBySession: {
|
|
684
|
+
s1: [{ deviceToken: 'alert-token', createdAt: 1, lastSeenAt: 1, platform: 'ios' }],
|
|
685
|
+
},
|
|
686
|
+
}));
|
|
687
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
688
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
689
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
690
|
+
const runtime = createApnsRuntime(makeDeps({ fsPromises }));
|
|
691
|
+
|
|
692
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
693
|
+
const store = JSON.parse(await fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
694
|
+
expect(store.version).toBe(2);
|
|
695
|
+
expect(store.tokensBySession.s1[0].deviceToken).toBe('alert-token');
|
|
696
|
+
expect(store.liveActivityTokensBySession.s1).toEqual([
|
|
697
|
+
expect.objectContaining({ token: 'la-token', activityId: 'act-1', sessionId: 'ses_1' }),
|
|
698
|
+
]);
|
|
699
|
+
|
|
700
|
+
fetchMock.mockClear();
|
|
701
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
702
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['alert-token']);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('registers idempotently and replaces the token for the same activity', async () => {
|
|
706
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
707
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
708
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
709
|
+
const deps = makeDeps();
|
|
710
|
+
const runtime = createApnsRuntime(deps);
|
|
711
|
+
|
|
712
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'ses_1');
|
|
713
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'ses_1');
|
|
714
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-b', 'act-1', 'ses_1');
|
|
715
|
+
|
|
716
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
717
|
+
expect(store.liveActivityTokensBySession.s1).toHaveLength(1);
|
|
718
|
+
expect(store.liveActivityTokensBySession.s1[0].token).toBe('la-token-b');
|
|
719
|
+
|
|
720
|
+
const unregisterCalls = fetchMock.mock.calls.filter(isLiveActivityUnregister);
|
|
721
|
+
expect(unregisterCalls).toHaveLength(1);
|
|
722
|
+
const unreg = JSON.parse(unregisterCalls[0][1].body);
|
|
723
|
+
expect(unreg.token).toBe('la-token-a');
|
|
724
|
+
expect(unreg.platform).toBe('ios');
|
|
725
|
+
expect(unreg.kind).toBe('liveactivity');
|
|
726
|
+
expect(await verifyRelaySignature(
|
|
727
|
+
unreg.publicKeyJwk,
|
|
728
|
+
`${unreg.ts}.${unreg.token}.${unreg.platform}.${unreg.kind}`,
|
|
729
|
+
unreg.sig,
|
|
730
|
+
)).toBe(true);
|
|
731
|
+
|
|
732
|
+
fetchMock.mockClear();
|
|
733
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
734
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
735
|
+
expect(sent.tokens).toEqual(['la-token-b']);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('refuses to unregister a token owned by a different UI session', async () => {
|
|
739
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
740
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
741
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
742
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
743
|
+
await runtime.addOrUpdateLiveActivityToken('owner', 'la-token', 'act-1', 'ses_1');
|
|
744
|
+
|
|
745
|
+
fetchMock.mockClear();
|
|
746
|
+
await runtime.removeLiveActivityToken('other', 'la-token');
|
|
747
|
+
expect(fetchMock.mock.calls.filter(isLiveActivityUnregister)).toHaveLength(0);
|
|
748
|
+
|
|
749
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
750
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body).tokens).toEqual(['la-token']);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('caps live activity tokens per UI session and lazily expires stale entries', async () => {
|
|
754
|
+
const fsPromises = createMemoryFs();
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
await fsPromises.writeFile('/tmp/apns-tokens.json', JSON.stringify({
|
|
757
|
+
version: 2,
|
|
758
|
+
tokensBySession: {},
|
|
759
|
+
liveActivityTokensBySession: {
|
|
760
|
+
s1: [{
|
|
761
|
+
token: 'expired-token',
|
|
762
|
+
activityId: 'act-old',
|
|
763
|
+
sessionId: 'ses_old',
|
|
764
|
+
createdAt: now - 48 * 60 * 60 * 1000,
|
|
765
|
+
lastSeenAt: now - 48 * 60 * 60 * 1000,
|
|
766
|
+
}],
|
|
767
|
+
},
|
|
768
|
+
liveActivityEventVersions: {},
|
|
769
|
+
}));
|
|
770
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
771
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
772
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
773
|
+
const runtime = createApnsRuntime(makeDeps({ fsPromises }));
|
|
774
|
+
|
|
775
|
+
for (let i = 0; i < 9; i += 1) {
|
|
776
|
+
await runtime.addOrUpdateLiveActivityToken('s1', `la-token-${i}`, `act-${i}`, `ses_${i}`);
|
|
777
|
+
}
|
|
778
|
+
const store = JSON.parse(await fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
779
|
+
expect(store.liveActivityTokensBySession.s1).toHaveLength(8);
|
|
780
|
+
expect(store.liveActivityTokensBySession.s1.some((entry) => entry.token === 'expired-token')).toBe(false);
|
|
781
|
+
expect(store.liveActivityTokensBySession.s1.some((entry) => entry.token === 'la-token-0')).toBe(false);
|
|
782
|
+
expect(store.liveActivityTokensBySession.s1[0].token).toBe('la-token-8');
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
describe('apns live activity routes', () => {
|
|
787
|
+
const register = (overrides = {}) => {
|
|
788
|
+
const { app, getRoute } = createRouteRegistry();
|
|
789
|
+
const addOrUpdateLiveActivityToken = vi.fn(async () => {});
|
|
790
|
+
const removeLiveActivityToken = vi.fn(async () => {});
|
|
791
|
+
registerNotificationRoutes(app, {
|
|
792
|
+
getUiSessionTokenFromRequest: () => 'ui-token',
|
|
793
|
+
addOrUpdateLiveActivityToken,
|
|
794
|
+
removeLiveActivityToken,
|
|
795
|
+
...overrides,
|
|
796
|
+
});
|
|
797
|
+
return { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken };
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
it('rejects missing UI auth with the apns-token error shape', async () => {
|
|
801
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register({
|
|
802
|
+
getUiSessionTokenFromRequest: () => null,
|
|
803
|
+
});
|
|
804
|
+
const postRes = createMockRes();
|
|
805
|
+
await getRoute('POST', '/api/push/live-activity-token')({ body: { token: 't', activityId: 'a', sessionId: 's' } }, postRes);
|
|
806
|
+
expect(postRes.statusCode).toBe(401);
|
|
807
|
+
expect(postRes.body).toEqual({ error: 'UI session missing' });
|
|
808
|
+
const delRes = createMockRes();
|
|
809
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: { token: 't' } }, delRes);
|
|
810
|
+
expect(delRes.statusCode).toBe(401);
|
|
811
|
+
expect(delRes.body).toEqual({ error: 'UI session missing' });
|
|
812
|
+
expect(addOrUpdateLiveActivityToken).not.toHaveBeenCalled();
|
|
813
|
+
expect(removeLiveActivityToken).not.toHaveBeenCalled();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('rejects overlong or incomplete bodies', async () => {
|
|
817
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register();
|
|
818
|
+
const postRes = createMockRes();
|
|
819
|
+
await getRoute('POST', '/api/push/live-activity-token')({ body: { token: 't', activityId: 'a' } }, postRes);
|
|
820
|
+
expect(postRes.statusCode).toBe(400);
|
|
821
|
+
expect(postRes.body).toEqual({ error: 'Invalid body' });
|
|
822
|
+
|
|
823
|
+
const longRes = createMockRes();
|
|
824
|
+
await getRoute('POST', '/api/push/live-activity-token')({
|
|
825
|
+
body: { token: 't'.repeat(513), activityId: 'act', sessionId: 'ses' },
|
|
826
|
+
}, longRes);
|
|
827
|
+
expect(longRes.statusCode).toBe(400);
|
|
828
|
+
|
|
829
|
+
const delRes = createMockRes();
|
|
830
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: {} }, delRes);
|
|
831
|
+
expect(delRes.statusCode).toBe(400);
|
|
832
|
+
expect(addOrUpdateLiveActivityToken).not.toHaveBeenCalled();
|
|
833
|
+
expect(removeLiveActivityToken).not.toHaveBeenCalled();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('registers and unregisters through the owning UI session', async () => {
|
|
837
|
+
const { getRoute, addOrUpdateLiveActivityToken, removeLiveActivityToken } = register();
|
|
838
|
+
const postRes = createMockRes();
|
|
839
|
+
await getRoute('POST', '/api/push/live-activity-token')({
|
|
840
|
+
body: { token: ' la-token ', activityId: ' act-1 ', sessionId: ' ses_1 ' },
|
|
841
|
+
}, postRes);
|
|
842
|
+
expect(postRes.body).toEqual({ ok: true });
|
|
843
|
+
expect(addOrUpdateLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token', 'act-1', 'ses_1');
|
|
844
|
+
|
|
845
|
+
const delRes = createMockRes();
|
|
846
|
+
await getRoute('DELETE', '/api/push/live-activity-token')({ body: { token: 'la-token' } }, delRes);
|
|
847
|
+
expect(delRes.body).toEqual({ ok: true });
|
|
848
|
+
expect(removeLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token');
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('apns live activity relay and direct delivery', () => {
|
|
853
|
+
it('sends a signed end payload without sessionId or user content, then drops local tokens', async () => {
|
|
854
|
+
const fetchMock = vi.fn(async (url) =>
|
|
855
|
+
String(url).endsWith('/register-live-activity-token') || String(url).endsWith('/live-activity')
|
|
856
|
+
? jsonResponse({ ok: true, results: [] })
|
|
857
|
+
: jsonResponse({ ok: true, results: [] }),
|
|
858
|
+
);
|
|
859
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
860
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
861
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
862
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
863
|
+
const sessionId = 'ses_secret_session';
|
|
864
|
+
const token = 'secret-la-token-xyz';
|
|
865
|
+
try {
|
|
866
|
+
await runtime.addOrUpdateLiveActivityToken('s1', token, 'act-1', sessionId);
|
|
867
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
868
|
+
|
|
869
|
+
fetchMock.mockClear();
|
|
870
|
+
const before = Date.now();
|
|
871
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
872
|
+
const after = Date.now();
|
|
873
|
+
|
|
874
|
+
expect(fetchMock.mock.calls.filter(isSend)).toHaveLength(0);
|
|
875
|
+
const sendCall = fetchMock.mock.calls.find(isLiveActivitySend);
|
|
876
|
+
expect(sendCall[0]).toBe('https://relay.test/v1/push/live-activity');
|
|
877
|
+
const sent = JSON.parse(sendCall[1].body);
|
|
878
|
+
expect(sent.tokens).toEqual([token]);
|
|
879
|
+
expect(sent.event).toBe('end');
|
|
880
|
+
expect(Object.keys(sent.contentState).sort()).toEqual(['endedAt', 'eventVersion', 'status', 'updatedAt']);
|
|
881
|
+
expect(sent.contentState.status).toBe('complete');
|
|
882
|
+
expect(sent.contentState.eventVersion).toBeGreaterThanOrEqual(Math.floor(before));
|
|
883
|
+
expect(sent.contentState.eventVersion).toBeLessThanOrEqual(after);
|
|
884
|
+
expect(sent.dismissalDate).toBeGreaterThanOrEqual(Math.floor(before / 1000) + 15 * 60);
|
|
885
|
+
expect(sent.dismissalDate).toBeLessThanOrEqual(Math.floor(after / 1000) + 15 * 60);
|
|
886
|
+
expect(sent.staleDate).toBeUndefined();
|
|
887
|
+
expect(sent).not.toHaveProperty('title');
|
|
888
|
+
expect(sent).not.toHaveProperty('body');
|
|
889
|
+
expect(sent).not.toHaveProperty('data');
|
|
890
|
+
expect(JSON.stringify(sent)).not.toContain(sessionId);
|
|
891
|
+
expect(JSON.stringify(sent)).not.toContain('alert');
|
|
892
|
+
expect(await verifyRelaySignature(
|
|
893
|
+
sent.publicKeyJwk,
|
|
894
|
+
`${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.event}.${sent.contentState.status}.${sent.contentState.eventVersion}.${sent.contentState.updatedAt}.${sent.contentState.endedAt ?? ''}.${sent.dismissalDate ?? ''}.${sent.staleDate ?? ''}`,
|
|
895
|
+
sent.sig,
|
|
896
|
+
)).toBe(true);
|
|
897
|
+
|
|
898
|
+
expect(leakedWarnText(warn)).not.toContain(token);
|
|
899
|
+
expect(leakedWarnText(warn)).not.toContain(sessionId);
|
|
900
|
+
|
|
901
|
+
fetchMock.mockClear();
|
|
902
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
903
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
|
|
904
|
+
} finally {
|
|
905
|
+
warn.mockRestore();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('uses a 60-minute dismissal for error ends', async () => {
|
|
910
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
911
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
912
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
913
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
914
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
915
|
+
const before = Date.now();
|
|
916
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'error' });
|
|
917
|
+
const after = Date.now();
|
|
918
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
919
|
+
expect(sent.contentState.status).toBe('error');
|
|
920
|
+
expect(sent.dismissalDate).toBeGreaterThanOrEqual(Math.floor(before / 1000) + 60 * 60);
|
|
921
|
+
expect(sent.dismissalDate).toBeLessThanOrEqual(Math.floor(after / 1000) + 60 * 60);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('forwards OPENCHAMBER_APNS_ENVIRONMENT=production as env on live-activity relay send', async () => {
|
|
925
|
+
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
|
926
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
927
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
928
|
+
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'production';
|
|
929
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
930
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
931
|
+
fetchMock.mockClear();
|
|
932
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
933
|
+
const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
|
|
934
|
+
expect(sent.env).toBe('production');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it('keeps tokens after a failed relay end so a later attempt can retry', async () => {
|
|
938
|
+
const fetchMock = vi.fn(async (url) => {
|
|
939
|
+
if (String(url).endsWith('/live-activity')) return jsonResponse({ error: true }, 500);
|
|
940
|
+
return jsonResponse({ ok: true, results: [] });
|
|
941
|
+
});
|
|
942
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
943
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
|
944
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
945
|
+
const runtime = createApnsRuntime(makeDeps());
|
|
946
|
+
try {
|
|
947
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
|
|
948
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
949
|
+
fetchMock.mockClear();
|
|
950
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
951
|
+
expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(1);
|
|
952
|
+
|
|
953
|
+
fetchMock.mockClear();
|
|
954
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
|
955
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['alert-token']);
|
|
956
|
+
|
|
957
|
+
fetchMock.mockImplementation(async () => jsonResponse({ ok: true, results: [] }));
|
|
958
|
+
fetchMock.mockClear();
|
|
959
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
|
|
960
|
+
expect(JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body).tokens).toEqual(['la-token']);
|
|
961
|
+
} finally {
|
|
962
|
+
warn.mockRestore();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('sends direct live-activity headers without mixing alert tokens', async () => {
|
|
967
|
+
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
|
968
|
+
const captured = [];
|
|
969
|
+
const http2 = {
|
|
970
|
+
connect: () => ({
|
|
971
|
+
on: () => {},
|
|
972
|
+
close: () => {},
|
|
973
|
+
request: (headers) => {
|
|
974
|
+
const listeners = {};
|
|
975
|
+
const req = {
|
|
976
|
+
on: (event, cb) => { listeners[event] = cb; return req; },
|
|
977
|
+
setEncoding: () => req,
|
|
978
|
+
end: (body) => {
|
|
979
|
+
captured.push({ headers, body });
|
|
980
|
+
queueMicrotask(() => {
|
|
981
|
+
listeners.response?.({ ':status': '200' });
|
|
982
|
+
listeners.end?.();
|
|
983
|
+
});
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
return req;
|
|
987
|
+
},
|
|
988
|
+
}),
|
|
989
|
+
};
|
|
990
|
+
const runtime = createApnsRuntime(
|
|
991
|
+
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }),
|
|
992
|
+
);
|
|
993
|
+
await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_secret');
|
|
994
|
+
await runtime.addOrUpdateApnsToken('s1', 'alert-token');
|
|
995
|
+
|
|
996
|
+
captured.length = 0;
|
|
997
|
+
await runtime.sendLiveActivityEnd({ sessionId: 'ses_secret', status: 'complete' });
|
|
998
|
+
expect(captured).toHaveLength(1);
|
|
999
|
+
expect(captured[0].headers[':path']).toBe('/3/device/la-token');
|
|
1000
|
+
expect(captured[0].headers['apns-topic']).toBe('com.openchamber.app.push-type.liveactivity');
|
|
1001
|
+
expect(captured[0].headers['apns-push-type']).toBe('liveactivity');
|
|
1002
|
+
expect(captured[0].headers.authorization).toMatch(/^bearer /);
|
|
1003
|
+
const body = JSON.parse(captured[0].body);
|
|
1004
|
+
expect(body.aps.event).toBe('end');
|
|
1005
|
+
expect(body.aps.alert).toBeUndefined();
|
|
1006
|
+
expect(body.aps['content-state'].status).toBe('complete');
|
|
1007
|
+
expect(JSON.stringify(body)).not.toContain('ses_secret');
|
|
1008
|
+
expect(JSON.stringify(body)).not.toContain('alert-token');
|
|
1009
|
+
|
|
1010
|
+
captured.length = 0;
|
|
1011
|
+
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
|
|
1012
|
+
expect(captured).toHaveLength(1);
|
|
1013
|
+
expect(captured[0].headers[':path']).toBe('/3/device/alert-token');
|
|
1014
|
+
expect(captured[0].headers['apns-push-type']).toBe('alert');
|
|
1015
|
+
expect(captured[0].headers['apns-topic']).toBe('com.openchamber.app');
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
describe('apns live activity host-relay compatibility', () => {
|
|
1020
|
+
const LIVE_ACTIVITY_BUNDLE_ID = 'com.yee94.openchamber';
|
|
1021
|
+
let pushServer;
|
|
1022
|
+
|
|
1023
|
+
afterEach(async () => {
|
|
1024
|
+
await pushServer?.stop();
|
|
1025
|
+
pushServer = undefined;
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('registers through an injected-provider push relay, ends with liveactivity topic, and clears the token', async () => {
|
|
1029
|
+
const sessionId = 'ses_secret_session_id';
|
|
1030
|
+
const token = crypto.createHash('sha256').update('host-relay-live-activity-e2e').digest('hex');
|
|
1031
|
+
const apnsCalls = [];
|
|
1032
|
+
const sent = [];
|
|
1033
|
+
const http2 = {
|
|
1034
|
+
connect() {
|
|
1035
|
+
return {
|
|
1036
|
+
closed: false,
|
|
1037
|
+
on() { return this; },
|
|
1038
|
+
close() { this.closed = true; },
|
|
1039
|
+
request(headers) {
|
|
1040
|
+
const listeners = {};
|
|
1041
|
+
const req = {
|
|
1042
|
+
on(event, cb) { listeners[event] = cb; return req; },
|
|
1043
|
+
close() {},
|
|
1044
|
+
end(body) {
|
|
1045
|
+
apnsCalls.push({ headers, body });
|
|
1046
|
+
queueMicrotask(() => {
|
|
1047
|
+
listeners.response?.({ ':status': '200' });
|
|
1048
|
+
listeners.end?.();
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
return req;
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
const innerProvider = createApnsProvider({
|
|
1058
|
+
keyId: 'KEYID12345',
|
|
1059
|
+
teamId: 'TEAMID1234',
|
|
1060
|
+
p8: P8,
|
|
1061
|
+
bundleId: LIVE_ACTIVITY_BUNDLE_ID,
|
|
1062
|
+
http2,
|
|
1063
|
+
});
|
|
1064
|
+
const apnsProvider = {
|
|
1065
|
+
async send(input) {
|
|
1066
|
+
sent.push(input);
|
|
1067
|
+
return innerProvider.send(input);
|
|
1068
|
+
},
|
|
1069
|
+
close() { innerProvider.close(); },
|
|
1070
|
+
};
|
|
1071
|
+
pushServer = createPushRelayServer({
|
|
1072
|
+
host: '127.0.0.1',
|
|
1073
|
+
port: 0,
|
|
1074
|
+
databasePath: ':memory:',
|
|
1075
|
+
apnsProvider,
|
|
1076
|
+
});
|
|
1077
|
+
await pushServer.start();
|
|
1078
|
+
const relayUrl = `${pushServer.url}/v1/push/send`;
|
|
1079
|
+
process.env.OPENCHAMBER_PUSH_RELAY_URL = relayUrl;
|
|
1080
|
+
delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED;
|
|
1081
|
+
delete process.env.OPENCHAMBER_APNS_KEY_ID;
|
|
1082
|
+
delete process.env.OPENCHAMBER_APNS_TEAM_ID;
|
|
1083
|
+
delete process.env.OPENCHAMBER_APNS_P8;
|
|
1084
|
+
process.env.OPENCHAMBER_APNS_BUNDLE_ID = LIVE_ACTIVITY_BUNDLE_ID;
|
|
1085
|
+
|
|
1086
|
+
const deps = makeDeps();
|
|
1087
|
+
const runtime = createApnsRuntime(deps);
|
|
1088
|
+
await runtime.addOrUpdateLiveActivityToken('s1', token, 'act-1', sessionId);
|
|
1089
|
+
expect(pushServer.getSnapshot()).toMatchObject({
|
|
1090
|
+
state: 'running',
|
|
1091
|
+
tokenCount: 1,
|
|
1092
|
+
reasons: expect.objectContaining({ authRejected: 0, policyRejected: 0 }),
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
1096
|
+
|
|
1097
|
+
expect(sent).toHaveLength(1);
|
|
1098
|
+
expect(sent[0].pushType).toBe('liveactivity');
|
|
1099
|
+
expect(sent[0].token).toBe(token);
|
|
1100
|
+
expect(apnsCalls).toHaveLength(1);
|
|
1101
|
+
expect(apnsCalls[0].headers['apns-topic']).toBe(`${LIVE_ACTIVITY_BUNDLE_ID}.push-type.liveactivity`);
|
|
1102
|
+
expect(apnsCalls[0].headers['apns-push-type']).toBe('liveactivity');
|
|
1103
|
+
const payload = JSON.parse(apnsCalls[0].body);
|
|
1104
|
+
expect(payload.aps.event).toBe('end');
|
|
1105
|
+
expect(payload.aps.alert).toBeUndefined();
|
|
1106
|
+
expect(JSON.stringify(payload)).not.toContain(sessionId);
|
|
1107
|
+
expect(JSON.stringify(sent[0].payload)).not.toContain(sessionId);
|
|
1108
|
+
expect(pushServer.getSnapshot().tokenCount).toBe(0);
|
|
1109
|
+
const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
|
|
1110
|
+
expect(store.liveActivityTokensBySession.s1).toBeUndefined();
|
|
1111
|
+
|
|
1112
|
+
sent.length = 0;
|
|
1113
|
+
apnsCalls.length = 0;
|
|
1114
|
+
await runtime.sendLiveActivityEnd({ sessionId, status: 'complete' });
|
|
1115
|
+
expect(sent).toHaveLength(0);
|
|
1116
|
+
expect(apnsCalls).toHaveLength(0);
|
|
1117
|
+
});
|
|
196
1118
|
});
|