Files
dpsapi/e2e/tests/host/config-usage.spec.ts
T
bryanpedini 0a6023e942 feat(cli): add help, token and audit commands
Entry point and dispatch, token list [all] / new / delete, audit [all | n | n<unit>] with padded table output, help, usage and exit codes; unit and e2e tests for commands, configuration precedence, logging and token lifecycle.
2026-10-06 10:52:25 +02:00

373 lines
19 KiB
TypeScript

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12 core item 16: every startup/configuration failure and every CLI usage failure, with
* exact stderr, exit code (2 usage/config, 1 runtime) and proof that the database was not
* created or modified inappropriately.
*/
import fs from 'node:fs';
import {
test,
expect,
assertDbInvariants,
expectCliFailure,
expectCliSuccess,
fileDigest,
goQuote,
isPortFree,
occupyPort,
parseLogs,
readTokens,
USAGE_ERROR_MESSAGES,
USAGE_TEXT,
} from '../../harness';
import { cliOutcome, StateModel } from '../../harness/personas';
/** Server mode ([]) and a CLI command must both reject a bad configuration identically. */
const MODES: Array<[string, string[]]> = [
['server mode', []],
['token list', ['token', 'list']],
['token new', ['token', 'new', 'x']],
['audit', ['audit', '5m']],
];
/** Configuration is validated before dispatch: also `help` and `token list all` (which never open the database). */
const CONFIG_MODES: Array<[string, string[]]> = [...MODES, ['token list all', ['token', 'list', 'all']], ['audit all', ['audit', 'all']], ['help', ['help']], ['help with an argument', ['help', 'x']]];
const addrMsg = (v: string) => `configuration error: invalid HTTP_ADDR ${goQuote(v)}: must be an IPv4 or IPv6 address`;
const portMsg = (v: string) => `configuration error: invalid HTTP_PORT ${goQuote(v)}: must be an integer between 1 and 65535`;
const levelMsg = (v: string) => `configuration error: invalid LOG_LEVEL ${goQuote(v)}: must be one of ERROR, WARN, INFO, DEBUG, TRACE`;
const CONFIG_CASES: Array<[string, Record<string, string | undefined>, string]> = [
['DB_PATH unset', { DB_PATH: undefined }, 'configuration error: DB_PATH is required'],
['DB_PATH empty', { DB_PATH: '' }, 'configuration error: DB_PATH must not be empty'],
['HTTP_ADDR empty', { HTTP_ADDR: '' }, 'configuration error: HTTP_ADDR must not be empty'],
...['localhost', 'example.com', '127.0.0.1:8080', '[::1]', '256.0.0.1', '1.2.3', '010.0.0.1', ' 127.0.0.1', '127.0.0.1 ', 'fe80::1%lo', '0x7f.0.0.1', '127.0.0.1\n', '*'].map(
(v) => [`HTTP_ADDR=${JSON.stringify(v)}`, { HTTP_ADDR: v }, addrMsg(v)] as [string, Record<string, string>, string],
),
['HTTP_PORT empty', { HTTP_PORT: '' }, 'configuration error: HTTP_PORT must not be empty'],
...['0', '00000', '-1', '65536', '99999999999999999999', 'abc', ' 80', '80 ', '+80', '1e3', '0x50', '80', '80\n', '8O'].map(
(v) => [`HTTP_PORT=${JSON.stringify(v)}`, { HTTP_PORT: v }, portMsg(v)] as [string, Record<string, string>, string],
),
['LOG_LEVEL empty', { LOG_LEVEL: '' }, 'configuration error: LOG_LEVEL must not be empty'],
...['VERBOSE', 'INFO ', ' info', 'warning', 'ERR', 'trace\n', 'İNFO', 'INFO', 'debug2', 'FATAL', '0'].map(
(v) => [`LOG_LEVEL=${JSON.stringify(v)}`, { LOG_LEVEL: v }, levelMsg(v)] as [string, Record<string, string>, string],
),
// precedence: DB_PATH, HTTP_ADDR, HTTP_PORT, LOG_LEVEL — first problem wins
['DB_PATH missing beats everything', { DB_PATH: undefined, HTTP_ADDR: 'x', HTTP_PORT: 'y', LOG_LEVEL: 'z' }, 'configuration error: DB_PATH is required'],
['HTTP_ADDR beats HTTP_PORT and LOG_LEVEL', { HTTP_ADDR: 'x', HTTP_PORT: 'y', LOG_LEVEL: 'z' }, addrMsg('x')],
['HTTP_PORT beats LOG_LEVEL', { HTTP_PORT: 'y', LOG_LEVEL: 'z' }, portMsg('y')],
];
test.describe('configuration errors → exit 2, exact message, no database', () => {
for (const [name, env, message] of CONFIG_CASES) {
test(name, async ({ cli, ws }) => {
for (const [mode, args] of CONFIG_MODES) {
await test.step(mode, async () => {
const r = await cli.run(args, { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1', ...env }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 2, message, usage: false });
expect(fs.existsSync(ws.dbPath), 'database not created on configuration error').toBe(false);
expect(fs.readdirSync(ws.dir), 'nothing written to the workspace').toEqual([]);
});
}
});
}
test('configuration errors take precedence over usage errors (no usage text)', async ({ cli, ws }) => {
const r = await cli.run(['bogus'], { env: { DB_PATH: undefined } });
expectCliFailure(r, { exitCode: 2, message: 'configuration error: DB_PATH is required', usage: false });
const r2 = await cli.run(['token', 'new', 'a', 'b'], { env: { LOG_LEVEL: 'nope' } });
expectCliFailure(r2, { exitCode: 2, message: levelMsg('nope'), usage: false });
const r3 = await cli.run(['audit', '0'], { env: { HTTP_PORT: '0' } });
expectCliFailure(r3, { exitCode: 2, message: portMsg('0'), usage: false });
expect(fs.existsSync(ws.dbPath)).toBe(false);
});
});
const USAGE_CASES: Array<[string[], string]> = [
[['bogus'], 'unknown command'],
[['-h'], 'unknown command'],
[['--help'], 'unknown command'],
[['Help'], 'unknown command'],
[['HELP'], 'unknown command'],
[['-help'], 'unknown command'],
[['help '], 'unknown command'],
[['help', 'x'], 'help takes no arguments'],
[['help', ''], 'help takes no arguments'],
[['help', 'help'], 'help takes no arguments'],
[['help', 'token', 'list'], 'help takes no arguments'],
[['token', 'help'], 'unknown token subcommand'],
[['audit', 'help'], 'invalid audit argument'],
[['Token', 'list'], 'unknown command'],
[['TOKEN'], 'unknown command'],
[[''], 'unknown command'],
[['tokens', 'list'], 'unknown command'],
[['token'], 'missing token subcommand'],
[['token', 'bogus'], 'unknown token subcommand'],
[['token', 'LIST'], 'unknown token subcommand'],
[['token', 'New'], 'unknown token subcommand'],
[['token', ''], 'unknown token subcommand'],
[['token', 'remove', 'x'], 'unknown token subcommand'],
[['token', 'list', 'extra'], 'unknown token list argument'],
[['token', 'list', ''], 'unknown token list argument'],
[['token', 'list', 'ALL'], 'unknown token list argument'],
[['token', 'list', 'All'], 'unknown token list argument'],
[['token', 'list', '--all'], 'unknown token list argument'],
[['token', 'list', 'all '], 'unknown token list argument'],
[['token', 'list', 'help'], 'unknown token list argument'],
[['token', 'list', 'all', 'all'], 'token list takes at most one argument'],
[['token', 'list', 'all', ''], 'token list takes at most one argument'],
[['token', 'list', 'x', 'y'], 'token list takes at most one argument'],
[['token', 'new', 'a', 'b'], 'token new takes at most one argument'],
[['token', 'new', '', ''], 'token new takes at most one argument'],
[['token', 'delete'], 'token delete requires exactly one argument'],
[['token', 'delete', 'a', 'b'], 'token delete requires exactly one argument'],
// audit is case-sensitive and singular; one representative per invalid-argument class
// (the exhaustive table is in cli-audit.spec.ts)
[['AUDIT'], 'unknown command'],
[['Audit', '10'], 'unknown command'],
[['audits'], 'unknown command'],
[['audit', '1', 'h'], 'audit takes at most one argument'],
[['audit', '0'], 'invalid audit argument'],
[['audit', '0h'], 'invalid audit argument'],
[['audit', '-5'], 'invalid audit argument'],
[['audit', ' 5'], 'invalid audit argument'],
[['audit', ''], 'invalid audit argument'],
[['audit', '5H'], 'invalid audit argument'],
[['audit', '5y'], 'invalid audit argument'],
[['audit', 'h'], 'invalid audit argument'],
[['audit', '1.5'], 'invalid audit argument'],
[['audit', '9223372036854775808'], 'invalid audit argument'],
[['audit', '15251w'], 'invalid audit argument'],
// `audit all`: `all` is exact and lowercase only, and takes no further argument
[['audit', 'ALL'], 'invalid audit argument'],
[['audit', 'All'], 'invalid audit argument'],
[['audit', 'all1'], 'invalid audit argument'],
[['audit', '--all'], 'invalid audit argument'],
[['audit', 'all', 'x'], 'audit takes at most one argument'],
[['audit', 'all', 'all'], 'audit takes at most one argument'],
];
test('the usage table covers every usage error message, and the CLI oracle agrees with it', () => {
const model = new StateModel();
for (const [args, msg] of USAGE_CASES) expect(cliOutcome(args, model), JSON.stringify(args)).toEqual({ kind: 'error', exitCode: 2, message: `usage error: ${msg}`, usage: true });
expect([...new Set(USAGE_CASES.map(([, m]) => m))].sort()).toEqual([...USAGE_ERROR_MESSAGES].sort());
expect(cliOutcome(['help'], model)).toEqual({ kind: 'help', exitCode: 0 });
expect(cliOutcome(['token', 'list'], model)).toEqual({ kind: 'list', exitCode: 0, scope: 'active' });
expect(cliOutcome(['token', 'list', 'all'], model)).toEqual({ kind: 'list', exitCode: 0, scope: 'all' });
expect(cliOutcome(['audit', 'all'], model)).toEqual({ kind: 'audit', exitCode: 0, selection: { all: true } });
expect(cliOutcome(['audit'], model)).toEqual({ kind: 'audit', exitCode: 0, selection: { limit: 10n } });
});
test.describe('help', () => {
test('help prints exactly the usage text on stdout, nothing on stderr, exit 0; no database created, an existing one byte-identical', async ({ cli, ws }) => {
const r = await cli.help();
expect(r.stdout).toBe(USAGE_TEXT);
expect(fs.existsSync(ws.dbPath), 'help never opens/creates the database').toBe(false);
expect(fs.readdirSync(ws.dir), 'nothing written to the workspace').toEqual([]);
// the same bytes as the usage text printed after a usage error
const bad = await cli.run(['bogus']);
expect(bad.stderr).toBe(`dpsapi: usage error: unknown command\n${r.stdout}`);
await cli.tokenNew('existing');
const digest = fileDigest(ws.dbPath);
const rows = JSON.stringify(readTokens(ws.dbPath));
await cli.help();
expect(fileDigest(ws.dbPath)).toBe(digest);
expect(JSON.stringify(readTokens(ws.dbPath))).toBe(rows);
// DB_PATH pointing somewhere unusable does not matter: the database is never opened
for (const db of [ws.path('missing-dir', 'x.db'), ws.mkdir('a-directory')]) await cli.help({ env: { DB_PATH: db } });
expect(fs.existsSync(ws.path('missing-dir'))).toBe(false);
});
test('a configuration error still wins over help: exit 2, the configuration error line only, no usage text, nothing on stdout', async ({ cli, ws }) => {
const cases: Array<[Record<string, string | undefined>, string]> = [
[{ DB_PATH: undefined }, 'configuration error: DB_PATH is required'],
[{ DB_PATH: '' }, 'configuration error: DB_PATH must not be empty'],
[{ HTTP_ADDR: 'localhost' }, addrMsg('localhost')],
[{ HTTP_PORT: '0' }, portMsg('0')],
[{ LOG_LEVEL: 'verbose' }, levelMsg('verbose')],
[{ LOG_LEVEL: '' }, 'configuration error: LOG_LEVEL must not be empty'],
];
for (const [env, message] of cases) {
for (const args of [['help'], ['help', 'x']]) {
const r = await cli.run(args, { env });
expectCliFailure(r, { exitCode: 2, message, usage: false });
expect(r.stderr, `${JSON.stringify(args)} ${JSON.stringify(env)}: exact stderr`).toBe(`dpsapi: ${message}\n`);
expect(r.stderr.includes('usage:'), 'no usage text').toBe(false);
}
}
expect(fs.readdirSync(ws.dir)).toEqual([]);
});
test('log levels: at DEBUG/TRACE stderr carries only log records (dispatch mode=cli, cli dispatch command=help) while stdout is still exactly the usage text; silent at INFO and above', async ({ cli, ws }) => {
for (const level of ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE', 'debug', 'Trace']) {
await test.step(level, async () => {
const r = await cli.run(['help'], { env: { LOG_LEVEL: level } });
expectCliSuccess(r, { stdout: USAGE_TEXT });
const verbose = /^(debug|trace)$/i.test(level);
if (!verbose) {
expect(r.stderr).toBe('');
return;
}
const logs = parseLogs(r.stderr);
expect(logs.unparsed.map((u) => u.text), 'every stderr line is a log record (no usage text, no dpsapi: line)').toEqual([]);
expect(logs.records.length).toBeGreaterThan(0);
expect(logs.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'cli' });
expect(logs.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([{ command: 'help' }]);
expect(logs.has({ msg: /^database / }), 'no database record').toBe(false);
expect(logs.count({ level: 'TRACE' }) > 0, 'TRACE records only at TRACE').toBe(/^trace$/i.test(level));
expect(r.stderr.includes('usage:'), 'the usage text is never on stderr').toBe(false);
});
}
expect(fs.existsSync(ws.dbPath)).toBe(false);
});
});
test.describe('usage errors → exit 2, error line + usage text, database untouched', () => {
for (const [args, msg] of USAGE_CASES) {
test(`${JSON.stringify(args)} → ${msg}`, async ({ cli, ws }) => {
// fresh workspace: the database must not be created
const r = await cli.run(args);
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
expect(r.stderr).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
// existing database: byte-identical afterwards
await cli.tokenNew('existing');
const digest = fileDigest(ws.dbPath);
const rows = JSON.stringify(readTokens(ws.dbPath));
const r2 = await cli.run(args);
expectCliFailure(r2, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
expect(fileDigest(ws.dbPath)).toBe(digest);
expect(JSON.stringify(readTokens(ws.dbPath))).toBe(rows);
});
}
});
test.describe('runtime failures → exit 1', () => {
test('DB_PATH in a missing directory: not created, exit 1 (server and CLI)', async ({ cli, ws }) => {
const db = ws.path('missing-dir', 'x.db');
for (const [mode, args] of MODES) {
await test.step(mode, async () => {
const r = await cli.run(args, { env: { DB_PATH: db, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
expect(fs.existsSync(ws.path('missing-dir'))).toBe(false);
});
}
});
test('DB_PATH is a directory → exit 1', async ({ cli, ws }) => {
const dir = ws.mkdir('a-directory');
for (const [mode, args] of MODES) {
await test.step(mode, async () => {
const r = await cli.run(args, { env: { DB_PATH: dir, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
expect(fs.readdirSync(dir)).toEqual([]);
});
}
});
test('DB_PATH is not a SQLite database → exit 1, file left untouched', async ({ cli, ws, rng }) => {
const garbage = ws.writeFile('garbage.db', rng.bytes(8192));
const digest = fileDigest(garbage);
for (const [mode, args] of MODES) {
await test.step(mode, async () => {
const r = await cli.run(args, { env: { DB_PATH: garbage, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: file is not a database (26)' });
expect(fileDigest(garbage)).toBe(digest);
});
}
});
test('DB_PATH in a read-only directory → exit 1, nothing created', async ({ cli, ws }) => {
const dir = ws.mkdir('readonly');
fs.chmodSync(dir, 0o555);
try {
for (const [mode, args] of MODES) {
await test.step(mode, async () => {
const r = await cli.run(args, { env: { DB_PATH: `${dir}/x.db`, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
expect(fs.readdirSync(dir)).toEqual([]);
});
}
} finally {
fs.chmodSync(dir, 0o755);
}
});
test('port already in use → exit 1 with the listen error; database still valid', async ({ cli, ws, e2e }) => {
const port = await e2e.allocatePort();
const holder = await occupyPort(port, '127.0.0.1');
try {
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: String(port) }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 127.0.0.1:${port}: bind: address already in use` });
assertDbInvariants(ws.dbPath, { tokenCount: 0, operationCount: 0 });
} finally {
await holder.close();
}
});
test('privileged port as an unprivileged user → exit 1 (permission denied)', async ({ cli }) => {
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: 'listen tcp4 127.0.0.1:1: bind: permission denied' });
});
test('address not assigned to this host → exit 1 (cannot assign requested address)', async ({ cli, e2e }) => {
const port = await e2e.allocatePort();
const r = await cli.run([], { env: { HTTP_ADDR: '192.0.2.1', HTTP_PORT: String(port) }, timeoutMs: 10_000 });
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 192.0.2.1:${port}: bind: cannot assign requested address` });
});
});
test.describe('valid edge-case configuration', () => {
test('HTTP_PORT with leading zeros and the 65535 upper bound are accepted', async ({ startServer, e2e }) => {
const port = await e2e.allocatePort();
const s = await startServer({ port: `000${port}`, logLevel: 'INFO' });
expect(s.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`127.0.0.1:${port}`);
expect((await s.stop()).exitCode).toBe(0);
expect(await isPortFree(65535), 'port 65535 must be free on this host for this check').toBe(true);
const top = await startServer({ port: 65535 });
expect(top.logs().find({ msg: 'server listening' })!.attrs.addr).toBe('127.0.0.1:65535');
expect((await top.stop()).exitCode).toBe(0);
});
test('HTTP_ADDR unset defaults to 0.0.0.0 (IPv4 only)', async ({ startServer, http, cli }) => {
const token = await cli.tokenNew();
const s = await startServer({ addr: null });
expect(s.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`0.0.0.0:${s.port}`);
expect((await http.get(s.url(), { token })).status).toBe(200);
// tcp4 listener: not reachable over IPv6 loopback
const v6 = await http.raw({ host: '::1', port: s.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
expect(v6.outcome).toBe('refused');
expect((await s.stop()).exitCode).toBe(0);
});
test('all defaults (0.0.0.0:80, INFO) in a private network namespace', async ({ startServer }) => {
const s = await startServer({ addr: null, port: null, logLevel: null, privateNetwork: true });
const rec = await s.waitForLog({ msg: 'server listening' });
expect(rec.level).toBe('INFO');
expect(rec.attrs.addr).toBe('0.0.0.0:80');
expect(s.env.HTTP_ADDR).toBeUndefined();
expect(s.env.HTTP_PORT).toBeUndefined();
expect(s.env.LOG_LEVEL).toBeUndefined();
const exit = await s.stop('SIGTERM');
expect(exit.exitCode).toBe(0);
expect(s.logs().count({ level: ['DEBUG', 'TRACE'] }), 'default level is INFO').toBe(0);
});
test('an existing database is reused by every mode (repeated schema initialization)', async ({ cli, ws, startServer }) => {
const t = await cli.tokenNew('first');
for (let i = 0; i < 3; i++) expectCliSuccess(await cli.run(['token', 'list']));
const s = await startServer();
expect((await s.stop()).exitCode).toBe(0);
const s2 = await startServer();
expect((await s2.stop()).exitCode).toBe(0);
const rows = readTokens(ws.dbPath);
expect(rows.map((r) => r.token)).toEqual([t]);
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 0 });
});
});