Power, careless, malicious, fuzzing, cross-abuse, operator-session and concurrency personas exercising the whole application with model-checked database state.
386 lines
26 KiB
TypeScript
386 lines
26 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §12 persona "Stupid user": common human mistakes against the CLI, the environment
|
|
* configuration and the HTTP API — omitted/extra arguments, misspelled commands, copy/paste
|
|
* whitespace around tokens, empty and absurdly large descriptions, malformed Authorization
|
|
* headers, wrong methods and paths, repeated/nonexistent deletions, bad environment variables,
|
|
* wrong content types and irrelevant bodies, operational blunders (second server on the same
|
|
* port, Ctrl-C twice, requests after stopping), and command sequencing errors.
|
|
*
|
|
* Unlike the core config/usage tables (fresh workspaces), every mistake here is made against a
|
|
* populated, live database: each must fail safely with the exact status / exit code, leave the
|
|
* logical state (stateDigest) and, for usage/config errors, the database file bytes unchanged —
|
|
* and the instance must keep working for the next valid operation.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import {
|
|
test,
|
|
expect,
|
|
expectCliFailure,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
fileDigest,
|
|
formatAuditOutput,
|
|
goQuote,
|
|
readOperations,
|
|
readToken,
|
|
type CliRunner,
|
|
type CurlResult,
|
|
type HttpResult,
|
|
type ServerHandle,
|
|
} from '../../harness';
|
|
import { ENDPOINT, StateModel, expectedAuditStdout, expectedTokenListStdout, stateDigest } from '../../harness/personas';
|
|
|
|
/** Populated database + running server; returns the model. */
|
|
async function populated(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>): Promise<{ model: StateModel; server: ServerHandle; active: string; inactive: string }> {
|
|
const model = new StateModel();
|
|
const active = model.add(await cli.tokenNew('my laptop'), 'my laptop').token;
|
|
const inactive = model.add(await cli.tokenNew('old laptop'), 'old laptop').token;
|
|
model.add(await cli.tokenNew(), '');
|
|
await cli.tokenDelete(inactive);
|
|
model.deactivate(inactive);
|
|
const server = await startServer();
|
|
server.fakeDocker!.many(2);
|
|
return { model, server, active, inactive };
|
|
}
|
|
|
|
test('CLI mistakes against a live, populated database: exact exit code and message, state and file untouched, next command works', async ({ cli, ws, startServer, http }) => {
|
|
test.setTimeout(90_000);
|
|
const { model, server, active, inactive } = await populated(cli, startServer);
|
|
const line = (await cli.tokenList()).find((r) => r.token === active)!.line;
|
|
const usage = (m: string) => ({ exitCode: 2, message: `usage error: ${m}` });
|
|
const notFound = { exitCode: 1, message: 'token not found' };
|
|
const mistakes: Array<[string, string[], { exitCode: number; message: string }]> = [
|
|
['no subcommand', ['token'], usage('missing token subcommand')],
|
|
['typo in command', ['tokne', 'list'], usage('unknown command')],
|
|
['plural command', ['tokens', 'list'], usage('unknown command')],
|
|
['capitalized command', ['Token', 'list'], usage('unknown command')],
|
|
['asking for help', ['--help'], usage('unknown command')],
|
|
['help subcommand', ['token', 'help'], usage('unknown token subcommand')],
|
|
['typo in subcommand', ['token', 'lsit'], usage('unknown token subcommand')],
|
|
['abbreviated subcommand', ['token', 'ls'], usage('unknown token subcommand')],
|
|
['upper-case subcommand', ['token', 'LIST'], usage('unknown token subcommand')],
|
|
['remove instead of delete', ['token', 'remove', active], usage('unknown token subcommand')],
|
|
['revoke instead of delete', ['token', 'revoke', active], usage('unknown token subcommand')],
|
|
['subcommand with a stray space', ['token', 'list '], usage('unknown token subcommand')],
|
|
['list with a filter argument', ['token', 'list', 'active'], usage('unknown token list argument')],
|
|
['list --all', ['token', 'list', '--all'], usage('unknown token list argument')],
|
|
['list -a', ['token', 'list', '-a'], usage('unknown token list argument')],
|
|
['list ALL (caps lock)', ['token', 'list', 'ALL'], usage('unknown token list argument')],
|
|
['list All', ['token', 'list', 'All'], usage('unknown token list argument')],
|
|
['list with an empty argument', ['token', 'list', ''], usage('unknown token list argument')],
|
|
['list all with a pasted trailing space', ['token', 'list', 'all '], usage('unknown token list argument')],
|
|
['list all twice', ['token', 'list', 'all', 'all'], usage('token list takes at most one argument')],
|
|
['list all and a filter', ['token', 'list', 'all', 'inactive'], usage('token list takes at most one argument')],
|
|
['help in the wrong place', ['token', 'list', 'help'], usage('unknown token list argument')],
|
|
['help with a topic', ['help', 'token'], usage('help takes no arguments')],
|
|
['help help', ['help', 'help'], usage('help takes no arguments')],
|
|
['help with an empty argument', ['help', ''], usage('help takes no arguments')],
|
|
['capitalized Help', ['Help'], usage('unknown command')],
|
|
['-h instead of help', ['-h'], usage('unknown command')],
|
|
['help with a stray space', ['help '], usage('unknown command')],
|
|
['unquoted multi-word description', ['token', 'new', 'my', 'new', 'laptop'], usage('token new takes at most one argument')],
|
|
['description and a flag', ['token', 'new', 'ci', '--expires=30d'], usage('token new takes at most one argument')],
|
|
['delete without the token', ['token', 'delete'], usage('token delete requires exactly one argument')],
|
|
['delete two tokens at once', ['token', 'delete', active, inactive], usage('token delete requires exactly one argument')],
|
|
['delete the whole pasted list line', ['token', 'delete', ...line.split(/ +/)], usage('token delete requires exactly one argument')],
|
|
['pasted with a leading space', ['token', 'delete', ` ${active}`], notFound],
|
|
['pasted with a trailing space', ['token', 'delete', `${active} `], notFound],
|
|
['pasted with a trailing newline', ['token', 'delete', `${active}\n`], notFound],
|
|
['pasted with CRLF', ['token', 'delete', `${active}\r\n`], notFound],
|
|
['pasted with a leading tab', ['token', 'delete', `\t${active}`], notFound],
|
|
['pasted with literal quotes', ['token', 'delete', `"${active}"`], notFound],
|
|
['pasted with single quotes', ['token', 'delete', `'${active}'`], notFound],
|
|
['pasted with the Bearer prefix', ['token', 'delete', `Bearer ${active}`], notFound],
|
|
['pasted the whole list line as one argument', ['token', 'delete', line], notFound],
|
|
['truncated copy', ['token', 'delete', active.slice(0, 40)], notFound],
|
|
['description instead of token', ['token', 'delete', 'my laptop'], notFound],
|
|
['the documentation placeholder', ['token', 'delete', '<token>'], notFound],
|
|
['an unexpanded variable', ['token', 'delete', '$TOKEN'], notFound],
|
|
['empty token', ['token', 'delete', ''], notFound],
|
|
['upper-cased token', ['token', 'delete', active.toUpperCase()], notFound],
|
|
['delete the already deleted token', ['token', 'delete', inactive], { exitCode: 1, message: 'token already inactive' }],
|
|
// audit: wrong argument shapes
|
|
['audit with a spelled-out count', ['audit', 'ten'], usage('invalid audit argument')],
|
|
['audit with count and unit as two words', ['audit', '10', 'hours'], usage('audit takes at most one argument')],
|
|
['audit with count and unit letter separated', ['audit', '10', 'h'], usage('audit takes at most one argument')],
|
|
['audit "10 hours" quoted as one argument', ['audit', '10 hours'], usage('invalid audit argument')],
|
|
['audit with a negative count', ['audit', '-5'], usage('invalid audit argument')],
|
|
['audit with an upper-case unit', ['audit', '1D'], usage('invalid audit argument')],
|
|
['audit zero', ['audit', '0'], usage('invalid audit argument')],
|
|
['audit zero hours', ['audit', '0h'], usage('invalid audit argument')],
|
|
['audit with a decimal window', ['audit', '1.5h'], usage('invalid audit argument')],
|
|
['audit with a unit but no number', ['audit', 'h'], usage('invalid audit argument')],
|
|
['audit with minutes spelled "min"', ['audit', '30min'], usage('invalid audit argument')],
|
|
['audit with years', ['audit', '1y'], usage('invalid audit argument')],
|
|
['audit like tail -n', ['audit', '-n', '10'], usage('audit takes at most one argument')],
|
|
['audit --tail=10', ['audit', '--tail=10'], usage('invalid audit argument')],
|
|
['audit with a pasted trailing space', ['audit', '10 '], usage('invalid audit argument')],
|
|
['audit with an empty argument', ['audit', ''], usage('invalid audit argument')],
|
|
['plural audits', ['audits'], usage('unknown command')],
|
|
['capitalized Audit', ['Audit', '10'], usage('unknown command')],
|
|
['audit as a token subcommand', ['token', 'audit'], usage('unknown token subcommand')],
|
|
// audit all: exact lowercase word, nothing after it
|
|
['audit ALL (caps lock)', ['audit', 'ALL'], usage('invalid audit argument')],
|
|
['audit All', ['audit', 'All'], usage('invalid audit argument')],
|
|
['audit --all like a flag', ['audit', '--all'], usage('invalid audit argument')],
|
|
['audit "all " with a pasted trailing space', ['audit', 'all '], usage('invalid audit argument')],
|
|
['audit all with a count', ['audit', 'all', '10'], usage('audit takes at most one argument')],
|
|
['audit all logs', ['audit', 'all', 'logs'], usage('audit takes at most one argument')],
|
|
];
|
|
for (const [name, args, exp] of mistakes) {
|
|
await test.step(name, async () => {
|
|
const digest = stateDigest(ws.dbPath);
|
|
const file = fileDigest(ws.dbPath);
|
|
const r = await cli.run(args);
|
|
expectCliFailure(r, exp);
|
|
if (args.length >= 3 && args[1] === 'delete' && args[2].length > 20) expect(r.stderr.includes(active), 'the token is never echoed').toBe(false);
|
|
expect(stateDigest(ws.dbPath), 'logical state unchanged').toBe(digest);
|
|
if (exp.exitCode === 2) expect(fileDigest(ws.dbPath), 'usage errors never open the database').toBe(file);
|
|
});
|
|
}
|
|
// the mistakes did not hurt the instance: the right commands work
|
|
model.verifyList(await cli.tokenList());
|
|
model.verifyList(await cli.tokenListAll(), 'token list all', 'all');
|
|
await cli.help();
|
|
for (let i = 0; i < 12; i++) expectContainersResponse(await http.get(server.url(), { token: active }));
|
|
model.audit(active, 12);
|
|
const ops = readOperations(ws.dbPath);
|
|
expect(formatAuditOutput(await cli.audit()), 'audit: the latest 10 rows').toBe(expectedAuditStdout(ops, { limit: 10n }));
|
|
expect(formatAuditOutput(await cli.audit('10')), 'audit 10').toBe(expectedAuditStdout(ops, { limit: 10n }));
|
|
expect(formatAuditOutput(await cli.audit('1h')), 'audit 1h: every row of this session').toBe(expectedAuditStdout(ops, { limit: BigInt(ops.length) }));
|
|
expect(formatAuditOutput(await cli.audit('all')), 'audit all: every row (more than the default 10)').toBe(expectedAuditStdout(ops, { all: true }));
|
|
expect(await cli.audit('all')).toHaveLength(ops.length);
|
|
const t = await cli.tokenNew('my new laptop');
|
|
model.add(t, 'my new laptop');
|
|
expectContainersResponse(await http.get(server.url(), { token: t }));
|
|
model.audit(t);
|
|
expectContainersResponse(await http.get(server.url(), { token: active }));
|
|
model.audit(active);
|
|
await cli.tokenDelete(active);
|
|
model.deactivate(active);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
model.verifyDb(ws.dbPath, 'final');
|
|
});
|
|
|
|
test('description mistakes: empty, whitespace-only, accidental token, absurdly large (argv limit, shell E2BIG) — accepted verbatim or refused by the OS without side effects', async ({ cli, ws }) => {
|
|
const model = new StateModel();
|
|
const t0 = model.add(await cli.tokenNew('existing'), 'existing').token;
|
|
for (const d of ['', ' ', '\t', '\n', 'TODO', 'description', '"', `${t0.slice(0, 10)}...`]) model.add(await cli.tokenNew(d), d);
|
|
// the largest single argument the kernel accepts (MAX_ARG_STRLEN - 1)
|
|
const huge = 'z'.repeat(131_071);
|
|
model.add(await cli.tokenNew(huge), huge);
|
|
const rows = await cli.tokenList();
|
|
model.verifyList(rows);
|
|
expect(rows.find((r) => r.description === huge)!.descriptionQuoted).toBe(goQuote(huge));
|
|
// the huge description sets the DESCRIPTION column width: every other row is padded to it
|
|
// (all quoted descriptions are ASCII here, so string offsets are rune offsets)
|
|
const activeAt = 45 + goQuote(huge).length + 2;
|
|
expect(rows.filter((r) => r.line.slice(activeAt - 1, activeAt + 5) !== ' true ').map((r) => r.descriptionQuoted.slice(0, 20)), 'ACTIVE column aligned after the huge description').toEqual([]);
|
|
expect((await cli.run(['token', 'list'])).stdout).toBe(expectedTokenListStdout(ws.dbPath));
|
|
// larger than the kernel limit: the shell cannot even exec the binary (126), nothing changes
|
|
const digest = stateDigest(ws.dbPath);
|
|
const r = await cli.shell(`"$DPSAPI_BIN" token new "$(head -c 200000 /dev/zero | tr '\\0' 'y')"`);
|
|
expect(r.exitCode, 'the shell reports E2BIG; dpsapi never ran').toBe(126);
|
|
expect(r.stderr).toMatch(/Argument list too long/);
|
|
expect(r.stdout).toBe('');
|
|
expect(stateDigest(ws.dbPath)).toBe(digest);
|
|
// the same mistake through argv is refused by the OS before dpsapi runs (spawn error, a harness-level refusal)
|
|
await expect(cli.run(['token', 'new', 'z'.repeat(200_000)])).rejects.toThrow(/harness:spawn|E2BIG/);
|
|
expect(stateDigest(ws.dbPath)).toBe(digest);
|
|
model.verifyDb(ws.dbPath);
|
|
});
|
|
|
|
test('environment mistakes when starting the server against an existing database: exit 2/1 with the exact message, database untouched, a correct start works', async ({ cli, ws, startServer, http, e2e }) => {
|
|
test.setTimeout(90_000);
|
|
const model = new StateModel();
|
|
const tok = model.add(await cli.tokenNew('env'), 'env').token;
|
|
const port = String(await e2e.allocatePort());
|
|
const ok = { HTTP_ADDR: '127.0.0.1', HTTP_PORT: port, LOG_LEVEL: 'INFO' };
|
|
const cfg = (v: string, val: string, tail: string) => `configuration error: invalid ${v} ${goQuote(val)}: ${tail}`;
|
|
const ADDR = 'must be an IPv4 or IPv6 address';
|
|
const PORT = 'must be an integer between 1 and 65535';
|
|
const LEVEL = 'must be one of ERROR, WARN, INFO, DEBUG, TRACE';
|
|
const mistakes: Array<[string, Record<string, string | undefined>, number, string | RegExp]> = [
|
|
['forgot DB_PATH', { DB_PATH: undefined }, 2, 'configuration error: DB_PATH is required'],
|
|
['DB_PATH exported empty', { DB_PATH: '' }, 2, 'configuration error: DB_PATH must not be empty'],
|
|
['DB_PATH with an unexpanded ~ (relative "~" directory does not exist)', { DB_PATH: '~/dpsapi.db' }, 1, 'initialize database schema: unable to open database file (14)'],
|
|
['DB_PATH pointing at the directory', { DB_PATH: ws.dir }, 1, 'initialize database schema: unable to open database file (14)'],
|
|
['port with a trailing space', { HTTP_PORT: `${port} ` }, 2, cfg('HTTP_PORT', `${port} `, PORT)],
|
|
['port written like a listen address', { HTTP_PORT: `:${port}` }, 2, cfg('HTTP_PORT', `:${port}`, PORT)],
|
|
['port given as a URL', { HTTP_PORT: `http://127.0.0.1:${port}` }, 2, cfg('HTTP_PORT', `http://127.0.0.1:${port}`, PORT)],
|
|
['port zero', { HTTP_PORT: '0' }, 2, cfg('HTTP_PORT', '0', PORT)],
|
|
['port 70000', { HTTP_PORT: '70000' }, 2, cfg('HTTP_PORT', '70000', PORT)],
|
|
['host name instead of an address', { HTTP_ADDR: 'localhost' }, 2, cfg('HTTP_ADDR', 'localhost', ADDR)],
|
|
['address with the port', { HTTP_ADDR: `127.0.0.1:${port}` }, 2, cfg('HTTP_ADDR', `127.0.0.1:${port}`, ADDR)],
|
|
['address as a URL', { HTTP_ADDR: 'http://127.0.0.1' }, 2, cfg('HTTP_ADDR', 'http://127.0.0.1', ADDR)],
|
|
['bracketed IPv6', { HTTP_ADDR: '[::1]' }, 2, cfg('HTTP_ADDR', '[::1]', ADDR)],
|
|
['log level verbose', { LOG_LEVEL: 'verbose' }, 2, cfg('LOG_LEVEL', 'verbose', LEVEL)],
|
|
['log level with a trailing space', { LOG_LEVEL: 'debug ' }, 2, cfg('LOG_LEVEL', 'debug ', LEVEL)],
|
|
['log level warning', { LOG_LEVEL: 'warning' }, 2, cfg('LOG_LEVEL', 'warning', LEVEL)],
|
|
['log level exported empty', { LOG_LEVEL: '' }, 2, 'configuration error: LOG_LEVEL must not be empty'],
|
|
];
|
|
const homeDb = `${process.env.HOME ?? '/nonexistent'}/dpsapi.db`;
|
|
const homeHad = fs.existsSync(homeDb);
|
|
for (const [name, over, code, message] of mistakes) {
|
|
await test.step(name, async () => {
|
|
const digest = stateDigest(ws.dbPath);
|
|
const file = fileDigest(ws.dbPath);
|
|
const r = await cli.run([], { env: { ...ok, ...over }, timeoutMs: 10_000 });
|
|
expectCliFailure(r, { exitCode: code, message, usage: false });
|
|
expect(stateDigest(ws.dbPath)).toBe(digest);
|
|
if (code === 2) expect(fileDigest(ws.dbPath), 'configuration errors never open the database').toBe(file);
|
|
});
|
|
}
|
|
expect(fs.existsSync(ws.path('~')), 'no "~" directory was created').toBe(false);
|
|
expect(fs.existsSync(homeDb), 'nothing was created in $HOME').toBe(homeHad);
|
|
// lower-case level and leading zeros are not mistakes
|
|
const server = await startServer({ port: `000${port}`, logLevel: 'info' });
|
|
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
|
model.audit(tok);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
model.verifyDb(ws.dbPath);
|
|
});
|
|
|
|
test('HTTP mistakes: wrong methods, wrong paths, malformed Authorization, wrong content types and irrelevant bodies — exact status, nothing audited, valid request still works', async ({ cli, ws, startServer, http }) => {
|
|
test.setTimeout(90_000);
|
|
const model = new StateModel();
|
|
const tok = model.add(await cli.tokenNew('http'), 'http').token;
|
|
const old = model.add(await cli.tokenNew('old'), 'old').token;
|
|
await cli.tokenDelete(old);
|
|
model.deactivate(old);
|
|
const server = await startServer({ logLevel: 'DEBUG' });
|
|
server.fakeDocker!.many(2);
|
|
const json = { 'Content-Type': 'application/json' };
|
|
type Case = [string, () => Promise<HttpResult | CurlResult>, 200 | 401 | 404 | 405];
|
|
const cases: Case[] = [
|
|
['POST with a JSON body', () => http.fetch(server.url(), { method: 'POST', token: tok, data: '{"all":true}', headers: json }), 405],
|
|
['PUT with a form body', () => http.fetch(server.url(), { method: 'PUT', token: tok, data: 'name=x', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 405],
|
|
['DELETE a container', () => http.fetch(server.url(`${ENDPOINT}/abc`), { method: 'DELETE', token: tok }), 404],
|
|
['DELETE the collection', () => http.fetch(server.url(), { method: 'DELETE', token: tok }), 405],
|
|
['PATCH', () => http.fetch(server.url(), { method: 'PATCH', token: tok, data: '[]', headers: json }), 405],
|
|
['curl -d (implicit POST)', () => http.curl({ url: server.url(), token: tok, data: 'all=1' }), 405],
|
|
['curl -I (HEAD)', () => http.curl({ url: server.url(), token: tok, head: true }), 405],
|
|
['missing /api prefix', () => http.get(server.url('/v1/containers'), { token: tok }), 404],
|
|
['missing version', () => http.get(server.url('/api/containers'), { token: tok }), 404],
|
|
['wrong version', () => http.get(server.url('/api/v2/containers'), { token: tok }), 404],
|
|
['singular resource', () => http.get(server.url('/api/v1/container'), { token: tok }), 404],
|
|
['trailing slash', () => http.get(server.url(`${ENDPOINT}/`), { token: tok }), 404],
|
|
['docker-style path', () => http.get(server.url('/containers/json'), { token: tok }), 404],
|
|
['root', () => http.get(server.url('/'), { token: tok }), 404],
|
|
['upper-case path', () => http.get(server.url('/API/V1/CONTAINERS'), { token: tok }), 404],
|
|
['.json suffix', () => http.get(server.url(`${ENDPOINT}.json`), { token: tok }), 404],
|
|
['no Authorization at all', () => http.get(server.url()), 401],
|
|
['token without scheme', () => http.get(server.url(), { headers: { Authorization: tok } }), 401],
|
|
['Token scheme', () => http.get(server.url(), { headers: { Authorization: `Token ${tok}` } }), 401],
|
|
['Basic with the token as password', () => http.get(server.url(), { headers: { Authorization: `Basic ${Buffer.from(`:${tok}`).toString('base64')}` } }), 401],
|
|
['colon after Bearer', () => http.get(server.url(), { headers: { Authorization: `Bearer: ${tok}` } }), 401],
|
|
['quoted token', () => http.get(server.url(), { headers: { Authorization: `Bearer "${tok}"` } }), 401],
|
|
['angle brackets from the docs', () => http.get(server.url(), { headers: { Authorization: `Bearer <${tok}>` } }), 401],
|
|
['unexpanded $TOKEN', () => http.get(server.url(), { headers: { Authorization: 'Bearer $TOKEN' } }), 401],
|
|
['two spaces after Bearer', () => http.get(server.url(), { headers: { Authorization: `Bearer ${tok}` } }), 401],
|
|
['description instead of token', () => http.get(server.url(), { headers: { Authorization: 'Bearer http' } }), 401],
|
|
['old (deleted) token', () => http.get(server.url(), { token: old }), 401],
|
|
['token in the query string', () => http.get(server.url(`${ENDPOINT}?token=${tok}`)), 401],
|
|
['token in X-Api-Key', () => http.get(server.url(), { headers: { 'X-Api-Key': tok } }), 401],
|
|
['token in a cookie', () => http.get(server.url(), { headers: { Cookie: `token=${tok}` } }), 401],
|
|
['token in a JSON body', () => http.fetch(server.url(), { method: 'GET', data: JSON.stringify({ token: tok }), headers: json }), 401],
|
|
['token pasted with a trailing newline (curl splits it off)', () => http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok}\n`] }), 200],
|
|
['copy/paste spaces around the value (trimmed by HTTP)', () => http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok} `] }), 200],
|
|
['XML content type on a GET', () => http.get(server.url(), { token: tok, headers: { 'Content-Type': 'application/xml' } }), 200],
|
|
['GET with a multipart body', () => http.fetch(server.url(), { method: 'GET', token: tok, data: '--b\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--b--\r\n', headers: { 'Content-Type': 'multipart/form-data; boundary=b' } }), 200],
|
|
['asking for HTML', () => http.get(server.url(), { token: tok, headers: { Accept: 'text/html' } }), 200],
|
|
['asking for XML', () => http.get(server.url(), { token: tok, headers: { Accept: 'application/xml' } }), 200],
|
|
];
|
|
let audited = 0;
|
|
for (const [name, send, status] of cases) {
|
|
await test.step(`${name} → ${status}`, async () => {
|
|
const digest = stateDigest(ws.dbPath);
|
|
const r = await send();
|
|
if (status === 200) {
|
|
expect(expectContainersResponse(r)).toHaveLength(2);
|
|
expect(r.headers['content-type'], 'always JSON, whatever was asked for').toBe('application/json');
|
|
model.audit(tok);
|
|
audited++;
|
|
} else {
|
|
expectErrorResponse(r, status, { head: name.includes('HEAD') });
|
|
expect(stateDigest(ws.dbPath), 'refused requests change nothing').toBe(digest);
|
|
}
|
|
});
|
|
}
|
|
expect(expectContainersResponse(await http.get(server.url(), { token: tok }))).toHaveLength(2);
|
|
model.audit(tok);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
expect(server.logs().requests()).toHaveLength(cases.length + 1);
|
|
expect(server.logs().count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
|
expect(audited).toBe(6);
|
|
model.verifyDb(ws.dbPath);
|
|
});
|
|
|
|
test('operational blunders and sequencing errors: using the API before creating a token, a second server on the same port, Ctrl-C twice, requests after stopping, deleting before creating', async ({ cli, ws, startServer, http }) => {
|
|
test.setTimeout(90_000);
|
|
const model = new StateModel();
|
|
|
|
await test.step('deleting and listing before anything exists (fresh database)', async () => {
|
|
expectCliFailure(await cli.run(['token', 'delete', 'whatever']), { exitCode: 1, message: 'token not found' });
|
|
expect(fs.existsSync(ws.dbPath), 'the CLI initialized the database').toBe(true);
|
|
model.verifyDb(ws.dbPath);
|
|
});
|
|
|
|
const server = await startServer();
|
|
server.fakeDocker!.many(1);
|
|
await test.step('calling the API before creating any token: 401, no audit', async () => {
|
|
expectErrorResponse(await http.get(server.url(), { headers: { Authorization: 'Bearer ' } }), 401);
|
|
expectErrorResponse(await http.get(server.url()), 401);
|
|
model.verifyDb(ws.dbPath);
|
|
});
|
|
|
|
const tok = model.add(await cli.tokenNew('finally'), 'finally').token;
|
|
await test.step('the token created afterwards works without restarting the server', async () => {
|
|
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
|
model.audit(tok);
|
|
});
|
|
|
|
await test.step('starting a second server on the same port: exit 1, the first keeps serving', async () => {
|
|
const digest = stateDigest(ws.dbPath);
|
|
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: String(server.port), LOG_LEVEL: 'INFO' }, timeoutMs: 10_000 });
|
|
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 127.0.0.1:${server.port}: bind: address already in use`, exactStderr: true });
|
|
expect(stateDigest(ws.dbPath)).toBe(digest);
|
|
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
|
model.audit(tok);
|
|
});
|
|
|
|
await test.step('Ctrl-C pressed twice: still a graceful exit 0', async () => {
|
|
server.signal('SIGINT');
|
|
server.signal('SIGINT');
|
|
const exit = await server.waitForExit(20_000);
|
|
// README documents SIGINT/SIGTERM → graceful exit 0; a second SIGINT during shutdown is
|
|
// undefined by the spec — observed: it is absorbed and the exit is still 0.
|
|
expect(exit.exitCode).toBe(0);
|
|
expect(server.logs().has({ msg: 'server stopped' })).toBe(true);
|
|
});
|
|
|
|
await test.step('requests after stopping: connection refused, nothing recorded', async () => {
|
|
const c = await http.curl({ url: server.url(), token: tok });
|
|
expect(c.exitCode, 'curl: connection refused').toBe(7);
|
|
expect(c.status).toBe(0);
|
|
const raw = await http.raw({ host: server.connectHost, port: server.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
|
|
expect(raw.outcome).toBe('refused');
|
|
});
|
|
|
|
await test.step('deleting twice, then using the deleted token, then listing', async () => {
|
|
await cli.tokenDelete(tok);
|
|
model.deactivate(tok);
|
|
expectCliFailure(await cli.run(['token', 'delete', tok]), { exitCode: 1, message: 'token already inactive' });
|
|
const s2 = await startServer({ dbPath: ws.dbPath });
|
|
expectErrorResponse(await http.get(s2.url(), { token: tok }), 401);
|
|
expect((await s2.stop()).exitCode).toBe(0);
|
|
model.verifyList(await cli.tokenList());
|
|
model.verifyList(await cli.tokenListAll(), 'token list all', 'all');
|
|
expect(readToken(ws.dbPath, tok)!.deleted_at).not.toBeNull();
|
|
});
|
|
model.verifyDb(ws.dbPath, 'final', { auditBeforeDeletion: true });
|
|
});
|