Files
selfphoneng/scripts/e2e/m11-t5.mjs
T
iamdoubz 60846afddd
ci / check (push) Successful in 54s
ci / images (push) Successful in 21s
integrations: webhooks and API tokens
API tokens (Settings -> API tokens): Authorization: Bearer sp_<40 base62>,
stored as sha256, shown once. A token acts as its admin with their current
role and stops when they're disabled; Bearer requests skip the CSRF header
and never fall back to the cookie. A token can't manage tokens.

Webhooks (Settings -> Webhooks): call.started/answered/ended,
voicemail.new, emergency.call, trunk/extension registration changes, from
the live state; queued per webhook, sent every 10 s with
X-SelfPhoneNG-Signature (HMAC of timestamp.body), retried after 1 m, 5 m,
30 m, 2 h, 12 h, then failed. Private/loopback targets are refused in the
connection's own DNS lookup (no rebinding), unless allowed. Send test and
a delivery log per webhook.

The e2e found voicemail.new missing for mailboxes without a user: the
voicemail scan now queues it for every mailbox. vmpin.test.ts gets 180 s:
Git Bash forks make it ~85 s on Windows (the earlier flaky gate run).

Task: M11-T5
2026-10-19 19:40:47 -05:00

180 lines
7.7 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T5: API tokens and webhooks, end to end.
// - Settings → API tokens makes a token in the browser; a script uses it (no cookie, no
// X-Requested-With) to add extension 201, can't manage tokens with it, and it stops working once
// revoked in the browser.
// - Settings → Webhooks adds a webhook to a receiver on this host: the test is refused (private
// address) until "Allow addresses on private networks" is ticked, then arrives signed.
// - A real call to 201 (Local channel, goes to voicemail) sends call.started, call.ended and
// voicemail.new, every one with a valid signature. The receiver fails the first POST with a 500:
// it comes again a minute later.
// Runs on the m2-live setup with a voicemail dir (api with VOICEMAIL_DIR), after m1-t4.mjs.
// Usage: node scripts/e2e/m11-t5.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHmac } from 'node:crypto';
import { createServer } from 'node:http';
import { launch } from './cdp.mjs';
const AST = 'sp-live-ast';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout;
const [base, email, pw, shotsDir] = process.argv.slice(2);
// The receiver: keeps every POST; the first call.* event gets a 500.
const got = [];
let failedOnce = false;
const server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const event = req.headers['x-selfphoneng-event'];
if (!failedOnce && String(event).startsWith('call.')) {
failedOnce = true;
got.push({ event, failed: true, at: Date.now() });
return res.writeHead(500).end();
}
got.push({ event, headers: req.headers, body, at: Date.now() });
res.writeHead(204).end();
});
});
await new Promise((r) => server.listen(18099, '127.0.0.1', r));
const hookUrl = 'http://127.0.0.1:18099/selfphoneng';
const withToken = (token, method, url, body) =>
fetch(`${base}${url}`, {
method,
headers: {
authorization: `Bearer ${token}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body && JSON.stringify(body),
});
const until = async (what, ms, fn) => {
const end = Date.now() + ms;
while (!(await fn())) {
assert.ok(Date.now() < end, `timed out: ${what}`);
await sleep(500);
}
};
const text = (p) => p.evaluate('document.body.innerText');
const p = await launch({ shotsDir });
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname.startsWith('/admin')`, 'signed in');
// API token, made in the browser.
await p.goto(`${base}/admin/settings/tokens`);
await p.waitFor(`document.body.innerText.includes('Create token')`, 'tokens tab');
await p.fill('Token name', 'e2e script');
await p.click('Create token');
await p.waitFor(`!!document.querySelector('[aria-label="New token"]')`, 'token shown');
const token = await p.evaluate(`document.querySelector('[aria-label="New token"]').innerText`);
assert.match(token, /^sp_[A-Za-z0-9]{40}$/);
await p.shot('m11-t5-01-token');
let r = await withToken(token, 'POST', '/api/extensions', { number: '201', name: 'Ann Lee' });
assert.equal(r.status, 201, `extension by token: ${r.status} ${await r.text()}`);
assert.equal((await withToken(token, 'GET', '/api/api-tokens')).status, 403);
console.log(
'token: made in the browser, added 201 without cookie or CSRF header, no token admin',
);
// Webhook, added in the browser.
await p.goto(`${base}/admin/settings/webhooks`);
await p.waitFor(`document.body.innerText.includes('Add webhook')`, 'webhooks tab');
await p.fill('Send events to', hookUrl);
for (const ev of ['call.started', 'voicemail.new'])
await p.evaluate(
`[...document.querySelectorAll('label')].find((l) => l.innerText.trim() === '${ev}').querySelector('input').click()`,
);
await p.click('Add webhook');
await p.waitFor(`document.body.innerText.includes('${hookUrl}')`, 'webhook listed');
await p.click('Send test');
await p.waitFor(`document.body.innerText.includes('Test failed')`, 'test refused');
assert.match(await text(p), /private address/);
assert.equal(got.length, 0);
await p.evaluate(
`[...document.querySelectorAll('label')].find((l) => l.innerText.includes('Allow addresses on private networks')).querySelector('input').click()`,
);
await until(
'allowPrivate saved',
5000,
async () =>
(await (await withToken(token, 'GET', '/api/settings')).json())['webhooks.allowPrivate'],
);
await p.click('Send test');
await p.waitFor(
`document.body.innerText.includes('Test sent: the server answered 204')`,
'test ok',
);
await p.shot('m11-t5-02-webhook');
const [hook] = (await (await withToken(token, 'GET', '/api/webhooks')).json()).items;
assert.deepEqual(hook.events.sort(), ['call.ended', 'call.started', 'voicemail.new']);
const checkSig = (g) => {
const ts = g.headers['x-selfphoneng-timestamp'];
assert.ok(Math.abs(Date.now() / 1000 - Number(ts)) < 300, 'fresh timestamp');
assert.equal(
g.headers['x-selfphoneng-signature'],
`sha256=${createHmac('sha256', hook.secret).update(`${ts}.${g.body}`).digest('hex')}`,
`signature of ${g.event}`,
);
const body = JSON.parse(g.body);
assert.equal(body.event, g.event);
return body;
};
checkSig(got[0]);
console.log('webhook: private address refused until allowed, then a signed test arrived');
// A real call: 201 has no phone, so voicemail answers.
await until('mailbox 201 in Asterisk', 30_000, () => /201/.test(cli('voicemail show users')));
got.length = 0;
const t0 = Date.now();
cli(
'channel originate Local/201@sp-vm-direct application Playback silence/10&silence/5&tt-weasels',
);
const ok = (ev) => got.filter((g) => g.event === ev && !g.failed);
await until('call.ended, voicemail.new and the retried call.started', 150_000, () =>
['call.started', 'call.ended', 'voicemail.new'].every((ev) => ok(ev).length),
);
const started = checkSig(ok('call.started')[0]);
const ended = checkSig(ok('call.ended')[0]);
const vm = checkSig(ok('voicemail.new')[0]);
assert.equal(started.data.linkedid, ended.data.linkedid);
assert.ok(ended.data.endedAt);
assert.equal(vm.event, 'voicemail.new');
assert.ok(vm.data.durationS >= 1, 'voicemail duration');
const fail = got.find((g) => g.failed);
assert.ok(fail, 'one POST failed');
const retry = ok(fail.event)[0];
const gap = (retry.at - fail.at) / 1000;
assert.ok(gap >= 55 && gap < 80, `retried a minute later (${gap} s)`);
console.log(
`call: ${got
.filter((g) => !g.failed)
.map((g) => g.event)
.join(', ')}, all signed; ` +
`${fail.event} failed once, came again after ${Math.round(gap)} s (${Math.round((Date.now() - t0) / 1000)} s total)`,
);
// Revoke in the browser.
await p.goto(`${base}/admin/settings/tokens`);
await p.waitFor(`document.body.innerText.includes('e2e script')`, 'token listed');
assert.match(await text(p), /last used \d/);
await p.click('Revoke');
await p.waitFor(`!!document.querySelector('dialog[open]')`, 'confirm');
await p.click('Revoke', 'dialog[open]');
await p.waitFor(`document.body.innerText.includes('No tokens.')`, 'revoked');
assert.equal((await withToken(token, 'GET', '/api/extensions')).status, 401);
console.log('token: revoked in the browser, then refused');
console.log('M11-T5: PASS');
} finally {
await p.close();
server.close();
}