feat: acknowledge Compose Doctor preflight findings per stack (#1560)

* feat: acknowledge Compose Doctor preflight findings per stack

Add node-scoped preflight acknowledgements with read-time filtering.

Supports four expiry modes and activeStatus for banner, tab dot, and readiness.

* fix: align preflight acknowledge UI with design system

Use Combobox, modal chrome, mono fields, and non-destructive clear confirm.

* fix: update test mocks to match new preflight field names

The preflight-acknowledgements feature renamed status-\>activeStatus and
highestSeverity-\>activeHighestSeverity in the preflight report shape. The
corresponding test mocks in three files still used the old field names,
causing 6 test failures across backend and frontend.

- backend: update-guard-service mock now passes activeStatus
- frontend PreflightPanel: Report interface and report() helper now include
  activeStatus, activeHighestSeverity, activeCount, acknowledgedCount
- frontend StackAnatomyPanel doctor: mock API response now includes
  activeHighestSeverity and activeStatus
This commit is contained in:
Anso
2026-07-05 04:12:03 -04:00
committed by GitHub
parent 122c1b8073
commit 4077546492
16 changed files with 1002 additions and 54 deletions
@@ -282,11 +282,45 @@ describe('getLatest', () => {
});
});
describe('preflight acknowledgements', () => {
const STACK = 'doctorack';
beforeEach(() => { writeStack(STACK); });
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
it('lowers activeStatus when a finding is acknowledged', async () => {
stubDocker(
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} },
);
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
expect(report.status).toBe('high');
const target = report.findings.find(f => f.ruleId === 'port-exposed-all-interfaces');
expect(target).toBeTruthy();
db().upsertPreflightAcknowledgement({
node_id: nodeId,
stack_name: STACK,
rule_id: target!.ruleId,
service: target!.service ?? null,
reason: 'intentional',
expiry_mode: 'forever',
expires_at: null,
anchor_rendered_hash: null,
anchor_image_ref: null,
created_by: 'tester',
created_at: Date.now(),
});
const latest = doctor().getLatest(nodeId, STACK);
expect(latest.acknowledgedCount).toBe(1);
expect(latest.activeCount).toBe(latest.findings.length - 1);
expect(latest.status).toBe('high');
expect(latest.activeStatus).not.toBe('high');
});
});
describe('node deletion cleanup', () => {
it('removes preflight runs and findings for a deleted node', () => {
const ghostNode = 987654;
db().replacePreflightRun(
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, service_images: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
);
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import type { PreflightAcknowledgement } from '../services/DatabaseService';
import { applyPreflightAcknowledgements, isPreflightAckActive } from '../utils/preflight-ack-filter';
import type { PreflightFinding } from '../services/preflight/types';
const baseFinding = (over: Partial<PreflightFinding> = {}): PreflightFinding => ({
ruleId: 'uid-gid-risk',
severity: 'warning',
title: 'Check UID/GID alignment',
message: 'test',
service: 'web',
...over,
});
const baseAck = (over: Partial<PreflightAcknowledgement> = {}): PreflightAcknowledgement => ({
id: 1,
node_id: 1,
stack_name: 'demo',
rule_id: 'uid-gid-risk',
service: 'web',
reason: 'verified ownership',
expiry_mode: 'forever',
expires_at: null,
anchor_rendered_hash: null,
anchor_image_ref: null,
created_by: 'admin',
created_at: Date.now(),
...over,
});
describe('applyPreflightAcknowledgements', () => {
const ctx = { renderedHash: 'hash-a', serviceImages: { web: 'nginx:1.2' } };
it('marks a matching service-scoped ack as acknowledged', () => {
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [baseAck()], Date.now());
expect(out[0].acknowledged).toBe(true);
expect(out[0].acknowledgementId).toBe(1);
});
it('prefers a service-scoped ack over a rule-wide ack', () => {
const ruleWide = baseAck({ id: 2, service: null });
const serviceScoped = baseAck({ id: 3, service: 'web' });
const out = applyPreflightAcknowledgements(
[baseFinding()],
ctx,
[ruleWide, serviceScoped],
Date.now(),
);
expect(out[0].acknowledgementId).toBe(3);
});
it('does not acknowledge when until_compose_change hash differs', () => {
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'old-hash' });
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(false);
});
it('acknowledges when until_compose_change hash matches', () => {
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'hash-a' });
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(true);
});
it('expires days mode after expires_at', () => {
const now = 1_000_000;
const ack = baseAck({ expiry_mode: 'days', expires_at: now - 1 });
expect(isPreflightAckActive(ack, ctx, now)).toBe(false);
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], now);
expect(out[0].acknowledged).toBe(false);
});
it('honors until_image_change while image ref matches', () => {
const ack = baseAck({
expiry_mode: 'until_image_change',
anchor_image_ref: 'nginx:1.2',
});
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(true);
});
it('re-surfaces until_image_change when image ref changes', () => {
const ack = baseAck({
expiry_mode: 'until_image_change',
anchor_image_ref: 'nginx:1.0',
});
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
expect(out[0].acknowledged).toBe(false);
});
});
@@ -80,3 +80,64 @@ describe('preflight routes', () => {
expect(res.status).toBe(404);
});
});
describe('preflight acknowledgement routes', () => {
let stackDir: string;
beforeEach(() => {
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:latest\n ports:\n - "8080:80"\n');
stub();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('POST acknowledges a finding and GET preflight reflects activeStatus', async () => {
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
expect(run.status).toBe(200);
const target = run.body.findings.find((f: { ruleId: string }) => f.ruleId === 'port-exposed-all-interfaces');
expect(target).toBeTruthy();
const ack = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: target.ruleId, service: target.service ?? null, reason: 'intentional', expiryMode: 'forever' });
expect(ack.status).toBe(201);
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(get.body.acknowledgedCount).toBeGreaterThanOrEqual(1);
const acked = get.body.findings.find((f: { ruleId: string; service?: string }) =>
f.ruleId === target.ruleId && f.service === target.service);
expect(acked?.acknowledged).toBe(true);
expect(get.body.activeCount).toBe(get.body.findings.length - get.body.acknowledgedCount);
});
it('DELETE clears an acknowledgement', async () => {
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
const list = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
const target = list.body.findings[0];
const ack = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: target.ruleId, service: target.service ?? null, expiryMode: 'forever' });
const del = await request(app)
.delete(`/api/stacks/${STACK}/preflight/acknowledgements/${ack.body.id}`)
.set('Authorization', authHeader);
expect(del.status).toBe(204);
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
const again = get.body.findings.find((f: { ruleId: string; service?: string }) =>
f.ruleId === target.ruleId && f.service === target.service);
expect(again?.acknowledged).toBe(false);
});
it('rejects until_image_change without a service', async () => {
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
const res = await request(app)
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
.set('Authorization', authHeader)
.send({ ruleId: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
expect(res.status).toBe(400);
});
});
@@ -39,17 +39,17 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
describe('preflightSignal', () => {
const cases = [
{ status: 'never-run', expected: 'unknown', affects: false },
{ status: 'blocker', expected: 'blocked', affects: true },
{ status: 'unrenderable', expected: 'attention', affects: true },
{ status: 'high', expected: 'attention', affects: true },
{ status: 'warning', expected: 'warning', affects: true },
{ status: 'pass', expected: 'ok', affects: true },
{ status: 'info', expected: 'ok', affects: true },
{ activeStatus: 'never-run', expected: 'unknown', affects: false },
{ activeStatus: 'blocker', expected: 'blocked', affects: true },
{ activeStatus: 'unrenderable', expected: 'attention', affects: true },
{ activeStatus: 'high', expected: 'attention', affects: true },
{ activeStatus: 'warning', expected: 'warning', affects: true },
{ activeStatus: 'pass', expected: 'ok', affects: true },
{ activeStatus: 'info', expected: 'ok', affects: true },
] as const;
it.each(cases)('maps preflight status $status to $expected', ({ status, expected, affects }) => {
const signal = preflightSignal({ status });
it.each(cases)('maps preflight activeStatus $activeStatus to $expected', ({ activeStatus, expected, affects }) => {
const signal = preflightSignal({ activeStatus });
expect(signal.status).toBe(expected);
expect(signal.affectsVerdict).toBe(affects);
});
@@ -182,10 +182,10 @@ describe('aggregateVerdict', () => {
});
it('reaches every verdict from realistic signal sets', () => {
expect(aggregateVerdict([preflightSignal({ status: 'blocker' }), driftSignal(0)])).toBe('blocked');
expect(aggregateVerdict([preflightSignal({ status: 'high' }), driftSignal(0)])).toBe('review_required');
expect(aggregateVerdict([preflightSignal({ activeStatus: 'blocker' }), driftSignal(0)])).toBe('blocked');
expect(aggregateVerdict([preflightSignal({ activeStatus: 'high' }), driftSignal(0)])).toBe('review_required');
expect(aggregateVerdict([containersSignal('error'), driftSignal(0)])).toBe('unknown');
expect(aggregateVerdict([driftSignal(1), preflightSignal({ status: 'pass' })])).toBe('ready_with_warnings');
expect(aggregateVerdict([driftSignal(0), preflightSignal({ status: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
expect(aggregateVerdict([driftSignal(1), preflightSignal({ activeStatus: 'pass' })])).toBe('ready_with_warnings');
expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
});
});
@@ -142,7 +142,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
});
it('produces a ready verdict from healthy collaborator outputs', async () => {
mockGetLatest.mockReturnValue({ status: 'pass' });
mockGetLatest.mockReturnValue({ activeStatus: 'pass' });
mockGetOpenDriftFindings.mockReturnValue([]);
mockListContainers.mockResolvedValue([{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' }]);
mockGetContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectResult()) });