Files
dpsapi/e2e/tests/host/lifecycle.spec.ts
T
bryanpedini b792ad256e feat(api): add authenticated containers http api
GET /api/v1/containers behind bearer token auth, JSON errors for 401/404/405/500, one audit row per authenticated request, graceful shutdown; unit and e2e tests for auth, routing, auditing, request logging and server lifecycle.
2026-10-06 10:52:25 +02:00

100 lines
4.7 KiB
TypeScript

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN ยง12 core items 3, 15 and the persistence matrix: HTTP readiness, SIGTERM/SIGINT graceful
* shutdown with exit code 0, in-flight requests drained, port released, database persistence
* across restarts, repeated schema initialization, and server/CLI sharing one database.
*/
import {
test,
expect,
assertDbInvariants,
expectContainersResponse,
isPortFree,
pollUntil,
readOperations,
readSchema,
readTokens,
} from '../../harness';
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
test(`${sig}: graceful shutdown, exit code 0, port released, connections refused afterwards`, async ({ cli, startServer, http }) => {
const tok = await cli.tokenNew();
const server = await startServer({ logLevel: 'DEBUG' });
expectContainersResponse(await http.get(server.url(), { token: tok }));
const pid = server.pid;
const exit = await server.stop(sig);
expect(exit.exitCode).toBe(0);
expect(exit.signal).toBeNull();
expect(exit.stdout).toBe('');
expect(exit.durationMs).toBeLessThan(5_000);
const logs = server.logs();
expect(logs.unparsed, 'no error line on graceful shutdown').toEqual([]);
const order = ['shutdown requested, draining http server', 'http server shut down', 'server stopped', 'database closed'].map((m) => logs.find({ msg: m })!.line);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(() => process.kill(pid, 0)).toThrow();
expect(await isPortFree(server.port)).toBe(true);
const r = await http.raw({ host: server.connectHost, port: server.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
expect(r.outcome).toBe('refused');
assertDbInvariants(server.dbPath, { operationCount: 1 });
});
}
test('an in-flight request completes during graceful shutdown; new connections are refused', async ({ cli, startServer, http }) => {
const tok = await cli.tokenNew();
const server = await startServer();
const f = server.fakeDocker!.many(2).delay(1500);
const inflight = http.get(server.url(), { token: tok });
await pollUntil('request to reach Docker', () => (f.listCalls() === 1 ? true : undefined));
const stopping = server.stop('SIGTERM');
const r = await inflight;
expect(expectContainersResponse(r)).toHaveLength(2);
expect((await stopping).exitCode).toBe(0);
expect(r.durationMs).toBeGreaterThan(1400);
expect(server.logs().requests().map((x) => x.attrs.status)).toEqual(['200']);
expect(readOperations(server.dbPath)).toHaveLength(1);
});
test('state persists across restarts; schema initialization is idempotent', async ({ cli, startServer, http, ws }) => {
test.slow();
const t1 = await cli.tokenNew('before first start');
const server = await startServer();
const schema = readSchema(ws.dbPath);
expectContainersResponse(await http.get(server.url(), { token: t1 }));
let ops = 1;
const created = [t1];
for (let gen = 2; gen <= 4; gen++) {
await server.restart();
expect(server.generation).toBe(gen);
for (const t of created) {
expectContainersResponse(await http.get(server.url(), { token: t }));
ops++;
}
created.push(await cli.tokenNew(`generation ${gen}`));
expect(readSchema(ws.dbPath), 'schema unchanged by repeated initialization').toEqual(schema);
}
await cli.tokenDelete(t1);
await server.restart();
const r = await http.get(server.url(), { token: t1 });
expect(r.status, 'deletion persisted across restart').toBe(401);
expect((await server.stop()).exitCode).toBe(0);
const rows = readTokens(ws.dbPath);
expect(rows.map((x) => [x.token, x.active])).toEqual(created.map((t) => [t, t === t1 ? 0 : 1]));
assertDbInvariants(ws.dbPath, { tokenCount: created.length, operationCount: ops });
for (const g of server.allOutput()) expect(g.exit?.exitCode, `generation ${g.generation} exit code`).toBe(0);
});
test('two servers on different ports share one database with concurrent CLI use', async ({ cli, startServer, http, ws }) => {
const a = await startServer({ label: 'a' });
const b = await startServer({ label: 'b' });
const toks = await Promise.all(Array.from({ length: 6 }, (_, i) => cli.tokenNew(`shared ${i}`)));
await Promise.all(toks.flatMap((t) => [http.get(a.url(), { token: t }), http.get(b.url(), { token: t })]).map(async (p) => expectContainersResponse(await p)));
await cli.tokenDelete(toks[0]);
expect((await http.get(a.url(), { token: toks[0] })).status).toBe(401);
expect((await http.get(b.url(), { token: toks[0] })).status).toBe(401);
expect((await a.stop()).exitCode).toBe(0);
expect((await b.stop()).exitCode).toBe(0);
assertDbInvariants(ws.dbPath, { tokenCount: 6, operationCount: 12, auditBeforeDeletion: true });
});