mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
79914fe750
* fix: recognize clean one-shot completions in health gate and drift Treat exit 0 with restart policy no/absent as successful completion so init and migration jobs no longer fail post-update observation or show as service-missing, while long-running restart policies still fail closed. * fix: ignore residual health on clean one-shots and honor deploy.restart_policy Completed exit-0 jobs with no-restart intent no longer fail the health gate on leftover starting/unhealthy state, and Drift treats deploy.restart_policy with Compose precedence so any/on-failure services are not mistaken for one-shots. * fix: require explicit Compose restart no for one-shot recognition Docker inspect reports restart no for both intentional jobs and bare services that omit restart, so Health Gate and Drift now require declared restart:""no"" (or deploy.restart_policy condition none) and load Compose intent once per gate.
432 lines
22 KiB
TypeScript
432 lines
22 KiB
TypeScript
/**
|
|
* Drift Ledger: the persistence layer on top of the read-only spatial engine.
|
|
* Covers the deploy baseline + temporal comparison, the reconcile step that
|
|
* records findings appearing and clearing (and the activity rows it writes), the
|
|
* supporting database methods, and the POST re-check route that persists on demand.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import DockerController from '../services/DockerController';
|
|
import type { StackDriftReport, StackDriftFinding, DriftFindingKind } from '../services/DriftDetectionService';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authHeader: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let DriftLedgerService: typeof import('../services/DriftLedgerService').DriftLedgerService;
|
|
let computeStackHashes: typeof import('../services/DriftLedgerService').computeStackHashes;
|
|
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
|
let nodeId: number;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ DriftLedgerService, computeStackHashes } = await import('../services/DriftLedgerService'));
|
|
({ LicenseService } = await import('../services/LicenseService'));
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
|
authHeader = `Bearer ${token}`;
|
|
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
function db() {
|
|
return DatabaseService.getInstance();
|
|
}
|
|
|
|
function clearLedger(stack: string) {
|
|
db().deleteStackDriftFindings(nodeId, stack);
|
|
db().getDb().prepare('DELETE FROM notification_history WHERE node_id = ? AND stack_name = ?').run(nodeId, stack);
|
|
db().deleteStackDossier(nodeId, stack);
|
|
}
|
|
|
|
function finding(kind: DriftFindingKind, service: string, expected?: string, actual?: string): StackDriftFinding {
|
|
return { kind, service, detail: `${service} ${kind}`, expected, actual };
|
|
}
|
|
|
|
function reportWith(findings: StackDriftFinding[], over: Partial<StackDriftReport> = {}): StackDriftReport {
|
|
return { stack: over.stack ?? 'web', status: findings.length ? 'drifted' : 'in-sync', hasComposeFile: true, hasContainers: true, findings, ...over };
|
|
}
|
|
|
|
function driftActivity(stack: string) {
|
|
return db().getStackActivity(nodeId, stack, { limit: 50 }).filter(e => e.category === 'drift_detected' || e.category === 'drift_resolved');
|
|
}
|
|
|
|
describe('computeStackHashes', () => {
|
|
it('is deterministic for identical content', () => {
|
|
const a = computeStackHashes('services:\n web:\n image: nginx:1.27\n');
|
|
const b = computeStackHashes('services:\n web:\n image: nginx:1.27\n');
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
it('source hash differs but rendered hash matches when only comments/whitespace change', () => {
|
|
const plain = computeStackHashes('services:\n web:\n image: nginx:1.27\n');
|
|
const commented = computeStackHashes('# a comment\nservices:\n web:\n image: nginx:1.27\n\n');
|
|
expect(commented.sourceHash).not.toBe(plain.sourceHash);
|
|
expect(commented.renderedHash).toBe(plain.renderedHash);
|
|
});
|
|
|
|
it('rendered hash changes when the model changes', () => {
|
|
const v1 = computeStackHashes('services:\n web:\n image: nginx:1.27\n');
|
|
const v2 = computeStackHashes('services:\n web:\n image: nginx:1.28\n');
|
|
expect(v2.renderedHash).not.toBe(v1.renderedHash);
|
|
});
|
|
|
|
it('returns a null rendered hash when the model cannot be parsed', () => {
|
|
// No services => the local parser reports a parse error and cannot model it.
|
|
const h = computeStackHashes('not_a_compose_key: true\n');
|
|
expect(typeof h.sourceHash).toBe('string');
|
|
expect(h.renderedHash).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('setStackDossierHashes', () => {
|
|
beforeEach(() => clearLedger('hashstack'));
|
|
|
|
it('creates a dossier row with empty notes when none exists', () => {
|
|
db().setStackDossierHashes(nodeId, 'hashstack', 'src1', 'rnd1');
|
|
const row = db().getStackDossier(nodeId, 'hashstack');
|
|
expect(row?.source_hash).toBe('src1');
|
|
expect(row?.rendered_hash).toBe('rnd1');
|
|
expect(row?.purpose).toBe('');
|
|
});
|
|
|
|
it('preserves operator notes when updating hashes', () => {
|
|
db().upsertStackDossier(nodeId, 'hashstack', {
|
|
purpose: 'reverse proxy', owner: 'ops', access_urls: '', static_ip: '', vlan: '',
|
|
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '', recovery_notes: '', custom_notes: '',
|
|
});
|
|
db().setStackDossierHashes(nodeId, 'hashstack', 'src2', 'rnd2');
|
|
const row = db().getStackDossier(nodeId, 'hashstack');
|
|
expect(row?.purpose).toBe('reverse proxy');
|
|
expect(row?.owner).toBe('ops');
|
|
expect(row?.source_hash).toBe('src2');
|
|
});
|
|
});
|
|
|
|
describe('drift finding store', () => {
|
|
beforeEach(() => clearLedger('findstack'));
|
|
|
|
it('inserts open findings and resolves them', () => {
|
|
const id = db().insertDriftFinding({
|
|
node_id: nodeId, stack_name: 'findstack', service: 'web', finding_type: 'image-mismatch',
|
|
severity: 'warning', message: 'm', expected_json: null, actual_json: null, detected_at: 1000,
|
|
});
|
|
expect(db().getOpenDriftFindings(nodeId, 'findstack')).toHaveLength(1);
|
|
db().resolveDriftFinding(id, 2000);
|
|
expect(db().getOpenDriftFindings(nodeId, 'findstack')).toHaveLength(0);
|
|
const recent = db().getRecentDriftFindings(nodeId, 'findstack', 10);
|
|
expect(recent).toHaveLength(1);
|
|
expect(recent[0].resolved_at).toBe(2000);
|
|
});
|
|
|
|
it('orders recent findings open-first, then resolved, each newest first', () => {
|
|
const mk = (service: string, detected: number) => db().insertDriftFinding({
|
|
node_id: nodeId, stack_name: 'findstack', service, finding_type: 'image-mismatch',
|
|
severity: 'warning', message: service, expected_json: null, actual_json: null, detected_at: detected,
|
|
});
|
|
mk('a', 100);
|
|
const b = mk('b', 300);
|
|
mk('c', 200);
|
|
db().resolveDriftFinding(b, 400); // b is the only resolved one (and the newest by detected_at)
|
|
// Open (c@200, a@100 by detected_at DESC) come before the resolved b despite b being newest.
|
|
expect(db().getRecentDriftFindings(nodeId, 'findstack', 10).map(r => r.service)).toEqual(['c', 'a', 'b']);
|
|
});
|
|
});
|
|
|
|
describe('DriftLedgerService.computeTemporal', () => {
|
|
beforeEach(() => clearLedger('tempstack'));
|
|
const content = 'services:\n web:\n image: nginx:1.27\n';
|
|
|
|
it('reports no baseline before a deploy', () => {
|
|
const t = DriftLedgerService.getInstance().computeTemporal(nodeId, 'tempstack', content);
|
|
expect(t).toEqual({ hasBaseline: false, sourceChanged: false, renderedChanged: false });
|
|
});
|
|
|
|
it('reports a match when content is unchanged since baseline', () => {
|
|
const { sourceHash, renderedHash } = computeStackHashes(content);
|
|
db().setStackDossierHashes(nodeId, 'tempstack', sourceHash, renderedHash);
|
|
const t = DriftLedgerService.getInstance().computeTemporal(nodeId, 'tempstack', content);
|
|
expect(t).toEqual({ hasBaseline: true, sourceChanged: false, renderedChanged: false });
|
|
});
|
|
|
|
it('flags source and rendered changes after the file changes', () => {
|
|
const baseline = computeStackHashes(content);
|
|
db().setStackDossierHashes(nodeId, 'tempstack', baseline.sourceHash, baseline.renderedHash);
|
|
const t = DriftLedgerService.getInstance().computeTemporal(nodeId, 'tempstack', 'services:\n web:\n image: nginx:1.28\n');
|
|
expect(t.hasBaseline).toBe(true);
|
|
expect(t.sourceChanged).toBe(true);
|
|
expect(t.renderedChanged).toBe(true);
|
|
});
|
|
|
|
it('flags source changed but not rendered for a comments/whitespace-only edit', () => {
|
|
const baseline = computeStackHashes(content);
|
|
db().setStackDossierHashes(nodeId, 'tempstack', baseline.sourceHash, baseline.renderedHash);
|
|
const t = DriftLedgerService.getInstance().computeTemporal(nodeId, 'tempstack', `# a note\n${content}\n`);
|
|
expect(t).toEqual({ hasBaseline: true, sourceChanged: true, renderedChanged: false });
|
|
});
|
|
});
|
|
|
|
describe('DriftLedgerService.reconcile', () => {
|
|
beforeEach(() => clearLedger('rec'));
|
|
const ledger = () => DriftLedgerService.getInstance();
|
|
|
|
it('records newly detected findings and a single activity row', () => {
|
|
const res = ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web', 'nginx:1.27', 'nginx:1.26')], { stack: 'rec' }));
|
|
expect(res).toEqual({ detected: 1, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
|
const activity = driftActivity('rec');
|
|
expect(activity).toHaveLength(1);
|
|
expect(activity[0].category).toBe('drift_detected');
|
|
});
|
|
|
|
it('is idempotent: re-checking the same drift writes nothing new', () => {
|
|
const report = reportWith([finding('image-mismatch', 'web')], { stack: 'rec' });
|
|
ledger().reconcile(nodeId, 'rec', report);
|
|
const res = ledger().reconcile(nodeId, 'rec', report);
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
|
expect(driftActivity('rec')).toHaveLength(1);
|
|
});
|
|
|
|
it('resolves a finding that has cleared and records a resolved activity row', () => {
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
const res = ledger().reconcile(nodeId, 'rec', reportWith([], { stack: 'rec', status: 'in-sync' }));
|
|
expect(res).toEqual({ detected: 0, resolved: 1 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(0);
|
|
expect(driftActivity('rec').filter(e => e.category === 'drift_resolved')).toHaveLength(1);
|
|
});
|
|
|
|
it('records exactly one row per direction when one finding clears as another appears', () => {
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
// In a single check, 'web' clears while 'db' newly appears.
|
|
const res = ledger().reconcile(nodeId, 'rec', reportWith([finding('service-missing', 'db')], { stack: 'rec' }));
|
|
expect(res).toEqual({ detected: 1, resolved: 1 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
|
const cats = driftActivity('rec').map(e => e.category);
|
|
expect(cats.filter(c => c === 'drift_detected')).toHaveLength(2); // first reconcile + this one
|
|
expect(cats.filter(c => c === 'drift_resolved')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not reconcile an unreachable report (open findings are not falsely resolved)', () => {
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
const res = ledger().reconcile(nodeId, 'rec', { stack: 'rec', status: 'unreachable', hasComposeFile: true, hasContainers: false, findings: [] });
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not reconcile a parse-error report', () => {
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
const res = ledger().reconcile(nodeId, 'rec', { stack: 'rec', status: 'drifted', hasComposeFile: false, hasContainers: false, findings: [], parseError: 'bad yaml' });
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'rec')).toHaveLength(1);
|
|
});
|
|
|
|
it('stamps the dossier last-checked time on every authoritative reconcile, including a no-op', () => {
|
|
expect(db().getStackDossier(nodeId, 'rec')?.last_drift_check_at ?? null).toBeNull();
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
const first = db().getStackDossier(nodeId, 'rec')?.last_drift_check_at;
|
|
expect(typeof first).toBe('number');
|
|
// A repeat check that records nothing new still advances the last-checked stamp.
|
|
ledger().reconcile(nodeId, 'rec', reportWith([finding('image-mismatch', 'web')], { stack: 'rec' }));
|
|
const second = db().getStackDossier(nodeId, 'rec')?.last_drift_check_at;
|
|
expect(second as number).toBeGreaterThanOrEqual(first as number);
|
|
});
|
|
|
|
it('does not stamp last-checked for a non-authoritative (unreachable) report', () => {
|
|
ledger().reconcile(nodeId, 'rec', { stack: 'rec', status: 'unreachable', hasComposeFile: true, hasContainers: false, findings: [] });
|
|
expect(db().getStackDossier(nodeId, 'rec')?.last_drift_check_at ?? null).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('DriftLedgerService.reconcileService', () => {
|
|
beforeEach(() => clearLedger('svcrec'));
|
|
const ledger = () => DriftLedgerService.getInstance();
|
|
|
|
it('inserts only the selected service\'s findings, ignoring other services in the same report', () => {
|
|
const report = reportWith([finding('image-mismatch', 'web'), finding('service-missing', 'db')], { stack: 'svcrec' });
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', report);
|
|
expect(res).toEqual({ detected: 1, resolved: 0 });
|
|
const open = db().getOpenDriftFindings(nodeId, 'svcrec');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0].service).toBe('web');
|
|
});
|
|
|
|
it('resolves only the selected service\'s open finding, leaving other open services untouched', () => {
|
|
ledger().reconcile(nodeId, 'svcrec', reportWith([finding('image-mismatch', 'web'), finding('service-missing', 'db')], { stack: 'svcrec' }));
|
|
// 'web' cleared, 'db' absent from this service-scoped report too, but only 'web' is in scope.
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', reportWith([], { stack: 'svcrec', status: 'in-sync' }));
|
|
expect(res).toEqual({ detected: 0, resolved: 1 });
|
|
const open = db().getOpenDriftFindings(nodeId, 'svcrec');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0].service).toBe('db');
|
|
});
|
|
|
|
it('preserves an empty-service (network-level) finding when reconciling one named service', () => {
|
|
ledger().reconcile(nodeId, 'svcrec', reportWith([finding('image-mismatch', 'web'), finding('network-missing', '')], { stack: 'svcrec' }));
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', reportWith([], { stack: 'svcrec', status: 'in-sync' }));
|
|
expect(res).toEqual({ detected: 0, resolved: 1 });
|
|
const open = db().getOpenDriftFindings(nodeId, 'svcrec');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0].service).toBe('');
|
|
});
|
|
|
|
it('does not touch findings when the same service is idempotently rechecked', () => {
|
|
const report = reportWith([finding('image-mismatch', 'web')], { stack: 'svcrec' });
|
|
ledger().reconcileService(nodeId, 'svcrec', 'web', report);
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', report);
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'svcrec')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not reconcile an unreachable report (open findings are not falsely resolved)', () => {
|
|
ledger().reconcile(nodeId, 'svcrec', reportWith([finding('image-mismatch', 'web')], { stack: 'svcrec' }));
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', { stack: 'svcrec', status: 'unreachable', hasComposeFile: true, hasContainers: false, findings: [] });
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'svcrec')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not reconcile a parse-error report', () => {
|
|
ledger().reconcile(nodeId, 'svcrec', reportWith([finding('image-mismatch', 'web')], { stack: 'svcrec' }));
|
|
const res = ledger().reconcileService(nodeId, 'svcrec', 'web', { stack: 'svcrec', status: 'drifted', hasComposeFile: false, hasContainers: false, findings: [], parseError: 'bad yaml' });
|
|
expect(res).toEqual({ detected: 0, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, 'svcrec')).toHaveLength(1);
|
|
});
|
|
|
|
it('names the service in the recorded activity message', () => {
|
|
ledger().reconcileService(nodeId, 'svcrec', 'web', reportWith([finding('image-mismatch', 'web')], { stack: 'svcrec' }));
|
|
const activity = driftActivity('svcrec');
|
|
expect(activity).toHaveLength(1);
|
|
expect(activity[0].message).toContain('web');
|
|
});
|
|
|
|
it('stamps the dossier last-checked time', () => {
|
|
expect(db().getStackDossier(nodeId, 'svcrec')?.last_drift_check_at ?? null).toBeNull();
|
|
ledger().reconcileService(nodeId, 'svcrec', 'web', reportWith([finding('image-mismatch', 'web')], { stack: 'svcrec' }));
|
|
expect(typeof db().getStackDossier(nodeId, 'svcrec')?.last_drift_check_at).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('DriftLedgerService.reconcileStack', () => {
|
|
const STACK_A = 'recstacka';
|
|
let dirA: string;
|
|
|
|
const composeDir = () => process.env.COMPOSE_DIR as string;
|
|
|
|
// A running container on a different image than compose declares => image-mismatch.
|
|
const driftedContainer = (stack: string) => ({
|
|
id: `${stack}-c1`, name: `${stack}-web-1`, service: 'web', composeProject: stack, stack,
|
|
state: 'running', exitCode: null, image: 'nginx:1.26', networks: [], volumes: [], ports: [],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
clearLedger(STACK_A);
|
|
dirA = path.join(composeDir(), STACK_A);
|
|
fs.mkdirSync(dirA, { recursive: true });
|
|
fs.writeFileSync(path.join(dirA, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(dirA, { recursive: true, force: true });
|
|
});
|
|
|
|
it('reconcileStack builds the report, persists drift, and stamps last-checked', async () => {
|
|
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
|
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [driftedContainer(STACK_A)], networks: [], volumes: [] }),
|
|
} as unknown as DockerController);
|
|
const res = await DriftLedgerService.getInstance().reconcileStack(nodeId, STACK_A);
|
|
expect(res).toEqual({ detected: 1, resolved: 0 });
|
|
expect(db().getOpenDriftFindings(nodeId, STACK_A)).toHaveLength(1);
|
|
expect(typeof db().getStackDossier(nodeId, STACK_A)?.last_drift_check_at).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('DriftLedgerService.recordBaseline', () => {
|
|
beforeEach(() => clearLedger('baseline'));
|
|
|
|
it('hashes the on-disk compose and stores it as the dossier baseline', async () => {
|
|
const stackDir = path.join(process.env.COMPOSE_DIR as string, 'baseline');
|
|
fs.mkdirSync(stackDir, { recursive: true });
|
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
|
|
try {
|
|
await DriftLedgerService.getInstance().recordBaseline(nodeId, 'baseline');
|
|
const row = db().getStackDossier(nodeId, 'baseline');
|
|
const expected = computeStackHashes('services:\n web:\n image: nginx:1.27\n');
|
|
expect(row?.source_hash).toBe(expected.sourceHash);
|
|
expect(row?.rendered_hash).toBe(expected.renderedHash);
|
|
} finally {
|
|
fs.rmSync(stackDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('drift route (GET read-only, POST recheck persists)', () => {
|
|
const STACK = 'recheckroute';
|
|
let stackDir: string;
|
|
|
|
// A running container on a different image than compose declares => image-mismatch.
|
|
const stubDriftedDocker = () => vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
|
getDependencySnapshot: vi.fn().mockResolvedValue({
|
|
containers: [{
|
|
id: 'c1', name: `${STACK}-web-1`, service: 'web', composeProject: STACK, stack: STACK,
|
|
state: 'running', exitCode: null, image: 'nginx:1.26', networks: [], volumes: [], ports: [],
|
|
}],
|
|
networks: [], volumes: [],
|
|
}),
|
|
} as unknown as DockerController);
|
|
|
|
beforeEach(() => {
|
|
clearLedger(STACK);
|
|
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
|
fs.mkdirSync(stackDir, { recursive: true });
|
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(stackDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('GET reports drift without writing the ledger or activity timeline', async () => {
|
|
stubDriftedDocker();
|
|
const res = await request(app).get(`/api/stacks/${STACK}/drift`).set('Authorization', authHeader);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('drifted');
|
|
expect(res.body.temporal).toBeDefined();
|
|
// A passive read must not persist anything.
|
|
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(0);
|
|
expect(driftActivity(STACK)).toHaveLength(0);
|
|
// Never reconciled, so the history has no "as of" time.
|
|
expect(res.body.lastCheckedAt).toBeNull();
|
|
});
|
|
|
|
it('POST recheck persists the current drift and returns temporal + ledger', async () => {
|
|
stubDriftedDocker();
|
|
const res = await request(app).post(`/api/stacks/${STACK}/drift/recheck`).set('Authorization', authHeader);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('drifted');
|
|
expect(res.body.temporal).toBeDefined();
|
|
expect(Array.isArray(res.body.ledger)).toBe(true);
|
|
expect(res.body.ledger).toHaveLength(1);
|
|
expect(res.body.ledger[0]).toMatchObject({ service: 'web', kind: 'image-mismatch', resolvedAt: null });
|
|
// The recheck reconciled, so the history carries an "as of" timestamp.
|
|
expect(typeof res.body.lastCheckedAt).toBe('number');
|
|
|
|
// The transition was recorded exactly once in the activity timeline.
|
|
const acts = driftActivity(STACK);
|
|
expect(acts).toHaveLength(1);
|
|
expect(acts[0].category).toBe('drift_detected');
|
|
// And persisted as an open finding.
|
|
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1);
|
|
});
|
|
});
|