/** * §8.2 — the classification, on demand. * * The value of this command is entirely in what it SAYS, so that is what these * assert: the role of every interface, and — for anything unaccounted for — * what the next converge will actually do about it. "1 alien interface" with no * further guidance would repeat the failure this whole change is about, which * was never a lack of information but a lack of celilo saying it. */ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { resetTestDbPath } from '../../test-utils/db-path'; // The command reaches boxes ONLY through these two modules, and both are // mocked below so the tests can drive the live-read and fallback paths without // a real machine. Spreading the actual module keeps every other export intact // for anything else that loads them. const actualDetector = await import('../../services/machine-detector'); const actualKeyManager = await import('../../services/ssh-key-manager'); type Detected = { interfaces: Array<{ name: string; ipAddress: string; zone?: string }>; role: string; }; let liveRead: (ip: string, user: string, keyPath: string) => Promise; let localRead: () => Promise; let tempKeyWriter: (machineId: string) => Promise; mock.module('../../services/machine-detector', () => ({ ...actualDetector, detectNetworkInterfaces: (ip: string, user: string, keyPath: string) => liveRead(ip, user, keyPath), detectNetworkInterfacesLocal: () => localRead(), })); mock.module('../../services/ssh-key-manager', () => ({ ...actualKeyManager, writeTemporarySshKey: (machineId: string) => tempKeyWriter(machineId), deleteTemporarySshKey: () => {}, })); let testDir: string; let logs: string[]; const originalLog = console.log; /** Capture stdout — the command's output IS its contract. */ function captureLogs() { logs = []; console.log = (...args: unknown[]) => { logs.push(args.map(String).join(' ')); }; } beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), 'celilo-fw-iface-')); process.env.CELILO_DB_PATH = join(testDir, 'test.db'); process.env.CELILO_DATA_DIR = testDir; // Key decryption (getMachineSshKey) needs a master key. const masterKeyPath = join(testDir, 'master.key'); writeFileSync(masterKeyPath, 'a'.repeat(64)); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; captureLogs(); }); afterEach(() => { console.log = originalLog; rmSync(testDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_DATA_DIR; delete process.env.CELILO_MASTER_KEY_PATH; }); describe('celilo firewall interface list', () => { test('an empty pool says so, and says what makes a machine a firewall', async () => { const { handleFirewallInterfaceList } = await import('./firewall-interface-list'); const result = await handleFirewallInterfaceList([], {}); expect(result.success).toBe(false); if (result.success) throw new Error('unreachable — asserted above'); expect(result.error).toContain('No firewalls'); // Not a dead end: it explains the rule rather than leaving the operator to // guess why their box is not listed. expect(result.error).toContain('router'); }); test('a named machine that does not exist names it, and points at machine list', async () => { const { handleFirewallInterfaceList } = await import('./firewall-interface-list'); const result = await handleFirewallInterfaceList(['no-such-box'], {}); expect(result.success).toBe(false); if (result.success) throw new Error('unreachable — asserted above'); expect(result.error).toContain('no-such-box'); expect(result.error).toContain('celilo machine list'); }); }); /** * The classification itself is `@celilo/capabilities`' job and is tested there * against far more cases than a CLI test should duplicate. * * What is worth pinning here is STRUCTURAL. Asserting the wording of the output * by grepping this command's own source would just be reading my strings back to * me — it would pass no matter what they said. The property below is different: * it is a claim about what the command may DO, and it can genuinely fail. */ describe('no side effects on the box', () => { test('reaches a box only through the read-only detectors', async () => { // #1287's CLI half came from the stored-read-only version of this command: // a snapshot taken at machine add kept reading ALIEN after the interface // was gone, so the command contradicted the converge it claims to preview. // The fix reads the box live through the same detectors machine add uses — // read-only by construction. What is still forbidden is any MUTATING // remote primitive, and any hand-built ssh that could grow one silently. const src = await Bun.file(join(import.meta.dir, 'firewall-interface-list.ts')).text(); expect(src).not.toContain('runAppCommand'); expect(src).not.toContain('execFileSync'); expect(src).not.toContain('ssh '); // The live read must come from the shared detector, not a private path. expect(src).toContain('detectNetworkInterfaces'); }); }); /** * The live-read contract, against a machine whose STORED snapshot is stale — * the #1287 shape: the alien cable was attached when `machine add` recorded * the interfaces, and nothing refreshes that snapshot afterwards. */ describe('reads the box live', () => { beforeEach(async () => { liveRead = async () => { throw new Error('liveRead not configured for this test'); }; localRead = async () => { throw new Error('localRead not configured for this test'); }; tempKeyWriter = async () => { throw new Error('tempKeyWriter not configured for this test'); }; const { getDb } = await import('../../db/client'); const { systemConfig } = await import('../../db/schema'); const { addMachine } = await import('../../services/machine-pool'); await getDb() .insert(systemConfig) .values([ { key: 'network.internal.subnet', value: '10.226.1.0/24' }, { key: 'network.app.subnet', value: '10.226.20.0/24' }, { key: 'network.dmz.subnet', value: '10.226.10.0/24' }, { key: 'network.secure.subnet', value: '10.226.30.0/24' }, ]); await addMachine({ hostname: 'fw-main', zone: 'internal', ipAddress: '10.226.1.254', sshUser: 'root', sshKey: 'test-key-material', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 }, role: 'host', interfaces: [ { name: 'eth0', ipAddress: '10.226.20.1', zone: 'app' }, { name: 'eth1', ipAddress: '10.226.10.1', zone: 'dmz' }, { name: 'eth2', ipAddress: '10.226.1.254', zone: 'internal' }, { name: 'eth3', ipAddress: '10.226.30.1', zone: 'secure' }, // The cable nobody documented, recorded because it was attached when // the machine was added. It left the box afterwards; the snapshot // never heard about it. { name: 'eth4', ipAddress: '172.31.99.2', zone: 'unknown' }, ], }); }); test('an interface removed after machine add no longer reads ALIEN', async () => { let keyWritten = false; tempKeyWriter = async () => { keyWritten = true; return '/tmp/celilo-fw-iface-test.key'; }; liveRead = async (ip, user) => { // The read went to the box named in the machine record, with its stored // credentials — the same targeting the converge uses. expect(ip).toBe('10.226.1.254'); expect(user).toBe('root'); return { interfaces: [ { name: 'eth0', ipAddress: '10.226.20.1' }, { name: 'eth1', ipAddress: '10.226.10.1' }, { name: 'eth2', ipAddress: '10.226.1.254' }, { name: 'eth3', ipAddress: '10.226.30.1' }, ], role: 'router', }; }; const { handleFirewallInterfaceList } = await import('./firewall-interface-list'); const result = await handleFirewallInterfaceList(['fw-main'], {}); expect(result.success).toBe(true); expect(keyWritten).toBe(true); const out = logs.join('\n'); // eth4 is gone from the box, so the answer says so — even though the // stored snapshot still carries it. expect(out).not.toContain('172.31.99.2'); expect(out).not.toContain('ALIEN'); for (const zone of ['app', 'dmz', 'internal', 'secure']) { expect(out).toContain(`zone:${zone}`); } expect(out).not.toContain('could not read the box live'); }); test('a box that cannot be reached says so, and labels the snapshot as stale', async () => { tempKeyWriter = async () => '/tmp/celilo-fw-iface-test.key'; liveRead = async () => { throw new Error('SSH command failed after 3 attempts: connection refused'); }; const { handleFirewallInterfaceList } = await import('./firewall-interface-list'); const result = await handleFirewallInterfaceList(['fw-main'], {}); // Unreachable is not a classification result, but the stored snapshot is // still the best available answer — as long as it says what it is. expect(result.success).toBe(true); const out = logs.join('\n'); expect(out).toContain('could not read the box live'); expect(out).toContain('may be stale'); // The stale eth4 is reported, under the label that keeps it from being // mistaken for a current finding. expect(out).toContain('ALIEN'); }); });