Files
hub/tools/signal-test.js
T
nmemmertandClaude Sonnet 5.5 d0070aa0bb
build-container / build (push) Successful in 6m44s
Signal chats in the app (desktop only) with a chat button on every screen
- signal.js: talks to signal-cli-rest-api (a linked device of your Signal account); keeps conversations in /data/signal; live updates; send/receive text, files, replies, reactions, groups, receipts
- Signal tab and a floating chat panel on every screen, shown only on desktop-class windows; new Signal permission per person
- compose: signal bridge container sharing the hub's network
- tools/signal-test.js (fake bridge), README

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 10:18:02 -04:00

143 lines
15 KiB
JavaScript

#!/usr/bin/env node
// Signal in the hub: linking, receiving (messages, groups, sync, reactions, receipts, edits, deletes), sending, unread, attachments, live events, access.
// Uses a fake signal-cli-rest-api (HTTP plus the websocket that json-rpc mode uses). Run: node tools/signal-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const until = async (f, ms = 8000) => { const t = Date.now(); while (Date.now() - t < ms) { const v = await f(); if (v) return v; await wait(100); } return false; };
const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
(async () => {
// ---- fake signal-cli-rest-api
const fake = { accounts: [], sends: [], receipts: [], reactions: [], sockets: new Set(), contacts: [{ number: '+15551234567', name: 'Sam Rivera' }], groups: [{ id: 'group.' + Buffer.from('GID123==').toString('base64'), internal_id: 'GID123==', name: 'Family' }] };
const frame = text => { const p = Buffer.from(text), n = p.length; const h = n < 126 ? Buffer.from([0x81, n]) : Buffer.from([0x81, 126, n >> 8, n & 255]); return Buffer.concat([h, p]); };
const push = o => { for (const s of fake.sockets) s.write(frame(JSON.stringify(o))); };
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const srv = http.createServer((rq, rs) => {
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
const b = Buffer.concat(body).toString(), u = new URL(rq.url, 'http://x'), j = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
if (u.pathname === '/v1/accounts') return j(200, fake.accounts);
if (u.pathname === '/v1/qrcodelink') { rs.writeHead(200, { 'content-type': 'image/png' }); return rs.end(png); }
if (u.pathname.startsWith('/v1/contacts/')) return j(200, fake.contacts);
if (u.pathname.startsWith('/v1/groups/')) return j(200, fake.groups);
if (u.pathname === '/v2/send') { fake.sends.push(JSON.parse(b)); return j(201, { timestamp: String(1700000000000 + fake.sends.length * 1000) }); }
if (u.pathname.startsWith('/v1/receipts/')) { fake.receipts.push(JSON.parse(b)); return j(204, {}); }
if (u.pathname.startsWith('/v1/reactions/')) { fake.reactions.push({ method: rq.method, ...JSON.parse(b) }); return j(200, {}); }
if (u.pathname === '/v1/attachments/att-1') { rs.writeHead(200, { 'content-type': 'image/png' }); return rs.end(png); }
j(404, { error: 'not found ' + u.pathname });
});
});
srv.on('upgrade', (rq, socket) => {
const accept = crypto.createHash('sha1').update(rq.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
fake.sockets.add(socket); socket.on('close', () => fake.sockets.delete(socket)); socket.on('error', () => {}); socket.on('data', () => {});
});
await new Promise(r => srv.listen(0, '127.0.0.1', r));
// ---- the hub
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'signal-test-')), port = 20000 + Math.floor(Math.random() * 20000);
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [
{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: [], handle: 'h', epoch: 1, disabled: false, created: 1 },
{ id: 'ufriend1', name: 'Friend', role: 'user', tabs: ['mail'], handle: 'h2', epoch: 1, disabled: false, created: 2 }] }));
const start = () => { const c = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, SIGNAL_API_URL: 'http://127.0.0.1:' + srv.address().port, SIGNAL_POLL_MS: '300', SEAFILE_URL: 'http://127.0.0.1:9' } }); c.stdout.on('data', () => {}); c.stderr.on('data', d => process.env.SIG_DEBUG && process.stderr.write(d)); return c; };
let child = start(); await wait(1500);
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5;
const ck = (uid) => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
const call = (p, o = {}, uid = 'uadmin1') => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: ck(uid), ...(o.json ? { 'content-type': 'application/json' } : {}), ...(o.headers || {}) }, body: o.json ? JSON.stringify(o.json) : o.body });
const getJ = async (p, uid) => (await call(p, {}, uid)).json();
const post = (p, json) => call(p, { method: 'POST', json });
// ---- not linked yet
let r = await call('/api/config'); ok('the Signal tab is offered when the service is set up', (await r.json()).signal === '1');
r = await call('/api/config', {}, 'ufriend1'); ok('but not to someone without the Signal tab', !(await r.json()).signal);
r = await call('/api/signal/status', {}, 'ufriend1'); ok('and they cannot use it', r.status === 403, r.status);
let st = await getJ('/api/signal/status'); ok('before linking, it says so', st.linked === false, st);
r = await call('/api/signal/qr'); ok('a link code (QR picture) comes from the Signal service', r.status === 200 && r.headers.get('content-type') === 'image/png' && (await r.arrayBuffer()).byteLength === png.length);
r = await call('/api/signal/convs'); ok('no conversations before linking', r.status === 409, r.status);
// ---- linked
fake.accounts = [ME];
st = await until(async () => { const s = await getJ('/api/signal/status'); return s.linked && s.connected ? s : false; });
ok('after linking in the phone app, it notices and connects', !!st && st.number === ME, st);
// live events
const events = []; const ac = new AbortController();
fetch(base + '/api/signal/events', { headers: { cookie: ck('uadmin1') }, signal: ac.signal }).then(async res => { const rd = res.body.getReader(); const dec = new TextDecoder(); for (;;) { const { value, done } = await rd.read(); if (done) break; for (const l of dec.decode(value).split('\n')) if (l.startsWith('data: ')) events.push(JSON.parse(l.slice(6))); } }).catch(() => {});
await wait(300);
const T0 = 1700000100000; // (later than the times the fake gives to what we send)
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0, dataMessage: { timestamp: T0, message: 'Hi there' } } });
push({ account: ME, envelope: { sourceNumber: '+15557654321', sourceName: 'Kim', timestamp: T0 + 1000, dataMessage: { timestamp: T0 + 1000, message: 'Dinner?', groupInfo: { groupId: 'GID123==', groupName: 'Family', type: 'DELIVER' } } } });
push({ account: ME, envelope: { sourceNumber: ME, timestamp: T0 + 2000, syncMessage: { sentMessage: { timestamp: T0 + 2000, destinationNumber: '+15551234567', message: 'Hello Sam (from my phone)' } } } });
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 3000, dataMessage: { timestamp: T0 + 3000, message: 'Look', attachments: [{ id: 'att-1', contentType: 'image/png', filename: 'cat.png', size: png.length }] } } });
let convs = await until(async () => { const c = (await getJ('/api/signal/convs')).convs; return c.length >= 2 && c.find(x => x.id === '+15551234567').preview === 'Look' ? c : false; });
ok('incoming direct and group messages make conversations, newest first', !!convs && convs[0].id === '+15551234567' && convs.some(c => c.id === 'g:GID123==' && c.type === 'group'), convs);
ok('the contact\'s saved name is used', !!convs && convs.find(c => c.id === '+15551234567').name === 'Sam Rivera', convs && convs.map(c => c.name));
ok('unread counts', !!convs && convs.find(c => c.id === '+15551234567').unread === 2 && convs.find(c => c.id === 'g:GID123==').unread === 1, convs);
ok('the preview shows the latest message', !!convs && convs.find(c => c.id === '+15551234567').preview === 'Look');
ok('the group keeps its name', !!convs && convs.find(c => c.id === 'g:GID123==').name === 'Family');
ok('live events reach the app, with the unread total', events.some(e => e.t === 'msg' && e.conv === '+15551234567') && events.some(e => e.unread >= 3), events.slice(-3));
let th = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'));
ok('a message written on your phone shows as yours, in order', th.msgs.map(m => (m.out ? 'me:' : 'in:') + (m.text || '')).join('|') === 'in:Hi there|me:Hello Sam (from my phone)|in:Look', th.msgs.map(m => m.text));
ok('an attachment is listed', th.msgs[2].att.length === 1 && th.msgs[2].att[0].name === 'cat.png');
r = await call('/api/signal/attachment/att-1'); ok('and it can be fetched through the hub', r.status === 200 && r.headers.get('content-type') === 'image/png' && (await r.arrayBuffer()).byteLength === png.length, r.status);
r = await call('/api/signal/attachment/..%2F..%2Fetc%2Fpasswd'); ok('attachment names cannot wander off', r.status === 400 || r.status === 404, r.status);
// reactions, receipts, edits, deletes
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 4000, dataMessage: { timestamp: T0 + 4000, reaction: { emoji: '👍', targetAuthorNumber: ME, targetSentTimestamp: T0 + 2000 } } } });
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 5000, editMessage: { targetSentTimestamp: T0, dataMessage: { message: 'Hi there (edited)' } } } });
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 6000, dataMessage: { timestamp: T0 + 6000, remoteDelete: { timestamp: T0 + 3000 } } } });
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); return t.msgs[1].react && t.msgs[0].edited && t.msgs[2].deleted ? t : false; });
ok('a reaction, an edit and a delete are applied', !!th && th.msgs[1].react['+15551234567'] === '👍' && th.msgs[0].text === 'Hi there (edited)' && th.msgs[2].text === '' && th.msgs[2].att.length === 0, th);
// sending
r = await post('/api/signal/send', { to: '+15551234567', text: 'On my way' }); let sent = (await r.json()).msg;
ok('sending to a person', r.status === 200 && fake.sends.length === 1 && fake.sends[0].number === ME && fake.sends[0].recipients[0] === '+15551234567' && fake.sends[0].message === 'On my way', fake.sends);
ok('your sent message appears at once', sent && sent.out && sent.status === 'sent');
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 7000, receiptMessage: { isDelivery: true, timestamps: [sent.ts] } } });
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 8000, receiptMessage: { isRead: true, timestamps: [sent.ts] } } });
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); const m = t.msgs.find(x => x.ts === sent.ts); return m && m.status === 'read' ? t : false; });
ok('delivered and read receipts update the message', !!th);
r = await post('/api/signal/send', { to: 'g:GID123==', text: 'Count me in' });
ok('sending to a group uses the group id Signal expects', r.status === 200 && fake.sends[1].recipients[0] === fake.groups[0].id, fake.sends[1]);
r = await post('/api/signal/send', { to: '+15551234567', text: 'photo', attachments: [{ name: 'a b.png', type: 'image/png', data: png.toString('base64') }], quote: T0 + 3000 });
ok('a picture goes out as an attachment, with the quote', fake.sends[2].base64_attachments[0].startsWith('data:image/png;filename=a%20b.png;base64,') && fake.sends[2].quote_timestamp === T0 + 3000, fake.sends[2]);
const msgs3 = (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs; const mine = msgs3.find(m => m.text === 'photo');
r = await call('/api/signal/attachment/' + mine.att[0].id); ok('your own sent picture can be shown again', r.status === 200 && (await r.arrayBuffer()).byteLength === png.length, r.status);
r = await post('/api/signal/send', { to: '+15551234567', text: ' ' }); ok('an empty message is refused', r.status === 400, r.status);
r = await post('/api/signal/send', { to: 'nobody', text: 'x' }); ok('so is an unknown conversation', r.status >= 400, r.status);
// reading and reacting
r = await post('/api/signal/read', { id: '+15551234567' }); await wait(300);
ok('opening a chat marks it read and tells the sender', (await getJ('/api/signal/convs')).convs.find(c => c.id === '+15551234567').unread === 0 && fake.receipts.length >= 1 && fake.receipts[0].receipt_type === 'read' && fake.receipts[0].recipient === '+15551234567', fake.receipts);
r = await post('/api/signal/react', { to: '+15551234567', ts: T0, emoji: '❤️' });
ok('reacting to a message', r.status === 200 && fake.reactions[0].reaction === '❤️' && fake.reactions[0].recipient === '+15551234567', fake.reactions);
r = await post('/api/signal/new', { number: '+1 (555) 000-9999' }); const nc = (await r.json()).conv;
ok('starting a chat with a new number', r.status === 200 && nc.id === '+15550009999', nc);
r = await post('/api/signal/new', { number: 'abc' }); ok('a bad number is refused', r.status === 400);
// restart: history stays; the socket coming back
ac.abort(); child.kill(); await wait(800); const before = JSON.parse(fs.readFileSync(path.join(dataDir, 'signal', 'store.json'), 'utf8'));
ok('conversations are saved on disk', Object.keys(before.convs).length >= 3, Object.keys(before.convs));
child = start(); await wait(1500);
convs = (await getJ('/api/signal/convs')).convs;
ok('after a restart the history is still there', Array.isArray(convs) && convs.some(c => c.id === '+15551234567'), convs);
await until(async () => (await getJ('/api/signal/status')).connected);
for (const s of fake.sockets) s.destroy(); // the service drops the connection
const back = await until(async () => { const s = await getJ('/api/signal/status'); return s.connected && fake.sockets.size > 0; }, 12000);
ok('it reconnects by itself if the Signal service drops the link', !!back);
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 9000, dataMessage: { timestamp: T0 + 9000, message: 'Still there?' } } });
ok('and messages keep arriving', !!(await until(async () => (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs.some(m => m.text === 'Still there?'))));
// the service going away
srv.close(); for (const s of fake.sockets) s.destroy(); await wait(1500);
st = await getJ('/api/signal/status'); ok('if the Signal service is down, the app is told', st.connected === false, st);
r = await call('/api/health').catch(() => null); const alive = await fetch(base + '/api/config', { headers: { cookie: ck('uadmin1') } }); ok('and the hub keeps running', alive.status === 200);
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll Signal checks passed'); process.exit(failed ? 1 : 0);
})();