// 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): 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', ''], 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, 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, 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 }); });