mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-23 08:29:20 +00:00
78475d96ef
* 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.
158 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|