import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { DnsRecordRequest, HookLogger } from '@celilo/capabilities'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { capabilities, moduleConfigs, modules, webRoutes } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { backfillWebRouteDns } from './dns-provider-backfill'; const silentLogger: HookLogger = { info() {}, warn() {}, error() {}, debug() {}, } as unknown as HookLogger; describe('backfillWebRouteDns (ISS-0029)', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-webroute-backfill-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); function seedModule(id: string) { getDb() .insert(modules) .values({ id, name: id, version: '1.0.0', manifestData: { id, name: id, version: '1.0.0', celilo_contract: '1.0' }, sourcePath: `/tmp/${id}`, }) .run(); } function seedFirewall(natIp: string) { seedModule('iptables'); getDb() .insert(capabilities) .values({ moduleId: 'iptables', capabilityName: 'firewall', version: '1.0.0', data: {} }) .run(); getDb() .insert(moduleConfigs) .values({ moduleId: 'iptables', key: 'nat_ip', value: natIp, valueJson: JSON.stringify(natIp), }) .run(); } function seedRoute(hostname: string, path = '/') { // web_routes.module_id is a FK → modules; ensure the owner exists. getDb() .insert(modules) .values({ id: 'caddy', name: 'caddy', version: '1.0.0', manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' }, sourcePath: '/tmp/caddy', }) .onConflictDoNothing() .run(); getDb() .insert(webRoutes) .values({ slug: `${hostname}${path}`, moduleId: 'caddy', type: 'reverse_proxy', path, hostname, }) .run(); } /** Stub capability loader returning a dns_internal that records its calls. */ function stubLoader(calls: DnsRecordRequest[]) { return async () => ({ dns_internal: { async registerRecord(req: DnsRecordRequest) { calls.push(req); }, async deleteRecord() {}, }, }); } it('registers each DISTINCT web-route hostname at the firewall nat_ip', async () => { seedFirewall('100.64.0.1'); seedRoute('apt.celilo.computer', '/'); seedRoute('apt.celilo.computer', '/-/publish'); // same host, different path → deduped seedRoute('registry.example.net', '/'); const calls: DnsRecordRequest[] = []; await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls)); expect(calls.map((c) => c.host).sort()).toEqual([ 'apt.celilo.computer', 'registry.example.net', ]); expect(calls.every((c) => c.value === '100.64.0.1' && c.type === 'A')).toBe(true); }); it('skips without throwing when no firewall nat_ip is available', async () => { seedRoute('apt.celilo.computer'); const calls: DnsRecordRequest[] = []; await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls)); expect(calls).toHaveLength(0); }); it('is a no-op when there are no web routes', async () => { seedFirewall('100.64.0.1'); const calls: DnsRecordRequest[] = []; await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls)); expect(calls).toHaveLength(0); }); it('aggregates per-host failures into one error', async () => { seedFirewall('100.64.0.1'); seedRoute('a.celilo.computer'); seedRoute('b.celilo.computer'); const failing = async () => ({ dns_internal: { async registerRecord(req: DnsRecordRequest) { if (req.host === 'a.celilo.computer') throw new Error('boom'); }, async deleteRecord() {}, }, }); await expect(backfillWebRouteDns('technitium', getDb(), silentLogger, failing)).rejects.toThrow( /failed for 1 host/, ); }); });