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
180 lines
7.7 KiB
JavaScript
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();
|
|
}
|