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
This commit is contained in:
iamdoubz committed 2026-10-19 12:41:45 -05:00
1 parent 4742ed52ea
commit 33b1456c0f
13 files changed
+653 -7

No files matched your search

+5 -1
View File
@@ -53,7 +53,10 @@ export function fakeAmi() {
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
export const H = { 'x-requested-with': 'selfphoneng' };
export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>) => void) {
export async function createTestApp(
extend?: (app: ReturnType<typeof buildApp>) => void,
env: Record<string, string> = {},
) {
const t = await createTestDb();
const config = loadConfig({
APP_SECRET: randomBytes(32).toString('base64'),
@@ -66,6 +69,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
...env,
});
setSecretKey(config.APP_SECRET);
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M9-T1: the voip.ms API client and what uses it (06 §5.1, §7), against a fake voip.ms.
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { getDids, voipmsApi, VoipmsError } from '../src/integrations/voipms/client.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
/** A fake rest.php: answers per method; remembers the queries it got. */
function fakeVoipms() {
const seen: URLSearchParams[] = [];
const answers: Record<string, unknown> = {
getServersInfo: {
status: 'success',
servers: [
{ server_name: 'Seattle 1', server_hostname: 'seattle1.voip.ms', server_ip: '1.2.3.4' },
{ server_name: 'Chicago 1', server_hostname: 'chicago1.voip.ms', server_ip: '1.2.3.5' },
],
},
getDIDsInfo: {
status: 'success',
dids: [
{ did: '3125551234', description: 'Main line', sms_enabled: '1' },
{ did: '3125555678', description: '', sms_enabled: '0' },
{ did: 'junk' },
],
},
};
const server: Server = createServer((req, res) => {
const q = new URL(req.url!, 'http://x').searchParams;
seen.push(q);
const ok = q.get('api_username') === 'me@example.com' && q.get('api_password') === 'api-pw';
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify(
ok
? (answers[q.get('method')!] ?? { status: 'invalid_method' })
: { status: 'invalid_credentials' },
),
);
});
return { server, seen, answers };
}
describe('voipmsApi', () => {
const fake = fakeVoipms();
let url: string;
const creds = { username: 'me@example.com', password: 'api-pw' };
beforeAll(async () => {
await new Promise<void>((r) => fake.server.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}/api/v1/rest.php`;
});
afterAll(() => fake.server.close());
it('sends the login and method as query parameters', async () => {
await voipmsApi(url, creds, 'getServersInfo', { foo: 'a b' });
const q = fake.seen.at(-1)!;
expect(Object.fromEntries(q)).toEqual({
api_username: 'me@example.com',
api_password: 'api-pw',
method: 'getServersInfo',
foo: 'a b',
});
});
it('errors carry the status and a message for people, never the password', async () => {
const e = await voipmsApi(url, { ...creds, password: 'wrong-pw' }, 'getDIDsInfo').catch(
(x: unknown) => x,
);
expect(e).toBeInstanceOf(VoipmsError);
expect((e as VoipmsError).status).toBe('invalid_credentials');
expect((e as VoipmsError).message).not.toContain('wrong-pw');
const down = await voipmsApi('http://127.0.0.1:9/rest.php', creds, 'x').catch((x) => x);
expect((down as VoipmsError).status).toBe('unreachable');
});
it('getDids normalizes numbers and skips what it can’t read; no_did is an empty list', async () => {
expect(await getDids(url, creds)).toEqual([
{ number: '13125551234', description: 'Main line', smsEnabled: true },
{ number: '13125555678', description: '', smsEnabled: false },
]);
fake.answers.getDIDsInfo = { status: 'no_did' };
expect(await getDids(url, creds)).toEqual([]);
});
});
describe.skipIf(!hasTestDb)('/api/trunks/:id voip.ms routes', () => {
const fake = fakeVoipms();
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
beforeAll(async () => {
await new Promise<void>((r) => fake.server.listen(0, '127.0.0.1', r));
const port = (fake.server.address() as AddressInfo).port;
t = await createTestApp(undefined, {
VOIPMS_API_URL: `http://127.0.0.1:${port}/api/v1/rest.php`,
});
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
trunkId = (
await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: 'chicago1.voip.ms',
username: '123456_pbx',
password: 'pw',
})
).json().id;
});
afterAll(async () => {
await t?.close();
fake.server.close();
});
it('needs API credentials on the trunk', async () => {
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
expect(r.statusCode).toBe(400);
expect(r.json().error.message).toMatch(/API username and password/);
});
it('a wrong API password is a 502 with voip.ms’s reason', async () => {
await req('PATCH', `/api/trunks/${trunkId}`, {
voipmsApiUsername: 'me@example.com',
voipmsApiPassword: 'nope',
});
const r = await req('GET', `/api/trunks/${trunkId}/voipms-servers`);
expect(r.statusCode).toBe(502);
expect(r.json().error.message).toBe('voip.ms rejected the API username or password');
expect(r.body).not.toContain('nope');
});
it('lists servers, previews and imports numbers (not routed, label from voip.ms)', async () => {
await req('PATCH', `/api/trunks/${trunkId}`, { voipmsApiPassword: 'api-pw' });
expect((await req('GET', `/api/trunks/${trunkId}/voipms-servers`)).json()).toEqual({
servers: ['chicago1.voip.ms', 'seattle1.voip.ms'],
});
await req('POST', '/api/dids', {
number: '3125555678',
trunkId,
destination: { type: 'hangup' },
});
const preview = (await req('GET', `/api/trunks/${trunkId}/import-dids`)).json().items;
expect(preview).toEqual([
{ number: '13125551234', description: 'Main line', smsEnabled: true, added: false },
{ number: '13125555678', description: '', smsEnabled: false, added: true },
]);
// a number that isn't on the account is ignored
const r = await req('POST', `/api/trunks/${trunkId}/import-dids`, {
numbers: ['13125551234', '19995550000'],
});
expect(r.json()).toEqual({ added: 1 });
const added = (await req('GET', '/api/dids')).json().items;
expect(added.find((d: { number: string }) => d.number === '13125551234')).toMatchObject({
label: 'Main line',
destination: { type: 'hangup' },
});
const again = await req('POST', `/api/trunks/${trunkId}/import-dids`, {
numbers: ['13125551234'],
});
expect(again.statusCode).toBe(400);
});
});