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.
100 lines
4.7 KiB
TypeScript
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 });
|
|
});
|