Files
dashcaddy/dashcaddy-api/__tests__/shipdeck-security-source-dc135.test.js
T
DashCaddy Polish Loop 11c719e635 [grade=B urn:ump:3wvdd3yegylr7e2pzn6tebyq5bisf2awmss7p4j3uwpwsujtnwoq] DC-134/135/136: Shipdeck integration - data-driven login pages, deploy events, badge suppression
DC-134: /api/v1/auth/login-page serves a generic gated auto-login page for
any service registered in services.json without a curated flow (App Selector
installs, DC-131 git installs). Curated pages always win; unknown services
still 404; sanitizer keeps digits/hyphens (shipdeck-style ids); display
names HTML-escaped. Kills the sso-gate.js edit + restart per new install.

DC-135: shipdeck journal.jsonl tail worker (startShipdeckWorker) ingests
deploy/rollback lifecycle rows into the Security Center as
source_type=shipdeck (notice/success, error/failure via verify[] block).

DC-136: deploy-aware badge suppression - suppressDuringDeploy() before the
bridge call in /deploy and /rollback, clearDeploySuppression() in finally
(every exit path incl. rejected fetches), reference-counted for overlapping
deploys, 10-min TTL auto-expiry (HEALTH_DEPLOY_SUPPRESS_MAX_MS).

23 new tests across 4 suites; full suite 2923/2923 green.

Codex judge: C (r1) -> C (r2) -> B (r3) -> B zero-blockers (r4,
urn:ump:3wvdd3yegylr7e2pzn6tebyq5bisf2awmss7p4j3uwpwsujtnwoq).
2026-09-16 03:10:54 -07:00

158 lines
6.2 KiB
JavaScript

/**
* 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);
});
});