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 { PortForwardStore } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { portForwards } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { deleteClaimedRows } from './capability-table-rows'; import { buildPortForwardStore } from './port-forwards'; const FW = '192.168.0.254'; const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' }; describe('port-forward store', () => { let dir: string; let db: DbClient; let store: PortForwardStore; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'pf-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); store = buildPortForwardStore(db, 'caddy'); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('declares and lists forwards for a firewall', () => { store.replace(FW, CADDY, [80, 443]); const forwards = store.list(FW); expect(forwards).toHaveLength(2); expect(forwards.map((f) => f.port).sort((a, b) => a - b)).toEqual([80, 443]); }); it('stamps registeredBy from the bound consumer, and no caller can supply it', () => { store.replace(FW, CADDY, [443]); expect(store.list(FW)[0].registeredBy).toBe('caddy'); // The write takes only (firewall, target, ports) — there is no parameter an // owner could be passed through, which is the point rather than an omission. expect(store.replace.length).toBe(3); }); it('replace is idempotent — same declaration twice → one row per port', () => { store.replace(FW, { ...CADDY, description: 'v1' }, [443]); store.replace(FW, { ...CADDY, description: 'v2' }, [443]); const forwards = store.list(FW); expect(forwards).toHaveLength(1); expect(forwards[0].description).toBe('v2'); }); it('scopes forwards by firewallIp', () => { store.replace(FW, CADDY, [443]); store.replace( '10.0.30.254', { internalIp: '10.0.30.5', protocol: 'UDP', description: 'b' }, [53], ); expect(store.list(FW)).toHaveLength(1); expect(store.list('10.0.30.254')).toHaveLength(1); }); it('ingressIp forwards are distinct from the public (NULL) ones', () => { const target = { internalIp: '10.0.20.5', protocol: 'UDP' as const, description: 'dns' }; store.replace(FW, target, [53]); store.replace(FW, { ...target, ingressIp: '10.0.10.53' }, [53]); expect(store.list(FW)).toHaveLength(2); // Re-declaring the public (NULL-ingress) set as empty leaves the ingress one. store.replace(FW, target, []); const forwards = store.list(FW); expect(forwards).toHaveLength(1); expect(forwards[0].ingressIp).toBe('10.0.10.53'); }); // D5b / celilo#855. Before this, `exposeService` upserted per port and nothing // ever removed a forward a consumer stopped wanting: a module that exposed // :8080 and redeployed exposing :9090 kept both, forever. it('re-declaring with a shorter port list drops the ports left out', () => { store.replace(FW, CADDY, [80, 443, 8080]); store.replace(FW, CADDY, [80, 443]); expect( store .list(FW) .map((f) => f.port) .sort((a, b) => a - b), ).toEqual([80, 443]); }); it('a consumer narrowing its declaration withdraws no other module rows', () => { const forgejo = buildPortForwardStore(db, 'forgejo'); store.replace(FW, CADDY, [80, 443]); forgejo.replace(FW, { internalIp: '10.0.20.42', protocol: 'TCP', description: 'git' }, [2222]); store.replace(FW, CADDY, [443]); expect(store.list(FW).filter((f) => f.registeredBy === 'forgejo')).toHaveLength(1); }); // D5a, the refcount case — the bug most likely to ship silently. The owner is // IN the unique index, so two consumers of the same forward are two rows and // one leaving does not delete a rule the other still needs. it('two consumers of the SAME forward are two rows, and one leaving leaves the other', () => { const other = buildPortForwardStore(db, 'greenwave-app'); store.replace(FW, CADDY, [443]); other.replace(FW, { ...CADDY, description: 'also 443' }, [443]); expect(store.list(FW)).toHaveLength(2); // Through the GENERIC declaration-driven path, which replaced // `deletePortForwardsForModule`. The property is unchanged; what changed is // that core no longer names this capability's table to clear it. deleteClaimedRows(db, 'caddy'); const left = store.list(FW); expect(left).toHaveLength(1); expect(left[0].registeredBy).toBe('greenwave-app'); expect(left[0].port).toBe(443); }); // Migration 0025 leaves pre-existing rows with an empty owner so the fleet // keeps serving. They are adopted the first time their owner re-declares the // same target, which is the only moment we can safely say who owns them. it('adopts an unattributed legacy row when its owner re-declares the target', () => { db.insert(portForwards) .values({ firewallIp: FW, internalIp: CADDY.internalIp, port: 443, protocol: 'TCP', description: 'legacy', registeredBy: '', }) .run(); store.replace(FW, CADDY, [443]); const rows = db.select().from(portForwards).where(eq(portForwards.firewallIp, FW)).all(); expect(rows).toHaveLength(1); expect(rows[0].registeredBy).toBe('caddy'); }); });