Files
selfphoneng/scripts/e2e/m3-t5.mjs
T
iamdoubz 57e0e3dac2 featurecodes: DND, forwarding, pickup, echo, say-ext; /me settings
Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always,
**<ext> directed pickup, *8 group pickup (also as a dialplan line, since
pickupexten only catches a phone's own INVITE), *43 echo test, *65 say
my extension; queue login and intercom stay for M8. SpToggle DND/CFU
update user_runtime_state; a *72 target the user's calling permission
doesn't allow is removed from AstDB, audited (forward_rejected) and sent
to the user as a toast (new private live message). Forward targets are
checked the same way everywhere (services/forwards.ts: an existing
internal number, or an outside number some outbound rule allows them).

GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN;
PostgreSQL first, then AstDB; AstDB resync on connect now includes DND
and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and
email options), a status card on Home, toasts.

e2e m6-t7 with real phones covers every code, the refused forward toast,
and the settings page reaching AstDB; it found the settings forms losing
"Saved." on remount.

Task: M6-T7
2026-10-18 11:19:21 -05:00

316 lines
12 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T5 acceptance: a fresh install taken to "inbound and outbound calls work" using only the
// setup wizard, against a fake voip.ms (a second Asterisk that registers the sub-account, calls
// our DID like voip.ms does, and records what our outbound calls look like).
// Needs `m2-live.mjs up` and the api on a FRESH database (initial admin password).
// Usage: node scripts/e2e/m3-t5.mjs <baseUrl> <adminEmail> <initialPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PROVIDER = 'sp-live-provider';
const PHONE = 'sp-live-phone101';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' }; // the fake provider's test account
const NEW_PW = 'correct-horse-battery-staple';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
};
const cli = (name, cmd) => docker('exec', name, 'asterisk', '-rx', cmd).out;
async function until(what, fn, seconds = 60) {
for (let i = 0; i < seconds * 2; i++) {
if (await fn()) return;
await sleep(500);
}
throw new Error(`timed out: ${what}`);
}
const [base, email, initialPw, shotsDir] = process.argv.slice(2);
// ── the fake voip.ms ──
const conf = mkdtempSync(join(tmpdir(), 'sp-provider-'));
for (const f of [
'rtp_selfphoneng.conf',
'extensions_globals_selfphoneng.conf',
'voicemail_selfphoneng.conf',
'queues_selfphoneng.conf',
'musiconhold_selfphoneng.conf',
'confbridge_selfphoneng.conf',
])
writeFileSync(join(conf, f), '');
writeFileSync(
join(conf, 'pjsip_selfphoneng.conf'),
`[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
`[${SUB.user}]\ntype = endpoint\ncontext = provider-in\ndisallow = all\nallow = ulaw\nauth = ${SUB.user}-auth\naors = ${SUB.user}\n\n` +
`[${SUB.user}-auth]\ntype = auth\nauth_type = userpass\nusername = ${SUB.user}\npassword = ${SUB.pass}\n\n` +
`[${SUB.user}]\ntype = aor\nmax_contacts = 1\nremove_existing = yes\n`,
);
writeFileSync(
join(conf, 'extensions_selfphoneng.conf'),
// Calls to our DID use a 10-digit Request-URI, like voip.ms (06 §3).
`[provider-out]\nexten => _X.,1,Set(CALLERID(all)="Mobile" <3125559999>)\n same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)\n\n` +
`[provider-echo]\nexten => s,1,Answer()\n same => n,Echo()\n\n` +
`[provider-in]\nexten => _X.,1,Set(GLOBAL(LAST_IN)=to=\${EXTEN} rpid=\${PJSIP_HEADER(read,Remote-Party-ID)})\n same => n,Answer()\n same => n,Echo()\n`,
);
docker('rm', '-f', PROVIDER, PHONE);
assert.ok(
docker(
'run',
'-d',
'--rm',
'--name',
PROVIDER,
'--network',
NET,
'-e',
'AMI_SECRET=provider',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
).ok,
);
await until(
'provider boot',
async () => docker('exec', PROVIDER, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const providerIp = docker(
'inspect',
'-f',
`{{(index .NetworkSettings.Networks "${NET}").IPAddress}}`,
PROVIDER,
).out;
const p = await launch({ shotsDir });
const text = () => p.evaluate('document.body.innerText');
const step = async (n, title) => {
await p.waitFor(
`document.querySelector('#step-title')?.textContent === '${n}. ${title}'`,
`step ${n}`,
20000,
);
};
/** Waits for the field (steps load their data first), then fills it. */
const fill = async (label, value) => {
await p.waitFor(
`[...document.querySelectorAll('label')].some(l => l.textContent.trim().startsWith(${JSON.stringify(label)}))`,
label,
20000,
);
await p.fill(label, value);
};
const clickNext = () => p.click('Next');
try {
// 1. Password (forced change on first sign-in)
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', initialPw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/change-password'`);
await p.fill('Current password', initialPw);
await p.fill('New password', NEW_PW);
await p.fill('Repeat new password', NEW_PW);
await p.click('Change password');
await p.waitFor(`location.pathname === '/setup'`, 'wizard opens');
await step(1, 'Password');
await clickNext();
// 2. Company
await step(2, 'Company');
await fill('Company name', 'Acme Dental');
await p.click('Save and continue');
// 3. Network (documentation address; Docker's 172.16/12 stays a local net). Saving changes the
// SIP transports, so the wizard restarts telephony.
await step(3, 'Network');
await fill('Public IP address', '203.0.113.10');
await p.evaluate(
`[...document.querySelectorAll('label')].find(l => l.textContent.includes("I've done this")).querySelector('input').click(), true`,
);
await p.click('Save and continue');
await step(4, 'Email');
const restarted = (await text()).includes('Telephony restarted');
console.log(`network saved${restarted ? '; telephony restarted' : ''}`);
assert.match(
cli(AST, 'pjsip show transport transport-udp'),
/external_media_address\s*:\s*203\.0\.113\.10/,
);
// 4. Email: skipped
await p.click('Skip for now');
// 5. Trunk
await step(5, 'Trunk');
await fill('Server', providerIp);
await fill('Sub-account username', SUB.user);
await fill('Password', SUB.pass);
await p.click('Create trunk');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'trunk saved');
await p.waitFor(
`![...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
'trunk Registered unlocks Next',
90000,
);
await p.shot('m3-t5-05-trunk');
console.log('trunk Registered');
await clickNext();
// 6. Numbers
await step(6, 'Phone numbers');
await fill('Phone number', '(312) 555-1234');
await p.click('Add number');
await p.waitFor(`document.body.innerText.includes('✓ (312) 555-1234')`, 'number added');
await clickNext();
// 7. Users
await step(7, 'Users');
for (const [aria, value] of [
['Name 1', 'Dan Doe'],
['Email 1', 'dan@example.com'],
])
await p.evaluate(`(() => {
const el = document.querySelector('input[aria-label="${aria}"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, ${JSON.stringify(value)});
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`);
await p.click('Add users');
await p.waitFor(`document.body.innerText.includes('shown only now')`, 'temporary password');
await p.shot('m3-t5-07-users');
const ext = /(\d{3,6})\s+Dan Doe/.exec(await text())?.[1];
assert.ok(ext, 'Dan got an extension');
await p.waitFor(`document.body.innerText.includes('✓ ${ext} Dan Doe')`);
await clickNext();
// 8. Main number → "Ring everyone" (M6: a ring group of all users; Dan is the only one)
await step(8, 'Main number');
const label = 'When someone calls (312) 555-1234 (Main line), ring:';
await p.click('Ring everyone');
await p.waitFor(
`document.querySelector('select[aria-label="${label}: type"]')?.value === 'ring_group' && document.querySelector('select[aria-label="${label}: ring group"]')?.selectedOptions[0]?.textContent.includes('Everyone')`,
'ring group Everyone picked',
);
await p.click('Save and continue');
// 9. Emergency: Next stays locked until a default E911 caller ID is saved.
await step(9, 'Emergency');
assert.ok(
await p.evaluate(
`[...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
),
);
const didId = await p.evaluate(
`[...document.querySelectorAll('option')].find(o => o.textContent.startsWith('(312) 555-1234')).value`,
);
await fill('Default emergency caller ID', didId);
await p.click('Save');
await p.waitFor(
`![...document.querySelectorAll('button')].find(b => b.textContent === 'Next').disabled`,
'Next unlocked',
);
await clickNext();
// 10. Done
await step(10, 'Done');
await p.click('Finish');
await p.waitFor(`location.pathname === '/admin'`, 'dashboard after finishing');
await p.waitFor(
`/Trunks up\\s*1 \\/ 1/.test(document.body.innerText)`,
'trunk up on the dashboard',
);
await p.shot('m3-t5-10-dashboard');
console.log('wizard finished');
// ── Calls work ──
const cookie = await (async () => {
const r = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: NEW_PW }),
});
return r.headers.get('set-cookie').split(';')[0];
})();
const exts = await (await fetch(`${base}/api/extensions`, { headers: { cookie } })).json();
const dan = exts.items.find((e) => e.number === ext);
const creds = await (
await fetch(`${base}/api/extensions/${dan.id}/sip-credentials`, { headers: { cookie } })
).json();
const dir = mkdtempSync(join(tmpdir(), 'sp-phone-'));
mkdirSync(join(dir, 'p'));
writeFileSync(
join(dir, 'p', 'config'),
'sip_listen 0.0.0.0:5070\nmodule_path /usr/lib/baresip/modules\nmodule g711.so\nmodule ausine.so\nmodule aufile.so\nmodule menu.so\nmodule account.so\naudio_source ausine,440\nausrc_srate 48000\nauplay_srate 48000\naudio_player aufile,/phone/p/heard.wav\naudio_alert aufile,/dev/null\n',
);
writeFileSync(
join(dir, 'p', 'accounts'),
`<sip:${ext}@${AST};transport=udp>;auth_pass=${creds.password};answermode=auto;audio_codecs=PCMU\n`,
);
assert.ok(
docker(
'run',
'-d',
'--rm',
'--name',
PHONE,
'--network',
NET,
'-v',
`${dir}:/phone`,
'debian:trixie-slim',
'sh',
'-c',
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && baresip -f /phone/p > /phone/p/log 2>&1',
).ok,
);
await until(
`phone ${ext} registers`,
async () => new RegExp(`${ext}/sip:.*Avail`).test(cli(AST, 'pjsip show contacts')),
180,
);
const bothUp = () => {
const up = cli(AST, 'core show channels concise')
.split('\n')
.filter((l) => l.includes('!Up!'));
return (
up.some((l) => l.startsWith(`PJSIP/${ext}-`)) && up.some((l) => l.startsWith('PJSIP/trunk-'))
);
};
// Inbound: a "mobile" calls the DID.
cli(PROVIDER, 'channel originate Local/3125551234@provider-out extension s@provider-echo');
await until('inbound call answered by Dan', async () => bothUp(), 30);
await p.waitFor(
`/On a call\\s*1/.test(document.body.innerText)`,
'dashboard shows the call',
10000,
);
await p.shot('m3-t5-11-inbound-call');
console.log(`inbound: (312) 555-1234 rang ${ext}, answered`);
cli(AST, 'channel request hangup all');
await sleep(2000);
// Outbound: Dan dials a 10-digit number.
cli(AST, `channel originate PJSIP/${ext} extension 3125550000@sp-from-ext`);
await until('outbound call connected', async () => bothUp(), 30);
const lastIn = /LAST_IN=(.*)/.exec(cli(PROVIDER, 'dialplan show globals'))?.[1] ?? '';
assert.match(lastIn, /^to=13125550000 /, 'provider got 11 digits');
assert.match(lastIn, /sip:13125551234@/, 'caller ID is the DID');
console.log(`outbound: provider received ${lastIn}`);
cli(AST, 'channel request hangup all');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('M3-T5 (wizard → calls work): PASS');
} catch (e) {
await p.shot('m3-t5-FAILED').catch(() => {});
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
process.exitCode = 1;
} finally {
await p.close();
docker('rm', '-f', PHONE);
}