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.
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §12 core item 11 (server side): deleting tokens through the binary while the server runs —
|
|
* deleted_at populated, active=0, history retained, 401 afterwards, audit rows keep satisfying the
|
|
* foreign key; credential rotation; tokens created while the server runs are usable immediately.
|
|
*/
|
|
import {
|
|
test,
|
|
expect,
|
|
assertDbInvariants,
|
|
expectCliFailure,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
msToNanos,
|
|
readOperations,
|
|
readToken,
|
|
rfc3339ToNanos,
|
|
tokenId,
|
|
} from '../../harness';
|
|
|
|
test('delete while serving: 401 afterwards, history and audit rows retained', async ({ cli, startServer, http, ws }) => {
|
|
const a = await cli.tokenNew('to delete');
|
|
const b = await cli.tokenNew('survivor');
|
|
const server = await startServer({ logLevel: 'DEBUG' });
|
|
expectContainersResponse(await http.get(server.url(), { token: a }));
|
|
expectContainersResponse(await http.curl({ url: server.url(), token: a }));
|
|
expectContainersResponse(await http.get(server.url(), { token: b }));
|
|
const createdA = readToken(ws.dbPath, a)!.created_at;
|
|
|
|
const t0 = msToNanos(Date.now() - 1);
|
|
await cli.tokenDelete(a);
|
|
const t1 = msToNanos(Date.now() + 1);
|
|
|
|
expectErrorResponse(await http.get(server.url(), { token: a }), 401);
|
|
expectErrorResponse(await http.curl({ url: server.url(), token: a }), 401);
|
|
expectContainersResponse(await http.get(server.url(), { token: b }));
|
|
|
|
const rowA = readToken(ws.dbPath, a)!;
|
|
expect(rowA.active).toBe(0);
|
|
expect(rowA.created_at).toBe(createdA);
|
|
expect(rowA.description).toBe('to delete');
|
|
const del = rfc3339ToNanos(rowA.deleted_at!);
|
|
expect(del >= t0 && del <= t1).toBe(true);
|
|
expect(readToken(ws.dbPath, b)!.active).toBe(1);
|
|
|
|
const ops = readOperations(ws.dbPath);
|
|
expect(ops.map((o) => o.token)).toEqual([a, a, b, b]);
|
|
for (const o of ops.filter((x) => x.token === a)) expect(rfc3339ToNanos(o.time) < del).toBe(true);
|
|
|
|
const list = await cli.tokenListAll();
|
|
expect(list.map((r) => [r.token, r.active, r.deletedAt])).toEqual([
|
|
[a, false, rowA.deleted_at],
|
|
[b, true, null],
|
|
]);
|
|
expect((await cli.tokenList()).map((r) => [r.token, r.active, r.deletedAt]), 'token list: the active token only').toEqual([[b, true, null]]);
|
|
expectCliFailure(await cli.run(['token', 'delete', a]), { exitCode: 1, message: 'token already inactive' });
|
|
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
const classes = server.logs().filter({ msg: 'authentication result' }).map((r) => [r.attrs.result, r.attrs.token_id]);
|
|
expect(classes).toEqual([
|
|
['ok', tokenId(a)],
|
|
['ok', tokenId(a)],
|
|
['ok', tokenId(b)],
|
|
['inactive_token', undefined],
|
|
['inactive_token', undefined],
|
|
['ok', tokenId(b)],
|
|
]);
|
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 4, auditBeforeDeletion: true });
|
|
});
|
|
|
|
test('credential rotation: new token works immediately, old one stops working, nothing restarts', async ({ cli, startServer, http, ws }) => {
|
|
const server = await startServer();
|
|
const old = await cli.tokenNew('rotation v1');
|
|
expectContainersResponse(await http.get(server.url(), { token: old }));
|
|
for (let v = 2; v <= 4; v++) {
|
|
const next = await cli.tokenNew(`rotation v${v}`); // created while the server runs
|
|
expectContainersResponse(await http.get(server.url(), { token: next }));
|
|
await cli.tokenDelete(v === 2 ? old : (await cli.tokenList()).find((r) => r.description === `rotation v${v - 1}`)!.token);
|
|
expectContainersResponse(await http.get(server.url(), { token: next }));
|
|
}
|
|
expectErrorResponse(await http.get(server.url(), { token: old }), 401);
|
|
const rows = await cli.tokenListAll();
|
|
expect(rows.map((r) => [r.description, r.active])).toEqual([
|
|
['rotation v1', false],
|
|
['rotation v2', false],
|
|
['rotation v3', false],
|
|
['rotation v4', true],
|
|
]);
|
|
expect((await cli.tokenList()).map((r) => r.description), 'token list: only the current credential').toEqual(['rotation v4']);
|
|
expect(server.pid, 'same process throughout').toBe(server.pid);
|
|
expect(server.generation).toBe(1);
|
|
expect((await server.stop()).exitCode).toBe(0);
|
|
assertDbInvariants(ws.dbPath, { tokenCount: 4, operationCount: 7, auditBeforeDeletion: true });
|
|
});
|