Files
sencho/backend/src/__tests__/drift-route.test.ts
T
Anso 421177e4a6 feat(stacks): add compose-vs-runtime drift detection (#1329)
* feat(stacks): add compose-vs-runtime drift engine

Add a read-only engine that compares a stack's on-disk compose model
against the live Docker runtime and reports where the two diverge.

GET /api/stacks/:stackName/drift returns a per-stack report with a
status (in-sync, drifted, missing-runtime, unreachable) and typed,
service-scoped findings: a declared service with no running container,
a running container not declared in compose, an image mismatch, and a
published-port mismatch. The report is computed at request time with no
persistence and is available on every tier.

The check reuses the existing compose parser and Docker dependency
snapshot; the compose parser now also captures each service's declared
image. Boundaries fail closed: an unreadable compose file reports
drifted and an unreachable Docker daemon reports unreachable, never a
false in-sync.

* fix(stacks): keep drift hasContainers accurate on compose parse error

assembleStackDrift hardcoded hasContainers: false on the parse-error
path, contradicting the field's contract when the runtime actually has
running containers. Compute it once from the container set and reuse it
across all return paths.

Also add a route test that exercises the successful 200 path for an
existing stack on the Community tier (stubbing only the Docker boundary),
so a tier gate or handler regression after the existence check is caught.

* feat(stacks): add a drift detection tab to the stack view

Surface the compose-vs-runtime drift report on the per-stack Anatomy
panel as a read-only Drift tab. It shows the stack's status (in sync,
drifted, not running, unreachable) and, when drifted, the specific
service-scoped reasons with the declared and running values side by
side. A re-check action reruns the comparison.

The tab lives in the shared anatomy panel, so it appears on both the
desktop stack view and the mobile stack detail. Available on every tier.

* fix(stacks): sanitize the logged error in the drift report builder

The compose-read and Docker-snapshot catch blocks logged the raw error
object, whose message can embed the user-controlled stack path (e.g. an
ENOENT path). Log the error through the existing sanitizer so a crafted
stack name cannot forge log lines, matching the pattern used elsewhere
in the stacks router.
2026-06-07 15:48:04 -04:00

66 lines
2.8 KiB
TypeScript

/**
* Route tests for the per-stack drift endpoint: auth enforcement and that the
* read-only report is reachable on the Community tier (no tier gate). Deep diff
* behaviour is covered by drift-detection.test.ts.
*/
import { describe, it, expect, beforeAll, afterAll, 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';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('GET /api/stacks/:stackName/drift', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/stacks/myapp/drift');
expect(res.status).toBe(401);
});
it('is reachable on the Community tier (404 for an unknown stack, not 403)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/stacks/myapp/drift').set('Authorization', authHeader);
expect(res.status).toBe(404);
vi.restoreAllMocks();
});
it('returns 200 with a report for an existing stack on the Community tier', async () => {
const composeDir = process.env.COMPOSE_DIR as string;
const stackDir = path.join(composeDir, 'driftroutetest');
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');
// Stub only the Docker boundary so the test is deterministic and daemon-free;
// the route, requireStackExists, compose parse, and the diff all run for real.
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
const res = await request(app).get('/api/stacks/driftroutetest/drift').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ stack: 'driftroutetest', status: 'missing-runtime' });
expect(Array.isArray(res.body.findings)).toBe(true);
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
});