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
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
// 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);
|
||
});
|
||
});
|