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,145 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 core items 8 and 10: the Authorization matrix (missing, empty, wrong scheme, malformed,
|
||||
* unknown, near-match, inactive, duplicated, unusual) with exact status, JSON body and headers,
|
||||
* the authentication result class logged at DEBUG, and audit rows only for successes.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
assertDbInvariants,
|
||||
buildRawRequest,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
readOperations,
|
||||
type CliRunner,
|
||||
type HttpClient,
|
||||
type RawResult,
|
||||
type ServerHandle,
|
||||
} from '../../harness';
|
||||
|
||||
interface Ctx {
|
||||
server: ServerHandle;
|
||||
active: string;
|
||||
other: string;
|
||||
inactive: string;
|
||||
}
|
||||
|
||||
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>): Promise<Ctx> {
|
||||
const active = await cli.tokenNew('active');
|
||||
const other = await cli.tokenNew('other');
|
||||
const inactive = await cli.tokenNew('inactive');
|
||||
await cli.tokenDelete(inactive);
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(1);
|
||||
return { server, active, other, inactive };
|
||||
}
|
||||
|
||||
/** Raw request with verbatim header lines. */
|
||||
function raw(http: HttpClient, s: ServerHandle, headerLines: Array<string | Buffer>, target = '/api/v1/containers'): Promise<RawResult> {
|
||||
const head = Buffer.concat([
|
||||
Buffer.from(`GET ${target} HTTP/1.1\r\nHost: localhost\r\n`, 'latin1'),
|
||||
...headerLines.map((h) => Buffer.concat([typeof h === 'string' ? Buffer.from(h, 'latin1') : h, Buffer.from('\r\n')])),
|
||||
Buffer.from('Connection: close\r\n\r\n'),
|
||||
]);
|
||||
return http.raw({ host: s.connectHost, port: s.port, data: head });
|
||||
}
|
||||
|
||||
function authClasses(s: ServerHandle): string[] {
|
||||
return s.logs().filter({ msg: 'authentication result' }).map((r) => r.attrs.result);
|
||||
}
|
||||
|
||||
const flipCase = (t: string) => [...t].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join('');
|
||||
|
||||
test('missing, empty and malformed Authorization headers → 401 (Playwright request)', async ({ cli, startServer, http }) => {
|
||||
const { server, active } = await setup(cli, startServer);
|
||||
const cases: Array<[string, Record<string, string>, string]> = [
|
||||
['no header', {}, 'missing_header'],
|
||||
['scheme only', { Authorization: 'Bearer' }, 'empty_token'],
|
||||
['scheme + trailing space (trimmed by HTTP)', { Authorization: 'Bearer ' }, 'empty_token'],
|
||||
['Basic credentials', { Authorization: 'Basic dXNlcjpwYXNz' }, 'bad_scheme'],
|
||||
['Token scheme', { Authorization: `Token ${active}` }, 'bad_scheme'],
|
||||
['bare token, no scheme', { Authorization: active }, 'bad_scheme'],
|
||||
['tab instead of space', { Authorization: `Bearer\t${active}` }, 'bad_scheme'],
|
||||
['scheme with colon', { Authorization: `Bearer: ${active}` }, 'bad_scheme'],
|
||||
['misspelled scheme', { Authorization: `Baerer ${active}` }, 'bad_scheme'],
|
||||
['scheme prefix', { Authorization: `BearerX ${active}` }, 'bad_scheme'],
|
||||
['double space before token', { Authorization: `Bearer ${active}` }, 'unknown_token'],
|
||||
['token followed by extra word', { Authorization: `Bearer ${active} extra` }, 'unknown_token'],
|
||||
['quoted token', { Authorization: `Bearer "${active}"` }, 'unknown_token'],
|
||||
['two credentials in one value', { Authorization: `Bearer ${active}, Bearer ${active}` }, 'unknown_token'],
|
||||
['Bearer Bearer', { Authorization: `Bearer Bearer ${active}` }, 'unknown_token'],
|
||||
['different header (Proxy-Authorization)', { 'Proxy-Authorization': `Bearer ${active}` }, 'missing_header'],
|
||||
['token in X-Auth-Token', { 'X-Auth-Token': active }, 'missing_header'],
|
||||
];
|
||||
for (const [name, headers] of cases) {
|
||||
await test.step(name, async () => {
|
||||
expectErrorResponse(await http.get(server.url(), { headers }), 401);
|
||||
});
|
||||
}
|
||||
// query-string tokens are not credentials
|
||||
expectErrorResponse(await http.get(server.url(`/api/v1/containers?access_token=${active}`)), 401);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual([...cases.map((c) => c[2]), 'missing_header']);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||||
});
|
||||
|
||||
test('empty and whitespace-only header values → 401 (curl and raw socket)', async ({ cli, startServer, http }) => {
|
||||
const { server } = await setup(cli, startServer);
|
||||
// curl: "Name;" sends an empty header
|
||||
expectErrorResponse(await http.curl({ url: server.url(), headers: ['Authorization;'] }), 401);
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['empty value', 'Authorization:', 'empty_header'],
|
||||
['spaces only', 'Authorization: ', 'empty_header'],
|
||||
['tab only', 'Authorization:\t', 'empty_header'],
|
||||
['no space after colon, Bearer only', 'Authorization:Bearer', 'empty_token'],
|
||||
['Bearer + spaces', 'Authorization: Bearer ', 'empty_token'],
|
||||
];
|
||||
for (const [name, line] of cases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await raw(http, server, [line]);
|
||||
expect(r.outcome).toBe('response');
|
||||
expectErrorResponse(r.response!, 401);
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual(['empty_header', ...cases.map((c) => c[2])]);
|
||||
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||||
});
|
||||
|
||||
test('unknown, near-match and inactive tokens → 401', async ({ cli, startServer, http, rng }) => {
|
||||
const { server, active, inactive } = await setup(cli, startServer);
|
||||
const lastFlip = active.slice(0, -1) + (active.endsWith('A') ? 'B' : 'A');
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['random unknown token', rng.string(43, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'), 'unknown_token'],
|
||||
['last character changed', lastFlip, 'unknown_token'],
|
||||
['case swapped', flipCase(active), 'unknown_token'],
|
||||
['one character short', active.slice(0, 42), 'unknown_token'],
|
||||
['one character extra', active + 'A', 'unknown_token'],
|
||||
['base64 std alphabet variant', active.replaceAll('-', '+').replaceAll('_', '/'), active.includes('-') || active.includes('_') ? 'unknown_token' : 'ok'],
|
||||
['padded with =', active + '=', 'unknown_token'],
|
||||
['percent-encoded first char', '%' + active.charCodeAt(0).toString(16) + active.slice(1), 'unknown_token'],
|
||||
['SQL injection', `' OR '1'='1`, 'unknown_token'],
|
||||
['SQL wildcard', '%', 'unknown_token'],
|
||||
['inactive (deleted) token', inactive, 'inactive_token'],
|
||||
['inactive token, case swapped', flipCase(inactive), 'unknown_token'],
|
||||
];
|
||||
let oks = 0;
|
||||
for (const [name, token, cls] of cases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await http.get(server.url(), { token });
|
||||
if (cls === 'ok') {
|
||||
expectContainersResponse(r);
|
||||
oks++;
|
||||
} else expectErrorResponse(r, 401);
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||||
expect(readOperations(server.dbPath)).toHaveLength(oks);
|
||||
assertDbInvariants(server.dbPath);
|
||||
});
|
||||
|
||||
test('accepted forms: case-insensitive scheme, header-name case, surrounding whitespace → 200 and audited', async ({ cli, startServer, http }) => {
|
||||
const { server, active, other } = await setup(cli, startServer);
|
||||
const pw: Array<[string, Record<string, string>, string]> = [
|
||||
['canonical', { Authorization: `Bearer ${active}` }, active],
|
||||
['lower-case scheme', { Authorization: `bearer ${active}` }, active],
|
||||
['upper-case scheme', { Authorization: `BEARER ${other}` }, other],
|
||||
['mixed-case scheme', { Authorization: `bEaReR ${other}` }, other],
|
||||
['lower-case header name', { authorization: `Bearer ${active}` }, active],
|
||||
];
|
||||
const audited: string[] = [];
|
||||
for (const [name, headers, tok] of pw) {
|
||||
await test.step(name, async () => {
|
||||
expectContainersResponse(await http.get(server.url(), { headers }));
|
||||
audited.push(tok);
|
||||
});
|
||||
}
|
||||
const rawCases: Array<[string, string, string]> = [
|
||||
['trailing spaces trimmed by HTTP parsing', `Authorization: Bearer ${active} `, active],
|
||||
['trailing tab trimmed', `Authorization: Bearer ${active}\t`, active],
|
||||
['leading whitespace before scheme trimmed', `Authorization: Bearer ${other}`, other],
|
||||
['UPPER-CASE header name', `AUTHORIZATION: Bearer ${other}`, other],
|
||||
['no space after colon', `Authorization:Bearer ${active}`, active],
|
||||
];
|
||||
for (const [name, line, tok] of rawCases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await raw(http, server, [line]);
|
||||
expectContainersResponse(r.response!);
|
||||
audited.push(tok);
|
||||
});
|
||||
}
|
||||
// curl
|
||||
expectContainersResponse(await http.curl({ url: server.url(), token: active }));
|
||||
audited.push(active);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual(audited.map(() => 'ok'));
|
||||
expect(readOperations(server.dbPath).map((o) => o.token)).toEqual(audited);
|
||||
assertDbInvariants(server.dbPath, { operationCount: audited.length });
|
||||
});
|
||||
|
||||
test('duplicated and conflicting Authorization headers → 401 (multiple_headers), never audited', async ({ cli, startServer, http }) => {
|
||||
const { server, active, other, inactive } = await setup(cli, startServer);
|
||||
const curlCases: Array<[string, string[]]> = [
|
||||
['same valid header twice', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${active}`]],
|
||||
['two different valid tokens', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${other}`]],
|
||||
['valid + invalid', [`Authorization: Bearer ${active}`, 'Authorization: Basic Zm9vOmJhcg==']],
|
||||
['invalid + valid', ['Authorization: Bearer nope', `Authorization: Bearer ${active}`]],
|
||||
['valid + empty', [`Authorization: Bearer ${active}`, 'Authorization;']],
|
||||
['valid + inactive', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${inactive}`]],
|
||||
];
|
||||
for (const [name, headers] of curlCases) {
|
||||
await test.step(`curl: ${name}`, async () => {
|
||||
const r = await http.curl({ url: server.url(), headers });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expectErrorResponse(r, 401);
|
||||
});
|
||||
}
|
||||
await test.step('raw: three headers with different name case', async () => {
|
||||
const r = await raw(http, server, [`authorization: Bearer ${active}`, `Authorization: Bearer ${active}`, `AUTHORIZATION: Bearer ${active}`]);
|
||||
expectErrorResponse(r.response!, 401);
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual([...curlCases.map(() => 'multiple_headers'), 'multiple_headers']);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
});
|
||||
|
||||
test('unusual but syntactically valid Authorization values → 401 without leaking anything', async ({ cli, startServer, http }) => {
|
||||
const { server, active } = await setup(cli, startServer);
|
||||
const cases: Array<[string, Buffer | string, string]> = [
|
||||
['very long token (16 KiB)', `Authorization: Bearer ${'A'.repeat(16 * 1024)}`, 'unknown_token'],
|
||||
['UTF-8 bytes in token (obs-text)', Buffer.concat([Buffer.from('Authorization: Bearer '), Buffer.from('tökén-ü', 'utf8')]), 'unknown_token'],
|
||||
['Cyrillic look-alike scheme', Buffer.concat([Buffer.from('Authorization: B'), Buffer.from('е', 'utf8'), Buffer.from(`arer ${active}`)]), 'bad_scheme'],
|
||||
['Latin-1 NBSP instead of space', Buffer.concat([Buffer.from('Authorization: Bearer\xa0', 'latin1'), Buffer.from(active)]), 'bad_scheme'],
|
||||
['token followed by more words', `Authorization: Bearer ${active} x`, 'unknown_token'],
|
||||
];
|
||||
for (const [name, line] of cases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await raw(http, server, [line]);
|
||||
expect(r.outcome).toBe('response');
|
||||
expectErrorResponse(r.response!, 401);
|
||||
});
|
||||
}
|
||||
// Control characters (other than HTAB) are invalid in field values: Go rejects the request
|
||||
// with a plain-text 400 before the application sees it (not logged, not audited).
|
||||
for (const ctl of ['\x7f', '\x01', '\x1b']) {
|
||||
await test.step(`control character ${JSON.stringify(ctl)} in the token → Go 400`, async () => {
|
||||
const r = await raw(http, server, [`Authorization: Bearer ${active.slice(0, 20)}${ctl}${active.slice(21)}`]);
|
||||
expect(r.response!.status).toBe(400);
|
||||
expect(r.response!.header('content-type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||||
expect(server.logs().requests(), 'Go-rejected requests are not logged').toHaveLength(cases.length);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
expect(server.stderr).not.toContain('AAAAAAAAAAAAAAAA');
|
||||
});
|
||||
|
||||
test('requests on non-endpoint routes and methods are not authenticated or audited even with a valid token', async ({ cli, startServer, http }) => {
|
||||
const { server, active } = await setup(cli, startServer);
|
||||
expectErrorResponse(await http.get(server.url('/api/v1/containers/'), { token: active }), 404);
|
||||
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token: active }), 405);
|
||||
expectErrorResponse(await http.fetch(server.url(), { method: 'DELETE', token: 'garbage' }), 405);
|
||||
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ method: 'HEAD', target: '/api/v1/containers', headers: [`Authorization: Bearer ${active}`] }) });
|
||||
expectErrorResponse(r.response!, 405, { head: true });
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(authClasses(server), 'authentication never ran').toEqual([]);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 core item 9: wrong methods, unknown routes, trailing slashes, path tricks, query
|
||||
* strings, fragments, repeated/unusual headers, empty and irrelevant bodies, and requests Go
|
||||
* rejects before routing. Every request reaching the application must be logged exactly once
|
||||
* at INFO; only authenticated GETs on the exact path are audited.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
assertDbInvariants,
|
||||
buildRawRequest,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
readOperations,
|
||||
type CliRunner,
|
||||
type ServerHandle,
|
||||
} from '../../harness';
|
||||
|
||||
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>) {
|
||||
const token = await cli.tokenNew('surface');
|
||||
const server = await startServer({ logLevel: 'INFO' });
|
||||
server.fakeDocker!.many(1);
|
||||
return { token, server };
|
||||
}
|
||||
|
||||
test('every non-GET method on the endpoint → 405 with Allow: GET, before authentication', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
const seen: Array<[string, string]> = [];
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) {
|
||||
for (const auth of [undefined, token, 'wrong']) {
|
||||
await test.step(`${method} auth=${auth === token ? 'valid' : auth ?? 'none'}`, async () => {
|
||||
expectErrorResponse(await http.fetch(server.url(), { method, token: auth }), 405);
|
||||
seen.push([method, '405']);
|
||||
});
|
||||
}
|
||||
}
|
||||
// HEAD: same status/headers, no body
|
||||
const head = await http.curl({ url: server.url(), head: true, token });
|
||||
expect(head.exitCode).toBe(0);
|
||||
expectErrorResponse({ status: head.status, headers: head.headers, body: head.body }, 405, { head: true });
|
||||
seen.push(['HEAD', '405']);
|
||||
// Methods curl/raw can send but browsers cannot
|
||||
for (const method of ['PROPFIND', 'TRACE', 'FOO', 'get', 'Get', 'G-E_T']) {
|
||||
await test.step(`curl -X ${method}`, async () => {
|
||||
const r = await http.curl({ url: server.url(), method, token });
|
||||
expectErrorResponse(r, 405);
|
||||
seen.push([method, '405']);
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests().map((r) => [r.attrs.method, r.attrs.status])).toEqual(seen);
|
||||
expect(server.logs().count({ msg: 'authentication result' })).toBe(0);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
});
|
||||
|
||||
test('unknown routes, trailing slashes, case and path tricks → 404 (no redirects, no cleaning)', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
const pw = [
|
||||
'/',
|
||||
'/api',
|
||||
'/api/',
|
||||
'/api/v1',
|
||||
'/api/v1/',
|
||||
'/api/v1/containers/',
|
||||
'/api/v1/containers//',
|
||||
'/api/v1/containers/x',
|
||||
'/api/v1/containers.json',
|
||||
'/api/v1/container',
|
||||
'/API/v1/containers',
|
||||
'/api/V1/containers',
|
||||
'/api/v1/Containers',
|
||||
'/api/v2/containers',
|
||||
'/api/v1/containers%2F',
|
||||
'/api/v1/containers%3Fx=1',
|
||||
'/api/v1/containers%00',
|
||||
'/api/v1/containers;jsessionid=1',
|
||||
'/favicon.ico',
|
||||
'/.env',
|
||||
];
|
||||
const logged: string[] = [];
|
||||
for (const p of pw) {
|
||||
await test.step(`GET ${p}`, async () => {
|
||||
expectErrorResponse(await http.get(server.url(p), { token }), 404);
|
||||
});
|
||||
logged.push(p);
|
||||
}
|
||||
// Normalizing clients would rewrite these; send them verbatim
|
||||
for (const p of ['//api/v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/./api/v1/containers', '/api//v1/containers', '/api/v1/containers/.', '/api/v1/containers/..']) {
|
||||
await test.step(`curl --path-as-is ${p}`, async () => {
|
||||
const r = await http.curl({ url: server.url(p), token, pathAsIs: true });
|
||||
expectErrorResponse(r, 404);
|
||||
});
|
||||
logged.push(p);
|
||||
}
|
||||
// other methods on unknown routes are 404, not 405
|
||||
expectErrorResponse(await http.fetch(server.url('/api/v1/containers/'), { method: 'POST', token }), 404);
|
||||
expectErrorResponse(await http.fetch(server.url('/nope'), { method: 'DELETE' }), 404);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const reqs = server.logs().requests();
|
||||
expect(reqs.slice(0, logged.length).map((r) => [r.attrs.path, r.attrs.status])).toEqual(
|
||||
logged.map((p) => [decodeURIComponent(p), '404']), // the app logs the decoded URL path
|
||||
);
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
});
|
||||
|
||||
test('percent-encoded slashes are not path separators', async ({ cli, startServer, http }) => {
|
||||
// %2F is not equivalent to "/", so these are unknown routes even with a valid token.
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
for (const p of ['/api/v1%2Fcontainers', '/api%2Fv1%2Fcontainers', '/api%2fv1%2fcontainers']) {
|
||||
expectErrorResponse(await http.curl({ url: server.url(p), token, pathAsIs: true }), 404);
|
||||
expectErrorResponse(await http.curl({ url: server.url(p), pathAsIs: true }), 404);
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(6).fill('404'));
|
||||
expect(readOperations(server.dbPath)).toEqual([]);
|
||||
});
|
||||
|
||||
test('query strings, percent-encoded unreserved characters and fragments', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
let audited = 0;
|
||||
for (const p of ['/api/v1/containers?x=1', '/api/v1/containers?', '/api/v1/containers?a=b&a=c', '/api/v1/containers?all=1&filters=%7B%7D', '/api/v1/containers?%00=%ff', '/api/v1/%63ontainers', '/%61pi/v1/containers']) {
|
||||
await test.step(`GET ${p} → endpoint`, async () => {
|
||||
expectContainersResponse(await http.get(server.url(p), { token }));
|
||||
audited++;
|
||||
});
|
||||
}
|
||||
// the app never forwards client query parameters to Docker (always running containers only)
|
||||
for (const q of server.fakeDocker!.requests.filter((r) => r.path === '/containers/json')) expect(q.query).not.toMatch(/all|filters/);
|
||||
|
||||
// curl strips the fragment client-side: endpoint reached
|
||||
expectContainersResponse(await http.curl({ url: server.url('/api/v1/containers#frag'), token }));
|
||||
audited++;
|
||||
// a fragment sent on the wire is part of the request target → not the endpoint
|
||||
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers#frag', headers: [`Authorization: Bearer ${token}`] }) });
|
||||
expectErrorResponse(r.response!, 404);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests().at(-1)!.attrs.path).toBe('/api/v1/containers#frag');
|
||||
expect(readOperations(server.dbPath)).toHaveLength(audited);
|
||||
assertDbInvariants(server.dbPath, { operationCount: audited });
|
||||
});
|
||||
|
||||
test('repeated, unusual and irrelevant headers do not change a valid request', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
const auth = `Authorization: Bearer ${token}`;
|
||||
const curlCases: Array<[string, string[]]> = [
|
||||
['repeated Accept', ['Accept: text/html', 'Accept: application/xml']],
|
||||
['Accept that excludes JSON', ['Accept: image/png']],
|
||||
['repeated custom header', ['X-Thing: 1', 'X-Thing: 2', 'X-Thing: 3']],
|
||||
['odd but valid Host', ['Host: evil.example:9999']],
|
||||
['User-Agent removed', ['User-Agent:']],
|
||||
['Accept-Encoding gzip', ['Accept-Encoding: gzip, br']],
|
||||
['Cookie and Referer', ['Cookie: session=abc; other=1', 'Referer: http://elsewhere/']],
|
||||
['Content-Type on a GET', ['Content-Type: application/x-www-form-urlencoded']],
|
||||
['many headers (100)', Array.from({ length: 100 }, (_, i) => `X-Filler-${i}: ${'v'.repeat(20)}`)],
|
||||
['one large header (32 KiB)', [`X-Large: ${'z'.repeat(32 * 1024)}`]],
|
||||
['Connection: keep-alive', ['Connection: keep-alive']],
|
||||
['Cache-Control/Pragma', ['Cache-Control: no-cache', 'Pragma: no-cache']],
|
||||
['If-None-Match', ['If-None-Match: "abc"', 'If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT']],
|
||||
['Range', ['Range: bytes=0-1']],
|
||||
];
|
||||
let n = 0;
|
||||
for (const [name, headers] of curlCases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await http.curl({ url: server.url(), headers: [auth, ...headers] });
|
||||
expect(r.exitCode).toBe(0);
|
||||
expectContainersResponse(r);
|
||||
expect(r.headers['content-encoding']).toBeUndefined();
|
||||
n++;
|
||||
});
|
||||
}
|
||||
await test.step('HTTP/1.0', async () => {
|
||||
const r = await http.curl({ url: server.url(), token, httpVersion: '1.0' });
|
||||
expectContainersResponse(r);
|
||||
n++;
|
||||
});
|
||||
await test.step('Playwright: header names in odd case and many custom headers', async () => {
|
||||
const headers: Record<string, string> = { aUtHoRiZaTiOn: `Bearer ${token}`, 'x-ünused': 'x', 'X-Empty': '' };
|
||||
delete headers['x-ünused'];
|
||||
expectContainersResponse(await http.get(server.url(), { headers }));
|
||||
n++;
|
||||
});
|
||||
await test.step('pipelined requests on one connection', async () => {
|
||||
const one = buildRawRequest({ target: '/api/v1/containers', headers: [auth], close: false });
|
||||
const r = await http.raw({ host: server.connectHost, port: server.port, data: one + one + buildRawRequest({ target: '/api/v1/containers', headers: [auth] }), expectResponses: 3 });
|
||||
expect(r.responses).toHaveLength(3);
|
||||
for (const resp of r.responses) expectContainersResponse(resp);
|
||||
n += 3;
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(n).fill('200'));
|
||||
expect(readOperations(server.dbPath)).toHaveLength(n);
|
||||
assertDbInvariants(server.dbPath, { operationCount: n });
|
||||
});
|
||||
|
||||
test('empty and irrelevant request bodies', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
// GET with bodies: ignored
|
||||
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: '{"filters":{"status":["exited"]},"all":true}', headers: { 'Content-Type': 'application/json' } }));
|
||||
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: Buffer.alloc(64 * 1024, 0x41) }));
|
||||
const zero = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer ${token}`, 'Content-Length: 0'] }) });
|
||||
expectContainersResponse(zero.response!);
|
||||
const chunked = await http.raw({
|
||||
host: server.connectHost,
|
||||
port: server.port,
|
||||
data: `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${token}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n`,
|
||||
});
|
||||
expectContainersResponse(chunked.response!);
|
||||
// non-GET with or without bodies: 405
|
||||
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token }), 405);
|
||||
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token, data: '' }), 405);
|
||||
expectErrorResponse(await http.fetch(server.url(), { method: 'PUT', token, data: 'a=1&b=2', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 405);
|
||||
expectErrorResponse(await http.curl({ url: server.url(), method: 'DELETE', token, data: '{"id":"x"}' }), 405);
|
||||
expectErrorResponse(await http.curl({ url: server.url(), token, data: 'payload' }), 405); // curl --data-binary defaults to POST
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(readOperations(server.dbPath)).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('requests rejected by Go before routing get plain-text errors, are not logged or audited, and the server survives', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
const raw = (data: string | Buffer, extra: object = {}) => http.raw({ host: server.connectHost, port: server.port, data, ...extra });
|
||||
const cases: Array<[string, string | Buffer, number]> = [
|
||||
['garbage request line', 'GARBAGE\r\n\r\n', 400],
|
||||
['missing HTTP version', 'GET /api/v1/containers\r\n\r\n', 400],
|
||||
['unsupported HTTP version', 'GET /api/v1/containers HTTP/9.9\r\nHost: x\r\n\r\n', 505],
|
||||
['HTTP/1.1 without Host', 'GET /api/v1/containers HTTP/1.1\r\nConnection: close\r\n\r\n', 400],
|
||||
['duplicate Host', 'GET /api/v1/containers HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n', 400],
|
||||
['space before colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization : Bearer x\r\n\r\n', 400],
|
||||
['header line without colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nNoColonHere\r\n\r\n', 400],
|
||||
['invalid header name', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nBad(Name): 1\r\n\r\n', 400],
|
||||
['conflicting Content-Length', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n', 400],
|
||||
['request target not a path', 'GET api/v1/containers HTTP/1.1\r\nHost: x\r\n\r\n', 400],
|
||||
// Go's limit is DefaultMaxHeaderBytes (1 MiB) plus 4 KiB of slack
|
||||
['headers of 2 MiB', `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nX-Big: ${'a'.repeat(2 * 1024 * 1024)}\r\n\r\n`, 431],
|
||||
];
|
||||
for (const [name, data, status] of cases) {
|
||||
await test.step(name, async () => {
|
||||
const r = await raw(data);
|
||||
expect(r.outcome, name).toBe('response');
|
||||
expect(r.response!.status, name).toBe(status);
|
||||
expect(r.response!.header('content-type') ?? 'text/plain; charset=utf-8').toMatch(/^text\/plain/);
|
||||
});
|
||||
}
|
||||
// obs-fold continuation lines are accepted and unfolded by Go (RFC 9112 §5.2 allows replacing with SP)
|
||||
const folded = await raw(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer\r\n ${token}\r\nConnection: close\r\n\r\n`);
|
||||
expectContainersResponse(folded.response!);
|
||||
const opts = await raw('OPTIONS * HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n');
|
||||
expect(opts.response!.status).toBe(200);
|
||||
// the server keeps serving
|
||||
expectContainersResponse(await http.get(server.url(), { token }));
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const logs = server.logs();
|
||||
expect(logs.requests().map((r) => r.attrs.status), 'only the two valid requests were logged').toEqual(['200', '200']);
|
||||
expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
||||
expect(readOperations(server.dbPath)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('half-open and abruptly closed client connections do not break the server', async ({ cli, startServer, http }) => {
|
||||
const { token, server } = await setup(cli, startServer);
|
||||
// client sends nothing and closes
|
||||
const a = await http.raw({ host: server.connectHost, port: server.port, data: '', endAfterWrite: true, timeoutMs: 3000 });
|
||||
expect(['closed', 'reset']).toContain(a.outcome);
|
||||
// client sends half a request and closes
|
||||
const b = await http.raw({ host: server.connectHost, port: server.port, data: 'GET /api/v1/contai', endAfterWrite: true, timeoutMs: 3000 });
|
||||
expect(['closed', 'reset', 'response']).toContain(b.outcome);
|
||||
if (b.outcome === 'response') expect(b.response!.status).toBe(400);
|
||||
expectContainersResponse(await http.get(server.url(), { token }));
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests()).toHaveLength(1);
|
||||
});
|
||||
@@ -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