Files
dpsapi/e2e/tests/host/cli-tokens.spec.ts
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

399 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});