generator: core apply loop, sanitizers, system config routes

render() is pure and gives every file the header (bodies arrive per task).
apply() runs one at a time, debounces requestApply, writes changed files
atomically (mode 0640), keeps .prev, sends each needed reload once, skips
pjsip reload and sets restartRequired when transports change, and records
generator_runs. AMI connects on startup and every reconnect forces a full
apply. Admin routes: config apply/rollback and a masked generated-files view.

Task: M2-T2
This commit is contained in:
iamdoubz committed 2026-10-17 16:17:13 -05:00
1 parent 70b0a92666
commit a5de247948
13 files changed
+725 -6

No files matched your search

+5
View File
@@ -7,13 +7,17 @@ import fastifyStatic from '@fastify/static';
import { sql } from 'drizzle-orm';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { systemRoutes } from './routes/system.ts';
declare module 'fastify' {
interface FastifyInstance {
db: Db;
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
}
}
@@ -67,6 +71,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(authRoutes);
app.register(userRoutes);
app.register(systemRoutes);
if (existsSync(publicDir)) {
app.register(fastifyStatic, { root: publicDir, wildcard: false });
+1
View File
@@ -8,6 +8,7 @@ const env = {
DATABASE_URL: 'postgres://u:p@127.0.0.1:5433/db',
INITIAL_ADMIN_EMAIL: 'admin@example.com',
INITIAL_ADMIN_PASSWORD: 'x',
AMI_SECRET: 'ami-secret-123',
};
describe('config', () => {
+6 -1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
// ponytail: only the vars read so far; AMI vars join in M2 with the code that reads them.
const EnvSchema = z.object({
APP_SECRET: z
.string()
@@ -10,6 +9,12 @@ const EnvSchema = z.object({
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
AMI_HOST: z.string().default('127.0.0.1'),
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
AMI_USERNAME: z.string().default('selfphoneng'),
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
+222
View File
@@ -0,0 +1,222 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// DB → Asterisk config (03). render() is pure; apply() writes changed files and reloads Asterisk.
import { createHash } from 'node:crypto';
import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { AmiClient } from '../ami/client.ts';
import type { Db } from '../db/client.ts';
import { generatorRuns } from '../db/schema.ts';
import { getSetting, setSetting } from '../services/settings.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
export const HEADER = '; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.\n';
/** Generated file → the AMI Command that makes Asterisk load it (03 §3). Order = reload order. */
export const RELOADS = {
'pjsip_selfphoneng.conf': 'pjsip reload',
'rtp_selfphoneng.conf': 'module reload res_rtp_asterisk.so',
'extensions_globals_selfphoneng.conf': 'dialplan reload',
'extensions_selfphoneng.conf': 'dialplan reload',
'voicemail_selfphoneng.conf': 'voicemail reload',
'queues_selfphoneng.conf': 'queue reload all',
'musiconhold_selfphoneng.conf': 'moh reload',
'confbridge_selfphoneng.conf': 'module reload app_confbridge.so',
} as const;
export type GeneratedFileName = keyof typeof RELOADS;
export const GENERATED_FILES = Object.keys(RELOADS) as GeneratedFileName[];
export type RenderedFiles = Record<GeneratedFileName, string>;
// Each file's body; tasks fill these in (M2-T3 pjsip/rtp, M2-T4 dialplan, ...).
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {};
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
export function render(snap: Snapshot): RenderedFiles {
return Object.fromEntries(
GENERATED_FILES.map((f) => [f, HEADER + (BODIES[f]?.(snap) ?? '')]),
) as RenderedFiles;
}
/** The transport block: [global], [system] and every type=transport section (03 §3). */
export function renderTransports(_snap: Snapshot): string {
return ''; // M2-T3
}
export type ApplyResult = {
ok: boolean;
error: string | null;
filesChanged: GeneratedFileName[];
commands: string[];
restartRequired: boolean;
};
export type GeneratorDeps = {
db: Db;
ami: Pick<AmiClient, 'isConnected' | 'command'>;
/** /etc/asterisk/selfphoneng */
dir: string;
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
onApplied?: (r: ApplyResult) => void;
debounceMs?: number;
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export function createGenerator(deps: GeneratorDeps) {
const { db, ami, dir, log } = deps;
let chain: Promise<unknown> = Promise.resolve();
let timer: NodeJS.Timeout | undefined;
/** One at a time: each call waits for the previous one (process-wide mutex). */
function locked<T>(fn: () => Promise<T>): Promise<T> {
const run = chain.then(fn);
chain = run.catch(() => undefined);
return run;
}
async function writeAtomic(name: string, content: string) {
const path = join(dir, name);
await writeFile(`${path}.tmp`, content, { mode: 0o640 });
await rename(`${path}.tmp`, path);
}
async function sendReloads(
commands: string[],
): Promise<{ sent: string[]; error: string | null }> {
if (!ami.isConnected()) return { sent: [], error: 'ami-disconnected' };
const sent: string[] = [];
for (const c of commands) {
try {
await ami.command(c);
sent.push(c);
} catch (e) {
return { sent, error: `${c}: ${(e as Error).message}` };
}
}
return { sent, error: null };
}
async function record(trigger: string, r: ApplyResult, t0: number) {
await db.insert(generatorRuns).values({
trigger,
filesChanged: r.filesChanged,
commands: r.commands,
ok: r.ok,
error: r.error,
durationMs: Math.round(performance.now() - t0),
});
log[r.ok ? 'info' : 'error'](
{ trigger, filesChanged: r.filesChanged, commands: r.commands, error: r.error },
'generator apply',
);
deps.onApplied?.(r);
}
async function doApply(trigger: string, force: boolean): Promise<ApplyResult> {
const t0 = performance.now();
const r: ApplyResult = {
ok: true,
error: null,
filesChanged: [],
commands: [],
restartRequired: false,
};
try {
const snap = await loadSnapshot(db);
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>();
for (const f of GENERATED_FILES) {
current.set(f, await readFile(join(dir, f), 'utf8').catch(() => null));
if (current.get(f) !== files[f]) r.filesChanged.push(f);
}
if (r.filesChanged.length === 0 && !force) {
await record(trigger, r, t0);
return r;
}
if (r.filesChanged.length) {
// Keep exactly one previous version for rollback.
for (const [f, content] of current)
if (content !== null) await writeFile(join(dir, '.prev', f), content, { mode: 0o640 });
for (const f of r.filesChanged) await writeAtomic(f, files[f]);
}
// PJSIP transports can't be reloaded safely (01 §4.3). The first apply ever (no stored hash)
// only adds transports to an Asterisk that booted with none, so a reload is fine then.
const hash = sha256(renderTransports(snap));
const prevHash = await getSetting(db, 'system.transportHash');
const transportsChanged = prevHash !== null && prevHash !== hash;
if (prevHash !== hash) await setSetting(db, 'system.transportHash', hash);
if (transportsChanged) await setSetting(db, 'system.restartRequired', true);
r.restartRequired = await getSetting(db, 'system.restartRequired');
const reloadFor = force ? GENERATED_FILES : r.filesChanged;
const commands = [...new Set(reloadFor.map((f) => RELOADS[f]))].filter(
(c) => !(transportsChanged && c === 'pjsip reload'),
);
// TODO(M8): after `queue reload all`, re-send QueuePause for paused members (03 §1 step 9).
const { sent, error } = await sendReloads(commands);
r.commands = sent;
if (error) Object.assign(r, { ok: false, error });
} catch (e) {
Object.assign(r, { ok: false, error: (e as Error).message });
}
await record(trigger, r, t0);
return r;
}
return {
dir,
apply: (opts: { trigger: string; force?: boolean }) =>
locked(() => doApply(opts.trigger, opts.force ?? false)),
/** Debounced (1 s by default): many quick changes become one apply. */
requestApply(trigger: string) {
clearTimeout(timer);
timer = setTimeout(() => {
void locked(() => doApply(trigger, false));
}, deps.debounceMs ?? 1000);
},
/** POST /api/system/config/rollback: put .prev back and reload everything. */
rollback: () =>
locked(async () => {
const t0 = performance.now();
const r: ApplyResult = {
ok: true,
error: null,
filesChanged: [],
commands: [],
restartRequired: false,
};
for (const f of GENERATED_FILES) {
const prev = join(dir, '.prev', f);
const restored = await copyFile(prev, join(dir, `${f}.tmp`)).then(
() => true,
() => false,
);
if (restored) {
await rename(join(dir, `${f}.tmp`), join(dir, f));
r.filesChanged.push(f);
}
}
if (r.filesChanged.length === 0)
Object.assign(r, { ok: false, error: 'no previous version' });
else {
const { sent, error } = await sendReloads([...new Set(Object.values(RELOADS))]);
r.commands = sent;
if (error) Object.assign(r, { ok: false, error });
}
r.restartRequired = await getSetting(db, 'system.restartRequired');
await record('rollback', r, t0);
return r;
}),
/** Cancels a pending debounced apply (shutdown, tests). */
cancelPending: () => clearTimeout(timer),
};
}
export type Generator = ReturnType<typeof createGenerator>;
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Everything render() needs, loaded in one go (03 §1). render() itself does no I/O.
import type { Db } from '../db/client.ts';
import { getAllSettings } from '../services/settings.ts';
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
settings: Awaited<ReturnType<typeof getAllSettings>>;
};
export function todayIn(timezone: string, now = new Date()): string {
// en-CA formats as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
}
export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot> {
const settings = await getAllSettings(db);
return { today: todayIn(settings['general.timezone'], now), settings };
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import * as s from './sanitize.ts';
const HOSTILE = [
'101); System(rm -rf /',
'101\n',
'101\r\nexten => 1,1,System(x)',
'',
' 101',
'${SHELL(id)}',
'101', // full-width digits
'१२३', // Devanagari digits
'101;comment',
'1'.repeat(300),
];
describe('sanitizers reject hostile input', () => {
const strict = [
'digits',
'dialable',
'pattern',
'sound',
'ident',
'secret',
'pin',
'host',
'ipv4',
'cidr',
] as const;
for (const fn of strict) {
it.each(HOSTILE)(`${fn}(%j) throws`, (v) => {
expect(() => s[fn](v)).toThrow(s.SanitizeError);
});
}
});
describe('sanitizers accept good input', () => {
it.each([
['digits', '13125551234'],
['dialable', '+4420794609*#'],
['pattern', '_NXXNXXXXXX'.slice(1)],
['pattern', '011.'],
['pattern', '1[2-9]XX!'],
['sound', 'custom/3f2a-bc_d'],
['ident', '3f2a1b4c-0000-4000-8000-000000000000'],
['secret', 'Ab3dEf9hIj2kLm4nOp6qRs8t'],
['pin', '1234'],
['pin', '0123456789'],
['host', 'chicago1.voip.ms'],
['host', '10.0.0.1'],
['ipv4', '203.0.113.7'],
['cidr', '192.168.0.0/16'],
] as const)('%s(%s)', (fn, v) => {
expect(s[fn](v)).toBe(v);
});
it('rejects near misses', () => {
expect(() => s.pin('123')).toThrow();
expect(() => s.pin('12345678901')).toThrow();
expect(() => s.ipv4('256.1.1.1')).toThrow();
expect(() => s.cidr('10.0.0.0/33')).toThrow();
expect(() => s.host('-bad.example')).toThrow();
expect(() => s.sound('Custom/X')).toThrow();
expect(() => s.ident('UPPER')).toThrow();
});
});
describe('name()', () => {
it.each([
['Dan Doe', 'Dan Doe'],
[' Dan ', 'Dan'],
['x"); System(rm -rf /)', 'x Systemrm -rf /'],
["Robert'); DROP TABLE;--", 'Robert DROP TABLE--'],
['line\nbreak\r\nexten => 1,1,Hangup', 'linebreakexten => 11Hangup'.replace('=>', '=')],
['${SHELL(id)}', 'SHELLid'],
['a\u202Eb\u200Bc', 'abc'], // bidi override and zero-width space
['Zoë Ångström 李', 'Zoë Ångström 李'],
['x'.repeat(60), 'x'.repeat(40)],
])('%j → %j', (input, expected) => {
expect(s.name(input)).toBe(expected);
});
});
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Every value that goes into generated Asterisk config passes through one of these (03 §2).
// The API validates with the same rules first, so a throw here means a bug: apply fails loudly.
export class SanitizeError extends Error {}
function check(re: RegExp, kind: string, max = 200) {
return (v: string): string => {
if (typeof v !== 'string' || v.length > max || !re.test(v))
throw new SanitizeError(`${kind}: invalid value ${JSON.stringify(String(v).slice(0, 40))}`);
return v;
};
}
/** Internal numbers, DIDs. */
export const digits = check(/^[0-9]+$/, 'digits', 32);
/** Forward targets, external numbers. */
export const dialable = check(/^[0-9*#+]+$/, 'dialable', 32);
/** Outbound route patterns. */
export const pattern = check(/^[0-9XZN.![\]-]+$/, 'pattern', 32);
/** Sound file names for Playback/Background. */
export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
/** Context-name parts (uuids), MOH class names. */
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
/** Generated SIP passwords. */
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
/** Voicemail / conference PINs. */
export const pin = check(/^[0-9]{4,10}$/, 'pin', 10);
/** Trunk server host: a hostname or an IPv4 address. */
export const host = check(
/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/,
'host',
253,
);
const octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
/** IPv4 address. */
export const ipv4 = check(new RegExp(`^${octet}(?:\\.${octet}){3}$`), 'ipv4', 15);
/** IPv4 CIDR, e.g. local nets. */
export const cidr = check(
new RegExp(`^${octet}(?:\\.${octet}){3}/(?:3[0-2]|[12]?\\d)$`),
'cidr',
18,
);
/**
* Caller ID names and labels: removes characters that mean something in dialplan or config
* (" ' \ ; , ( ) [ ] { } $ < > |) and control characters, trims, max 40. Never throws.
*/
export function name(v: string): string {
return String(v)
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, '') // controls, incl. CR/LF, and invisible format chars
.replace(/["'\\;,()[\]{}$<>|]/g, '')
.trim()
.slice(0, 40)
.trim();
}
+31 -4
View File
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Startup order (04 §1).
import { resolve } from 'node:path';
import { AmiClient } from './ami/client.ts';
import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { createGenerator } from './generator/index.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
let config;
@@ -18,17 +21,41 @@ setSecretKey(config.APP_SECRET);
const { db, pool } = createDb(config.DATABASE_URL);
const app = buildApp(config, db); // creates the pino logger
const ami = new AmiClient({
host: config.AMI_HOST,
port: config.AMI_PORT,
username: config.AMI_USERNAME,
secret: config.AMI_SECRET,
logger: app.log,
});
const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
log: app.log,
});
app.decorate('generator', generator);
await runMigrations(db);
app.log.info('database migrated');
if (
(await ensureInitialAdmin(db, config.INITIAL_ADMIN_EMAIL, config.INITIAL_ADMIN_PASSWORD)) ===
'created'
)
const admin = await ensureInitialAdmin(
db,
config.INITIAL_ADMIN_EMAIL,
config.INITIAL_ADMIN_PASSWORD,
);
if (admin === 'created')
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
// Every (re)connect: Asterisk may have restarted, so push the full config again.
// TODO(M2-T6): load live state; TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => void generator.apply({ trigger: 'startup', force: true }));
ami.start();
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.once(sig, () => {
generator.cancelPending();
ami.stop();
void app
.close()
.then(() => pool.end())
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { maskSecrets } from './system.ts';
describe('maskSecrets', () => {
it('hides SIP passwords, AMI secrets and voicemail PINs, nothing else', () => {
const conf = [
'[101-auth]',
'type = auth',
'username = 101',
'password = Ab3dEf9hIj2kLm4nOp6qRs8t',
'secret=abc',
'[default]',
'101 => 482913,Dan Doe,,,tz=local|attach=no',
'exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))',
].join('\n');
const out = maskSecrets(conf);
expect(out).not.toMatch(/Ab3dEf9h|=abc|482913/);
expect(out).toContain('password = ********');
expect(out).toContain('101 => ********,Dan Doe,,,tz=local|attach=no');
expect(out).toContain('exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))');
expect(out).toContain('username = 101');
});
});
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/system/* (04 §7.3). Admin only.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { GENERATED_FILES } from '../generator/index.ts';
/** Generated files hold plaintext SIP passwords and PINs: mask them in the debug view. */
export function maskSecrets(conf: string): string {
return conf
.replace(/^(\s*(?:password|secret)\s*=\s*).+$/gim, '$1********')
.replace(/^(\s*\d+\s*=>\s*)[^,\n]*/gm, '$1********'); // voicemail.conf: "101 => PIN,Name,..."
}
export async function systemRoutes(app: FastifyInstance) {
app.addHook('preHandler', requireAdmin);
app.post('/api/system/config/apply', async (req) => {
const r = await app.generator.apply({ trigger: 'manual', force: true });
await audit(app.db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'config_apply',
summary: `Applied Asterisk config (${r.ok ? 'ok' : r.error})`,
});
return r;
});
app.post('/api/system/config/rollback', async (req) => {
const r = await app.generator.rollback();
await audit(app.db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'config_rollback',
summary: `Rolled back Asterisk config (${r.ok ? 'ok' : r.error})`,
});
return r;
});
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
GENERATED_FILES.map(async (name) => ({
name,
content: maskSecrets(await readFile(join(app.generator.dir, name), 'utf8').catch(() => '')),
})),
),
}));
}
+26
View File
@@ -12,8 +12,25 @@ import { users } from '../src/db/schema.ts';
import { hashPassword } from '../src/auth/passwords.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
import { createGenerator } from '../src/generator/index.ts';
import { createTestDb } from './db.ts';
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
export function fakeAmi() {
const commands: string[] = [];
return {
connected: true,
commands,
isConnected() {
return this.connected;
},
async command(c: string) {
commands.push(c);
return '';
},
};
}
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
export const H = { 'x-requested-with': 'selfphoneng' };
@@ -25,12 +42,19 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
DATABASE_URL: t.url,
INITIAL_ADMIN_EMAIL: ADMIN.email,
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
});
setSecretKey(config.APP_SECRET);
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
const app = buildApp(config, t.db, pub);
const ami = fakeAmi();
const genDir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
app.decorate(
'generator',
createGenerator({ db: t.db, ami, dir: genDir, log: app.log, debounceMs: 20 }),
);
extend?.(app);
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
resetLoginLimiter();
@@ -65,6 +89,8 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
return {
app,
db: t.db,
ami,
genDir,
login,
addUser,
clearMustChange,
+197
View File
@@ -0,0 +1,197 @@
// 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 } 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([
'pjsip reload',
'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(HEADER);
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,
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');
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('pjsip reload');
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'`,
);
});
});
+4 -1
View File
@@ -8,7 +8,10 @@ export default tseslint.config(
{ ignores: ['**/dist/', 'data/', '**/node_modules/', '.pnpm-store/'] },
js.configs.recommended,
tseslint.configs.recommended,
{ languageOptions: { globals: globals.node } },
{
languageOptions: { globals: globals.node },
rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] },
},
{
files: ['apps/web/**/*.{ts,tsx}'],
languageOptions: { globals: globals.browser },