Files
dpsapi/e2e/tests/host/harness-selfcheck.spec.ts
T
bryanpedini fe0cd4bf6d test(e2e): add playwright e2e harness
Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
2026-10-06 10:52:25 +02:00

226 lines
12 KiB
TypeScript
Raw 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
/**
* Self-checks of the harness guarantees the other suites rely on: infrastructure failures are
* HarnessErrors that can never satisfy an expected-failure assertion; parsers and redaction work.
*/
import {
test,
expect,
EMPTY_AUDIT,
EMPTY_TOKEN_LIST,
expectCliFailure,
expectContainersResponse,
formatAuditOutput,
formatTokenList,
padTable,
parseAuditOutput,
parseTokenList,
redactValue,
safeStringify,
goQuote,
goUnquote,
HarnessError,
isHarnessError,
parseLogLine,
redactText,
Rng,
runProcess,
toolEnv,
tokenId,
printsTokens,
USAGE_TEXT,
type ProcResult,
} from '../../harness';
test('timeouts, signals and spawn failures are HarnessErrors, never results', async ({ cli }) => {
const timeout = await runProcess('sleep', ['5'], { env: toolEnv(), timeoutMs: 200 }).catch((e) => e);
expect(isHarnessError(timeout) && timeout.kind).toBe('timeout');
const signal = await runProcess('sh', ['-c', 'kill -SEGV $$'], { env: toolEnv() }).catch((e) => e);
expect(isHarnessError(signal) && signal.kind).toBe('signal');
const spawn = await runProcess('/nonexistent/binary', [], { env: toolEnv() }).catch((e) => e);
expect(isHarnessError(spawn) && spawn.kind).toBe('spawn');
// A run that does not finish is killed and reported as a timeout, never as a failure exit
const reallyHung = await cli.shell('sleep 5', { timeoutMs: 200 }).catch((e) => e);
expect(isHarnessError(reallyHung) && reallyHung.kind).toBe('timeout');
// expectCliFailure refuses abnormal terminations even if the "exit code" would match
const fake: ProcResult = { command: 'x', argv: [], stdout: '', stderr: 'dpsapi: x\n', stdoutBuffer: Buffer.alloc(0), stderrBuffer: Buffer.alloc(0), exitCode: null, signal: 'SIGKILL', timedOut: false, durationMs: 1 };
expect(() => expectCliFailure(fake, { exitCode: 1 })).toThrow(HarnessError);
expect(() => expectCliFailure({ ...fake, signal: null, exitCode: 1, timedOut: true }, { exitCode: 1 })).toThrow(HarnessError);
});
test('Go quoting round-trips and matches strconv.Quote conventions', () => {
const cases: Array<[string | Buffer, string]> = [
['', '""'],
['plain', '"plain"'],
['a"b\\c', '"a\\"b\\\\c"'],
['\x07\b\f\n\r\t\v', '"\\a\\b\\f\\n\\r\\t\\v"'],
['\x00\x01\x1f\x7f', '"\\x00\\x01\\x1f\\x7f"'],
['é日🚀', '"é日🚀"'],
[' ​‹\u0085', '"\\u00a0\\u200b\\u2028\\u0085"'],
['\u{E0001}', '"\\U000e0001"'],
[Buffer.from([0x61, 0xff, 0xc0, 0x80]), '"a\\xff\\xc0\\x80"'],
];
for (const [input, quoted] of cases) {
expect(goQuote(input)).toBe(quoted);
if (typeof input === 'string') expect(goUnquote(quoted)).toBe(input);
}
});
test('slog text parser handles quoting, escapes and non-record lines', () => {
const r = parseLogLine('time=2026-10-05T20:03:15.908Z level=INFO msg="this request has been made" method=GET path="/a b\\"c" status=200 empty="" eq="a=b"')!;
expect(r.level).toBe('INFO');
expect(r.msg).toBe('this request has been made');
expect(r.attrs).toEqual({ method: 'GET', path: '/a b"c', status: '200', empty: '', eq: 'a=b' });
expect(parseLogLine('dpsapi: token not found')).toBeUndefined();
expect(parseLogLine('usage:')).toBeUndefined();
});
test('redaction replaces registered secrets anywhere and token-shaped strings', ({ secrets }) => {
const tok = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ';
expect(tok).toHaveLength(43);
secrets.register(tok);
expect(secrets.redact(`x${tok}y`)).toBe(`x[REDACTED:${tokenId(tok)}]y`);
expect(redactText(`Bearer ${'Z'.repeat(43)}`, [])).toBe(`Bearer [REDACTED:${tokenId('Z'.repeat(43))}]`);
expect(secrets.findIn(`... ${tok} ...`)).toHaveLength(1);
expect(secrets.findIn('nothing')).toEqual([]);
});
test('seeded PRNG is deterministic per seed and independent per fork', ({ rng }) => {
const a = new Rng('s');
const b = new Rng('s');
expect(Array.from({ length: 5 }, () => a.nextUint32())).toEqual(Array.from({ length: 5 }, () => b.nextUint32()));
expect(new Rng('s').fork('x').next()).not.toBe(new Rng('s').fork('y').next());
expect(rng.seed).toContain('seeded PRNG is deterministic');
expect(test.info().annotations.find((x) => x.type === 'seed')).toBeTruthy();
});
test('padTable reproduces the tabwriter layout of the spec examples byte for byte; the strict parsers round-trip and reject deviations', () => {
const t1 = 'whEsbopp2tB2xXN7IC3LBnHhVge2F_-Gf910hIDL2pQ';
const t2 = 'S_muyryYKV3fegqvOSlEbHJ8ZvHz_s5tPov3dkY49Bo';
const list =
'TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n' +
`${t1} "smoke test" false 2026-10-05T20:03:15.894338161Z 2026-10-05T20:03:15.94012Z\n` +
`${t2} "" true 2026-10-05T20:05:01.042950671Z -\n`;
const entries = [
{ token: t1, description: 'smoke test', active: false, createdAt: '2026-10-05T20:03:15.894338161Z', deletedAt: '2026-10-05T20:03:15.94012Z' },
{ token: t2, description: '', active: true, createdAt: '2026-10-05T20:05:01.042950671Z', deletedAt: null },
];
expect(formatTokenList(entries)).toBe(list);
expect(parseTokenList(list).map((r) => [r.token, r.description, r.active, r.createdAt, r.deletedAt])).toEqual(entries.map((e) => [e.token, e.description, e.active, e.createdAt, e.deletedAt]));
const audit = 'TIME TOKEN ORIGIN OPERATION\n' + `2026-10-05T20:03:16.001Z ${t1} 127.0.0.1 GET containers\n`;
expect(formatAuditOutput([{ time: '2026-10-05T20:03:16.001Z', token: t1, origin: '127.0.0.1', operation: 'GET containers' }])).toBe(audit);
expect(parseAuditOutput(audit).map((r) => [r.time, r.token, r.origin, r.operation])).toEqual([['2026-10-05T20:03:16.001Z', t1, '127.0.0.1', 'GET containers']]);
expect(EMPTY_TOKEN_LIST).toBe('TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n');
expect(EMPTY_AUDIT).toBe('TIME TOKEN ORIGIN OPERATION\n');
expect(parseTokenList(EMPTY_TOKEN_LIST)).toEqual([]);
expect(parseAuditOutput(EMPTY_AUDIT)).toEqual([]);
// widths count runes (code points), not UTF-16 units, bytes or display width; the last cell is never padded
// (an empty last cell leaves the padding of the cell before it, as tabwriter does; the app never prints one)
expect(padTable([['a', '🚀🚀', 'x y'], ['日本語', 'b', '']])).toBe('a 🚀🚀 x y\n日本語 b \n');
expect(padTable([['e\u0301', 'z'], ['ab', 'z']])).toBe('e\u0301 z\nab z\n');
// descriptions with runs of spaces and escaped quotes are parsed as Go-quoted strings, not split on blanks
const tricky = formatTokenList([{ token: t1, description: 'a "b" c\\', active: true, createdAt: '2026-10-05T20:05:01Z', deletedAt: null }]);
expect(parseTokenList(tricky)[0].description).toBe('a "b" c\\');
// deviations are rejected: tabs (old format), trailing blanks, wrong padding, unterminated quote, wrong header
const bad = [
'TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n',
list.replace('DELETED_AT\n', 'DELETED_AT \n'),
list.replace('"smoke test" false', '"smoke test" false '),
list.replace(`${t2} ""`, `${t2} "`),
list.replace('ACTIVE', 'ACTIV'),
list.slice(0, -1),
list.replace(`${t2} `, `${t2.slice(1)} `),
];
for (const b of bad) expect(() => parseTokenList(b), JSON.stringify(b.slice(0, 60))).toThrow();
expect(() => parseAuditOutput(audit.replace('127.0.0.1 GET', '127.0.0.1 GET '))).toThrow();
expect(() => parseAuditOutput('TIME\tTOKEN\tORIGIN\tOPERATION\n')).toThrow();
});
test('BigInt values (audit selections) serialize in recorded fuzz cases and redacted artifacts', ({ fuzz }) => {
const sel = { kind: 'audit', exitCode: 0, selection: { windowNs: 3_600_000_000_000n } };
expect(() => JSON.stringify(sel)).toThrow(/BigInt/);
expect(safeStringify(sel)).toBe('{"kind":"audit","exitCode":0,"selection":{"windowNs":"3600000000000"}}');
expect(JSON.stringify(redactValue({ cases: [sel] }, []))).toBe('{"cases":[{"kind":"audit","exitCode":0,"selection":{"windowNs":"3600000000000"}}]}');
const c = fuzz.record({ i: 0, expected: sel });
expect(c.expected.selection.windowNs).toBe(3_600_000_000_000n);
expect(JSON.stringify(fuzz.cases)).toContain('"windowNs":"3600000000000"');
});
test('tokens printed by token list all and audit are registered as secrets by the runner (help registers nothing)', async ({ cli, secrets, startServer, http }) => {
// created and deleted behind the harness runners' back (plain runProcess): not registered yet
const outside = async (args: string[]) => {
const r = await runProcess(cli.binary, args, { env: cli.env() });
expect(r.exitCode, args.slice(0, 2).join(' ')).toBe(0);
return r.stdout.trim();
};
const listed = await outside(['token', 'new', 'only in list all']);
const audited = await outside(['token', 'new', 'only in audit']);
expect(secrets.has(listed) || secrets.has(audited)).toBe(false);
const server = await startServer();
expectContainersResponse(await http.get(server.url(), { token: audited }));
expect((await server.stop()).exitCode).toBe(0);
await outside(['token', 'delete', listed]);
await outside(['token', 'delete', audited]);
await cli.help();
await cli.tokenList(); // inactive tokens are not printed by plain token list
expect(secrets.has(listed) || secrets.has(audited), 'nothing printed, nothing registered').toBe(false);
await cli.tokenListAll();
expect(secrets.has(listed) && secrets.has(audited), 'token list all registers every printed token').toBe(true);
const third = await outside(['token', 'new', 'audit only']);
const s2 = await startServer();
expectContainersResponse(await http.get(s2.url(), { token: third }));
expect((await s2.stop()).exitCode).toBe(0);
await outside(['token', 'delete', third]);
expect(secrets.has(third)).toBe(false);
expect((await cli.audit()).map((r) => r.token)).toEqual([audited, third]);
expect(secrets.has(third), 'audit registers the printed tokens').toBe(true);
// `audit all` (via the runner's audit('all') and via plain run()) registers every printed token
const fourth = await outside(['token', 'new', 'audit all only']);
const s3 = await startServer();
expectContainersResponse(await http.get(s3.url(), { token: fourth }));
expect((await s3.stop()).exitCode).toBe(0);
await outside(['token', 'delete', fourth]);
expect(secrets.has(fourth)).toBe(false);
expect(printsTokens(['audit', 'all'])).toBe(true);
expect((await cli.audit('all')).map((r) => r.token)).toEqual([audited, third, fourth]);
expect(secrets.has(fourth), 'audit all registers the printed tokens').toBe(true);
const fifth = await outside(['token', 'new', 'audit all via run']);
const s4 = await startServer();
expectContainersResponse(await http.get(s4.url(), { token: fifth }));
expect((await s4.stop()).exitCode).toBe(0);
expect(secrets.has(fifth)).toBe(false);
expect((await cli.run(['audit', 'all'])).exitCode).toBe(0);
expect(secrets.has(fifth), 'run([audit, all]) registers the printed tokens').toBe(true);
});
test('usage text: one line per command form, descriptions aligned at offset 34, `audit all` last', () => {
const lines = USAGE_TEXT.split('\n');
expect(lines.shift()).toBe('usage:');
expect(lines.pop(), 'ends with a newline').toBe('');
expect(lines.map((l) => l.slice(0, 34).trimEnd())).toEqual([
' dpsapi',
' dpsapi help',
' dpsapi token list [all]',
' dpsapi token new [description]',
' dpsapi token delete <token>',
' dpsapi audit',
' dpsapi audit <n>',
' dpsapi audit <n><unit>',
' dpsapi audit all',
]);
for (const l of lines) expect(l[34] !== ' ' && l[33] === ' ', `description starts at offset 34: ${JSON.stringify(l)}`).toBe(true);
expect(lines.at(-1)).toBe(' dpsapi audit all' + ' '.repeat(16) + 'print every audit row');
});