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.
146 lines
7.3 KiB
TypeScript
146 lines
7.3 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §12 core item 10: audit rows verified directly in SQLite — exact token/origin/operation/
|
|
* time values, one row per authenticated GET only, written before the Docker call (so Docker
|
|
* failures are audited), origin = TCP peer (forwarding headers ignored), IPv4/IPv6 origins.
|
|
*/
|
|
import {
|
|
test,
|
|
expect,
|
|
assertDbInvariants,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
msToNanos,
|
|
readOperations,
|
|
rfc3339ToNanos,
|
|
RFC3339NANO_UTC_RE,
|
|
type DockerBackend,
|
|
type FakeDocker,
|
|
} from '../../harness';
|
|
|
|
test('each authenticated GET creates exactly one row with exact values, in request order', async ({ cli, startServer, http }) => {
|
|
const a = await cli.tokenNew('a');
|
|
const b = await cli.tokenNew('b');
|
|
const server = await startServer();
|
|
const plan = [a, b, a, a, b, a];
|
|
const windows: Array<[bigint, bigint]> = [];
|
|
for (const [i, tok] of plan.entries()) {
|
|
const t0 = msToNanos(Date.now() - 1);
|
|
const r = i % 2 ? await http.curl({ url: server.url(), token: tok }) : await http.get(server.url(), { token: tok });
|
|
windows.push([t0, msToNanos(Date.now() + 1)]);
|
|
expectContainersResponse(r);
|
|
}
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const ops = readOperations(server.dbPath);
|
|
expect(ops.map((o) => [o.token, o.origin, o.operation])).toEqual(plan.map((t) => [t, '127.0.0.1', 'GET containers']));
|
|
for (const [i, o] of ops.entries()) {
|
|
expect(o.time).toMatch(RFC3339NANO_UTC_RE);
|
|
const t = rfc3339ToNanos(o.time);
|
|
expect(t >= windows[i][0] && t <= windows[i][1], `row ${i} time ${o.time} inside its request window`).toBe(true);
|
|
if (i > 0) expect(o.rowid).toBeGreaterThan(ops[i - 1].rowid);
|
|
}
|
|
assertDbInvariants(server.dbPath, { tokenCount: 2, operationCount: plan.length, auditBeforeDeletion: true });
|
|
});
|
|
|
|
test('unsuccessful requests never create rows (401 variants, 404, 405, Go-level rejects)', async ({ cli, startServer, http }) => {
|
|
const tok = await cli.tokenNew();
|
|
const dead = await cli.tokenNew();
|
|
await cli.tokenDelete(dead);
|
|
const server = await startServer();
|
|
expectErrorResponse(await http.get(server.url()), 401);
|
|
expectErrorResponse(await http.get(server.url(), { token: dead }), 401);
|
|
expectErrorResponse(await http.get(server.url(), { token: tok + 'x' }), 401);
|
|
expectErrorResponse(await http.get(server.url(), { headers: { Authorization: `Basic ${tok}` } }), 401);
|
|
expectErrorResponse(await http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok}`, `Authorization: Bearer ${tok}`] }), 401);
|
|
expectErrorResponse(await http.get(server.url('/api/v1/containers/'), { token: tok }), 404);
|
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token: tok }), 405);
|
|
const bad = await http.raw({ host: server.connectHost, port: server.port, data: `GET /api/v1/containers HTTP/1.1\r\nAuthorization: Bearer ${tok}\r\n\r\n` }); // no Host → 400
|
|
expect(bad.response!.status).toBe(400);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(readOperations(server.dbPath)).toEqual([]);
|
|
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
|
});
|
|
|
|
test('authenticated requests are audited even when Docker fails (row written before the Docker call)', async ({ cli, startServer, http, ws }) => {
|
|
const tok = await cli.tokenNew();
|
|
const backends: Array<[string, DockerBackend | ((f: FakeDocker) => void)]> = [
|
|
['engine 500', (f) => f.error500()],
|
|
['connection dropped', (f) => f.destroy()],
|
|
['malformed JSON', (f) => f.badJson()],
|
|
['engine 404', (f) => f.error(404, 'page not found')],
|
|
['socket inaccessible (EACCES)', 'inaccessible'],
|
|
['socket missing (ENOENT)', 'missing'],
|
|
];
|
|
let expected = 0;
|
|
for (const [name, b] of backends) {
|
|
await test.step(name, async () => {
|
|
const server = typeof b === 'function' ? await startServer({ logLevel: 'DEBUG' }) : await startServer({ docker: b, logLevel: 'DEBUG' });
|
|
if (typeof b === 'function') b(server.fakeDocker!);
|
|
const r = await http.get(server.url(), { token: tok });
|
|
expectErrorResponse(r, 500);
|
|
expected++;
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const logs = server.logs();
|
|
const audit = logs.find({ msg: 'audit row inserted' });
|
|
const dockerStart = logs.find({ msg: 'docker request started' });
|
|
expect(audit && dockerStart && audit.line < dockerStart.line, 'audit insert logged before the Docker request').toBe(true);
|
|
expect(logs.has({ level: 'ERROR', msg: 'docker request failed' })).toBe(true);
|
|
expect(readOperations(server.dbPath)).toHaveLength(expected);
|
|
});
|
|
}
|
|
assertDbInvariants(ws.dbPath, { operationCount: expected });
|
|
});
|
|
|
|
test('forwarding headers cannot spoof the audited origin', async ({ cli, startServer, http }) => {
|
|
const tok = await cli.tokenNew();
|
|
const server = await startServer();
|
|
const spoof: Array<Record<string, string>> = [
|
|
{ 'X-Forwarded-For': '203.0.113.7' },
|
|
{ 'X-Forwarded-For': '203.0.113.7, 198.51.100.1' },
|
|
{ Forwarded: 'for=203.0.113.7;proto=https' },
|
|
{ 'X-Real-IP': '203.0.113.7' },
|
|
{ 'True-Client-IP': '203.0.113.7' },
|
|
{ 'CF-Connecting-IP': '203.0.113.7' },
|
|
{ 'X-Client-IP': '203.0.113.7', 'X-Originating-IP': '203.0.113.7', 'X-Remote-Addr': '203.0.113.7' },
|
|
];
|
|
for (const h of spoof) expectContainersResponse(await http.get(server.url(), { token: tok, headers: h }));
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(readOperations(server.dbPath).map((o) => o.origin)).toEqual(spoof.map(() => '127.0.0.1'));
|
|
expect(server.logs().requests().map((r) => r.attrs.origin)).toEqual(spoof.map(() => '127.0.0.1'));
|
|
expect(server.stderr).not.toContain('203.0.113.7');
|
|
});
|
|
|
|
test('IPv6 and dual-stack listeners record the peer address family correctly', async ({ cli, startServer, http }) => {
|
|
const tok = await cli.tokenNew();
|
|
const v6 = await startServer({ addr: '::1' });
|
|
expect(v6.baseURL).toBe(`http://[::1]:${v6.port}`);
|
|
expect(v6.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`[::1]:${v6.port}`);
|
|
expectContainersResponse(await http.get(v6.url(), { token: tok }));
|
|
expectContainersResponse(await http.curl({ url: v6.url(), token: tok }));
|
|
expect((await v6.stop()).exitCode).toBe(0);
|
|
|
|
const dual = await startServer({ addr: '::' });
|
|
expectContainersResponse(await http.get(`http://127.0.0.1:${dual.port}/api/v1/containers`, { token: tok }));
|
|
expectContainersResponse(await http.get(`http://[::1]:${dual.port}/api/v1/containers`, { token: tok }));
|
|
expect((await dual.stop()).exitCode).toBe(0);
|
|
|
|
expect(readOperations(v6.dbPath).map((o) => o.origin)).toEqual(['::1', '::1', '127.0.0.1', '::1']);
|
|
assertDbInvariants(v6.dbPath, { operationCount: 4 });
|
|
});
|
|
|
|
test('audit history survives restarts and is append-only', async ({ cli, startServer, http }) => {
|
|
const tok = await cli.tokenNew();
|
|
const server = await startServer();
|
|
for (let i = 0; i < 3; i++) expectContainersResponse(await http.get(server.url(), { token: tok }));
|
|
const before = readOperations(server.dbPath);
|
|
await server.restart();
|
|
for (let i = 0; i < 2; i++) expectContainersResponse(await http.get(server.url(), { token: tok }));
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const after = readOperations(server.dbPath);
|
|
expect(after.slice(0, 3)).toEqual(before);
|
|
expect(after).toHaveLength(5);
|
|
assertDbInvariants(server.dbPath, { operationCount: 5 });
|
|
});
|