@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.281

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.
@@ -0,0 +1,797 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import {
4
+ WebPushModule,
5
+ WebPushError,
6
+ detectWebPushSupport,
7
+ recordIdString,
8
+ PUSH_ENDPOINT_KEY,
9
+ type WebPushGlobals,
10
+ } from './index';
11
+ import { AuthService } from '../auth/index';
12
+ import { base64UrlToBytes, bytesToBase64Url } from '../../push/types';
13
+
14
+ const noop = () => {};
15
+ const logger: any = {
16
+ debug: noop,
17
+ info: noop,
18
+ warn: noop,
19
+ error: noop,
20
+ trace: noop,
21
+ child: () => logger,
22
+ };
23
+ const KEY = bytesToBase64Url(new Uint8Array([4, 10, 20, 30, 40]));
24
+ const NEW_KEY = bytesToBase64Url(new Uint8Array([4, 99, 98, 97, 96]));
25
+ const flush = async (n = 8) => {
26
+ for (let i = 0; i < n; i++) await Promise.resolve();
27
+ };
28
+
29
+ interface Row {
30
+ id: RecordId;
31
+ endpoint: string;
32
+ kid: string;
33
+ label?: string;
34
+ rules?: string[];
35
+ user_agent?: string;
36
+ created_at?: Date;
37
+ failures?: number;
38
+ current?: boolean;
39
+ disabled_at?: Date;
40
+ }
41
+
42
+ function harness(
43
+ opts: {
44
+ permission?: NotificationPermission;
45
+ config?: Record<string, unknown>;
46
+ globals?: Partial<WebPushGlobals>;
47
+ } = {}
48
+ ) {
49
+ const order: string[] = [];
50
+ const server = {
51
+ info: { enabled: true, publicKey: KEY, kid: 'kid1' } as Record<string, unknown>,
52
+ rows: [] as Row[],
53
+ fail: false,
54
+ };
55
+ const remote = {
56
+ query: vi.fn(async (sql: string, vars?: Record<string, any>) => {
57
+ order.push(sql);
58
+ if (server.fail) throw new Error('socket gone');
59
+ switch (sql) {
60
+ case 'RETURN fn::push::info()':
61
+ return [server.info];
62
+ case 'RETURN fn::push::subscribe($sub, $opts)': {
63
+ const row: Row = {
64
+ id: new RecordId('_00_push_subscription', `s${server.rows.length + 1}`),
65
+ endpoint: vars!.sub.endpoint,
66
+ kid: String(server.info.kid),
67
+ label: vars!.opts.label,
68
+ rules: vars!.opts.rules,
69
+ user_agent: vars!.opts.userAgent,
70
+ created_at: new Date(1_700_000_000_000),
71
+ current: true,
72
+ };
73
+ server.rows = [...server.rows.filter((r) => r.endpoint !== row.endpoint), row];
74
+ return [row];
75
+ }
76
+ case 'RETURN fn::push::list()':
77
+ return [server.rows];
78
+ case 'RETURN fn::push::unsubscribe($endpoint)': {
79
+ const before = server.rows.length;
80
+ server.rows = server.rows.filter((r) => r.endpoint !== vars!.endpoint);
81
+ return [before - server.rows.length];
82
+ }
83
+ case 'RETURN fn::push::unsubscribe(NONE)': {
84
+ const n = server.rows.length;
85
+ server.rows = [];
86
+ return [n];
87
+ }
88
+ case 'RETURN fn::push::update($endpoint, $opts)':
89
+ return [
90
+ server.rows
91
+ .filter((r) => r.endpoint === vars!.endpoint)
92
+ .map((r) => Object.assign(r, { label: vars!.opts.label ?? r.label })),
93
+ ];
94
+ case 'RETURN fn::push::notify($msg)':
95
+ return [
96
+ {
97
+ id: new RecordId('_00_push_message', 'm1'),
98
+ status: 'pending',
99
+ send_at: vars!.msg.sendAt ? new Date(vars!.msg.sendAt) : undefined,
100
+ created_at: new Date(1),
101
+ },
102
+ ];
103
+ case 'RETURN fn::push::test($opts)':
104
+ return [{ id: new RecordId('_00_push_message', 't1'), status: 'pending' }];
105
+ case 'RETURN fn::push::cancel($id)':
106
+ return [true];
107
+ default:
108
+ throw new Error(`unscripted ${sql}`);
109
+ }
110
+ }),
111
+ };
112
+ const persisted = new Map<string, unknown>();
113
+ const persistence = {
114
+ get: async <T>(k: string) => (persisted.get(k) as T) ?? null,
115
+ set: async (k: string, v: unknown) => void persisted.set(k, v),
116
+ remove: async (k: string) => void persisted.delete(k),
117
+ };
118
+ const authCbs: Array<(u: unknown) => void> = [];
119
+ const hooks: Array<(ctx: any) => Promise<void> | void> = [];
120
+ const auth = {
121
+ token: 'tok-1' as string | null,
122
+ currentUser: { id: new RecordId('user', 'alice') } as { id?: unknown } | null,
123
+ isAuthenticated: true,
124
+ impersonation: null as unknown,
125
+ subscribe: (cb: (u: unknown) => void) => {
126
+ authCbs.push(cb);
127
+ cb(auth.currentUser?.id ?? null);
128
+ return () => void authCbs.splice(authCbs.indexOf(cb), 1);
129
+ },
130
+ onBeforeSignOut: (h: (ctx: any) => Promise<void> | void) => {
131
+ hooks.push(h);
132
+ return () => void hooks.splice(hooks.indexOf(h), 1);
133
+ },
134
+ emit: () => authCbs.forEach((cb) => cb(auth.currentUser?.id ?? null)),
135
+ };
136
+ let n = 0;
137
+ const browser = { sub: null as any };
138
+ const makeSub = (endpoint: string, key: Uint8Array | null) => ({
139
+ endpoint,
140
+ options: { applicationServerKey: key ? key.slice().buffer : null },
141
+ toJSON: () => ({ endpoint, expirationTime: null, keys: { p256dh: 'p', auth: 'a' } }),
142
+ unsubscribe: vi.fn(async () => {
143
+ order.push(`unsubscribe ${endpoint}`);
144
+ if (browser.sub?.endpoint === endpoint) browser.sub = null;
145
+ return true;
146
+ }),
147
+ });
148
+ const pushManager = {
149
+ getSubscription: vi.fn(async () => browser.sub),
150
+ subscribe: vi.fn(async (o: { userVisibleOnly: boolean; applicationServerKey: Uint8Array }) => {
151
+ order.push('pushManager.subscribe');
152
+ browser.sub = makeSub(`https://push.example/${++n}`, new Uint8Array(o.applicationServerKey));
153
+ return browser.sub;
154
+ }),
155
+ };
156
+ const active = { postMessage: vi.fn() };
157
+ const registration = { active, pushManager };
158
+ const messageListeners: Array<(e: { data: unknown }) => void> = [];
159
+ const serviceWorker = {
160
+ getRegistration: vi.fn(async () => registration),
161
+ ready: Promise.resolve(registration),
162
+ register: vi.fn(),
163
+ controller: null,
164
+ addEventListener: vi.fn((_t: string, l: (e: { data: unknown }) => void) =>
165
+ messageListeners.push(l)
166
+ ),
167
+ removeEventListener: vi.fn(
168
+ (_t: string, l: (e: { data: unknown }) => void) =>
169
+ void messageListeners.splice(messageListeners.indexOf(l), 1)
170
+ ),
171
+ };
172
+ const Notification = {
173
+ permission: opts.permission ?? ('default' as NotificationPermission),
174
+ requestPermission: vi.fn(async () => {
175
+ order.push('requestPermission');
176
+ Notification.permission = 'granted';
177
+ return 'granted' as NotificationPermission;
178
+ }),
179
+ };
180
+ const globals: WebPushGlobals = {
181
+ window: {},
182
+ navigator: {
183
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130',
184
+ platform: 'Linux x86_64',
185
+ maxTouchPoints: 0,
186
+ serviceWorker,
187
+ } as any,
188
+ Notification,
189
+ isSecureContext: true,
190
+ PushManager: function PushManager() {},
191
+ ...opts.globals,
192
+ };
193
+ const connection = {
194
+ cbs: [] as Array<(s: string) => void>,
195
+ subscribe(cb: (s: string) => void) {
196
+ this.cbs.push(cb);
197
+ cb('connecting');
198
+ return noop;
199
+ },
200
+ };
201
+ const mod = new WebPushModule({
202
+ remote: remote as any,
203
+ auth,
204
+ persistence,
205
+ logger,
206
+ database: { endpoint: 'wss://db.example/rpc', namespace: 'ns', database: 'db' },
207
+ connection,
208
+ config: opts.config as never,
209
+ globals: () => globals,
210
+ });
211
+ return {
212
+ mod,
213
+ remote,
214
+ server,
215
+ persisted,
216
+ auth,
217
+ hooks,
218
+ browser,
219
+ makeSub,
220
+ pushManager,
221
+ active,
222
+ serviceWorker,
223
+ Notification,
224
+ globals,
225
+ order,
226
+ connection,
227
+ messageListeners,
228
+ };
229
+ }
230
+
231
+ afterEach(() => {
232
+ vi.useRealTimers();
233
+ });
234
+
235
+ describe('detectWebPushSupport', () => {
236
+ const base = (): WebPushGlobals => ({
237
+ window: {},
238
+ navigator: { userAgent: 'Chrome', serviceWorker: {} } as any,
239
+ Notification: {
240
+ permission: 'granted',
241
+ requestPermission: async () => 'granted' as NotificationPermission,
242
+ },
243
+ isSecureContext: true,
244
+ PushManager: {},
245
+ });
246
+
247
+ it('names what is missing', () => {
248
+ expect(detectWebPushSupport({})).toEqual({
249
+ supported: false,
250
+ permission: 'unsupported',
251
+ reason: 'no-window',
252
+ });
253
+ expect(detectWebPushSupport({ ...base(), isSecureContext: false }).reason).toBe(
254
+ 'insecure-context'
255
+ );
256
+ expect(detectWebPushSupport({ ...base(), navigator: { userAgent: 'x' } as any }).reason).toBe(
257
+ 'no-service-worker'
258
+ );
259
+ expect(detectWebPushSupport({ ...base(), PushManager: undefined }).reason).toBe(
260
+ 'no-push-manager'
261
+ );
262
+ expect(detectWebPushSupport({ ...base(), Notification: undefined })).toEqual({
263
+ supported: false,
264
+ permission: 'unsupported',
265
+ reason: 'no-notification',
266
+ });
267
+ expect(detectWebPushSupport(base())).toEqual({ supported: true, permission: 'granted' });
268
+ });
269
+
270
+ it('iOS and iPadOS Safari need the home-screen app', () => {
271
+ const iphone = {
272
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
273
+ serviceWorker: {},
274
+ } as any;
275
+ expect(
276
+ detectWebPushSupport({
277
+ ...base(),
278
+ navigator: iphone,
279
+ PushManager: undefined,
280
+ Notification: undefined,
281
+ }).reason
282
+ ).toBe('ios-not-installed');
283
+ const ipad = {
284
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
285
+ platform: 'MacIntel',
286
+ maxTouchPoints: 5,
287
+ serviceWorker: {},
288
+ } as any;
289
+ expect(detectWebPushSupport({ ...base(), navigator: ipad }).reason).toBe('ios-not-installed');
290
+ expect(
291
+ detectWebPushSupport({ ...base(), navigator: { ...iphone, standalone: true } }).supported
292
+ ).toBe(true);
293
+ expect(
294
+ detectWebPushSupport({ ...base(), navigator: iphone, matchMedia: () => ({ matches: true }) })
295
+ .supported
296
+ ).toBe(true);
297
+ });
298
+
299
+ it('is unsupported in Node without throwing', () => {
300
+ const h = harness({ globals: { window: undefined } });
301
+ expect(h.mod.support()).toEqual({
302
+ supported: false,
303
+ permission: 'unsupported',
304
+ reason: 'no-window',
305
+ });
306
+ const real = new WebPushModule({
307
+ remote: {} as any,
308
+ auth: {} as any,
309
+ persistence: {} as any,
310
+ logger,
311
+ database: { namespace: 'n', database: 'd' },
312
+ });
313
+ expect(real.support().supported).toBe(false);
314
+ });
315
+ });
316
+
317
+ describe('WebPushModule.subscribe', () => {
318
+ it('asks for permission first, subscribes with the deployment key, registers and persists the device', async () => {
319
+ const h = harness();
320
+ const device = await h.mod.subscribe({ label: 'Laptop', rules: ['dm'], meta: { app: '1.2' } });
321
+ expect(h.order[0]).toBe('requestPermission');
322
+ expect(h.order).toEqual([
323
+ 'requestPermission',
324
+ 'RETURN fn::push::info()',
325
+ 'pushManager.subscribe',
326
+ 'RETURN fn::push::subscribe($sub, $opts)',
327
+ ]);
328
+ const call = h.pushManager.subscribe.mock.calls[0][0];
329
+ expect(call.userVisibleOnly).toBe(true);
330
+ expect(new Uint8Array(call.applicationServerKey)).toEqual(base64UrlToBytes(KEY));
331
+ const vars = h.remote.query.mock.calls.find(
332
+ (c) => c[0] === 'RETURN fn::push::subscribe($sub, $opts)'
333
+ )![1]!;
334
+ expect(vars).toEqual({
335
+ sub: {
336
+ endpoint: 'https://push.example/1',
337
+ expirationTime: null,
338
+ keys: { p256dh: 'p', auth: 'a' },
339
+ },
340
+ opts: {
341
+ label: 'Laptop',
342
+ rules: ['dm'],
343
+ meta: { app: '1.2' },
344
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130',
345
+ },
346
+ });
347
+ expect(device).toMatchObject({
348
+ id: '_00_push_subscription:s1',
349
+ endpoint: 'https://push.example/1',
350
+ kid: 'kid1',
351
+ label: 'Laptop',
352
+ rules: ['dm'],
353
+ thisDevice: true,
354
+ failures: 0,
355
+ });
356
+ expect(device.createdAt).toEqual(new Date(1_700_000_000_000));
357
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({
358
+ endpoint: 'https://push.example/1',
359
+ userId: 'user:alice',
360
+ kid: 'kid1',
361
+ publicKey: KEY,
362
+ });
363
+ expect(await h.mod.isSubscribed()).toBe(true);
364
+ });
365
+
366
+ it('reuses a subscription made with the same key, replaces one made with another', async () => {
367
+ const same = harness({ permission: 'granted' });
368
+ same.browser.sub = same.makeSub('https://push.example/kept', base64UrlToBytes(KEY));
369
+ await same.mod.subscribe({ rules: [] });
370
+ expect(same.pushManager.subscribe).not.toHaveBeenCalled();
371
+ expect(same.remote.query.mock.calls.at(-1)![1]!.opts).toEqual({
372
+ userAgent: expect.any(String),
373
+ });
374
+
375
+ const other = harness({ permission: 'granted' });
376
+ const stale = other.makeSub('https://push.example/stale', base64UrlToBytes(NEW_KEY));
377
+ other.browser.sub = stale;
378
+ const device = await other.mod.subscribe();
379
+ expect(stale.unsubscribe).toHaveBeenCalled();
380
+ expect(other.pushManager.subscribe).toHaveBeenCalledTimes(1);
381
+ expect(device.endpoint).toBe('https://push.example/1');
382
+ });
383
+
384
+ it('refuses with typed errors: impersonating, signed out, unsupported, denied, disabled', async () => {
385
+ const imp = harness();
386
+ imp.auth.impersonation = { target: 'user:alice' };
387
+ await expect(imp.mod.subscribe()).rejects.toMatchObject({ code: 'impersonating' });
388
+ await expect(imp.mod.notify({ notification: { title: 'x' } })).rejects.toMatchObject({
389
+ code: 'impersonating',
390
+ });
391
+ await expect(imp.mod.update({ label: 'x' })).rejects.toMatchObject({ code: 'impersonating' });
392
+ expect(imp.Notification.requestPermission).not.toHaveBeenCalled();
393
+ expect(imp.remote.query).not.toHaveBeenCalled();
394
+
395
+ const out = harness();
396
+ out.auth.isAuthenticated = false;
397
+ out.auth.currentUser = null;
398
+ const err = await out.mod.subscribe().catch((e) => e);
399
+ expect(err).toBeInstanceOf(WebPushError);
400
+ expect(err.code).toBe('signed-out');
401
+ await expect(out.mod.devices()).rejects.toMatchObject({ code: 'signed-out' });
402
+
403
+ const none = harness({ globals: { PushManager: undefined } });
404
+ await expect(none.mod.subscribe()).rejects.toMatchObject({ code: 'unsupported' });
405
+
406
+ const denied = harness({ permission: 'denied' });
407
+ await expect(denied.mod.subscribe()).rejects.toMatchObject({ code: 'permission-denied' });
408
+ const noPrompt = harness();
409
+ await expect(noPrompt.mod.subscribe({ requestPermission: false })).rejects.toMatchObject({
410
+ code: 'permission-default',
411
+ });
412
+
413
+ const off = harness({ permission: 'granted' });
414
+ off.server.info = { enabled: false };
415
+ await expect(off.mod.subscribe()).rejects.toMatchObject({ code: 'disabled' });
416
+
417
+ const broken = harness({ permission: 'granted' });
418
+ broken.pushManager.subscribe.mockRejectedValueOnce(new Error('AbortError: push service error'));
419
+ await expect(broken.mod.subscribe()).rejects.toMatchObject({ code: 'subscribe-failed' });
420
+ });
421
+ });
422
+
423
+ describe('WebPushModule.sync', () => {
424
+ function subscribed() {
425
+ const h = harness({ permission: 'granted' });
426
+ return h;
427
+ }
428
+
429
+ it('says why it did nothing', async () => {
430
+ const h = subscribed();
431
+ expect(await h.mod.sync()).toBe('not-subscribed');
432
+ h.Notification.permission = 'default';
433
+ expect(await h.mod.sync()).toBe('permission-default');
434
+ h.Notification.permission = 'denied';
435
+ expect(await h.mod.sync()).toBe('permission-denied');
436
+ h.auth.impersonation = {};
437
+ expect(await h.mod.sync()).toBe('impersonating');
438
+ h.auth.isAuthenticated = false;
439
+ expect(await h.mod.sync()).toBe('signed-out');
440
+ expect(await harness({ globals: { window: undefined } }).mod.sync()).toBe('unsupported');
441
+ const off = subscribed();
442
+ off.server.info = { enabled: false };
443
+ expect(await off.mod.sync({ autoResubscribe: true })).toBe('disabled');
444
+ });
445
+
446
+ it('ok when browser, key and server row agree; autoResubscribe subscribes a granted browser', async () => {
447
+ const h = subscribed();
448
+ await h.mod.subscribe();
449
+ h.pushManager.subscribe.mockClear();
450
+ expect(await h.mod.sync()).toBe('ok');
451
+ expect(h.pushManager.subscribe).not.toHaveBeenCalled();
452
+
453
+ const auto = subscribed();
454
+ expect(await auto.mod.sync({ autoResubscribe: true })).toBe('resubscribed');
455
+ expect(auto.server.rows).toHaveLength(1);
456
+ });
457
+
458
+ it('re-creates a browser subscription that went away, keeping label and rules, dropping the dead endpoint', async () => {
459
+ const h = subscribed();
460
+ await h.mod.subscribe({ label: 'Phone', rules: ['dm'] });
461
+ h.browser.sub = null;
462
+ expect(await h.mod.sync()).toBe('resubscribed');
463
+ expect(h.server.rows.map((r) => [r.endpoint, r.label, r.rules])).toEqual([
464
+ ['https://push.example/2', 'Phone', ['dm']],
465
+ ]);
466
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({
467
+ endpoint: 'https://push.example/2',
468
+ });
469
+ });
470
+
471
+ it('resubscribes when the deployment key rotated', async () => {
472
+ const h = subscribed();
473
+ await h.mod.subscribe({ label: 'Desk' });
474
+ const old = h.browser.sub;
475
+ h.server.info = { enabled: true, publicKey: NEW_KEY, kid: 'kid2' };
476
+ h.server.rows = h.server.rows.map((r) => ({ ...r, current: false }));
477
+ expect(await h.mod.sync()).toBe('resubscribed');
478
+ expect(old.unsubscribe).toHaveBeenCalled();
479
+ expect(
480
+ new Uint8Array(h.pushManager.subscribe.mock.calls.at(-1)![0].applicationServerKey)
481
+ ).toEqual(base64UrlToBytes(NEW_KEY));
482
+ expect(h.server.rows.map((r) => [r.endpoint, r.kid, r.label])).toEqual([
483
+ ['https://push.example/2', 'kid2', 'Desk'],
484
+ ]);
485
+ expect(h.persisted.get(PUSH_ENDPOINT_KEY)).toMatchObject({ kid: 'kid2', publicKey: NEW_KEY });
486
+ });
487
+
488
+ it('a browser that cannot tell its key falls back to the stored kid', async () => {
489
+ const h = subscribed();
490
+ await h.mod.subscribe();
491
+ h.browser.sub = h.makeSub(h.browser.sub.endpoint, null);
492
+ expect(await h.mod.sync()).toBe('ok');
493
+ h.server.info = { enabled: true, publicKey: NEW_KEY, kid: 'kid2' };
494
+ expect(await h.mod.sync()).toBe('resubscribed');
495
+ });
496
+
497
+ it('re-registers a server row that went missing or was disabled; reports errors instead of throwing', async () => {
498
+ const h = subscribed();
499
+ await h.mod.subscribe();
500
+ h.server.rows = [];
501
+ expect(await h.mod.sync()).toBe('registered');
502
+ expect(h.server.rows).toHaveLength(1);
503
+ h.server.rows[0].disabled_at = new Date();
504
+ expect(await h.mod.sync()).toBe('resubscribed');
505
+ h.server.fail = true;
506
+ expect(await h.mod.sync()).toBe('error');
507
+ });
508
+
509
+ it('auto-syncs once the socket is connected and a user is signed in', async () => {
510
+ vi.useFakeTimers();
511
+ const h = harness({ permission: 'granted' });
512
+ const sync = vi.spyOn(h.mod, 'sync').mockResolvedValue('ok');
513
+ h.mod.attach();
514
+ await vi.advanceTimersByTimeAsync(2_000);
515
+ expect(sync).not.toHaveBeenCalled();
516
+ h.connection.cbs.forEach((cb) => cb('connected'));
517
+ await vi.advanceTimersByTimeAsync(2_000);
518
+ expect(sync).toHaveBeenCalledTimes(1);
519
+ // Same user again: nothing new.
520
+ h.auth.emit();
521
+ await vi.advanceTimersByTimeAsync(2_000);
522
+ expect(sync).toHaveBeenCalledTimes(1);
523
+ // The worker saw the subscription rotate.
524
+ h.messageListeners.forEach((l) =>
525
+ l({ data: { type: 'sp00ky:subscriptionchange', endpoint: 'x' } })
526
+ );
527
+ expect(sync).toHaveBeenCalledTimes(2);
528
+ h.mod.dispose();
529
+ });
530
+ });
531
+
532
+ describe('WebPushModule sign-out', () => {
533
+ it('drops this device for the user and tells the worker, before the token goes', async () => {
534
+ const h = harness({ permission: 'granted' });
535
+ h.mod.attach();
536
+ await h.mod.subscribe();
537
+ h.active.postMessage.mockClear();
538
+ await h.hooks[0]({ userId: 'user:alice', token: 'tok-1', impersonating: false });
539
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::unsubscribe($endpoint)', {
540
+ endpoint: 'https://push.example/1',
541
+ });
542
+ expect(h.server.rows).toHaveLength(0);
543
+ expect(h.persisted.has(PUSH_ENDPOINT_KEY)).toBe(false);
544
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
545
+ });
546
+
547
+ it('while impersonating (or with unsubscribeOnSignOut: false) only the worker is told', async () => {
548
+ const h = harness({ permission: 'granted' });
549
+ h.mod.attach();
550
+ await h.mod.subscribe();
551
+ h.remote.query.mockClear();
552
+ await h.hooks[0]({ userId: 'user:alice', token: 't', impersonating: true });
553
+ expect(h.remote.query).not.toHaveBeenCalled();
554
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
555
+
556
+ const k = harness({ permission: 'granted', config: { unsubscribeOnSignOut: false } });
557
+ k.mod.attach();
558
+ await k.mod.subscribe();
559
+ k.remote.query.mockClear();
560
+ await k.hooks[0]({ userId: 'user:alice', token: 't', impersonating: false });
561
+ expect(k.remote.query).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it('runs inside AuthService.signOut, ahead of the token removal, and never holds it past the timeout', async () => {
565
+ const calls: string[] = [];
566
+ let hang = false;
567
+ const remote = {
568
+ setAuthToken: noop,
569
+ getClient: () => ({ invalidate: async () => void calls.push('invalidate') }),
570
+ query: vi.fn(async (sql: string) => {
571
+ calls.push(sql);
572
+ if (hang) return new Promise(() => {});
573
+ return [1];
574
+ }),
575
+ };
576
+ const store = new Map<string, unknown>([
577
+ [PUSH_ENDPOINT_KEY, { endpoint: 'https://push.example/e', userId: 'user:alice', at: 1 }],
578
+ ]);
579
+ const persistence = {
580
+ get: async <T>(k: string) => (store.get(k) as T) ?? null,
581
+ set: async (k: string, v: unknown) => void store.set(k, v),
582
+ remove: async (k: string) => {
583
+ calls.push(`remove ${k}`);
584
+ store.delete(k);
585
+ },
586
+ };
587
+ const auth = new AuthService(
588
+ { tables: [], relationships: [] } as any,
589
+ remote as any,
590
+ persistence,
591
+ logger
592
+ );
593
+ Object.assign(auth, {
594
+ token: 'tok',
595
+ isAuthenticated: true,
596
+ currentUser: { id: new RecordId('user', 'alice') },
597
+ });
598
+ const h = harness({ permission: 'granted' });
599
+ const mod = new WebPushModule({
600
+ remote: remote as any,
601
+ auth,
602
+ persistence,
603
+ logger,
604
+ database: { endpoint: 'wss://db/rpc', namespace: 'n', database: 'd' },
605
+ globals: () => h.globals,
606
+ });
607
+ mod.attach();
608
+ await auth.signOut();
609
+ const unsub = calls.indexOf('RETURN fn::push::unsubscribe($endpoint)');
610
+ expect(unsub).toBeGreaterThanOrEqual(0);
611
+ expect(unsub).toBeLessThan(calls.indexOf('remove sp00ky_auth_token'));
612
+ expect(h.active.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:signout' });
613
+ expect(auth.token).toBeNull();
614
+
615
+ // A socket that never answers costs at most the hook timeout.
616
+ vi.useFakeTimers();
617
+ hang = true;
618
+ store.set(PUSH_ENDPOINT_KEY, {
619
+ endpoint: 'https://push.example/e',
620
+ userId: 'user:alice',
621
+ at: 1,
622
+ });
623
+ Object.assign(auth, {
624
+ token: 'tok2',
625
+ isAuthenticated: true,
626
+ currentUser: { id: 'user:alice' },
627
+ });
628
+ let done = false;
629
+ const out = auth.signOut().then(() => void (done = true));
630
+ await vi.advanceTimersByTimeAsync(1_000);
631
+ expect(done).toBe(false);
632
+ await vi.advanceTimersByTimeAsync(1_100);
633
+ await out;
634
+ expect(done).toBe(true);
635
+ expect(auth.token).toBeNull();
636
+ });
637
+ });
638
+
639
+ describe('WebPushModule bridge', () => {
640
+ it('posts the session while permission is granted, again on token change, never an impersonation token', async () => {
641
+ const h = harness({ permission: 'granted' });
642
+ h.server.info = { enabled: true, publicKey: KEY, kid: 'kid1' };
643
+ await h.mod.info();
644
+ h.mod.attach();
645
+ await flush();
646
+ expect(h.active.postMessage).toHaveBeenCalledWith({
647
+ type: 'sp00ky:token',
648
+ token: 'tok-1',
649
+ userId: 'user:alice',
650
+ endpoint: 'wss://db.example/rpc',
651
+ namespace: 'ns',
652
+ database: 'db',
653
+ publicKey: KEY,
654
+ });
655
+ h.active.postMessage.mockClear();
656
+ h.auth.emit();
657
+ await flush();
658
+ expect(h.active.postMessage).not.toHaveBeenCalled();
659
+ h.auth.token = 'tok-2';
660
+ h.auth.emit();
661
+ await flush();
662
+ expect(h.active.postMessage).toHaveBeenCalledWith(expect.objectContaining({ token: 'tok-2' }));
663
+ h.active.postMessage.mockClear();
664
+ h.auth.token = 'imp-token';
665
+ h.auth.impersonation = { target: 'user:bob' };
666
+ h.auth.emit();
667
+ await flush();
668
+ expect(h.active.postMessage).not.toHaveBeenCalled();
669
+ h.mod.dispose();
670
+ });
671
+
672
+ it('the automatic bridge waits for permission; an explicit bridge() does not; bridge: false opts out', async () => {
673
+ const h = harness({ permission: 'default' });
674
+ h.mod.attach();
675
+ await flush();
676
+ expect(h.active.postMessage).not.toHaveBeenCalled();
677
+ const reg = { active: { postMessage: vi.fn() } };
678
+ const off = h.mod.bridge(reg as any);
679
+ await flush();
680
+ expect(reg.active.postMessage).toHaveBeenCalledWith(
681
+ expect.objectContaining({ type: 'sp00ky:token', token: 'tok-1' })
682
+ );
683
+ off();
684
+ h.auth.token = 'tok-3';
685
+ h.auth.emit();
686
+ await flush();
687
+ expect(reg.active.postMessage).toHaveBeenCalledTimes(1);
688
+
689
+ const k = harness({ permission: 'granted', config: { bridge: false } });
690
+ k.mod.attach();
691
+ await flush();
692
+ expect(k.active.postMessage).not.toHaveBeenCalled();
693
+ });
694
+
695
+ it('onMessage delivers only sp00ky messages', () => {
696
+ const h = harness();
697
+ const seen: unknown[] = [];
698
+ const off = h.mod.onMessage((m) => seen.push(m));
699
+ h.messageListeners.forEach((l) => l({ data: { type: 'sp00ky:push', payload: { v: 1 } } }));
700
+ h.messageListeners.forEach((l) => l({ data: { type: 'other' } }));
701
+ h.messageListeners.forEach((l) => l({ data: null }));
702
+ expect(seen).toEqual([{ type: 'sp00ky:push', payload: { v: 1 } }]);
703
+ off();
704
+ expect(h.messageListeners).toHaveLength(0);
705
+ });
706
+ });
707
+
708
+ describe('WebPushModule devices and direct messages', () => {
709
+ it('lists devices with this one marked, updates and unsubscribes', async () => {
710
+ const h = harness({ permission: 'granted' });
711
+ h.server.rows.push({
712
+ id: new RecordId('_00_push_subscription', 'other'),
713
+ endpoint: 'https://push.example/other',
714
+ kid: 'kid1',
715
+ user_agent: 'Firefox',
716
+ failures: 2,
717
+ current: true,
718
+ });
719
+ await h.mod.subscribe({ label: 'Laptop' });
720
+ const devices = await h.mod.devices();
721
+ expect(devices.map((d) => [d.id, d.thisDevice, d.userAgent ?? null, d.failures])).toEqual([
722
+ ['_00_push_subscription:other', false, 'Firefox', 2],
723
+ ['_00_push_subscription:s2', true, 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130', 0],
724
+ ]);
725
+ const updated = await h.mod.update({ label: 'Work', rules: null });
726
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::update($endpoint, $opts)', {
727
+ endpoint: 'https://push.example/1',
728
+ opts: { label: 'Work', rules: [] },
729
+ });
730
+ expect(updated?.label).toBe('Work');
731
+
732
+ expect(await h.mod.unsubscribe({ keepBrowserSubscription: true })).toBe(1);
733
+ expect(h.browser.sub).not.toBeNull();
734
+ expect(h.persisted.has(PUSH_ENDPOINT_KEY)).toBe(false);
735
+ expect(await h.mod.isSubscribed()).toBe(false);
736
+ await expect(
737
+ harness({ permission: 'granted' }).mod.update({ label: 'x' })
738
+ ).rejects.toMatchObject({ code: 'not-subscribed' });
739
+
740
+ expect(await h.mod.unsubscribe({ all: true })).toBe(1);
741
+ expect(h.browser.sub).toBeNull();
742
+ });
743
+
744
+ it('notify, test and cancel call the fn::push API with the right shapes', async () => {
745
+ const h = harness({ permission: 'granted' });
746
+ const at = new Date('2026-10-01T09:00:00.000Z');
747
+ const msg = await h.mod.notify({
748
+ notification: { title: 'Stand up', url: '/break' },
749
+ topic: 'reminder',
750
+ sendAt: at,
751
+ ttl: 600,
752
+ });
753
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::notify($msg)', {
754
+ msg: {
755
+ notification: { title: 'Stand up', url: '/break' },
756
+ topic: 'reminder',
757
+ ttl: 600,
758
+ sendAt: '2026-10-01T09:00:00.000Z',
759
+ },
760
+ });
761
+ expect(msg).toEqual({
762
+ id: '_00_push_message:m1',
763
+ status: 'pending',
764
+ sendAt: at,
765
+ createdAt: new Date(1),
766
+ });
767
+ expect(await h.mod.test({ title: 'Hi' })).toMatchObject({ id: '_00_push_message:t1' });
768
+ expect(h.remote.query).toHaveBeenLastCalledWith('RETURN fn::push::test($opts)', {
769
+ opts: { title: 'Hi' },
770
+ });
771
+ expect(await h.mod.cancel('_00_push_message:m1')).toBe(true);
772
+ expect(h.remote.query.mock.calls.at(-1)![1]).toEqual({
773
+ id: new RecordId('_00_push_message', 'm1'),
774
+ });
775
+ await h.mod.cancel(new RecordId('_00_push_message', 'm2'));
776
+ expect(h.remote.query.mock.calls.at(-1)![1]).toEqual({
777
+ id: new RecordId('_00_push_message', 'm2'),
778
+ });
779
+ h.server.fail = true;
780
+ await expect(h.mod.test()).rejects.toMatchObject({ code: 'server' });
781
+ });
782
+
783
+ it('caches info until refreshed; a failed read is not cached', async () => {
784
+ const h = harness();
785
+ expect(await h.mod.info()).toEqual({ enabled: true, publicKey: KEY, kid: 'kid1' });
786
+ await h.mod.info();
787
+ expect(h.remote.query).toHaveBeenCalledTimes(1);
788
+ h.server.info = { enabled: true, publicKey: null, kid: null };
789
+ expect(await h.mod.info({ refresh: true })).toEqual({ enabled: false });
790
+ h.server.fail = true;
791
+ await expect(h.mod.info({ refresh: true })).rejects.toMatchObject({ code: 'server' });
792
+ h.server.fail = false;
793
+ expect((await h.mod.info()).enabled).toBe(false);
794
+ expect(recordIdString({ table: { name: 't' }, id: 'x' })).toBe('t:x');
795
+ expect(recordIdString('a:b')).toBe('a:b');
796
+ });
797
+ });