Files
sencho/backend/src/__tests__/drift-ledger.test.ts
T
Anso 3c4c057467 feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply

Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator.

* fix(git): contain stack-dir probes before filesystem access

The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync.

* fix(git): address managed-file change plan audit blockers

Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset.

* fix(git): unblock change-plan CI sinks and fifo test

Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync.

* fix(git): preserve unowned context files and align candidate validation

Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion.

* fix(git): contain live context and candidate env path sinks

Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink.

* fix(git): resolve live context walks from the compose root

Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base.

* fix(git): validate synced env removal against post-promotion files

A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names.

* docs(git): capture classified change-plan review screenshots

Replace the old Monaco pull-preview images with the classified operation list used by Apply.

* fix(git): treat invocation drift as reviewable, not a file conflict

A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
2026-08-14 09:53:31 -04:00

508 lines
25 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);
});
});
describe('DriftLedgerService managed-path conflicts', () => {
const STACK = 'gitpath';
beforeEach(() => clearLedger(STACK));
it('upserts without resolving and keeps the original detected_at', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const first = db().getOpenDriftFindings(nodeId, STACK);
expect(first).toHaveLength(1);
expect(first[0].finding_type).toBe('managed-path-conflict');
expect(first[0].message).toBe('compose-primary local-modified');
const detectedAt = first[0].detected_at;
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const second = db().getOpenDriftFindings(nodeId, STACK);
expect(second).toHaveLength(1);
expect(second[0].detected_at).toBe(detectedAt);
expect(second[0].id).toBe(first[0].id);
});
it('redacts high-sensitivity paths in the stored message', () => {
DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [
{ path: '.env', op: 'local-modified', role: 'env', sensitivity: 'high' },
]);
const open = db().getOpenDriftFindings(nodeId, STACK);
expect(open[0].message).toBe('secret-bearing managed path (local-modified)');
expect(open[0].service).not.toContain('.env');
});
it('does not resolve a git finding during spatial reconcile', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const res = ledger.reconcile(nodeId, STACK, reportWith([], { stack: STACK, status: 'in-sync' }));
expect(res.resolved).toBe(0);
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1);
expect(db().getOpenDriftFindings(nodeId, STACK)[0].finding_type).toBe('managed-path-conflict');
});
it('resolves git findings only through resolveManagedPathConflicts', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
ledger.resolveManagedPathConflicts(nodeId, STACK);
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(0);
});
it('GET drift redacts the opaque service key for managed-path conflicts', async () => {
const 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');
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const stored = db().getOpenDriftFindings(nodeId, STACK)[0];
expect(stored.service.length).toBeGreaterThan(0);
const res = await request(app).get(`/api/stacks/${STACK}/drift`).set('Authorization', authHeader);
expect(res.status).toBe(200);
const gitRow = res.body.ledger.find((r: { kind: string }) => r.kind === 'managed-path-conflict');
expect(gitRow).toBeDefined();
expect(gitRow.service).toBe('');
expect(JSON.stringify(res.body)).not.toContain(stored.service);
fs.rmSync(stackDir, { recursive: true, force: true });
});
});