Files
dpsapi/e2e/tests/host/persona-stupid-user.spec.ts
T
bryanpedini a139f6793d test(e2e): add persona e2e suites
Power, careless, malicious, fuzzing, cross-abuse, operator-session and concurrency personas exercising the whole application with model-checked database state.
2026-10-06 10:52:25 +02:00

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 });
});