Files
sencho/backend/src/__tests__/preflight-route.test.ts
T
Anso 78475d96ef fix(compose-doctor): resolve effective healthcheck coverage (#1713)
* fix(compose-doctor): resolve effective healthcheck coverage

Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active.

* fix(compose-doctor): fix healthcheck project label and empty compose HC

Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active.

* fix(compose-doctor): treat inherited healthcheck as All Clear note

Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
2026-07-28 14:26:00 -04:00

158 lines
7.1 KiB
TypeScript

/**
* Compose Doctor routes: GET returns the stored run (never-run before any run),
* POST runs and persists. Both require stack:read and reject unauthenticated and
* missing-stack requests. Docker render + snapshot are mocked.
*/
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 { ComposeService } from '../services/ComposeService';
import { isPreflightNoteFinding } from '../services/preflight/rules';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
const STACK = 'preflightroute';
function stub() {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({
rendered: JSON.stringify({ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} }),
stderr: '', code: 0, timedOut: false,
}),
} as unknown as ComposeService);
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('preflight 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('GET returns a never-run report before any run', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.status).toBe('never-run');
expect(res.body.findings).toEqual([]);
});
it('POST runs preflight, persists, and GET then returns the stored run', async () => {
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
expect(run.status).toBe(200);
expect(run.body.renderable).toBe(true);
expect(run.body.findings.length).toBeGreaterThan(0);
expect(run.body.findings.map((f: { ruleId: string }) => f.ruleId)).toContain('port-exposed-all-interfaces');
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
expect(get.body.status).toBe(run.body.status);
expect(get.body.findings.length).toBe(run.body.findings.length);
});
it('rejects an unauthenticated request', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/preflight`);
expect(res.status).toBe(401);
});
it('returns 404 for a stack that does not exist', async () => {
const res = await request(app).post('/api/stacks/nope-not-here/preflight/run').set('Authorization', authHeader);
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.filter((f: { ruleId: string; acknowledged?: boolean }) =>
!f.acknowledged && !isPreflightNoteFinding(f.ruleId)).length,
);
});
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);
});
it('rejects acknowledging informational note findings', 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: 'healthcheck-inherited', service: 'web', expiryMode: 'forever' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/notes cannot be acknowledged/i);
});
});