/** * Tests for the read-only module journal surface * (openspec/changes/fix-signal-inbound-delivery, spec transport-diagnostics). * * The read-only scenarios are the point of this file. They are not assertions * of intent — each one runs the operation against a stand-in daemon and proves * a property of what actually reached the SSH seam: * * - "does not steal inbound": a fake daemon holds a queue that only its * `receive` verb drains. The diagnostic runs; the queue is then drained by * the collection path and the pending reply is still there. * - "cannot change the transport": every command string the operation emits * is inspected. Anything that is not a `journalctl` read fails the test. */ 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 RunResult, type Runner, createMockRunner } from '@celilo/capabilities'; import type { DbClient } from '../db/client'; import { moduleSystems, modules } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { planJournalRead, readModuleJournal } from './module-journal'; /** * The command the REMOTE shell actually receives: strip the local `ssh … host` * prefix and undo the single-quote escaping remoteExec applied. Assertions run * against this rather than the ssh invocation, so they say something about what * runs on the host. */ function remotePayload(sshCmd: string): string { const match = sshCmd.match(/ root@[\d.]+ (.*)$/s); if (!match) throw new Error(`not an ssh invocation: ${sshCmd}`); return match[1].replace(/^'/, '').replace(/'$/, '').split("'\\''").join("'"); } /** * The payload with every quoted argument blanked out — what the remote shell * reads as SYNTAX rather than data. A `;` surviving this is a real second * command; a `;` inside a quoted argument is inert text. */ function shellSyntax(payload: string): string { let out = ''; let quoted = false; for (let i = 0; i < payload.length; i++) { const char = payload[i]; if (!quoted && char === '\\') { i++; // the escaped character is data, never syntax continue; } if (char === "'") { quoted = !quoted; out += "'"; continue; } if (!quoted) out += char; } return out; } let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'module-journal-')); db = await setupTestDatabaseAt(join(dir, 'celilo.db')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); function seedModule(id: string, hosts: Array<{ name: string; ip: string }>): void { db.insert(modules) .values({ id, name: id, version: '1.0.0', manifestData: { requires: { system: { zone: 'internal' } } }, sourcePath: `/tmp/${id}`, state: 'VERIFIED', }) .run(); for (const host of hosts) { db.insert(moduleSystems) .values({ moduleId: id, name: host.name, hostname: host.name, ipv4Address: host.ip, zone: 'internal', infraType: 'container_service', }) .run(); } } /** * A stand-in for a transport daemon holding one pending inbound reply. * * The queue drains ONLY on the daemon's own `receive` verb — exactly like the * real thing, where reading the journal and consuming the message stream are * different operations against different state. Any command that is not a * recognised read is recorded as a mutation so a test can fail on it. */ function fakeDaemonHost() { const queue: string[] = ['ack BPRJEH']; const commands: string[] = []; const runner: Runner = (cmd): RunResult => { commands.push(cmd); if (cmd.includes('signal-cli') && cmd.includes('receive')) { const drained = queue.splice(0, queue.length); return { ok: true, stdout: drained.join('\n'), stderr: '' }; } if (cmd.includes('journalctl')) { return { ok: true, stdout: 'Jul 31 17:02:11 signal signal-cli[812]: Received sync sent message', stderr: '', }; } return { ok: true, stdout: '', stderr: '' }; }; /** What the normal collection path would get if it ran now. */ const collect = () => runner('signal-cli -a +15555550100 receive').stdout; return { runner, commands, collect, pending: () => queue.length }; } describe('planJournalRead', () => { it('defaults the unit to a glob on the module id so signal → signal-cli', () => { expect(planJournalRead({ moduleId: 'signal' })).toMatchObject({ unit: 'signal*', lines: 100 }); }); it('rejects a unit carrying shell metacharacters', () => { const plan = planJournalRead({ moduleId: 'signal', unit: 'signal-cli; rm -rf /' }); expect(plan).toHaveProperty('error'); }); it('rejects a --since that is not a time expression', () => { const plan = planJournalRead({ moduleId: 'signal', since: '$(id)' }); expect(plan).toHaveProperty('error'); }); it('rejects a non-positive --lines', () => { expect(planJournalRead({ moduleId: 'signal', lines: 0 })).toHaveProperty('error'); }); }); describe('readModuleJournal', () => { it('returns a deployed module’s daemon logs without shell access to the host', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const host = fakeDaemonHost(); const report = readModuleJournal({ moduleId: 'signal' }, db, host.runner); expect(report).not.toHaveProperty('error'); if ('error' in report) throw new Error('unreachable'); expect(report.systems).toHaveLength(1); expect(report.systems[0].ok).toBe(true); expect(report.systems[0].lines.join('\n')).toContain('Received sync sent message'); }); // Scenario: "The surface is not transport-specific". A module with no // notification capability at all reaches its journal by the same path. it('reads the journal of a module that is not a notification transport', () => { seedModule('caddy', [{ name: 'caddy', ip: '10.0.10.20' }]); const runner = createMockRunner([ { match: 'journalctl', result: { ok: true, stdout: 'certificate obtained', stderr: '' } }, ]); const report = readModuleJournal({ moduleId: 'caddy' }, db, runner.run); if ('error' in report) throw new Error(report.error); expect(report.systems[0].lines).toEqual(['certificate obtained']); expect(remotePayload(runner.calls[0].cmd)).toContain("journalctl -u 'caddy*'"); }); it('reads every system serving the module', () => { seedModule('forgejo', [ { name: 'a', ip: '10.0.20.1' }, { name: 'b', ip: '10.0.20.2' }, ]); const runner = createMockRunner([ { match: 'journalctl', result: { ok: true, stdout: 'line', stderr: '' } }, ]); const report = readModuleJournal({ moduleId: 'forgejo' }, db, runner.run); if ('error' in report) throw new Error(report.error); expect(report.systems.map((s) => s.ipv4Address)).toEqual(['10.0.20.1', '10.0.20.2']); }); // Rule 6.2 / the defect class of this whole change: an unreachable host must // not present identically to a host whose journal is genuinely empty. it('reports an unreachable host instead of returning empty lines', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const runner = createMockRunner([ { match: 'journalctl', result: { ok: false, stdout: '', stderr: 'Connection timed out' } }, ]); const report = readModuleJournal({ moduleId: 'signal' }, db, runner.run); if ('error' in report) throw new Error(report.error); expect(report.systems[0].ok).toBe(false); expect(report.systems[0].error).toBe('Connection timed out'); }); it('an empty journal is reported as empty, not as a failure', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const runner = createMockRunner([ { match: 'journalctl', result: { ok: true, stdout: '', stderr: '' } }, ]); const report = readModuleJournal({ moduleId: 'signal' }, db, runner.run); if ('error' in report) throw new Error(report.error); expect(report.systems[0]).toMatchObject({ ok: true, lines: [] }); }); it('names the module when it has no deployed systems', () => { seedModule('api-only', []); const report = readModuleJournal({ moduleId: 'api-only' }, db, () => { throw new Error('must not reach a host'); }); expect(report).toHaveProperty('error'); }); it('rejects an unknown module before reaching any host', () => { const report = readModuleJournal({ moduleId: 'nope' }, db, () => { throw new Error('must not reach a host'); }); expect(report).toMatchObject({ error: 'Module not found: nope' }); }); }); describe('read-only (transport-diagnostics acceptance)', () => { // Scenario: "Diagnostics do not steal inbound messages". it('leaves a pending inbound reply for the normal collection path', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const host = fakeDaemonHost(); expect(host.pending()).toBe(1); readModuleJournal({ moduleId: 'signal', grep: 'Received' }, db, host.runner); // The reply is still queued, and the collection path still gets it. expect(host.pending()).toBe(1); expect(host.collect()).toBe('ack BPRJEH'); }); // Scenario: "Diagnostics cannot change the transport". Proved by inspecting // every command that reached the SSH seam, not by asserting the intent. it('emits nothing but a journalctl read — no send, no reconfigure', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const host = fakeDaemonHost(); readModuleJournal( { moduleId: 'signal', unit: 'signal-cli', lines: 20, since: '30 min ago', grep: 'sync' }, db, host.runner, ); expect(host.commands).toHaveLength(1); const remote = remotePayload(host.commands[0]); expect(remote).toBe( "journalctl -u 'signal-cli' --no-pager -n 20 --since '30 min ago' | grep -F 'sync'", ); for (const forbidden of [ 'systemctl', 'receive', 'send', 'daemon', 'link', 'register', 'rm ', '>', 'tee', ]) { expect(shellSyntax(remote)).not.toContain(forbidden); } }); it('a grep pattern cannot smuggle a second command onto the host', () => { seedModule('signal', [{ name: 'signal', ip: '10.0.20.40' }]); const host = fakeDaemonHost(); readModuleJournal( { moduleId: 'signal', grep: "'; systemctl stop signal-cli; #" }, db, host.runner, ); // The injected text survives as DATA — a quoted argument to grep — and // never as syntax the remote shell would execute. const syntax = shellSyntax(remotePayload(host.commands[0])); expect(syntax).not.toContain(';'); expect(syntax).not.toContain('systemctl'); }); });