Files
dpsapi/e2e/tests/host/auth.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

254 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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([]);
});