/** * DC-135: shipdeck journal → Security Center pipeline. * * The shipdeck CLI appends one JSON row per lifecycle event to * /var/lib/shipdeck/journal.jsonl. startShipdeckWorker() tails that file * and appends a source_type='shipdeck' security event for each deploy / * rollback row, with severity mapped from the verify[] block. * * Tests run the REAL worker against a temp journal file (hermetic sink, * same pattern as caddy-worker-pipeline-dc113.test.js). Assertions pin: * - VALID_SOURCE_TYPES admits 'shipdeck' (store accepts, unknown rejected) * - deploy rows ingest as notice/success with service + epoch metadata * - failed verify[] rows escalate to error severity * - non-lifecycle rows (health checks etc.) do NOT ingest * - unparseable lines are skipped without killing the worker * - first-start replay cap: an oversized pre-existing backlog is skipped * to the tail window (offset set to size - 1 MiB), not fully ingested */ 'use strict'; const path = require('path'); const fs = require('fs'); const os = require('os'); const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc135-shipdeck-')); const JOURNAL = path.join(TMP_DIR, 'journal.jsonl'); const DATA_DIR = path.join(TMP_DIR, 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); process.env.SHIPDECK_JOURNAL_FILE = JOURNAL; process.env.SECURITY_EVENT_LOG_FILE = path.join(TMP_DIR, 'security-events.jsonl'); // point platformPaths.dataDir at the hermetic dir BEFORE requiring the module jest.doMock('../platform-paths', () => ({ dataDir: DATA_DIR }), { virtual: true }); const { VALID_SOURCE_TYPES } = require('../src/security/event-store'); const storeModule = require('../src/security/event-store'); const workers = require('../src/security/event-workers'); const silence = { info: () => {}, warn: () => {}, error: () => {} }; function row(overrides = {}) { return JSON.stringify(Object.assign({ time: '2026-09-16T10:00:00Z', service: 'demo-hi3', host: 'dns2', epoch: 1789548000, pkg_sha256: '', duration_s: 35.93, action: 'deploy', spec: { host: 'dns2', unit: 'demo-hi3.service', port: 8953, record: 'hi3.sami', verify_http: 'https://hi3.sami/' }, verify: [{ check: 'systemd-active', ok: true, detail: 'active' }, { check: 'http-tailnet', ok: true, detail: 'HTTP 200' }], }, overrides)); } function shipdeckEvents() { return storeModule.getStore({ log: silence }).query({ source_type: 'shipdeck', limit: 100 }).events; } async function waitTicks(n = 3) { // createTail polls every 1s; give the worker a few ticks to consume await new Promise(r => setTimeout(r, n * 1100)); } describe('DC-135: shipdeck source in the Security Center', () => { let worker; beforeAll(() => { worker = workers.startShipdeckWorker({ log: silence }); }); afterAll(() => { worker.stop(); }); test("store admits source_type 'shipdeck'", () => { expect(VALID_SOURCE_TYPES.has('shipdeck')).toBe(true); }); test('a successful deploy row ingests as notice/success with metadata', async () => { fs.appendFileSync(JOURNAL, row() + '\n'); await waitTicks(); const evs = shipdeckEvents(); expect(evs.length).toBeGreaterThanOrEqual(1); const ev = evs.find(e => e.target === 'demo-hi3' && e.action === 'shipdeck.deploy'); expect(ev).toBeDefined(); expect(ev.severity).toBe('notice'); expect(ev.outcome).toBe('success'); expect(ev.source_host).toBe('dns2'); expect(ev.metadata.epoch).toBe(1789548000); expect(ev.metadata.record).toBe('hi3.sami'); expect(Array.isArray(ev.metadata.verify)).toBe(true); }); test('a failed verify[] row escalates to error severity', async () => { fs.appendFileSync(JOURNAL, row({ service: 'broken-app', action: 'rollback', verify: [{ check: 'systemd-active', ok: false, detail: 'failed' }], }) + '\n'); await waitTicks(); const ev = shipdeckEvents().find(e => e.target === 'broken-app' && e.action === 'shipdeck.rollback'); expect(ev).toBeDefined(); expect(ev.severity).toBe('error'); expect(ev.outcome).toBe('error'); }); test('non-lifecycle rows (health checks) do not ingest', async () => { const before = shipdeckEvents().length; fs.appendFileSync(JOURNAL, row({ service: 'demo-hi3', action: 'health', verify: [] }) + '\n'); fs.appendFileSync(JOURNAL, 'not-json-at-all\n'); await waitTicks(); expect(shipdeckEvents().length).toBe(before); }); test('first-start replay cap: oversized backlog is skipped to the tail window', async () => { // Judge r3: hermetic restart — fresh journal path (env read at worker // start), fresh offset file (so this is a genuine first start), and // delta-based assertions on the shared store singleton. worker.stop(); const BIG = path.join(TMP_DIR, 'journal-big.jsonl'); const offsetFile = path.join(DATA_DIR, '.shipdeck-tail-offset'); if (fs.existsSync(offsetFile)) fs.rmSync(offsetFile); // Build a backlog > firstStartMaxBytes (1 MiB) of lifecycle rows that // WOULD all ingest without the cap; the final row carries a distinct // service name so we can prove the tail window itself was processed. const pad = row({ service: 'oldsvc', epoch: 1 }) + '\n'; const need = Math.ceil((2 * 1024 * 1024) / pad.length); let out = ''; for (let i = 0; i < need; i++) out += pad; out += row({ service: 'tailsvc', epoch: 2 }) + '\n'; fs.writeFileSync(BIG, out); const prevJournal = process.env.SHIPDECK_JOURNAL_FILE; process.env.SHIPDECK_JOURNAL_FILE = BIG; const deltaBefore = shipdeckEvents().length; const w2 = workers.startShipdeckWorker({ log: silence }); try { await waitTicks(4); } finally { w2.stop(); process.env.SHIPDECK_JOURNAL_FILE = prevJournal; fs.rmSync(BIG, { force: true }); } const delta = shipdeckEvents().length - deltaBefore; // cap proof: a 2MB backlog must not become `need` events (that would // mean the whole pre-existing file was replayed on first start) expect(delta).toBeLessThan(need); expect(delta).toBeGreaterThan(0); // tail window still ingested // and specifically the tail of the file made it in expect(shipdeckEvents().some(e => e.target === 'tailsvc')).toBe(true); }); });