Files
selfphoneng/scripts/e2e/m10-t4.mjs
T
iamdoubz 8c750c67ca
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s
auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.

Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).

Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
  base64 character can carry ignored bits, so another spelling decoded
  to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
  test box, m8-check dials whatever slot the park took (findslot =
  next), m10-t4 uses the admin's existing extension, m11-t1 clears the
  public IP on its own first (local nets are in the transports while
  one is set, and transports change only on a restart).

e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.

Task: M11-T1
2026-10-19 15:41:53 -05:00

154 lines
5.5 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T4 with real phones: 380 calls 381; the admin (desk phone 382, or theirs) presses Listen, then Whisper,
// then Barge on the dashboard. Each time it rings, answers, and Asterisk runs ChanSpy on the
// caller's leg with the mode's options (q, E, and w / B). When the call ends, the spy leg ends too.
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m10-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
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);
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
'x-requested-with': 'selfphoneng',
cookie,
},
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
const waitUntil = async (fn, what, ms = 30_000) => {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await sleep(300);
}
throw new Error(`timeout waiting for: ${what}`);
};
const channels = () =>
cli('core show channels concise')
.split('\n')
.filter(Boolean)
.map((l) => {
const f = l.split('!');
return { name: f[0], state: f[4], app: f[5], data: f[6] };
});
const me = (await api('GET', '/api/auth/me')).user;
// The admin's own extension is the spy phone; another script may have given them one already.
const boss =
me.extension ??
(await api('POST', '/api/extensions', { number: '382', name: 'Boss Bo', userId: me.id }));
const BOSS = boss.number;
const phoneList = [];
for (const [n, name] of [
['380', 'Cal Caller'],
['381', 'Agent Ava'],
[BOSS, null],
]) {
const e = name ? await api('POST', '/api/extensions', { number: n, name }) : boss;
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext: n, password: pass, answer: 'auto', toneHz: 300 + (Number(n) % 500) });
}
const phones = startPhones({ name: 'sp-live-phones-m10t4', net: NET, ast: AST, phones: phoneList });
const p = await launch({ shotsDir });
const row = `[...document.querySelectorAll('ul[aria-label="Calls now"] > li')].find(li => li.innerText.includes('381'))`;
const press = (label) =>
p.evaluate(`(() => {
const b = [...${row}.querySelectorAll('button')].find(b => b.textContent === ${JSON.stringify(label)});
b.click();
return true;
})()`);
try {
await waitUntil(
() =>
['380', '381', BOSS].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
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 === '/admin'`);
cli('channel originate PJSIP/380 extension 381@sp-from-ext');
await p.waitFor(`!!${row}?.innerText.includes('Talking')`, 'call on the dashboard', 20_000);
console.log('380 → 381 talking, on the dashboard');
for (const [label, opts] of [
['Listen', 'qE'],
['Whisper', 'qEw'],
['Barge', 'qEB'],
]) {
await press(label);
const spyLeg = await waitUntil(
() => channels().find((c) => c.name.startsWith(`PJSIP/${BOSS}-`) && c.app === 'ChanSpy'),
`${label}: ${BOSS} runs ChanSpy`,
);
assert.equal(spyLeg.state, 'Up');
assert.match(spyLeg.data, new RegExp(`^PJSIP/38[01]-[0-9a-f]+,${opts}$`), spyLeg.data);
await p.waitFor(
`document.body.innerText.includes('Your desk phone is ringing')`,
`${label}: status shown`,
);
if (label === 'Listen') await p.shot('m10-t4-01-listening');
console.log(`${label}: ${BOSS} answered and runs ChanSpy(${spyLeg.data})`);
if (label !== 'Barge') {
cli(`channel request hangup ${spyLeg.name}`);
await waitUntil(
() => !channels().some((c) => c.name.startsWith(`PJSIP/${BOSS}-`)),
'spy off',
);
}
}
// The call ends: the spy leg (E) goes with it.
const caller = channels().find((c) => c.name.startsWith('PJSIP/380-'));
cli(`channel request hangup ${caller.name}`);
await waitUntil(
() => !channels().some((c) => c.name.startsWith(`PJSIP/${BOSS}-`)),
'spy ends',
15_000,
);
console.log('the call ended and the barge leg ended with it');
console.log('M10-T4 (listen, whisper, barge): PASS');
} catch (e) {
await p.shot('m10-t4-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`channels: ${cli('core show channels concise')}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
await p.close();
phones.stop();
}