Files
selfphoneng/scripts/e2e/m8-check.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

220 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8 milestone check (09 M8), as real calls from outside (into a trunk's entry context):
// DID → queue 552 with agents 340 and 341, max wait 30 s, then 342. 340 pauses with *45552 and
// 341 is paused too, so 3 callers wait. 341 comes back and gets the longest waiting caller,
// parks them (transfer to 700) on 701, and 343 picks them up by dialing 701. 341 is free again
// and takes the next caller; the last one waits past 30 s and goes to 342. Today's stats agree.
// Phones (baresip, auto-answer): 340–343. Runs on m2-live after m1-t4.
// Usage: node scripts/e2e/m8-check.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { action, originate } from './ami.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const DID = '13125558808';
const MAX_WAIT = 30;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const astLog = () =>
// eslint-disable-next-line no-control-regex -- Asterisk colors its log
spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout.replace(/\x1b\[[0-9;]*m/g, '');
const [base, email, pw] = 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], app: f[5], callerId: f[7], bridge: f[12] };
});
const chan = (n) => channels().find((c) => c.name.startsWith(`PJSIP/${n}-`));
/** Phone n (directly, or as a queue agent's Local leg) shares a bridge with the caller (whose
* number the trunk entry has normalized to 1NXX…). */
const talkingTo = (n, caller) => {
const mine = channels().filter(
(c) => c.name.startsWith(`PJSIP/${n}-`) || c.name.startsWith(`Local/${n}@sp-queue-member-`),
);
const theirs = channels().filter((c) => c.callerId?.endsWith(caller) && c.bridge);
return mine.some((m) => m.bridge && theirs.some((t) => t.bridge === m.bridge && t !== m));
};
const queueShow = () => cli('queue show 552');
const parkedSlot = () =>
/Space\s*:\s*(\d+)/.exec(cli('parking show default').split('Parked Calls')[1] ?? '')?.[1];
const ext = {};
const phoneList = [];
for (const [n, name] of [
['340', 'Ann Agent'],
['341', 'Bob Agent'],
['342', 'Olga Overflow'],
['343', 'Gil Grab'],
]) {
const e = await api('POST', '/api/extensions', { number: n, name });
ext[n] = e.id;
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext: n, password: pass, answer: 'auto' });
}
const phones = startPhones({ name: 'sp-live-phones-m8chk', net: NET, ast: AST, phones: phoneList });
const queue = await api('POST', '/api/queues', {
number: '552',
name: 'Support',
members: [
{ extensionId: ext['340'], penalty: 0 },
{ extensionId: ext['341'], penalty: 0 },
],
maxWaitSeconds: MAX_WAIT,
retrySeconds: 1,
joinWhenNoAgents: true,
timeoutDestination: { type: 'user', id: ext['342'] },
});
const trunk = await api('POST', '/api/trunks', {
name: 'M8 check',
serverHost: '127.0.0.1',
username: 'm8check',
password: 'unused-pw',
});
await api('POST', '/api/dids', {
number: DID,
trunkId: trunk.id,
destination: { type: 'queue', id: queue.id },
});
const callers = ['3125552201', '3125552202', '3125552203'];
const inbound = (n) =>
originate({
Channel: `Local/${DID}@sp-from-trunk-${trunk.id}`,
CallerID: `"Caller ${n.slice(-1)}" <${n}>`,
Application: 'Wait',
Data: '120',
});
try {
await waitUntil(
() =>
['340', '341', '342', '343'].every((e) =>
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
),
'phones',
180_000,
);
await waitUntil(
() => cli(`dialplan show ${DID}@sp-inbound`).includes('sp-queue-'),
'DID routes to the queue',
);
// 340 pauses with *45552; 341 is paused by an admin, so nobody answers yet.
const at = astLog().length;
cli('channel originate PJSIP/340 extension *45552@sp-from-ext');
await waitUntil(() => /Playing 'agent-loggedoff/.test(astLog().slice(at)), '*45 says logged off');
await waitUntil(
() => /Local\/340@sp-queue-member\/n.*\(paused/.test(queueShow()),
'340 paused in app_queue',
);
await api('POST', `/api/queues/${queue.id}/members/${ext['341']}/pause`);
await waitUntil(() => /Local\/341@sp-queue-member\/n.*\(paused/.test(queueShow()), '341 paused');
await waitUntil(() => !chan('340'), '*45 call over');
console.log('340 paused with *45552, 341 paused by an admin');
// Three callers from outside wait.
const joined = [];
for (const n of callers) {
await inbound(n);
joined.push(Date.now());
await sleep(2500);
}
await waitUntil(() => /552 has 3 calls/.test(queueShow()), '3 callers waiting');
console.log('3 callers from the DID are waiting in queue 552 with 2 agents, both paused');
// 341 is back: the longest waiting caller reaches 341.
await api('POST', `/api/queues/${queue.id}/members/${ext['341']}/unpause`);
await waitUntil(() => talkingTo('341', callers[0]), 'caller 1 talks to 341', 20_000);
console.log('341 unpaused: caller 1 (longest waiting) talks to 341');
// 341 parks caller 1 (blind transfer to 700, the phone's transfer key) and 343 picks up 701.
await action({
Action: 'BlindTransfer',
Channel: chan('341').name,
Context: 'sp-from-ext',
Exten: '700',
});
// findslot = next: 701 on a fresh system, the slot after the last one used otherwise.
const slot = await waitUntil(() => parkedSlot(), 'caller 1 parked');
console.log(`341 parked caller 1 on ${slot}`);
cli(`channel originate PJSIP/343 extension ${slot}@sp-from-ext`);
await waitUntil(() => talkingTo('343', callers[0]), '343 talks to caller 1', 20_000);
await waitUntil(() => !parkedSlot(), 'slot free');
console.log(`343 dialed ${slot} and talks to caller 1`);
// 341 is free again: caller 2 reaches 341; caller 3 waits past the limit and goes to 342.
await waitUntil(() => talkingTo('341', callers[1]), 'caller 2 talks to 341', 20_000);
console.log('341 is free again: caller 2 talks to 341');
await waitUntil(
() => talkingTo('342', callers[2]),
'caller 3 reaches 342',
(MAX_WAIT + 15) * 1000,
);
const waited = (Date.now() - joined[2]) / 1000;
assert.ok(waited >= MAX_WAIT - 1, `caller 3 left after ${waited.toFixed(1)} s`);
console.log(`caller 3 waited ${waited.toFixed(0)} s (max ${MAX_WAIT}), then rang 342`);
cli('channel request hangup all');
await waitUntil(() => channels().length === 0, 'calls ended');
const s = await waitUntil(async () => {
const r = await api('GET', `/api/queues/${queue.id}/stats`);
return r.offered === 3 && r;
}, 'stats count 3 calls');
assert.deepEqual(
{ answered: s.answered, abandoned: s.abandoned, timedOut: s.timedOut },
{ answered: 2, abandoned: 0, timedOut: 1 },
JSON.stringify(s),
);
// service level: the timed-out caller counts as missed, so at most 2 of 3
assert.ok(s.withinSlaPercent <= 67, `service level ${s.withinSlaPercent}%`);
console.log(`stats: ${JSON.stringify(s)}`);
console.log('M8 milestone check: PASS');
} catch (e) {
console.log(`FAIL: ${e.message}`);
console.log(`queue show: ${queueShow()}`);
console.log(`parking: ${cli('parking show default')}`);
console.log(`channels: ${cli('core show channels concise')}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
phones.stop();
}