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).
158 lines
6.2 KiB
JavaScript
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);
|
|
});
|
|
});
|