Files
selfphoneng/apps/api/test/generator.test.ts
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

251 lines
9.8 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M2-T2: apply() writes files atomically and sends only the reloads it needs (03 §1, §3).
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { asc } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Db } from '../src/db/client.ts';
import { generatorRuns } from '../src/db/schema.ts';
import {
GENERATED_FILES,
HEADER,
createGenerator,
render,
type Generator,
} from '../src/generator/index.ts';
import { loadSnapshot, todayIn } from '../src/generator/load.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { createTestDb, hasTestDb } from './db.ts';
import { fakeAmi } from './app.ts';
const silent = { info() {}, error() {} };
describe('loadSnapshot / render', () => {
it('todayIn uses the company timezone, not UTC', () => {
const lateNightUtc = new Date('2026-03-01T03:30:00Z'); // still Feb 28 in Chicago
expect(todayIn('America/Chicago', lateNightUtc)).toBe('2026-02-28');
expect(todayIn('UTC', lateNightUtc)).toBe('2026-03-01');
});
});
describe.skipIf(!hasTestDb)('generator apply', () => {
let db: Db;
let drop: () => Promise<void>;
let dir: string;
let ami: ReturnType<typeof fakeAmi>;
let gen: Generator;
const applied: unknown[] = [];
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
ami = fakeAmi();
gen = createGenerator({
db,
ami,
dir,
log: silent,
debounceMs: 30,
onApplied: (r) => applied.push(r),
});
});
it('render() gives every file the header and is deterministic', async () => {
const snap = await loadSnapshot(db);
const a = render(snap);
expect(Object.keys(a).sort()).toEqual([...GENERATED_FILES].sort());
for (const f of GENERATED_FILES) expect(a[f].startsWith(HEADER)).toBe(true);
expect(render(structuredClone(snap))).toEqual(a);
});
it('first apply writes every file and reloads everything once', async () => {
const r = await gen.apply({ trigger: 'startup' });
expect(r).toMatchObject({ ok: true, error: null });
expect(r.filesChanged.sort()).toEqual([...GENERATED_FILES].sort());
expect(ami.commands).toEqual([
'module reload res_pjsip.so',
'module reload res_rtp_asterisk.so',
'dialplan reload', // two dialplan files, one reload
'voicemail reload',
'queue reload all',
'moh reload',
'module reload app_confbridge.so',
]);
const names = readdirSync(dir).sort();
expect(names).toEqual(['.prev', ...GENERATED_FILES].sort()); // no leftover .tmp files
expect(readFileSync(join(dir, 'pjsip_selfphoneng.conf'), 'utf8')).toBe(
render(await loadSnapshot(db))['pjsip_selfphoneng.conf'],
);
expect(await getSetting(db, 'system.restartRequired')).toBe(false);
});
it('nothing changed → no writes, no reloads, but a run is recorded', async () => {
await gen.apply({ trigger: 'startup' });
ami.commands.length = 0;
const before = (await db.select().from(generatorRuns)).length;
const r = await gen.apply({ trigger: 'api-change' });
expect(r).toMatchObject({ ok: true, filesChanged: [], commands: [] });
expect(ami.commands).toEqual([]);
const runs = await db.select().from(generatorRuns).orderBy(asc(generatorRuns.id));
expect(runs).toHaveLength(before + 1);
expect(runs.at(-1)).toMatchObject({
trigger: 'api-change',
ok: true,
filesChanged: [],
commands: [],
});
});
it('only the changed file is rewritten and only its module reloaded; .prev keeps the old one', async () => {
await gen.apply({ trigger: 'startup' });
ami.commands.length = 0;
writeFileSync(join(dir, 'queues_selfphoneng.conf'), '; hand edit\n'); // drift on disk
const r = await gen.apply({ trigger: 'api-change' });
expect(r.filesChanged).toEqual(['queues_selfphoneng.conf']);
expect(ami.commands).toEqual(['queue reload all']);
expect(readFileSync(join(dir, 'queues_selfphoneng.conf'), 'utf8')).toBe(HEADER);
expect(readFileSync(join(dir, '.prev', 'queues_selfphoneng.conf'), 'utf8')).toBe(
'; hand edit\n',
);
});
it('force reloads everything even when no file changed', async () => {
await gen.apply({ trigger: 'startup' });
ami.commands.length = 0;
const r = await gen.apply({ trigger: 'startup', force: true });
expect(r.filesChanged).toEqual([]);
expect(ami.commands).toHaveLength(7);
});
it('AMI down → files written, run recorded as ami-disconnected', async () => {
ami.connected = false;
const r = await gen.apply({ trigger: 'api-change' });
expect(r).toMatchObject({ ok: false, error: 'ami-disconnected', commands: [] });
expect(r.filesChanged.length).toBeGreaterThan(0);
const [last] = await db
.select()
.from(generatorRuns)
.orderBy(asc(generatorRuns.id))
.limit(1)
.offset((await db.select().from(generatorRuns)).length - 1);
expect(last).toMatchObject({ ok: false, error: 'ami-disconnected' });
});
it('applies run one at a time and requestApply debounces bursts into one apply', async () => {
const order: string[] = [];
const slowAmi = {
isConnected: () => true,
action: async () => ({}),
command: async (c: string) => {
order.push(`start ${c}`);
await new Promise((r) => setTimeout(r, 5));
order.push(`end ${c}`);
return '';
},
};
const g = createGenerator({ db, ami: slowAmi, dir, log: silent });
await Promise.all([
g.apply({ trigger: 'a', force: true }),
g.apply({ trigger: 'b', force: true }),
]);
for (let i = 0; i < order.length; i += 2) {
expect(order[i]!.replace('start', '')).toBe(order[i + 1]!.replace('end', '')); // never interleaved
}
applied.length = 0;
for (let i = 0; i < 5; i++) gen.requestApply('api-change');
// Wait for the apply (slow under a full parallel run), then make sure no second one follows.
await vi.waitFor(() => expect(applied.length).toBeGreaterThan(0), { timeout: 5000 });
await new Promise((r) => setTimeout(r, 150));
expect(applied).toHaveLength(1);
});
it('transport change → no pjsip reload, restartRequired set', async () => {
// Simulate a previous apply that recorded a different transport block.
await setSetting(db, 'system.transportHash', 'an-old-hash');
await setSetting(db, 'system.restartRequired', false);
writeFileSync(join(dir, 'pjsip_selfphoneng.conf'), '; old\n');
const r = await gen.apply({ trigger: 'api-change' });
expect(r.restartRequired).toBe(true);
expect(ami.commands).not.toContain('module reload res_pjsip.so');
expect(await getSetting(db, 'system.restartRequired')).toBe(true);
await setSetting(db, 'system.restartRequired', false);
});
it('rollback restores .prev and reloads everything', async () => {
await gen.apply({ trigger: 'startup' });
writeFileSync(join(dir, 'moh_x'), ''); // unrelated files are ignored
writeFileSync(join(dir, '.prev', 'musiconhold_selfphoneng.conf'), '; previous\n');
ami.commands.length = 0;
const r = await gen.rollback();
expect(r.ok).toBe(true);
expect(readFileSync(join(dir, 'musiconhold_selfphoneng.conf'), 'utf8')).toBe('; previous\n');
expect(ami.commands).toHaveLength(7);
});
it('a render error fails the apply loudly without touching the files', async () => {
await gen.apply({ trigger: 'startup' });
await db.execute(
// Bypass the settings service validation the way a bug would.
(await import('drizzle-orm'))
.sql`insert into settings (key, value) values ('general.timezone', '"Not/AZone"')
on conflict (key) do update set value = excluded.value`,
);
const r = await gen.apply({ trigger: 'api-change' });
expect(r.ok).toBe(false);
expect(r.error).toMatch(/time ?zone/i);
await db.execute(
(await import('drizzle-orm')).sql`delete from settings where key = 'general.timezone'`,
);
});
});
describe.skipIf(!hasTestDb)('loadSnapshot', () => {
// The database is made in a hook (60 s): under a full parallel run, creating and migrating it
// can pass the 20 s a test gets.
let testDb: Awaited<ReturnType<typeof createTestDb>>;
beforeAll(async () => {
testDb = await createTestDb();
});
afterAll(() => testDb?.drop());
it('decrypts SIP passwords and resolves caller ID DIDs and admin flags', async () => {
const { db } = testDb;
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
});
});