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.
This commit is contained in:
1 parent
9a85e9acc8
commit
b792ad256e
11 files changed
+2028
No files matched your search
@@ -0,0 +1,99 @@
|
||||
// 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 });
|
||||
});
|
||||
Reference in new issue
Block a user