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
172 lines
7.2 KiB
JavaScript
172 lines
7.2 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M11-T1 with real Asterisk and a real browser:
|
|
// - The admin pages load under the Content-Security-Policy with no violations.
|
|
// - Phone ACL: with "only accept desk phones from the networks below" on and the phones' Docker
|
|
// network not in the list, a phone can't register; back in the list, it can.
|
|
// - Voicemail PIN changed from the phone (*98 → options → change password): a weak one is refused
|
|
// by sp-vm-pincheck, a good one is stored by the api and stays in the generated config.
|
|
// Needs the Asterisk image with sp-vm-pincheck. Runs on m2-live, after m1-t4.mjs.
|
|
// Usage: node scripts/e2e/m11-t1.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir> <confDir>
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
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, confDir] = 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 vmConf = () => readFileSync(join(confDir, 'voicemail_selfphoneng.conf'), 'utf8');
|
|
const registered = (n) => new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts'));
|
|
const settings = await api('GET', '/api/settings');
|
|
|
|
let phones;
|
|
const p = await launch({ shotsDir });
|
|
try {
|
|
// CSP: the admin pages work with no violations.
|
|
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'`);
|
|
for (const page of ['Users & Extensions', 'Sounds & music', 'Phones', 'Settings', 'Dashboard'])
|
|
await p.click(page);
|
|
await p.click('Settings');
|
|
await p.click('Security');
|
|
await p.waitFor(`document.body.innerText.includes('Only accept desk phones')`, 'Security tab');
|
|
await p.shot('m11-t1-01-security');
|
|
const csp = p.consoleErrors.filter((e) => /Content.Security.Policy/i.test(e));
|
|
assert.deepEqual(csp, [], 'CSP violations');
|
|
console.log('admin pages load under the CSP with no violations; Settings → Security shown');
|
|
|
|
// Phone ACL.
|
|
const exts = {};
|
|
for (const [n, name] of [
|
|
['390', 'Acl Inside'],
|
|
['391', 'Pin Changer'],
|
|
]) {
|
|
const e = await api('POST', '/api/extensions', { number: n, name, voicemailPin: '4826' });
|
|
exts[n] = e;
|
|
}
|
|
// With a public IP set, the local nets are also in the SIP transports, which only change on a
|
|
// restart (01 §4.3); another script may have set one. Without it, they reload at once.
|
|
// Clearing it is itself a transport change (that apply skips the PJSIP reload), so on its own.
|
|
await api('PATCH', '/api/settings', { 'network.publicIp': null });
|
|
await sleep(3000);
|
|
await api('PATCH', '/api/settings', {
|
|
'sip.restrictPhonesToLocalNets': true,
|
|
'network.localNets': ['192.168.0.0/16'],
|
|
});
|
|
await waitUntil(() => /permit/.test(cli('pjsip show endpoint 390')), 'ACL loaded');
|
|
const pass = (await api('GET', `/api/extensions/${exts['390'].id}/sip-credentials`)).password;
|
|
let at = astLog().length;
|
|
phones = startPhones({
|
|
name: 'sp-live-phones-m11t1',
|
|
net: NET,
|
|
ast: AST,
|
|
phones: [{ ext: '390', password: pass, answer: 'auto' }],
|
|
});
|
|
await waitUntil(
|
|
() =>
|
|
/390.*(failed ACL|ACL)/i.test(astLog().slice(at)) ||
|
|
/Failed to authenticate/.test(astLog().slice(at)),
|
|
'registration refused by the ACL',
|
|
180_000,
|
|
);
|
|
assert.ok(!registered('390'), '390 must not register');
|
|
console.log('ACL on, Docker network not listed: 390 is refused');
|
|
at = astLog().length;
|
|
await api('PATCH', '/api/settings', { 'network.localNets': ['172.16.0.0/12'] });
|
|
await waitUntil(() => registered('390'), '390 registers once its network is allowed', 120_000);
|
|
console.log('its network allowed: 390 registers');
|
|
await api('PATCH', '/api/settings', {
|
|
'sip.restrictPhonesToLocalNets': settings['sip.restrictPhonesToLocalNets'],
|
|
'network.localNets': settings['network.localNets'],
|
|
});
|
|
|
|
// A PIN changed from the phone. *98 asks for the mailbox and PIN; 0 = mailbox options,
|
|
// 5 = change password, then the new PIN twice. Each w waits half a second.
|
|
const changePin = (pin) =>
|
|
cli(
|
|
`channel originate Local/*98@sp-from-ext application SendDTMF ${'w'.repeat(8)}391#${'w'.repeat(8)}4826#${'w'.repeat(14)}0${'w'.repeat(10)}5${'w'.repeat(8)}${pin}#${'w'.repeat(8)}${pin}#${'w'.repeat(20)}`,
|
|
);
|
|
at = astLog().length;
|
|
changePin('1111');
|
|
await waitUntil(
|
|
() =>
|
|
/Password doesn't match policies|invalid-password|vm-invalid-password/.test(
|
|
astLog().slice(at),
|
|
),
|
|
'weak PIN refused by sp-vm-pincheck',
|
|
60_000,
|
|
);
|
|
assert.match(vmConf(), /^391 => 4826,/m);
|
|
console.log('a weak PIN (1111) from the phone is refused; the config keeps 4826');
|
|
cli('channel request hangup all');
|
|
await sleep(2000);
|
|
changePin('7391');
|
|
await waitUntil(() => /^391 => 7391,/m.test(vmConf()), 'new PIN in the generated config', 60_000);
|
|
console.log('7391 from the phone: stored by the api and in the generated config');
|
|
// A later apply keeps it (the database has it now).
|
|
await api('PATCH', `/api/extensions/${exts['391'].id}`, { name: 'Pin Changer 2' });
|
|
await sleep(3000);
|
|
assert.match(vmConf(), /^391 => 7391,/m);
|
|
console.log('a later config change keeps the new PIN');
|
|
console.log('M11-T1 (headers, phone ACL, PIN rules): PASS');
|
|
} catch (e) {
|
|
await p.shot('m11-t1-fail').catch(() => {});
|
|
console.log(`FAIL: ${e.message}`);
|
|
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await api('PATCH', '/api/settings', {
|
|
'sip.restrictPhonesToLocalNets': settings['sip.restrictPhonesToLocalNets'],
|
|
'network.localNets': settings['network.localNets'],
|
|
'network.publicIp': settings['network.publicIp'],
|
|
}).catch(() => {});
|
|
cli('channel request hangup all');
|
|
await p.close();
|
|
phones?.stop();
|
|
}
|