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,142 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* Secret registry and redaction.
|
||||
*
|
||||
* Every token created during a test is registered (automatically for tokens printed by
|
||||
* `token new` / `token list` through the harness CLI runners). Registered tokens are:
|
||||
* - replaced by `[REDACTED:<token_id>]` in every artifact the harness attaches;
|
||||
* - persisted to a per-test file read by the redacting reporter, which scrubs error
|
||||
* messages, stdout/stderr and text attachments of the final report;
|
||||
* - searched for in captured logs (`assertNoSecretsIn`), automatically at teardown.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/** Token format produced by `dpsapi token new`: 32 random bytes, base64url, no padding. */
|
||||
export const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
/** A token-shaped run of characters not adjacent to other token characters. */
|
||||
export const TOKEN_CANDIDATE_RE = /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g;
|
||||
|
||||
/** The identifier the application logs instead of a token: first 8 hex chars of SHA-256. */
|
||||
export function tokenId(token: string): string {
|
||||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export function redactionFor(secret: string): string {
|
||||
return `[REDACTED:${tokenId(secret)}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each secret (longest first) and, unless disabled, every token-shaped
|
||||
* 43-character run, with a redaction marker.
|
||||
*/
|
||||
export function redactText(text: string, secrets: Iterable<string>, { patterns = true }: { patterns?: boolean } = {}): string {
|
||||
let out = text;
|
||||
const list = [...new Set(secrets)].filter((s) => s.length > 0).sort((a, b) => b.length - a.length);
|
||||
for (const s of list) {
|
||||
if (out.includes(s)) out = out.split(s).join(redactionFor(s));
|
||||
}
|
||||
if (patterns) out = out.replace(TOKEN_CANDIDATE_RE, (m) => redactionFor(m));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Recursively redacts every string inside a JSON-like value (BigInts become decimal strings, so the result always serializes). */
|
||||
export function redactValue<T>(value: T, secrets: Iterable<string>): T {
|
||||
const list = [...secrets];
|
||||
const walk = (v: unknown): unknown => {
|
||||
if (typeof v === 'string') return redactText(v, list);
|
||||
if (typeof v === 'bigint') return v.toString(); // JSON has no BigInt: keep the value, as text
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
if (Buffer.isBuffer(v)) return redactText(v.toString('utf8'), list);
|
||||
if (v && typeof v === 'object') {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const [k, x] of Object.entries(v)) o[k] = walk(x);
|
||||
return o;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return walk(value) as T;
|
||||
}
|
||||
|
||||
export class SecretRegistry {
|
||||
private readonly secrets = new Map<string, string>(); // secret -> label
|
||||
constructor(private readonly persistFile?: string) {}
|
||||
|
||||
/** Registers a secret (idempotent). Returns it for chaining. */
|
||||
register(secret: string, label = 'token'): string {
|
||||
if (!secret || this.secrets.has(secret)) return secret;
|
||||
this.secrets.set(secret, label);
|
||||
if (this.persistFile) {
|
||||
fs.mkdirSync(path.dirname(this.persistFile), { recursive: true, mode: 0o700 });
|
||||
fs.appendFileSync(this.persistFile, JSON.stringify(secret) + '\n', { mode: 0o600 });
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Registers every token-shaped string found in text (e.g. `token list` output). */
|
||||
registerAllIn(text: string, label = 'token'): string[] {
|
||||
const found = text.match(TOKEN_CANDIDATE_RE) ?? [];
|
||||
for (const t of found) this.register(t, label);
|
||||
return found;
|
||||
}
|
||||
|
||||
has(secret: string): boolean {
|
||||
return this.secrets.has(secret);
|
||||
}
|
||||
|
||||
all(): string[] {
|
||||
return [...this.secrets.keys()];
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.secrets.size;
|
||||
}
|
||||
|
||||
redact(text: string): string {
|
||||
return redactText(text, this.secrets.keys());
|
||||
}
|
||||
|
||||
redactValue<T>(value: T): T {
|
||||
return redactValue(value, this.secrets.keys());
|
||||
}
|
||||
|
||||
/** Secrets (redacted labels) found verbatim in text. */
|
||||
findIn(text: string | Buffer): string[] {
|
||||
const s = typeof text === 'string' ? text : text.toString('latin1');
|
||||
const b = typeof text === 'string' ? undefined : text;
|
||||
return this.all()
|
||||
.filter((secret) => (b ? b.includes(Buffer.from(secret, 'utf8')) : s.includes(secret)))
|
||||
.map((secret) => redactionFor(secret));
|
||||
}
|
||||
|
||||
/** Fails the test if any registered secret occurs in text. `where` names the source for the message. */
|
||||
assertNoSecretsIn(text: string | Buffer, where = 'text'): void {
|
||||
const leaked = this.findIn(text);
|
||||
expect(leaked, `registered token secret(s) found in ${where}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads every secret persisted by workers for this run (used by the reporter). */
|
||||
export function loadPersistedSecrets(dir: string): string[] {
|
||||
const out = new Set<string>();
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const f of files) {
|
||||
try {
|
||||
for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {
|
||||
if (line.trim()) out.add(JSON.parse(line) as string);
|
||||
}
|
||||
} catch {
|
||||
/* partially written line: ignore */
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
Reference in new issue
Block a user