Files
selfphoneng/scripts/e2e/m10-t1.mjs
T
iamdoubz fa63908261
ci / check (push) Successful in 52s
ci / images (push) Successful in 1m44s
provisioning: phones, /prov with Basic auth, rate limit and IP block
/api/devices CRUD (MAC in any format → 12 lowercase hex; a 20-char
provisioning password, stored encrypted and shown to admins, who type
it into the phone) and GET /:id/config-preview with passwords masked.

GET /prov/... per 07 §2: <mac>.cfg (Yealink), cfg<mac>.xml
(Grandstream), the empty Yealink common file. Basic auth with the MAC
and the device's password (timing-safe), 404 for unknown MACs or the
other vendor's file, 403 outside the local nets when LAN-only, 404 when
provisioning is off, Cache-Control no-store, last provisioned time and
IP recorded. Limits: 20 requests / 10 min per IP outside the local nets
(an office's phones rebooting together would pass it), and 10 failures
block the IP for an hour. A first request without credentials isn't a
failure: phones ask that way and retry after the 401.

Templates (pure functions of a ProvisionContext) hold the account,
voicemail, pickup codes and the provisioning settings; BLF, time zone
and phonebook come with M10-T2.

Web: Phones page (table, drawer with what to type into the phone,
config preview; manual settings for generic phones). e2e m10-t1: phone
added in the UI, 401 then the file, and a baresip phone registers with
only the account from it.

Also: the loadSnapshot test made its database inside the test (20 s);
under a full parallel run that timed out now and then. It now does it
in a hook like the others.

Task: M10-T1
2026-10-19 13:38:18 -05:00

150 lines
6.1 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T1: a Yealink added on the Phones page; its drawer shows the URL, user and password to type
// into the phone and a preview with the passwords masked. Then the phone's side: /prov/<mac>.cfg
// answers 401 without credentials and the file with them, and a baresip phone registers with
// nothing but the account from that file. The page then shows when and from where it was fetched.
// No real Yealink here: the hardware check stays open (09 M10).
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m10-t1.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 MAC = '001565123456';
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 ext = await (
await fetch(`${base}/api/extensions`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: JSON.stringify({ number: '350', name: 'Desk Phone Dee' }),
})
).json();
assert.ok(ext.id, JSON.stringify(ext));
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 pick = (label, text) =>
p.evaluate(`(() => {
const s = [...document.querySelectorAll('dialog[open] select')].find(s => s.labels?.[0]?.textContent === ${JSON.stringify(label)});
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
s.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
/** The value next to `term` in the drawer's setup box. */
const setupValue = (term) =>
p.evaluate(`(() => {
const dt = [...document.querySelectorAll('[aria-label="Set up the phone"] dt')].find(d => d.textContent === ${JSON.stringify(term)});
return dt?.nextElementSibling?.textContent ?? '';
})()`);
let phones;
const p = await launch({ shotsDir });
try {
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'`);
// Add the phone.
await p.click('Phones');
await p.click('Add phone');
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.fill('MAC address', '00:15:65:12:34:5', 'dialog[open]');
await p.fill('Model', 'T54W', 'dialog[open]');
await p.waitFor(
`[...document.querySelectorAll('dialog[open] option')].some(o => o.textContent.includes('Desk Phone Dee'))`,
'users listed',
);
await pick('User', 'Desk Phone Dee');
await p.click('Save', 'dialog[open]');
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('A MAC address like')`,
'short MAC refused',
);
await p.fill('MAC address', '00:15:65:12:34:56', 'dialog[open]');
await p.click('Save', 'dialog[open]');
await p.waitFor(`!!document.querySelector('[aria-label="Set up the phone"]')`, 'saved');
assert.equal(await setupValue('Username'), MAC);
assert.match(await setupValue('Server URL'), /\/prov\/$/);
const provPassword = await setupValue('Password');
assert.match(provPassword, /^[A-Za-z0-9]{20}$/);
await p.click('Preview config', 'dialog[open]');
await p.waitFor(`!!document.querySelector('[aria-label="Config preview"]')`, 'preview');
const preview = await p.evaluate(
`document.querySelector('[aria-label="Config preview"]').textContent`,
);
assert.match(preview, /account\.1\.user_name = 350/);
assert.match(preview, /account\.1\.password = \*{8}/);
assert.ok(!preview.includes(provPassword), 'preview shows the provisioning password');
await p.shot('m10-t1-01-phone');
console.log('phone 00:15:65:12:34:56 added in the UI (a short MAC refused); preview masked');
// The phone's side.
const url = `${base}/prov/${MAC}.cfg`;
assert.equal((await fetch(url)).status, 401, 'no credentials: 401');
const r = await fetch(url, {
headers: { authorization: `Basic ${Buffer.from(`${MAC}:${provPassword}`).toString('base64')}` },
});
assert.equal(r.status, 200);
const file = await r.text();
const val = (k) => new RegExp(`^${k.replace(/\./g, '\\.')} = (.*)$`, 'm').exec(file)?.[1];
assert.equal(val('account.1.user_name'), '350');
console.log('the phone gets 401 without credentials and its file with them');
// A phone that knows only what the file says registers.
phones = startPhones({
name: 'sp-live-phones-m10t1',
net: NET,
ast: AST,
phones: [
{ ext: val('account.1.user_name'), password: val('account.1.password'), answer: 'auto' },
],
});
await waitUntil(
() => /350\/sip:.*Avail/.test(cli('pjsip show contacts')),
'350 registers',
180_000,
);
console.log('a phone with only the account from the file registers as 350');
await p.goto(`${base}/admin/devices`);
await p.waitFor(`document.body.innerText.includes('Yealink T54W')`, 'listed');
assert.doesNotMatch(await p.text(), /\bNever\b/);
await p.shot('m10-t1-02-list');
console.log('Phones lists it with the time and IP it fetched its settings from');
console.log('M10-T1 (phones, provisioning): PASS');
} catch (e) {
await p.shot('m10-t1-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
phones?.stop();
}