Files
selfphoneng/apps/api/test/trunks.test.ts
T
iamdoubz 4496f6b89a outbound: routes with failover and caller ID, test box, Outbound rules page
Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.

Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.

UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).

trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).

Task: M3-T3
2026-10-17 20:04:35 -05:00

257 lines
10 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T1: trunks CRUD, generated PJSIP, and registration status replayed from a real Asterisk 24
// session against a fake provider (trunk-test.ts --record): one trunk registers, one is rejected.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field } from '../src/ami/events.ts';
import { decrypt } from '../src/crypto/secrets.ts';
import { auditLog, dids, outboundRoutes, trunks } from '../src/db/schema.ts';
import { trunkOfObject } from '../src/live/state.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const MSGS = new AmiParser().push(
readFileSync(new URL('./fixtures/ami/trunk-registration.raw', import.meta.url), 'latin1'),
);
const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
const reply = (id: string) => MSGS.filter((m) => field(m, 'ActionID') === id && m.Event);
const LIVE_EVENTS = MSGS.filter((m) => m.Event && !m.ActionID);
describe('trunk schema', () => {
const base = { name: 'voip.ms', serverHost: 'chicago1.voip.ms' };
it('registration needs a username and password; IP auth does not', () => {
expect(TrunkCreateSchema.safeParse(base).success).toBe(false);
expect(TrunkCreateSchema.safeParse({ ...base, authMode: 'ip' }).success).toBe(true);
expect(
TrunkCreateSchema.safeParse({ ...base, username: '123456_pbx', password: 'pw' }).success,
).toBe(true);
});
it.each([
['password with ;', { password: 'a;b' }],
['password with a newline', { password: 'a\nb' }],
['password with an edge space', { password: ' ab' }],
['username with @', { username: 'a@b' }],
['host with a space', { serverHost: 'chicago1 .voip.ms' }],
['SRTP over UDP', { srtp: true }],
['unknown codec', { codecs: ['opus'] }],
])('rejects %s', (_, patch) => {
const r = TrunkCreateSchema.safeParse({
...base,
username: '123456_pbx',
password: 'pw',
...patch,
});
expect(r.success).toBe(false);
});
it('maps Asterisk object names to trunk ids', () => {
expect(trunkOfObject(`trunk-${GOOD}`)).toBe(GOOD);
expect(trunkOfObject(`trunk-${GOOD}-reg`)).toBe(GOOD);
expect(trunkOfObject(`trunk-${GOOD}-auth`)).toBeNull();
expect(trunkOfObject('101')).toBeNull();
});
});
describe.skipIf(!hasTestDb)('trunks API', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
let req: (
method: string,
url: string,
payload?: unknown,
cookie?: string,
) => Promise<LightMyRequestResponse>;
const pjsip = () => readFileSync(join(t.genDir, 'pjsip_selfphoneng.conf'), 'utf8');
const settle = async () => {
await new Promise((r) => setTimeout(r, 60));
await t.app.generator.apply({ trigger: 'test' });
};
let id: string;
const voipms = {
name: 'voip.ms',
serverHost: '127.0.0.1', // an IP: no DNS lookup in tests
username: '123456_pbx',
password: 'Sub-account pw!',
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload, cookie = admin) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
});
afterAll(() => t?.close());
it('is admin only', async () => {
await t.addUser('u@example.com', 'user-password-123');
const user = await t.login('u@example.com', 'user-password-123');
expect((await req('GET', '/api/trunks', undefined, user)).statusCode).toBe(403);
});
it('creates a voip.ms trunk with defaults; the password is stored encrypted and never returned', async () => {
const r = await req('POST', '/api/trunks', voipms);
expect(r.statusCode).toBe(201);
const dto = r.json();
id = dto.id;
expect(dto).toMatchObject({
provider: 'voipms',
serverPort: 5060,
transport: 'udp',
authMode: 'registration',
codecs: ['ulaw'],
hasPassword: true,
hasApiPassword: false,
});
expect(JSON.stringify(dto)).not.toContain('Sub-account pw!');
const [row] = await t.db.select().from(trunks).where(eq(trunks.id, id));
expect(decrypt(row!.passwordEnc!)).toBe('Sub-account pw!');
const [a] = await t.db.select().from(auditLog).where(eq(auditLog.entityId, id));
expect(JSON.stringify(a)).not.toContain('Sub-account pw!');
await settle();
const conf = pjsip();
expect(conf).toContain(`[trunk-${id}-reg]`);
expect(conf).toContain(`client_uri = sip:123456_pbx@127.0.0.1:5060`);
expect(conf).toContain(`password = Sub-account pw!`);
expect(conf).toContain(`context = sp-from-trunk-${id}`);
});
it('refuses duplicates, transports that are off, and servers that do not resolve', async () => {
expect((await req('POST', '/api/trunks', voipms)).statusCode).toBe(409);
const tls = await req('POST', '/api/trunks', { ...voipms, name: 'tls', transport: 'tls' });
expect(tls.statusCode).toBe(400);
expect(tls.json().error.message).toMatch(/TLS/);
const dns = await req('POST', '/api/trunks', {
...voipms,
name: 'typo',
serverHost: 'no-such-host.invalid',
});
expect(dns.statusCode).toBe(400);
expect(dns.json().error.message).toMatch(/Can't find the server/);
});
it('updates keep the stored password unless a new one is given; disabling removes the config', async () => {
const r = await req('PATCH', `/api/trunks/${id}`, { maxChannels: 4, codecs: ['ulaw', 'alaw'] });
expect(r.statusCode).toBe(200);
expect(r.json()).toMatchObject({ maxChannels: 4, hasPassword: true });
await settle();
expect(pjsip()).toContain('allow = ulaw,alaw');
expect(pjsip()).toContain('password = Sub-account pw!');
await req('PATCH', `/api/trunks/${id}`, { enabled: false });
await settle();
expect(pjsip()).not.toContain(`trunk-${id}`);
await req('PATCH', `/api/trunks/${id}`, { enabled: true });
});
it('the first trunk gets the default outbound rules (international off)', async () => {
const routes = (await req('GET', '/api/outbound-routes')).json().items;
expect(routes.map((r: { name: string; enabled: boolean }) => [r.name, r.enabled])).toEqual([
['Toll-free', true],
['Local and national (US/Canada)', true],
['International', false],
]);
expect(routes.every((r: { trunkIds: string[] }) => r.trunkIds[0] === id)).toBe(true);
});
it('refuses to delete a trunk that a phone number or an outbound rule uses', async () => {
const [d] = await t.db
.insert(dids)
.values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } })
.returning();
const r = await req('DELETE', `/api/trunks/${id}`);
expect(r.statusCode).toBe(409);
expect(r.json().error.details.usedBy).toEqual([
'number (312) 555-1234',
'outbound rule "Toll-free"',
'outbound rule "Local and national (US/Canada)"',
'outbound rule "International"',
]);
await t.db.delete(dids).where(eq(dids.id, d!.id));
await t.db.delete(outboundRoutes);
expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200);
await settle();
expect(pjsip()).not.toContain('trunk-');
});
});
describe.skipIf(!hasTestDb)('trunk live state (recorded session)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
beforeAll(async () => {
t = await createTestApp();
for (const [tid, name] of [
[GOOD, 'voip.ms'],
[BAD, 'wrong password'],
] as const)
await t.db.insert(trunks).values({ id: tid, name, serverHost: 'sp-trunk-provider' });
t.ami.lists.PJSIPShowRegistrationsOutbound = reply('rec-2');
t.ami.lists.PJSIPShowEndpoint = reply('rec-4');
});
afterAll(() => t?.close());
it('loads registration and reachability on connect', async () => {
await t.live.loadFromAsterisk();
const byId = Object.fromEntries(t.live.snapshot(true).trunks!.map((x) => [x.trunkId, x]));
expect(byId[GOOD]).toMatchObject({ registration: 'registered', reachable: true });
expect(byId[BAD]).toMatchObject({ registration: 'rejected', reachable: true }); // canned reply
expect(t.live.snapshot(false).trunks).toBeUndefined(); // users never see trunks
});
it('follows Registry and ContactStatus events', async () => {
t.ami.lists.PJSIPShowRegistrationsOutbound = [];
const msgs: LiveMessage[] = [];
t.live.on('message', (m) => msgs.push(m));
for (const m of LIVE_EVENTS) t.live.handleEvent(m); // replays two Registry events
await new Promise((r) => setTimeout(r, 20));
const trunk = (tid: string) => t.live.snapshot(true).trunks!.find((x) => x.trunkId === tid)!;
expect(trunk(GOOD).registration).toBe('unknown'); // re-read: the canned list is now empty
t.live.handleEvent({
Event: 'ContactStatus',
ContactStatus: 'Unreachable',
AOR: `trunk-${GOOD}`,
EndpointName: `trunk-${GOOD}`,
URI: 'sip:sp-trunk-provider:5060',
});
expect(trunk(GOOD).reachable).toBe(false);
expect(msgs.some((m) => m.type === 'trunk.updated' && m.data.trunkId === GOOD)).toBe(true);
expect(msgs.some((m) => m.type === 'extension.updated')).toBe(false);
});
it('POST /:id/test qualifies the trunk and explains the result', async () => {
t.ami.lists.PJSIPShowRegistrationsOutbound = reply('rec-2');
t.live.amiConnected = true;
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'POST',
url: `/api/trunks/${GOOD}/test`,
headers: { ...H, cookie },
});
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({
registration: 'registered',
rttMs: 1,
detail: 'Registered with sp-trunk-provider',
});
expect(t.ami.actions).toContainEqual({ Action: 'PJSIPQualify', Endpoint: `trunk-${GOOD}` });
const bad = await t.app.inject({
method: 'POST',
url: `/api/trunks/${BAD}/test`,
headers: { ...H, cookie },
});
expect(bad.json().detail).toBe('sp-trunk-provider rejected the username or password');
});
});