/** * The ONE consumer-removal rule, driven by the declarations * (openspec/changes/capability-owned-tables task 2.4). * * Before this, "the row dies with its consumer" had five implementations for one * rule, and two of them were functions core imported BY NAME from * `consumer-cleanup.ts` — the file whose whole purpose is deleting exactly that * kind of per-capability special-casing. * * The property worth protecting is not "port forwards get deleted". It is that * core clears EVERY declared table without naming any of them, so declaring a * new one is sufficient to have it cleaned up. */ 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 { allDeclaredTables } from '@celilo/capabilities'; import type { DbClient } from '../db/client'; import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows'; const FW = '192.168.0.254'; describe('planClaimedRowDeletion', () => { /** * The anti-regression that matters. If someone declares a table and this plan * does not grow, their rows outlive the consumer silently — which is the whole * failure the declaration exists to make impossible. */ it('covers every declared table, so declaring one is enough to have it cleared', () => { const planned = new Set(planClaimedRowDeletion().map((t) => t.table)); const declared = allDeclaredTables().map((d) => d.declaration.table); expect(declared.length).toBeGreaterThan(0); for (const table of declared) expect(planned.has(table)).toBe(true); }); it("carries each table's OWN claim column, which is spelled three ways", () => { const byTable = new Map(planClaimedRowDeletion().map((t) => [t.table, t.claimColumn])); expect(byTable.get('web_routes')).toBe('module_id'); expect(byTable.get('port_forwards')).toBe('registered_by'); expect(byTable.get('trusted_sources')).toBe('registered_by'); expect(byTable.get('dns_internal_records')).toBe('consumer_module_id'); }); }); describe('deleteClaimedRows', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'claimed-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); for (const id of ['caddy', 'forgejo', 'technitium']) { db.insert(modules) .values({ id, name: id, version: '1.0.0', state: 'VERIFIED', sourcePath: `/tmp/${id}`, manifestData: {}, }) .run(); } }); afterEach(() => { db.$client.close(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); function seed(consumer: string, hostname: string, port: number, subnet: string) { db.insert(webRoutes) .values({ slug: `${consumer}-slug`, moduleId: consumer, type: 'reverse_proxy', path: `/${consumer}`, hostname, }) .run(); db.insert(portForwards) .values({ firewallIp: FW, internalIp: '10.0.20.5', port, protocol: 'TCP', registeredBy: consumer, }) .run(); db.insert(trustedSources).values({ firewallIp: FW, subnet, registeredBy: consumer }).run(); db.insert(dnsInternalRecords) .values({ providerModuleId: 'technitium', consumerModuleId: consumer, host: hostname, ip: '10.0.20.5', }) .run(); } it('clears the departing consumer from all four declared tables at once', () => { seed('caddy', 'a.example.org', 443, '10.1.0.0/24'); const cleared = deleteClaimedRows(db, 'caddy'); expect(cleared.map((c) => c.table).sort()).toEqual([ 'dns_internal_records', 'port_forwards', 'trusted_sources', 'web_routes', ]); expect(db.select().from(webRoutes).all()).toHaveLength(0); expect(db.select().from(portForwards).all()).toHaveLength(0); expect(db.select().from(trustedSources).all()).toHaveLength(0); expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0); }); /** * The refcount case, the bug most likely to ship silently. Two consumers each * hold their own row, and one leaving must not withdraw what the other still * needs. */ it("leaves another consumer's rows untouched", () => { seed('caddy', 'a.example.org', 443, '10.1.0.0/24'); seed('forgejo', 'b.example.org', 2222, '10.2.0.0/24'); deleteClaimedRows(db, 'caddy'); expect( db .select() .from(webRoutes) .all() .map((r) => r.moduleId), ).toEqual(['forgejo']); expect( db .select() .from(portForwards) .all() .map((r) => r.registeredBy), ).toEqual(['forgejo']); expect( db .select() .from(trustedSources) .all() .map((r) => r.registeredBy), ).toEqual(['forgejo']); expect( db .select() .from(dnsInternalRecords) .all() .map((r) => r.consumerModuleId), ).toEqual(['forgejo']); }); /** * `dns_internal_records` is claimed by its CONSUMER, never its provider. The * declaration cannot express anything else, which is what makes celilo#1010 * unrepresentable — but the runtime rule has to agree, or the declaration is * decoration. */ it('does not treat the PROVIDER as the claimant of a dns_internal record', () => { seed('caddy', 'a.example.org', 443, '10.1.0.0/24'); deleteClaimedRows(db, 'technitium'); expect(db.select().from(dnsInternalRecords).all()).toHaveLength(1); }); it('is a no-op for a module that claimed nothing', () => { seed('caddy', 'a.example.org', 443, '10.1.0.0/24'); deleteClaimedRows(db, 'forgejo'); expect(db.select().from(webRoutes).all()).toHaveLength(1); expect(db.select().from(portForwards).all()).toHaveLength(1); }); });