Files
sencho/backend/src/__tests__/stack-scan-status.test.ts
T
Anso d727a55a5f feat(stacks): surface post-deploy scan attempt status (#1198)
triggerPostDeployScan was fire-and-forget. When Trivy was missing on a
node, when the registry refused the digest lookup, or when a single
image scan threw, the failure went to console.error and the user
never learned. Open the security tab later, see stale data, no
indicator that the scan even tried.

Backend:
- New stack_scan_attempts table (node_id, stack_name, status,
  attempted_at, error_message). One row per stack; latest attempt
  overwrites the previous one.
- DatabaseService gains recordStackScanAttempt /
  getStackScanAttempt / clearStackScanAttempts. Status is one of
  'ok' | 'partial' | 'failed' | 'skipped'.
- triggerPostDeployScan in helpers/policyGate.ts now records every
  exit path: 'skipped' when Trivy is unavailable or no images to
  scan; 'failed' when container enumeration or all images fail;
  'partial' when some images scan and others fail; 'ok' on full
  success.
- New GET /api/stacks/:name/scan-status returns { status,
  attemptedAt, errorMessage } or { status: null } when never tried.
- DELETE /:stackName cleanup chain now clears the row alongside
  the existing update-status / auto-update cleanups.

Frontend:
- StackAnatomyPanel fetches /scan-status on stackName change.
- Renders a small warning strip below the update banner when
  status !== 'ok' (failed / partial / skipped). Hidden when status
  is 'ok' or unknown (never attempted). Title attribute carries
  the full error message for hover inspection.

Cross-feature note: the audit doc flagged this as M-6 with a
coordination note for the pending Security feature audit. The
schema kept intentionally narrow (one row per stack, simple
status enum) so the Security audit can extend it (richer history,
per-image-row breakdown, etc.) without a destructive migration.

Resolves M-6 from the stack-management audit.
2026-05-24 15:44:12 -04:00

108 lines
4.1 KiB
TypeScript

/**
* Tests for the post-deploy scan-attempt persistence + read endpoint.
* Covers the database layer (recordStackScanAttempt / getStackScanAttempt)
* and the GET /api/stacks/:name/scan-status route shape.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
const STACK = 'web';
const NODE_ID = 1;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
DatabaseService.getInstance().clearStackScanAttempts(NODE_ID, STACK);
});
describe('DatabaseService stack scan attempts', () => {
it('returns null when no attempt has been recorded', () => {
expect(DatabaseService.getInstance().getStackScanAttempt(NODE_ID, STACK)).toBeNull();
});
it('records and reads back a successful attempt', () => {
const db = DatabaseService.getInstance();
db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null);
const row = db.getStackScanAttempt(NODE_ID, STACK);
expect(row).not.toBeNull();
expect(row?.status).toBe('ok');
expect(row?.error_message).toBeNull();
expect(typeof row?.attempted_at).toBe('number');
});
it('overwrites a previous attempt (one row per stack)', () => {
const db = DatabaseService.getInstance();
db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null);
db.recordStackScanAttempt(NODE_ID, STACK, 'failed', 'trivy crashed');
const row = db.getStackScanAttempt(NODE_ID, STACK);
expect(row?.status).toBe('failed');
expect(row?.error_message).toBe('trivy crashed');
});
it('accepts skipped and partial statuses', () => {
const db = DatabaseService.getInstance();
db.recordStackScanAttempt(NODE_ID, STACK, 'skipped', 'Trivy not available');
expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('skipped');
db.recordStackScanAttempt(NODE_ID, STACK, 'partial', '1 of 3 failed');
expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('partial');
});
it('clearStackScanAttempts removes the row', () => {
const db = DatabaseService.getInstance();
db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null);
db.clearStackScanAttempts(NODE_ID, STACK);
expect(db.getStackScanAttempt(NODE_ID, STACK)).toBeNull();
});
it('keys per (nodeId, stackName) - other rows are unaffected', () => {
const db = DatabaseService.getInstance();
db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null);
db.recordStackScanAttempt(NODE_ID, 'api', 'failed', 'oops');
db.recordStackScanAttempt(2, STACK, 'partial', '1 failed');
expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('ok');
expect(db.getStackScanAttempt(NODE_ID, 'api')?.status).toBe('failed');
expect(db.getStackScanAttempt(2, STACK)?.status).toBe('partial');
});
});
describe('GET /api/stacks/:stackName/scan-status', () => {
it('returns {status: null} when no scan has been attempted', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/scan-status`)
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: null });
});
it('returns the recorded attempt shape', async () => {
DatabaseService.getInstance().recordStackScanAttempt(NODE_ID, STACK, 'failed', 'trivy missing');
const res = await request(app)
.get(`/api/stacks/${STACK}/scan-status`)
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.status).toBe('failed');
expect(res.body.errorMessage).toBe('trivy missing');
expect(typeof res.body.attemptedAt).toBe('number');
});
it('returns 401 without auth', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/scan-status`);
expect(res.status).toBe(401);
});
});