235ce394f90d | atagen | 2026-10-26 06:54 UTC | 1 | import assert from 'node:assert/strict'; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 2 | import { test } from 'node:test'; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 3 | import { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 4 | bytesToUrlBase64, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 5 | devicePusherRequest, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 6 | parsePush, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 7 | parseResolved, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 8 | parseStoredPush, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 9 | roomFromTag, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 10 | roomTarget, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 11 | shouldSilence, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 12 | stalePushers, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 13 | syncAction, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 14 | urlBase64ToBytes |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 15 | } from '../src/lib/push-model.ts'; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 16 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 17 | const ORIGIN = 'https://chat.example'; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 18 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 19 | const FULL = { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 20 | web_push: 8030, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 21 | notification: { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 22 | title: 'Ada in General', |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 23 | body: 'hello', |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 24 | navigate: `${ORIGIN}/?room=%21r%3Ax`, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 25 | tag: 'room:!r:x', |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 26 | silent: false, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 27 | app_badge: '4' |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 28 | }, |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 29 | malice: { room_id: '!r:x', event_id: '$e', encrypted: true, mention: true, unread: 7 } |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 30 | }; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 31 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 32 | test('parsePush reads the full contract', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 33 | const d = parsePush(JSON.stringify(FULL), ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 34 | assert.equal(d.title, 'Ada in General'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 35 | assert.equal(d.body, 'hello'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 36 | assert.equal(d.tag, 'room:!r:x'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 37 | assert.equal(d.navigate, `${ORIGIN}/?room=%21r%3Ax`); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 38 | assert.equal(d.badge, 4); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 39 | assert.equal(d.roomId, '!r:x'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 40 | assert.equal(d.eventId, '$e'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 41 | assert.equal(d.mention, true); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 42 | assert.equal(d.encrypted, true); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 43 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 44 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 45 | test('parsePush falls back for garbage, empty and missing data', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 46 | for (const raw of [null, '', 'not json', '[]', '"str"', '{}', 42]) { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 47 | const d = parsePush(raw, ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 48 | assert.equal(d.title, 'New message'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 49 | assert.equal(d.body, ''); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 50 | assert.equal(d.tag, 'malice'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 51 | assert.equal(d.roomId, null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 52 | assert.equal(d.badge, null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 53 | } |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 54 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 55 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 56 | test('parsePush derives the tag and badge from the malice block', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 57 | const d = parsePush({ malice: { room_id: '!a:b', unread: 3 } }, ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 58 | assert.equal(d.tag, 'room:!a:b'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 59 | assert.equal(d.badge, 3); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 60 | assert.equal(parsePush({ notification: { app_badge: '0' } }, ORIGIN).badge, 0); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 61 | assert.equal(parsePush({ notification: { app_badge: '-2' } }, ORIGIN).badge, null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 62 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 63 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 64 | test('parsePush drops navigate urls from other origins', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 65 | const d = parsePush({ notification: { navigate: 'https://evil.example/?room=x' } }, ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 66 | assert.equal(d.navigate, null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 67 | assert.equal(parsePush({ notification: { navigate: '/?room=x' } }, ORIGIN).navigate, `${ORIGIN}/?room=x`); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 68 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 69 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 70 | test('roomTarget prefers navigate then the room then the root', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 71 | assert.equal(roomTarget('!r:x', `${ORIGIN}/?room=y`), `${ORIGIN}/?room=y`); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 72 | assert.equal(roomTarget('!r:x', null), '/?room=!r%3Ax'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 73 | assert.equal(roomTarget(null, null), '/'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 74 | assert.equal(roomFromTag('room:!r:x'), '!r:x'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 75 | assert.equal(roomFromTag('malice'), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 76 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 77 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 78 | test('shouldSilence keeps the banner but mutes it for a focused window or a silent payload', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 79 | const loud = parsePush(FULL, ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 80 | assert.equal(shouldSilence(loud, false), false); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 81 | assert.equal(shouldSilence(loud, true), true); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 82 | assert.equal(shouldSilence({ ...loud, silent: true }, false), true); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 83 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 84 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 85 | test('parseResolved only replaces when the content differs', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 86 | const d = parsePush(FULL, ORIGIN); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 87 | assert.deepEqual(parseResolved({ title: 'Ada', body: 'real text' }, d), { title: 'Ada', body: 'real text' }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 88 | assert.deepEqual(parseResolved({ body: 'real text' }, d), { title: d.title, body: 'real text' }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 89 | assert.equal(parseResolved({ title: d.title, body: d.body }, d), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 90 | assert.equal(parseResolved(null, d), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 91 | assert.equal(parseResolved({}, d), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 92 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 93 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 94 | test('syncAction repairs a lost or rotated subscription', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 95 | const stored = { endpoint: 'https://push/a', pushkey: 'k' }; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 96 | assert.equal(syncAction(false, null, stored), 'none'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 97 | assert.equal(syncAction(true, null, stored), 'subscribe'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 98 | assert.equal(syncAction(true, 'https://push/b', stored), 'register'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 99 | assert.equal(syncAction(true, 'https://push/a', null), 'register'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 100 | assert.equal(syncAction(true, 'https://push/a', stored), 'none'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 101 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 102 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 103 | test('base64url round trips without padding', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 104 | const bytes = new Uint8Array([251, 255, 190, 1, 2]); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 105 | const text = bytesToUrlBase64(bytes); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 106 | assert.equal(text, '-_--AQI'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 107 | assert.deepEqual([...urlBase64ToBytes(text)], [...bytes]); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 108 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 109 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 110 | test('parseStoredPush tolerates junk', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 111 | assert.equal(parseStoredPush(null), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 112 | assert.equal(parseStoredPush('{'), null); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 113 | assert.deepEqual(parseStoredPush('{"endpoint":"e","pushkey":"k"}'), { endpoint: 'e', pushkey: 'k' }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 114 | assert.deepEqual(parseStoredPush('{"endpoint":"e"}'), { endpoint: 'e', pushkey: null }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 115 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 116 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 117 | test('devicePusherRequest targets the same-origin gateway', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 118 | const r = devicePusherRequest({ endpoint: 'https://push/a', p256dh: 'pk', auth: 'au' }, ORIGIN, 'en', 'b1'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 119 | assert.equal(r.op, 'set_pusher'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 120 | assert.equal(r.app_id, 'dev.malice.device'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 121 | assert.equal(r.pushkey, 'pk'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 122 | assert.equal(r.url, `${ORIGIN}/_matrix/push/v1/notify`); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 123 | assert.equal(r.format, 'Full'); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 124 | assert.deepEqual(r.data, { endpoint: 'https://push/a', auth: 'au', browser: 'b1' }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 125 | }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 126 | |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 127 | test('stalePushers only picks this browser own old endpoints', () => { |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 128 | const mk = (pushkey: string, endpoint: string, browser: string, app_id = 'dev.malice.device') => ({ app_id, pushkey, data: { endpoint, browser } }); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 129 | const reply = { pushers: [mk('a', 'old', 'b1'), mk('b', 'cur', 'b1'), mk('c', 'old', 'b2'), mk('d', 'old', 'b1', 'other.app')] }; |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 130 | assert.deepEqual(stalePushers(reply, 'b1', 'cur'), ['a']); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 131 | assert.deepEqual(stalePushers(null, 'b1', 'cur'), []); |
235ce394f90d | atagen | 2026-10-26 06:54 UTC | 132 | }); |