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:
1 parent
b792ad256e
commit
0a6023e942
10 files changed
+4138
No files matched your search
@@ -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)', ' nbz
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&>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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user