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.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent b792ad256e
commit 0a6023e942
10 files changed
+4138

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12 core items 4, 5, 11 (CLI side): token creation with every description family (no-shell
* argv and through a real shell), verbatim storage, deterministic `token list` (active tokens) and
* `token list all` (every token) as padded tables, their usage errors, deletion.
*/
import fs from 'node:fs';
import {
test,
expect,
assertDbInvariants,
countRows,
expectCliFailure,
expectCliSuccess,
goQuote,
msToNanos,
parseTokenList,
readOperations,
readToken,
readTokens,
rfc3339ToNanos,
RFC3339NANO_UTC_RE,
shellQuote,
EMPTY_TOKEN_LIST,
USAGE_TEXT,
fileDigest,
formatTokenList,
padTable,
runeLength,
type CliRunner,
type TokenListRow,
} from '../../harness';
import { expectedTokenListStdout, stateDigest } from '../../harness/personas';
const hex = (s: string | Buffer) => Buffer.from(s as string).toString('hex').toUpperCase();
/** Creates a token, asserting created_at lies within the invocation window. */
async function createChecked(cli: CliRunner, dbPath: string, description: string | undefined): Promise<string> {
const before = msToNanos(Date.now() - 1);
const token = await cli.tokenNew(description);
const after = msToNanos(Date.now() + 1);
const row = readToken(dbPath, token)!;
expect(row, 'row exists').toBeTruthy();
expect(row.descriptionHex, 'description stored byte-for-byte').toBe(hex(description ?? ''));
expect(row.active).toBe(1);
expect(row.deleted_at).toBeNull();
expect(row.created_at).toMatch(RFC3339NANO_UTC_RE);
const created = rfc3339ToNanos(row.created_at);
expect(created >= before && created <= after, `created_at ${row.created_at} within the invocation window`).toBe(true);
return token;
}
const DESCRIPTIONS: Array<[string, string | undefined]> = [
['omitted (no argument)', undefined],
['explicit empty argument', ''],
['normal words', 'ci pipeline'],
['whitespace only', ' \t '],
['leading/trailing spaces', ' padded value '],
['newlines, tabs, CR', 'line1\nline2\tcol\r\nend'],
['ASCII punctuation', `!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`],
['Unicode letters, emoji, RTL, combining', 'café ☕ 日本語 עברית 🚀 é Ωmega'],
['non-printable Unicode (NBSP, ZWSP, LS, NEL, tag)', ' nb​z
x\u0085y\u{E0001}'],
['ASCII control characters and ESC sequences', '\x01\x07\x08\x1b[31mred\x1b[0m\x7f'],
['SQL injection payload', `'); DROP TABLE tokens; --`],
['SQL quote games', `x' OR '1'='1`],
['shell metacharacters', '$(touch /tmp/pwned) `id` ; rm -rf / && echo $HOME | cat > /dev/null &'],
['looks like an option', '--help'],
['looks like a subcommand', 'delete'],
['Go format verbs', '%s %d %v %!x(MISSING) %%'],
['backslash escapes kept literal', '\\n \\t \\x00 \\u0041'],
['very long ASCII (100 000 bytes)', 'x'.repeat(100_000)],
['long multi-byte (30 000 × 日 = 90 000 bytes)', '日'.repeat(30_000)],
['maximum single argv string (131 071 bytes)', 'a'.repeat(131_071)],
];
test.describe('token new: description stored verbatim (argv, no shell)', () => {
for (const [name, description] of DESCRIPTIONS) {
test(name, async ({ cli, ws }) => {
const token = await createChecked(cli, ws.dbPath, description);
const rows = await cli.tokenList();
expect(rows).toHaveLength(1);
expect(rows[0].token).toBe(token);
expect(rows[0].descriptionQuoted, 'DESCRIPTION column is Go-quoted').toBe(goQuote(description ?? ''));
expect(rows[0].descriptionBytes.equals(Buffer.from(description ?? '')), 'list round-trips the bytes').toBe(true);
expect(rows[0].active).toBe(true);
expect(rows[0].deletedAt).toBeNull();
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 0 });
});
}
test('one argument larger than the kernel limit is refused by the OS, not the app', async ({ cli }) => {
// MAX_ARG_STRLEN is 131072 bytes including the terminating NUL: exec fails before dpsapi runs.
await expect(cli.run(['token', 'new', 'a'.repeat(131_072)])).rejects.toThrow(/harness:spawn|E2BIG/);
});
});
test.describe('token new through a real shell (quoting)', () => {
const tricky = [
'two words',
`it's "quoted"`,
'$HOME and $(id) and `id`',
'semi;colon|pipe&amp>redirect<in',
'glob * ? [a-z] ~',
'new\nline',
"'",
'\\',
'',
];
test('single-quoted descriptions reach the binary byte-exact', async ({ cli, ws }) => {
for (const d of tricky) {
await test.step(JSON.stringify(d), async () => {
const r = await cli.shell(`"$DPSAPI_BIN" token new ${shellQuote(d)}`);
expectCliSuccess(r, { stderr: '' });
expect(r.stdout).toMatch(/^[A-Za-z0-9_-]{43}\n$/);
expect(readToken(ws.dbPath, r.stdout.trim())!.descriptionHex).toBe(hex(d));
});
}
const rows = await cli.tokenList();
expect(rows.map((r) => r.description).sort()).toEqual([...tricky].sort());
assertDbInvariants(ws.dbPath, { tokenCount: tricky.length });
});
test('unquoted multi-word description is a usage error and creates nothing', async ({ cli, ws }) => {
const r = await cli.shell('"$DPSAPI_BIN" token new ci pipeline');
expectCliFailure(r, { exitCode: 2, message: 'usage error: token new takes at most one argument' });
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
});
test('double quotes expand variables in the shell (the app stores what it receives)', async ({ cli, ws }) => {
const r = await cli.shell('"$DPSAPI_BIN" token new "home=$HOME_FOR_TEST"', { env: { HOME_FOR_TEST: '/home/someone' } });
expectCliSuccess(r);
expect(readToken(ws.dbPath, r.stdout.trim())!.description).toBe('home=/home/someone');
});
test('invalid UTF-8 bytes from the shell are stored exactly and listed with \\x escapes', async ({ cli, ws }) => {
const r = await cli.shell(`"$DPSAPI_BIN" token new "$(printf 'bad\\377byte\\300\\200')"`);
expectCliSuccess(r);
const token = r.stdout.trim();
expect(readToken(ws.dbPath, token)!.descriptionHex).toBe('626164FF62797465C080');
const rows = await cli.tokenList();
expect(rows[0].descriptionQuoted).toBe('"bad\\xffbyte\\xc0\\x80"');
expect(rows[0].descriptionBytes.toString('hex').toUpperCase()).toBe('626164FF62797465C080');
assertDbInvariants(ws.dbPath);
});
});
test.describe('token list', () => {
test('fresh database: padded header only (list and list all), database and schema created', async ({ cli, ws }) => {
expect(EMPTY_TOKEN_LIST).toBe('TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n');
for (const args of [['token', 'list'], ['token', 'list', 'all']]) {
const db = ws.path(`fresh-${args.length}.db`);
expect(fs.existsSync(db)).toBe(false);
const r = await cli.run(args, { env: { DB_PATH: db } });
expectCliSuccess(r, { stdout: EMPTY_TOKEN_LIST, stderr: '' });
expect(fs.existsSync(db), `${args.join(' ')}: database created`).toBe(true);
assertDbInvariants(db, { tokenCount: 0, operationCount: 0 });
}
});
test('only inactive tokens: token list prints the header only, token list all prints them', async ({ cli, ws }) => {
const a = await cli.tokenNew('gone a');
const b = await cli.tokenNew('gone b');
await cli.tokenDelete(a);
await cli.tokenDelete(b);
expectCliSuccess(await cli.run(['token', 'list']), { stdout: EMPTY_TOKEN_LIST, stderr: '' });
const all = await cli.tokenListAll();
expect(all.map((r) => [r.token, r.active, r.deletedAt !== null])).toEqual([
[a, false, true],
[b, false, true],
]);
expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(expectedTokenListStdout(ws.dbPath, 'all'));
});
test('deterministic, ordered by created_at then token, exact values for active and inactive rows', async ({ cli, ws }) => {
test.slow();
const tokens: string[] = [];
// sequential and concurrent creations (concurrent ones may share timestamps closely)
for (let i = 0; i < 6; i++) tokens.push(await cli.tokenNew(`seq ${i}`));
tokens.push(...(await Promise.all(Array.from({ length: 12 }, (_, i) => cli.tokenNew(`par ${i}`)))));
const deleted = [tokens[1], tokens[7], tokens[12]];
for (const t of deleted) await cli.tokenDelete(t);
const a = await cli.run(['token', 'list', 'all']);
const b = await cli.run(['token', 'list', 'all']);
expectCliSuccess(a, { stderr: '' });
expect(b.stdout, 'two consecutive listings are byte-identical').toBe(a.stdout);
expect(a.stdout, 'token list all equals the stored rows (padded table)').toBe(expectedTokenListStdout(ws.dbPath, 'all'));
const rows = parseTokenList(a.stdout);
expect(rows).toHaveLength(tokens.length);
expect(new Set(rows.map((r) => r.token))).toEqual(new Set(tokens));
// plain `token list`: exactly the active rows, same order, its own (narrower or equal) widths
const active = await cli.run(['token', 'list']);
expectCliSuccess(active, { stderr: '' });
expect(active.stdout, 'token list equals the stored active rows').toBe(expectedTokenListStdout(ws.dbPath, 'active'));
expect(parseTokenList(active.stdout).map((r) => r.token)).toEqual(rows.filter((r) => r.active).map((r) => r.token));
// ordering: created_at (as time, not string), then token (bytewise)
const key = (r: TokenListRow) => [rfc3339ToNanos(r.createdAt), r.token] as const;
for (let i = 1; i < rows.length; i++) {
const [ta, ka] = key(rows[i - 1]);
const [tb, kb] = key(rows[i]);
expect(ta < tb || (ta === tb && ka < kb), `row ${i} ordered after row ${i - 1}`).toBe(true);
}
// every row equals the database
const db = new Map(readTokens(ws.dbPath).map((t) => [t.token, t]));
for (const r of rows) {
const d = db.get(r.token)!;
expect(r.createdAt).toBe(d.created_at);
expect(r.deletedAt).toBe(d.deleted_at);
expect(r.active).toBe(d.active === 1);
expect(r.descriptionQuoted).toBe(goQuote(d.description));
expect(r.active).toBe(!deleted.includes(r.token));
if (!r.active) {
expect(r.deletedAt).toMatch(RFC3339NANO_UTC_RE);
expect(rfc3339ToNanos(r.deletedAt!) >= rfc3339ToNanos(r.createdAt)).toBe(true);
expect(r.line).toMatch(new RegExp(` false ${r.createdAt.replace(/\./g, '\\.')} {2,}${r.deletedAt!.replace(/\./g, '\\.')}$`));
} else {
expect(r.line).toMatch(new RegExp(` true ${r.createdAt.replace(/\./g, '\\.')} {2,}-$`));
}
}
assertDbInvariants(ws.dbPath, { tokenCount: 18, operationCount: 0 });
});
});
test.describe('token list [all]: active by default, every token with "all", padded table', () => {
test('default hides inactive tokens; all shows them with ACTIVE false and DELETED_AT set; both byte-exact padded tables', async ({ cli, ws }) => {
const keep = await cli.tokenNew('keep');
// the longest description belongs to a token that gets deleted: widths differ between the two listings
const long = await cli.tokenNew('a much longer description that only token list all prints');
const other = await cli.tokenNew('other');
await cli.tokenDelete(long);
const active = await cli.run(['token', 'list']);
const all = await cli.run(['token', 'list', 'all']);
expectCliSuccess(active, { stderr: '' });
expectCliSuccess(all, { stderr: '' });
const db = new Map(readTokens(ws.dbPath).map((t) => [t.token, t]));
const entry = (t: string) => {
const d = db.get(t)!;
return { token: t, description: d.description, active: d.active === 1, createdAt: d.created_at, deletedAt: d.deleted_at };
};
expect(active.stdout, 'token list: active rows only').toBe(formatTokenList([keep, other].map(entry)));
expect(all.stdout, 'token list all: every row').toBe(formatTokenList([keep, long, other].map(entry)));
// explicit layout of the default listing (widths computed over the printed lines only)
const k = entry(keep);
const o = entry(other);
const cw = 2 + Math.max(10, k.createdAt.length, o.createdAt.length);
expect(active.stdout).toBe(
'TOKEN' + ' '.repeat(40) + 'DESCRIPTION ACTIVE ' + 'CREATED_AT'.padEnd(cw) + 'DELETED_AT\n' +
`${keep} "keep" true ${k.createdAt.padEnd(cw)}-\n` +
`${other} "other" true ${o.createdAt.padEnd(cw)}-\n`,
);
const deleted = parseTokenList(all.stdout).find((r) => r.token === long)!;
expect(deleted.active).toBe(false);
expect(deleted.deletedAt).toBe(db.get(long)!.deleted_at);
expect(deleted.deletedAt).toMatch(RFC3339NANO_UTC_RE);
expect(active.stdout.includes(long), 'the inactive token is not printed by token list').toBe(false);
expect(active.stdout.split('\n')[0].length, 'narrower DESCRIPTION column without the long inactive row').toBeLessThan(all.stdout.split('\n')[0].length);
for (const out of [active.stdout, all.stdout]) expect(out.split('\n').filter((l) => / $/.test(l)), 'no trailing whitespace').toEqual([]);
expect(readOperations(ws.dbPath)).toEqual([]);
assertDbInvariants(ws.dbPath, { tokenCount: 3, operationCount: 0 });
});
test('descriptions with runs of spaces, escaped quotes, multi-byte runes and escapes: columns aligned in runes, parser round-trips', async ({ cli, ws }) => {
const descriptions = ['', 'two spaces three', 'say "hi" \\ back', '日本語', '🚀🚀', 'e\u0301 combining', 'tab\there', '\x01\x7f\xff', '"', ' ', 'x'.repeat(70)];
const tokens: string[] = [];
for (const d of descriptions) tokens.push(await cli.tokenNew(d));
await cli.tokenDelete(tokens[3]);
for (const scope of ['active', 'all'] as const) {
const r = await cli.run(scope === 'all' ? ['token', 'list', 'all'] : ['token', 'list']);
expectCliSuccess(r, { stderr: '' });
expect(r.stdout, `${scope}: exact padded table`).toBe(expectedTokenListStdout(ws.dbPath, scope));
const rows = parseTokenList(r.stdout);
const want = scope === 'all' ? descriptions : descriptions.filter((_, i) => i !== 3);
expect(rows.map((x) => x.description).sort()).toEqual([...want].sort());
expect(rows.every((x) => x.descriptionQuoted === goQuote(x.descriptionBytes))).toBe(true);
// every cell of a column starts at the same rune offset (display width is irrelevant to the app)
const lines = r.stdout.slice(0, -1).split('\n');
const offset = (l: string, cell: string) => runeLength(l.slice(0, l.indexOf(cell)));
const hdr = lines[0];
for (const x of rows) {
expect(offset(x.line, x.descriptionQuoted), 'DESCRIPTION column').toBe(offset(hdr, 'DESCRIPTION'));
expect(runeLength(x.line) - runeLength(x.line.slice(x.line.lastIndexOf(x.deletedAt ?? '-'))), 'DELETED_AT column').toBe(offset(hdr, 'DELETED_AT'));
}
expect(padTable([lines[0].split(/ +/), ...rows.map((x) => [x.token, x.descriptionQuoted, String(x.active), x.createdAt, x.deletedAt ?? '-'])])).toBe(r.stdout);
}
});
const UNKNOWN_ARGUMENT = 'unknown token list argument';
const TOO_MANY = 'token list takes at most one argument';
const LIST_USAGE: Array<[string[], string]> = [
[['ALL'], UNKNOWN_ARGUMENT],
[['All'], UNKNOWN_ARGUMENT],
[[''], UNKNOWN_ARGUMENT],
[['--all'], UNKNOWN_ARGUMENT],
[['-a'], UNKNOWN_ARGUMENT],
[[' all'], UNKNOWN_ARGUMENT],
[['all '], UNKNOWN_ARGUMENT],
[['all\n'], UNKNOWN_ARGUMENT],
[['\u0430ll'], UNKNOWN_ARGUMENT],
[['active'], UNKNOWN_ARGUMENT],
[['inactive'], UNKNOWN_ARGUMENT],
[['extra'], UNKNOWN_ARGUMENT],
[['all', 'all'], TOO_MANY],
[['all', ''], TOO_MANY],
[['', 'all'], TOO_MANY],
[['ALL', 'x'], TOO_MANY],
[['a', 'b', 'c'], TOO_MANY],
];
test(`${LIST_USAGE.length} invalid token list arguments → exit 2, exact stderr (nothing echoed), no database created, existing one byte-identical`, async ({ cli, ws }) => {
const run = async (dbPath: string) => {
for (const [args, msg] of LIST_USAGE) {
const r = await cli.run(['token', 'list', ...args], { env: { DB_PATH: dbPath } });
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
expect(r.stderr, JSON.stringify(args)).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
}
};
await run(ws.dbPath);
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
expect(fs.readdirSync(ws.dir)).toEqual([]);
const t = await cli.tokenNew('existing');
await cli.tokenDelete(t);
await cli.tokenNew('still active');
const file = fileDigest(ws.dbPath);
const state = stateDigest(ws.dbPath);
await run(ws.dbPath);
expect(fileDigest(ws.dbPath)).toBe(file);
expect(stateDigest(ws.dbPath)).toBe(state);
});
});
test.describe('token delete', () => {
test('deactivates silently; repeated/unknown/near-match deletions fail without changing state', async ({ cli, ws }) => {
const keep = await cli.tokenNew('keep');
const victim = await cli.tokenNew('victim');
const before = msToNanos(Date.now() - 1);
const r = await cli.run(['token', 'delete', victim]);
const after = msToNanos(Date.now() + 1);
expectCliSuccess(r, { stdout: '', stderr: '' });
const row = readToken(ws.dbPath, victim)!;
expect(row.active).toBe(0);
expect(row.deleted_at).toMatch(RFC3339NANO_UTC_RE);
const del = rfc3339ToNanos(row.deleted_at!);
expect(del >= before && del <= after, 'deleted_at within the delete invocation').toBe(true);
expect(readToken(ws.dbPath, keep)!.active).toBe(1);
const snapshot = JSON.stringify(readTokens(ws.dbPath));
const flip = (s: string) => s.replace(/[a-zA-Z]/, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
const failures: Array<[string, string, string]> = [
['already inactive', victim, 'token already inactive'],
['unknown', 'A'.repeat(43), 'token not found'],
['case changed', flip(keep), 'token not found'],
['leading space', ` ${keep}`, 'token not found'],
['trailing newline', `${keep}\n`, 'token not found'],
['prefix', keep.slice(0, 42), 'token not found'],
['extended', keep + 'x', 'token not found'],
['empty string', '', 'token not found'],
['SQL wildcard', '%', 'token not found'],
['SQL injection', `' OR '1'='1`, 'token not found'],
];
for (const [name, arg, message] of failures) {
await test.step(name, async () => {
const f = await cli.run(['token', 'delete', arg]);
expectCliFailure(f, { exitCode: 1, message });
if (arg.length > 0) expect(f.stderr.includes(arg), 'supplied token never echoed').toBe(false);
expect(JSON.stringify(readTokens(ws.dbPath)), 'no token row changed').toBe(snapshot);
});
}
const rows = await cli.tokenListAll();
expect(rows.map((x) => [x.token, x.active])).toEqual([
[keep, true],
[victim, false],
]);
expect((await cli.tokenList()).map((x) => x.token), 'token list hides the deleted token').toEqual([keep]);
expect(readOperations(ws.dbPath), 'CLI commands never write audit rows').toEqual([]);
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 0 });
});
test('a deleted token stays deleted and keeps its original created_at and description', async ({ cli, ws }) => {
const t = await cli.tokenNew('history');
const created = readToken(ws.dbPath, t)!.created_at;
await cli.tokenDelete(t);
const first = readToken(ws.dbPath, t)!;
expectCliFailure(await cli.run(['token', 'delete', t]), { exitCode: 1, message: 'token already inactive' });
const second = readToken(ws.dbPath, t)!;
expect(second).toEqual(first);
expect(second.created_at).toBe(created);
expect(second.description).toBe('history');
expect(countRows(ws.dbPath, 'tokens')).toBe(1);
});
});