Files
selfphoneng/scripts/e2e/m9-t1.mjs
T
iamdoubz 33b1456c0f voipms: API client, POP server list, import numbers
integrations/voipms/client.ts: voipmsApi(url, creds, method, params)
per 06 §5.1 (GET rest.php, 15 s timeout, status "success" or an error
code). The API password is only in the query string: never logged,
never in an error. Known codes get a message for people
(invalid_credentials, ip_not_enabled, ...). getServers and getDids
parse forgivingly; no_did is an empty list.

Routes: GET /api/trunks/:id/voipms-servers, GET and POST
/api/trunks/:id/import-dids. Import adds only numbers voip.ms lists
and that aren't added yet, label from the description, destination
hangup. 400 without API credentials, 502 provider_error with voip.ms's
reason. VOIPMS_API_URL overrides the endpoint for tests.

Web: the trunk form's server list comes from voip.ms once the trunk
has API access, falling back to the built-in list with the reason.
"Import numbers from voip.ms" sits in the trunk form's API section and
the setup wizard's numbers step. Saving a new API password asks
voip.ms again (found in e2e: the old error stayed).

The response shapes are from the 06 spec, not a real account: voip.ms's
API docs need a login. Marked TODO(spec) and (verify) in 06. Tests use
a fake rest.php; e2e m9-t1 too (trunk created off, so nothing registers
to the real voip.ms).

Task: M9-T1
2026-10-19 12:41:45 -05:00

154 lines
6.2 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M9-T1 in a browser, against a fake voip.ms API on 127.0.0.1:18090 (start the api with
// VOIPMS_API_URL=http://127.0.0.1:18090/api/v1/rest.php). A trunk with API access gets its POP
// list from voip.ms, a wrong API password shows voip.ms's reason, and "Import numbers from
// voip.ms" adds the picked numbers, not routed yet. The API password never reaches the api log.
// Runs after m1-t4.mjs.
// Usage: node scripts/e2e/m9-t1.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir> [<apiLog>]
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { launch } from './cdp.mjs';
const [base, email, pw, shotsDir, apiLog] = process.argv.slice(2);
const API_PW = 'Vms-api-pw-7731';
// The fake rest.php.
const seen = [];
const fake = createServer((req, res) => {
const q = new URL(req.url, 'http://x').searchParams;
seen.push(q.get('method'));
const ok = q.get('api_username') === 'owner@example.com' && q.get('api_password') === API_PW;
const answers = {
getServersInfo: {
status: 'success',
servers: [
{ server_name: 'Chicago 4', server_hostname: 'chicago4.voip.ms' },
{ server_name: 'Dallas 2', server_hostname: 'dallas2.voip.ms' },
],
},
getDIDsInfo: {
status: 'success',
dids: [
{ did: '3125550144', description: 'Front desk', sms_enabled: '1' },
{ did: '3125550145', description: 'Fax', sms_enabled: '0' },
],
},
};
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(ok ? answers[q.get('method')] : { status: 'invalid_credentials' }));
});
await new Promise((r) => fake.listen(18090, '127.0.0.1', r));
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 made = await (
await fetch(`${base}/api/trunks`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: JSON.stringify({
name: 'voip.ms import',
enabled: false, // never register to the real voip.ms from a test
serverHost: 'chicago1.voip.ms',
username: '123456_m9import',
password: 'sub-pw',
voipmsApiUsername: 'owner@example.com',
voipmsApiPassword: 'wrong-pw-1',
}),
})
).json();
assert.ok(made.id, `trunk: ${JSON.stringify(made)}`);
const p = await launch({ shotsDir });
const openApi = `(() => { const d = [...document.querySelectorAll('dialog[open] details')].find(d => d.innerText.includes('voip.ms API')); d.open = true; return true; })()`;
const datalist = `[...document.querySelectorAll('#voipms-servers option')].map(o => o.value).join()`;
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'`);
// Wrong API password: the built-in server list and voip.ms's reason.
await p.click('Trunks');
await p.waitFor(`document.body.innerText.includes('123456_m9import@')`, 'trunk listed');
await p.evaluate(`(() => {
const li = [...document.querySelectorAll('li')].find(l => l.innerText.includes('123456_m9import@'));
[...li.querySelectorAll('button')].find(b => b.textContent === 'Edit').click();
return true;
})()`);
await p.waitFor(`!!document.querySelector('dialog[open]')`);
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('rejected the API username or password')`,
'server list error shown',
);
assert.match(await p.evaluate(datalist), /chicago1\.voip\.ms/);
console.log('wrong API password: the form says voip.ms rejected it, built-in server list');
// The right one: the account's servers.
await p.evaluate(openApi);
await p.fill('API password', API_PW, 'dialog[open]');
await p.click('Save', 'dialog[open]');
await p.waitFor(`${datalist} === 'chicago4.voip.ms,dallas2.voip.ms'`, 'servers from voip.ms');
console.log('API password saved: the server list comes from voip.ms (chicago4, dallas2)');
// Import one of the two numbers.
await p.evaluate(openApi);
await p.click('Import numbers from voip.ms', 'dialog[open]');
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('Front desk')`,
'numbers listed',
);
await p.evaluate(`(() => {
const l = [...document.querySelectorAll('dialog[open] label')].find(l => l.textContent.includes('Front desk'));
l.querySelector('input').click();
return true;
})()`);
await p.click('Import 1 number', 'dialog[open]');
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('Added 1.')`,
'imported',
);
await p.waitFor(
`document.querySelector('dialog[open]').innerText.includes('(added)')`,
'marked added',
);
await p.evaluate(
`document.querySelector('dialog[open] fieldset legend')?.scrollIntoView({ block: 'start' }) ?? true`,
);
await p.shot('m9-t1-01-import');
console.log('imported (312) 555-0144 "Front desk"; the list marks it added');
await p.goto(`${base}/admin/numbers`);
await p.waitFor(`document.body.innerText.includes('(312) 555-0144')`, 'on Phone numbers');
const text = await p.text();
assert.match(text, /Front desk/);
assert.doesNotMatch(text, /555-0145/);
await p.shot('m9-t1-02-numbers');
console.log('Phone numbers lists it with its label; the other number was not imported');
assert.ok(seen.includes('getServersInfo') && seen.includes('getDIDsInfo'));
if (apiLog) {
const log = readFileSync(apiLog, 'utf8');
assert.ok(!log.includes(API_PW) && !log.includes('wrong-pw-1'), 'API password in the log');
console.log('the api log never contains the API password');
}
console.log('M9-T1 (voip.ms API, import): PASS');
} catch (e) {
await p.shot('m9-t1-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
process.exitCode = 1;
} finally {
await p.close();
fake.close();
}