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.
This commit is contained in:
1 parent
a02eef13a7
commit
fe0cd4bf6d
33 files changed
+6370
No files matched your search
@@ -0,0 +1,225 @@
|
||||
// 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');
|
||||
});
|
||||
Reference in new issue
Block a user