Files
selfphoneng/scripts/e2e/m8-t3.mjs
T
iamdoubz a6c423de97
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
conferences: rooms with PIN and admin PIN, per-room limits, recording
/api/conferences CRUD: PINs stored encrypted, shown to admins (they hand
them out), never in the audit log; an admin PIN needs a different room
PIN (checked against the stored values on PATCH). The generator renders
sp-conf-{id} per 03 §6.11 (PIN prompt, 3 tries, admin PIN joins with
sp_admin_user), the number in sp-local-numbers and destApp conference.

Two changes from the spec's snippet, both found with real calls and
written back into 03:
- Set(CONFBRIDGE(bridge,max_members)) is ignored once ConfBridge()
  names a bridge profile: a 4th caller got into a room of 3. Each room
  now has its own [sp_room_<n>](default_bridge) profile in
  confbridge_selfphoneng.conf.
- A recorded room runs sp-sub-record on each caller's leg (just before
  ConfBridge) instead of record_conference, so the files are ordinary
  call recordings: call history, Recordings page, retention.

Web: Conference rooms page (list, drawer form), "Conference room" in
the DestinationPicker. Golden case conferences. e2e m8-t3: room built
in the UI, wrong PIN refused and asked again, admin PIN joins with
flags AM, a 4th caller turned away, recordings listed, and a no-PIN
room with two desk phones.

Task: M8-T3
2026-10-19 04:56:41 -05:00

207 lines
8.0 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T3 with real calls: room 800 built in the UI (PIN 4826, admin PIN 917355, 3 people at most,
// recorded). Callers are Local channels typing the PIN with SendDTMF: a wrong PIN is refused and
// asked again, the admin PIN joins as admin (marked), the room fills at 3 and a 4th caller is
// turned away, and each recorded caller shows up in Recordings. Room 801 has no PIN: two desk
// phones (300, 301) dial it and are in at once.
// Runs on m2-live with a recordings dir, after m1-t4.mjs.
// Usage: node scripts/e2e/m8-t3.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 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, 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: { '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}`);
};
/** `confbridge list <room>` participant lines (channel, flags). */
const members = (room) =>
cli(`confbridge list ${room}`)
.split('\n')
.filter((l) => /^(Local|PJSIP)\//.test(l))
.map((l) => ({ channel: l.split(/\s+/)[0], flags: l.split(/\s+/)[1] ?? '' }));
/** A caller dialing `room` that types `keys` (w = half a second) and then stays a minute. */
const caller = (room, keys) =>
cli(`channel originate Local/${room}@sp-from-ext application SendDTMF ${keys}${'w'.repeat(120)}`);
const phoneList = [];
for (const [ext, name] of [
['300', 'Cora Conf'],
['301', 'Cy Conf'],
]) {
const e = await api('POST', '/api/extensions', { number: ext, name });
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
phoneList.push({ ext, password: pass, answer: 'auto' });
}
const phones = startPhones({ name: 'sp-live-phones-m8t3', net: NET, ast: AST, phones: phoneList });
const p = await launch({ shotsDir });
const check = (text) =>
p.evaluate(`(() => {
const l = [...document.querySelectorAll('dialog[open] label')].find(l => l.textContent.includes(${JSON.stringify(text)}));
const box = l.querySelector('input[type=checkbox]');
if (!box.checked) box.click();
return true;
})()`);
try {
await waitUntil(
() =>
['300', '301'].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'`);
// Room 800 in the UI.
await p.click('Conferences');
await p.click('Add room');
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.waitFor(
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Number')?.control.value === '800'`,
'next free room number suggested',
);
await p.fill('Name', 'Board', 'dialog[open]');
await p.fill('PIN', '4826', 'dialog[open]');
await p.fill('Most people at once', '3', 'dialog[open]');
await check('Record');
// First a mistake the form catches (admin PIN = PIN), then the real one.
await p.fill('Admin PIN', '4826', 'dialog[open]');
await p.click('Save', 'dialog[open]');
await p.waitFor(
`document.querySelector('dialog[open]')?.innerText.includes('different')`,
'same PIN refused',
);
await p.fill('Admin PIN', '917355', 'dialog[open]');
await p.click('Save', 'dialog[open]');
await p.waitFor(
`document.body.innerText.includes('PIN 4826 · admin PIN 917355 · up to 3 · recorded')`,
'saved',
);
await waitUntil(
() => /sp-conf-/.test(cli('dialplan show 800@sp-local-numbers')),
'dialplan has 800',
);
await p.shot('m8-t3-01-rooms');
console.log('room 800 built in the UI (the form refused an admin PIN equal to the PIN)');
// A wrong PIN is refused and asked again; then the right one gets in.
let at = astLog().length;
caller('800', 'wwwwww1111#wwwwwwwwww4826#');
await waitUntil(() => /Playing 'conf-invalidpin/.test(astLog().slice(at)), 'wrong PIN refused');
await waitUntil(() => members('800').length === 1, 'first caller in', 20_000);
console.log('wrong PIN: conf-invalidpin and asked again; the right PIN joins');
// The admin PIN joins as admin (A) and marked (M).
caller('800', 'wwwwww917355#');
await waitUntil(() => members('800').length === 2, 'admin in', 20_000);
const admin = members('800').find((m) => /A/.test(m.flags));
assert.ok(admin && /M/.test(admin.flags), `flags: ${JSON.stringify(members('800'))}`);
console.log(`admin PIN: joined with flags ${admin.flags}`);
// The room holds 3: a 4th caller is turned away.
caller('800', 'wwwwww4826#');
await waitUntil(() => members('800').length === 3, 'third in', 20_000);
caller('800', 'wwwwww4826#');
await sleep(10_000); // dialed, typed the PIN (3 s) and would be in by now
assert.equal(
members('800').length,
3,
`room should stay at 3: ${JSON.stringify(members('800'))}`,
);
console.log('max 3: a 4th caller with the right PIN was turned away');
// Room 801 without a PIN: two desk phones dial it and are in.
await api('POST', '/api/conferences', {
number: '801',
name: 'Huddle',
pin: null,
adminPin: null,
});
await waitUntil(
() => /sp-conf-/.test(cli('dialplan show 801@sp-local-numbers')),
'dialplan has 801',
);
cli('channel originate PJSIP/300 extension 801@sp-from-ext');
cli('channel originate PJSIP/301 extension 801@sp-from-ext');
await waitUntil(
() =>
members('801')
.map((m) => m.channel.slice(0, 10))
.sort()
.join() === 'PJSIP/300-,PJSIP/301-',
'both phones in 801',
20_000,
);
console.log('room 801 (no PIN): desk phones 300 and 301 are in');
// Everyone leaves; the three recorded callers of 800 are in Recordings.
cli('channel request hangup all');
const recs = await waitUntil(
async () => {
const r = await api('GET', '/api/recordings');
return r.items.length >= 3 && r;
},
'3 recordings',
40_000,
);
// 3 callers in the room + the one turned away (recording starts as ConfBridge is tried)
console.log(`recordings: ${recs.items.length}, one per caller who dialed 800 with the PIN`);
await p.goto(`${base}/admin/recordings`);
await p.waitFor(
`document.body.innerText.includes('800')`,
'recordings page shows room 800 calls',
);
await p.shot('m8-t3-02-recordings');
console.log('M8-T3 (conferences): PASS');
} catch (e) {
await p.shot('m8-t3-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`800: ${cli('confbridge list 800')}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
cli('channel request hangup all');
await p.close();
phones.stop();
}