aboutsummaryrefslogtreecommitdiffstats
path: root/tools/nwcpush-call-test.js
blob: 99b366b5ecb38e3d559d63d4e34faad899f727bf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// nwcpush and calls: a device registered for its pubkey is woken for a call
// offer (a kind 21059 wrap big enough to carry an SDP), with a push naming
// the wrap, which it then fetches from /wrap/<id>; the small wraps that
// follow an offer (ICE candidates, a hang-up) wake nobody; a device that
// turned calls off isn't woken.
// The device here is a UnifiedPush endpoint (the notifier POSTs the payload
// as is), so the test can read what it was sent.
// Run: bun tools/nwcpush-call-test.js
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import webpush from 'web-push';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };

// a relay: every event to every subscription it matches
const subs = new Set();
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const relay = Bun.serve({ port: 0, hostname: '127.0.0.1',
  fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response('ws', { status: 426 })),
  websocket: {
    open(ws) { ws.data = new Map(); },
    message(ws, raw) {
      const m = JSON.parse(raw);
      if (m[0] === 'REQ') { const s = { ws, id: m[1], filters: m.slice(2) }; ws.data.set(m[1], s); subs.add(s); ws.send(JSON.stringify(['EOSE', m[1]])); }
      if (m[0] === 'CLOSE') { subs.delete(ws.data.get(m[1])); }
      if (m[0] === 'EVENT') {
        ws.send(JSON.stringify(['OK', m[1].id, true, '']));
        for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, m[1]))) s.ws.send(JSON.stringify(['EVENT', s.id, m[1]]));
      }
    },
    close(ws) { for (const s of ws.data.values()) subs.delete(s); },
  } });
// the devices: what each was pushed
const got = { on: [], off: [], old: [] };
const devices = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: async (req) => { got[new URL(req.url).pathname.slice(1)].push(await req.json()); return new Response('ok'); } });

const dir = mkdtempSync(tmpdir() + '/nwcpush-call-');
const port = 18000 + Math.floor(Math.random() * 1000);
writeFileSync(dir + '/config.json', JSON.stringify({ port, relays: [`ws://127.0.0.1:${relay.port}`], vapid: { ...webpush.generateVAPIDKeys(), subject: 'mailto:[email protected]' }, stateFile: dir + '/state.json' }));
const proc = Bun.spawn(['bun', 'nwcpush/server.js'], { env: { ...process.env, NWCPUSH_CONFIG: dir + '/config.json' }, stdout: 'pipe', stderr: 'pipe' });
const N = `http://127.0.0.1:${port}`;
const ME = getPublicKey(generateSecretKey()), OTHER = getPublicKey(generateSecretKey());
// a call wrap as an app sends one: the throwaway key, p = who it's for, NIP-44 content of some size
const wrap = (to, size) => finalizeEvent({ kind: 21059, created_at: Math.floor(Date.now() / 1000), tags: [['p', to]], content: 'A'.repeat(size) }, generateSecretKey());
const publish = (ev) => new Promise((resolve) => {
  const ws = new WebSocket(`ws://127.0.0.1:${relay.port}`);
  ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev]));
  ws.onmessage = () => { ws.close(); resolve(); };
});

try {
  for (let i = 0; i < 40; i++) { try { if ((await fetch(N + '/health')).ok) break; } catch {} await sleep(150); }
  const reg = (path, reasons) => fetch(N + '/register', { method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ subscription: { endpoint: `http://127.0.0.1:${devices.port}/${path}`, unifiedpush: true }, notify: { ptags: [ME], reasons } }) });
  check('a device registers for its pubkey', (await reg('on', { call: true })).ok);
  check('...an older app, saying nothing of calls', (await reg('old', {})).ok);
  check('...another with calls turned off', (await reg('off', { call: false })).ok);
  await sleep(1000); // the notifier resubscribes

  console.log('\n[an offer]');
  const offer = wrap(ME, 6000);
  await publish(offer);
  await sleep(800);
  const push = got.on.find((p) => p.reason === 'call');
  check('the device is woken: a call, naming the wrap', !!push && push.type === 'notify' && push.wrapId === offer.id, JSON.stringify(got.on));
  check('...not the one that turned calls off', !got.off.some((p) => p.reason === 'call'));
  check('...nor an older app (its worker would call it chat)', !got.old.some((p) => p.reason === 'call'));
  const fetched = await (await fetch(N + '/wrap/' + offer.id)).json();
  check('the wrap can be fetched from the notifier, as sent', fetched.id === offer.id && fetched.content === offer.content);
  check('...from any page (CORS)', (await fetch(N + '/wrap/' + offer.id)).headers.get('access-control-allow-origin') === '*');

  console.log('\n[what follows an offer]');
  const before = got.on.length;
  for (let i = 0; i < 5; i++) await publish(wrap(ME, 900)); // candidates
  await publish(wrap(ME, 500)); // a hang-up
  await sleep(800);
  check('ICE candidates and a hang-up wake nobody', got.on.length === before, (got.on.length - before) + ' pushes');
  await publish(wrap(OTHER, 6000));
  await sleep(800);
  check('an offer for someone else wakes nobody here', got.on.length === before);
  check('a wrap it never saw: 404', (await fetch(N + '/wrap/' + 'f'.repeat(64))).status === 404);
} catch (e) { console.error(e); ok = false; } finally {
  proc.kill(); relay.stop(true); devices.stop(true);
  rmSync(dir, { recursive: true, force: true });
}
console.log(ok ? '\n✅ nwcpush wakes a device for a call' : '\n❌ failed');
process.exit(ok ? 0 : 1);