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