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 } from '@celilo/capabilities'; import type { ViewOverride } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { dnsInternalRecords, modules } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { listDnsInternalRecords, listViewOverrides, recordDnsInternalRecord, removeDnsInternalRecord, withDnsInternalLedger, } from './dns-internal-records'; describe('dns-internal-records ledger', () => { let dir: string; let dbPath: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'dns-int-')); dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); // FK targets: provider + consumer modules. for (const id of ['technitium', 'forgejo']) { db.insert(modules) .values({ id, name: id, version: '1.0.0', state: 'VERIFIED', manifestData: {}, sourcePath: `/src/${id}`, }) .run(); } }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); const ctx = () => ({ db, providerModuleId: 'technitium', consumerModuleId: 'forgejo' }); it('records, lists, and upserts by (provider, host)', () => { recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '10.0.20.14' }); let rows = listDnsInternalRecords(db); expect(rows).toHaveLength(1); expect(rows[0].ip).toBe('10.0.20.14'); // Same (provider, host) updates in place — the natIp fix re-registering. recordDnsInternalRecord(db, { ...ctx(), host: 'git-ssh.x', ip: '192.168.0.253' }); rows = listDnsInternalRecords(db); expect(rows).toHaveLength(1); expect(rows[0].ip).toBe('192.168.0.253'); }); it('removes a record by (provider, host)', () => { recordDnsInternalRecord(db, { ...ctx(), host: 'a.x', ip: '1.1.1.1' }); recordDnsInternalRecord(db, { ...ctx(), host: 'b.x', ip: '2.2.2.2' }); removeDnsInternalRecord(db, { providerModuleId: 'technitium', host: 'a.x' }); const rows = listDnsInternalRecords(db); expect(rows.map((r) => r.host)).toEqual(['b.x']); }); it('listViewOverrides returns only fronted records (zone IP set), as host→zoneIp', () => { recordDnsInternalRecord(db, { ...ctx(), host: 'plain.x', ip: '192.168.0.253' }); recordDnsInternalRecord(db, { ...ctx(), host: 'git.celilo.computer', ip: '192.168.0.253', zoneRoutableIp: '10.0.10.10', }); const overrides = listViewOverrides(db, 'technitium'); expect(overrides).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]); }); describe('withDnsInternalLedger', () => { function fakeProvider(withViews = false) { const calls: Array<['register' | 'delete', DnsRecordRequest]> = []; const reconcileCalls: ViewOverride[][] = []; const iface: { registerRecord(req: DnsRecordRequest): Promise; deleteRecord(req: DnsRecordRequest): Promise; reconcileViews?(o: ViewOverride[]): Promise; } = { async registerRecord(req: DnsRecordRequest) { calls.push(['register', req]); }, async deleteRecord(req: DnsRecordRequest) { calls.push(['delete', req]); }, }; if (withViews) { iface.reconcileViews = async (o: ViewOverride[]) => { reconcileCalls.push(o); }; } return { iface, calls, reconcileCalls }; } it('records A-record registrations and passes the call through', async () => { const { iface, calls } = fakeProvider(); const wrapped = withDnsInternalLedger(iface, ctx()); await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' }); expect(calls).toHaveLength(1); // underlying provider still called const rows = listDnsInternalRecords(db); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ host: 'git-ssh.x', ip: '192.168.0.253' }); }); it('does NOT ledger non-A records', async () => { const { iface } = fakeProvider(); const wrapped = withDnsInternalLedger(iface, ctx()); await wrapped.registerRecord({ host: 'mail.x', type: 'MX', value: 'mx.x' }); expect(listDnsInternalRecords(db)).toHaveLength(0); }); it('removes the ledger row on A-record delete', async () => { const { iface } = fakeProvider(); const wrapped = withDnsInternalLedger(iface, ctx()); await wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' }); await wrapped.deleteRecord({ host: 'git-ssh.x', type: 'A', value: '192.168.0.253' }); expect(listDnsInternalRecords(db)).toHaveLength(0); }); it('reconciles views from the full ledger set when a fronted record is registered', async () => { const { iface, reconcileCalls } = fakeProvider(true); const wrapped = withDnsInternalLedger(iface, ctx()); // A plain (non-fronted) record must NOT trigger a view reconcile. await wrapped.registerRecord({ host: 'plain.x', type: 'A', value: '192.168.0.253' }); expect(reconcileCalls).toHaveLength(0); // A fronted record (zoneRoutableValue set) triggers a reconcile from the // COMPLETE fronted set in the ledger. await wrapped.registerRecord({ host: 'git.celilo.computer', type: 'A', value: '192.168.0.253', zoneRoutableValue: '10.0.10.10', }); expect(reconcileCalls).toHaveLength(1); expect(reconcileCalls[0]).toEqual([{ host: 'git.celilo.computer', ip: '10.0.10.10' }]); }); it('reconciles views on delete (a removed host drops from the set)', async () => { const { iface, reconcileCalls } = fakeProvider(true); const wrapped = withDnsInternalLedger(iface, ctx()); await wrapped.registerRecord({ host: 'git.celilo.computer', type: 'A', value: '192.168.0.253', zoneRoutableValue: '10.0.10.10', }); await wrapped.deleteRecord({ host: 'git.celilo.computer', type: 'A', value: '192.168.0.253', }); // Last reconcile reflects the now-empty fronted set. expect(reconcileCalls.at(-1)).toEqual([]); }); it('is a no-op reconcile path for providers without view support', async () => { const { iface } = fakeProvider(false); const wrapped = withDnsInternalLedger(iface, ctx()); // Must not throw despite a fronted registration when reconcileViews is absent. await wrapped.registerRecord({ host: 'git.celilo.computer', type: 'A', value: '192.168.0.253', zoneRoutableValue: '10.0.10.10', }); expect(listViewOverrides(db, 'technitium')).toHaveLength(1); }); it('does not write the ledger if the underlying register throws', async () => { const iface = { async registerRecord(): Promise { throw new Error('resolver down'); }, async deleteRecord(): Promise {}, }; const wrapped = withDnsInternalLedger(iface, ctx()); await expect( wrapped.registerRecord({ host: 'git-ssh.x', type: 'A', value: '1.2.3.4' }), ).rejects.toThrow('resolver down'); expect(listDnsInternalRecords(db)).toHaveLength(0); }); }); }); /** * celilo#1010 — swapping the internal DNS provider must not delete the ledger. * * `dns_internal_records` used to cascade on `provider_module_id` as well as on * the consumer, so removing `technitium` to install `knot-unbound-internal` took * the fleet's entire internal DNS ledger with it, `zone_routable_ip` view * overrides included — the durable desired state the resolver's split-horizon * config is reconciled from. `web_routes` cascades on its consumer only, and the * two tables' docblocks claimed to be siblings, so the divergence read as intent * and was not. * * The claim on a capability-owned table is the CONSUMER * (openspec/changes/capability-owned-tables D3/D8). Migration 0027 makes the * table agree with the declaration, which cannot express anything else. */ describe('provider cascade (celilo#1010)', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'dns-cascade-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) { db.insert(modules) .values({ id, name: id, version: '1.0.0', state: 'VERIFIED', sourcePath: `/tmp/${id}`, manifestData: {}, }) .run(); } db.insert(dnsInternalRecords) .values({ providerModuleId: 'technitium', consumerModuleId: 'caddy', host: 'auth.example.org', ip: '192.168.0.253', zoneRoutableIp: '10.0.10.14', }) .run(); }); afterEach(() => { db.$client.close(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); it('keeps the ledger when the PROVIDER is removed, view overrides included', () => { db.delete(modules).where(eq(modules.id, 'technitium')).run(); const left = db.select().from(dnsInternalRecords).all(); expect(left).toHaveLength(1); // The override is the part whose loss is silent: the resolver keeps // answering, just with the wrong address for in-zone clients. expect(left[0]?.zoneRoutableIp).toBe('10.0.10.14'); }); it('still dies with its CONSUMER, which is the rule that did not change', () => { db.delete(modules).where(eq(modules.id, 'caddy')).run(); expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0); }); });