/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
251 lines
9.8 KiB
TypeScript
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,
|
|
}),
|
|
]);
|
|
});
|
|
});
|