test(e2e): add persona e2e suites
Power, careless, malicious, fuzzing, cross-abuse, operator-session and concurrency personas exercising the whole application with model-checked database state.
This commit is contained in:
1 parent
581a672fcf
commit
a139f6793d
7 files changed
+2983
No files matched your search
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 "at least one Playwright test must deliberately mix concurrent authenticated requests,
|
||||
* invalid authentication attempts, token creation/deletion, database reads, and Docker container
|
||||
* lifecycle operations, then verify the final database and API state against the operations
|
||||
* actually performed".
|
||||
*
|
||||
* Everything below runs at the same time against one server on the real Docker Engine:
|
||||
* - steady request loops (Playwright + curl) for long-lived tokens;
|
||||
* - request loops for "doomed" tokens that get deleted mid-flight;
|
||||
* - an invalid-authentication loop (random, near-match, revoked, malformed);
|
||||
* - a CLI creator (new tokens are used immediately) and a CLI deleter;
|
||||
* - a DB reader taking consistent snapshots (and `token list`, `audit`) while everyone writes;
|
||||
* - real container lifecycle operations (run / stop / remove / start) on test-owned containers.
|
||||
*
|
||||
* Exact invariant for requests racing a deletion (justified by the code: authenticate() reads the
|
||||
* token row, then RecordOperation inserts the audit row, with no common transaction): a request
|
||||
* may be answered 200 only if it started before `token delete` returned; it is audited iff it was
|
||||
* answered 200; an audit row may carry a time after deleted_at only for such a racing request, and
|
||||
* never after the end of the token's last 200 response.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
assertDbInvariants,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
msToNanos,
|
||||
parseAuditOutput,
|
||||
parseTokenList,
|
||||
pollUntil,
|
||||
readOperations,
|
||||
readToken,
|
||||
rfc3339ToNanos,
|
||||
tokenId,
|
||||
type TestContainer,
|
||||
} from '../../harness';
|
||||
import { StateModel, TOKEN_CHARS, authOf, consistentSnapshot, nearMatches, snapshotProblems } from '../../harness/personas';
|
||||
|
||||
interface ApiContainer {
|
||||
Id: string;
|
||||
State: string;
|
||||
Labels: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
token: string;
|
||||
start: number;
|
||||
end: number;
|
||||
endWall: number;
|
||||
status: number;
|
||||
own?: string[];
|
||||
}
|
||||
|
||||
test('concurrency mix: authenticated + invalid requests, token creation/deletion, DB reads and real container lifecycle at once — final DB and API state equal the operations performed', async ({ cli, ws, startServer, http, docker, rng }) => {
|
||||
test.setTimeout(180_000);
|
||||
const model = new StateModel();
|
||||
const steady = await Promise.all(Array.from({ length: 4 }, (_, i) => cli.tokenNew(`steady ${i}`)));
|
||||
steady.forEach((t, i) => model.add(t, `steady ${i}`));
|
||||
const doomed = await Promise.all(Array.from({ length: 4 }, (_, i) => cli.tokenNew(`doomed ${i}`)));
|
||||
doomed.forEach((t, i) => model.add(t, `doomed ${i}`));
|
||||
const revokedEarly = model.add(await cli.tokenNew('revoked early'), 'revoked early').token;
|
||||
await cli.tokenDelete(revokedEarly);
|
||||
model.deactivate(revokedEarly);
|
||||
|
||||
const everCreated = new Map<string, TestContainer>();
|
||||
for (const c of await docker.runMany(4, (i) => ({ suffix: `init${i}`, labels: { 'mix.phase': 'initial' } }), 4)) everCreated.set(c.id, c);
|
||||
const server = await startServer({ docker: 'real', logLevel: 'INFO' });
|
||||
|
||||
const samples: Sample[] = [];
|
||||
const invalid: number[] = [];
|
||||
const deletedAt = new Map<string, number>();
|
||||
const createdTokens: string[] = [];
|
||||
let snapshots = 0;
|
||||
let auditRows = 0;
|
||||
let stopping = false;
|
||||
|
||||
const request = async (token: string, viaCurl: boolean): Promise<void> => {
|
||||
const start = performance.now();
|
||||
const r = viaCurl ? await http.curl({ url: server.url(), token }) : await http.get(server.url(), { token });
|
||||
const end = performance.now();
|
||||
const s: Sample = { token, start, end, endWall: Date.now(), status: r.status };
|
||||
if (r.status === 200) {
|
||||
const list = expectContainersResponse<ApiContainer>(r);
|
||||
expect(list.every((c) => c.State === 'running'), 'only running containers, ever').toBe(true);
|
||||
s.own = list.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((c) => c.Id);
|
||||
} else expectErrorResponse(r, 401);
|
||||
samples.push(s);
|
||||
};
|
||||
|
||||
const steadyLoop = async (t: string, k: number) => {
|
||||
for (let n = 0; !stopping; n++) await request(t, (n + k) % 5 === 0);
|
||||
};
|
||||
const doomedLoop = async (t: string) => {
|
||||
let after = 0;
|
||||
for (let n = 0; !stopping; n++) {
|
||||
const start = performance.now();
|
||||
await request(t, n % 4 === 0);
|
||||
if (deletedAt.has(t) && start > deletedAt.get(t)! && ++after >= 4) return;
|
||||
}
|
||||
};
|
||||
const invalidLoop = async () => {
|
||||
while (!stopping) {
|
||||
const base = rng.pick(model.all());
|
||||
const near = nearMatches(base, rng, 1)[0]?.value ?? 'x';
|
||||
const header = rng.pick([`Bearer ${rng.string(43, TOKEN_CHARS)}`, `Bearer ${near}`, `Bearer ${revokedEarly}`, `Basic ${base}`, 'Bearer ']);
|
||||
// Playwright carries printable ASCII only; skip values the oracle says would authenticate (OWS trimming)
|
||||
if (!/^[\x20-\x7e]*$/.test(header) || authOf([header], (t) => model.lookup(t)).cls === 'ok') continue;
|
||||
const r = await http.get(server.url(), { headers: { Authorization: header } });
|
||||
expectErrorResponse(r, 401);
|
||||
invalid.push(r.status);
|
||||
}
|
||||
};
|
||||
|
||||
const creator = async () => {
|
||||
for (let i = 0; i < 6 && !stopping; i++) {
|
||||
const t = await cli.tokenNew(`created mid-flight ${i}`);
|
||||
model.add(t, `created mid-flight ${i}`);
|
||||
createdTokens.push(t);
|
||||
for (let n = 0; n < 5; n++) await request(t, n === 4);
|
||||
}
|
||||
};
|
||||
const deleter = async () => {
|
||||
for (const t of rng.shuffle(doomed)) {
|
||||
const warm = rng.int(5, 25);
|
||||
await pollUntil('traffic for the doomed token', () => (stopping || samples.filter((s) => s.token === t).length >= warm ? true : undefined), { timeoutMs: 30_000 });
|
||||
if (stopping) return;
|
||||
await cli.tokenDelete(t);
|
||||
deletedAt.set(t, performance.now());
|
||||
model.deactivate(t);
|
||||
}
|
||||
};
|
||||
const reader = async () => {
|
||||
while (!stopping) {
|
||||
const snap = consistentSnapshot(ws.dbPath);
|
||||
expect(snapshotProblems(snap), 'consistent snapshot taken mid-flight is structurally valid').toEqual([]);
|
||||
// alternately `token list` (active only: no inactive row may slip through) and `token list all`
|
||||
const all = snapshots % 2 === 1;
|
||||
const list = await cli.run(all ? ['token', 'list', 'all'] : ['token', 'list']);
|
||||
expect(list.exitCode).toBe(0);
|
||||
const rows = parseTokenList(list.stdout);
|
||||
if (!all) expect(rows.filter((r) => !r.active || r.deletedAt !== null).map((r) => tokenId(r.token)), 'token list prints active tokens only').toEqual([]);
|
||||
const known = new Set(model.all());
|
||||
// a token being created right now is in the database before `token new` has returned
|
||||
expect(rows.every((r) => known.has(r.token) || /^created mid-flight \d$/.test(r.description)), 'no token appears that was not created by the test').toBe(true);
|
||||
// `audit` while everyone writes: a complete chronological table, append-only, only known tokens
|
||||
const audit = await cli.run(['audit', '1000000']);
|
||||
expect(audit.exitCode).toBe(0);
|
||||
const arows = parseAuditOutput(audit.stdout);
|
||||
const times = arows.map((r) => rfc3339ToNanos(r.time));
|
||||
expect(times.every((t, k) => k === 0 || times[k - 1] <= t), 'audit output is chronological').toBe(true);
|
||||
expect(arows.length, 'audit history only grows').toBeGreaterThanOrEqual(auditRows);
|
||||
auditRows = arows.length;
|
||||
const users = new Set(model.all());
|
||||
expect(arows.every((r) => users.has(r.token) && r.operation === 'GET containers'), 'audit rows belong to tokens the test created').toBe(true);
|
||||
snapshots++;
|
||||
}
|
||||
};
|
||||
const lifecycle = async () => {
|
||||
const extra = await docker.runMany(4, (i) => ({ suffix: `mid${i}`, command: i % 2 ? ['tail', '-f', '/dev/null'] : ['sleep', '3600'], labels: { 'mix.phase': 'mid', 'mix.i': String(i) } }), 2);
|
||||
for (const c of extra) everCreated.set(c.id, c);
|
||||
const all = [...everCreated.values()];
|
||||
await Promise.all(all.slice(0, 2).map((c) => docker.stop(c, 1)));
|
||||
await Promise.all(all.slice(2, 4).map((c) => docker.remove(c)));
|
||||
await docker.start(all[0]);
|
||||
for (const c of await docker.runMany(2, (i) => ({ suffix: `late${i}`, labels: { 'mix.phase': 'late' } }), 2)) everCreated.set(c.id, c);
|
||||
};
|
||||
|
||||
// any failure stops every loop (a failed worker must not turn into a hang)
|
||||
const guard = <T,>(p: Promise<T>) => p.catch((e) => {
|
||||
stopping = true;
|
||||
throw e;
|
||||
});
|
||||
const orchestration = (async () => {
|
||||
const work = await Promise.allSettled([creator(), deleter(), lifecycle()].map(guard));
|
||||
const bad = work.find((x) => x.status === 'rejected');
|
||||
if (bad) throw (bad as PromiseRejectedResult).reason;
|
||||
await pollUntil('doomed loops finished', () => (doomed.every((t) => samples.filter((s) => s.token === t && s.start > deletedAt.get(t)!).length >= 8) ? true : undefined), { timeoutMs: 30_000 });
|
||||
stopping = true;
|
||||
})();
|
||||
// wait for every worker to wind down (no stray CLI/docker processes), then surface the first failure
|
||||
const settled = await Promise.allSettled([guard(orchestration), ...[...steady.map((t, k) => steadyLoop(t, k)), ...doomed.flatMap((t) => [doomedLoop(t), doomedLoop(t)]), invalidLoop(), reader()].map(guard)]);
|
||||
const failed = settled.find((x) => x.status === 'rejected');
|
||||
if (failed) throw (failed as PromiseRejectedResult).reason;
|
||||
|
||||
// ---- final API state equals docker ps for our containers
|
||||
const running = (await docker.runningIds()).sort();
|
||||
const finalList = expectContainersResponse<ApiContainer>(await http.get(server.url(), { token: steady[0] }));
|
||||
expect(finalList.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((c) => c.Id).sort(), 'final API state equals docker ps').toEqual(running);
|
||||
expect(running.length).toBe(7); // 4 initial + 4 mid + 2 late \u2212 2 removed \u2212 1 still stopped
|
||||
samples.push({ token: steady[0], start: performance.now(), end: performance.now(), endWall: Date.now(), status: 200 });
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
|
||||
// every container ever seen in a 200 belongs to the set this test created
|
||||
for (const s of samples) for (const id of s.own ?? []) expect(everCreated.has(id), 'only containers created by this test carry its label').toBe(true);
|
||||
|
||||
// ---- database equals the operations actually performed
|
||||
const ops = readOperations(ws.dbPath);
|
||||
for (const t of model.all()) {
|
||||
const mine = samples.filter((s) => s.token === t);
|
||||
const oks = mine.filter((s) => s.status === 200);
|
||||
model.audit(t, oks.length);
|
||||
expect(ops.filter((o) => o.token === t).length, `${tokenId(t)}: audit rows equal the 200 responses`).toBe(oks.length);
|
||||
}
|
||||
for (const t of [...steady, ...createdTokens]) expect(samples.filter((s) => s.token === t).every((s) => s.status === 200), `${tokenId(t)}: never refused`).toBe(true);
|
||||
let racing = 0;
|
||||
for (const t of doomed) {
|
||||
const del = deletedAt.get(t)!;
|
||||
const mine = samples.filter((s) => s.token === t);
|
||||
expect(mine.filter((s) => s.start > del && s.status !== 401), `${tokenId(t)}: everything started after the deletion is 401`).toEqual([]);
|
||||
expect(mine.filter((s) => s.status === 200).every((s) => s.start < del), `${tokenId(t)}: every 200 started before the deletion returned`).toBe(true);
|
||||
const row = readToken(ws.dbPath, t)!;
|
||||
const lastOk = Math.max(0, ...mine.filter((s) => s.status === 200).map((s) => s.endWall));
|
||||
for (const o of ops.filter((x) => x.token === t)) {
|
||||
expect(rfc3339ToNanos(o.time) <= msToNanos(lastOk + 1), 'audit row precedes the end of the last successful response').toBe(true);
|
||||
if (rfc3339ToNanos(o.time) > rfc3339ToNanos(row.deleted_at!)) racing++;
|
||||
}
|
||||
}
|
||||
expect(ops.filter((o) => o.token === revokedEarly)).toEqual([]);
|
||||
expect(invalid.length).toBeGreaterThan(20);
|
||||
expect(snapshots).toBeGreaterThan(3);
|
||||
model.verifyDb(ws.dbPath, 'final database equals the model');
|
||||
model.verifyList(await cli.tokenList(), 'final token list');
|
||||
model.verifyList(await cli.tokenListAll(), 'final token list all', 'all');
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 4 + 4 + 1 + 6, operationCount: samples.filter((s) => s.status === 200).length });
|
||||
test.info().annotations.push({ type: 'mix', description: JSON.stringify({ requests: samples.length, invalid: invalid.length, snapshots, racingAuditRows: racing, containers: everCreated.size }) });
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 cross-persona abuse sequence: a power-user state (tokens, revocations, audit history
|
||||
* across restarts, a large container set) is attacked by malicious and fuzzer traffic — HTTP
|
||||
* enumeration, near-matches, oracle-checked fuzz requests, malformed requests, failing CLI
|
||||
* garbage and a foreign write lock, all at the same time — then the server is killed (SIGKILL)
|
||||
* in the middle of a burst, restarted on the same database, and finally administered normally.
|
||||
* Recovery and integrity are verified against the model after every phase.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
expectCliFailure,
|
||||
errorMessage,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
mapLimit,
|
||||
pollUntil,
|
||||
formatAuditOutput,
|
||||
readOperations,
|
||||
type CliResult,
|
||||
type Rng,
|
||||
} from '../../harness';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
StateModel,
|
||||
TOKEN_CHARS,
|
||||
authOf,
|
||||
checkResponse,
|
||||
cliOutcome,
|
||||
configOutcome,
|
||||
expectHttp,
|
||||
expectNoTokenFragments,
|
||||
fuzzAddr,
|
||||
fuzzLogLevel,
|
||||
fuzzPort,
|
||||
fuzzRequest,
|
||||
fuzzText,
|
||||
malformedRequest,
|
||||
matchesConfigError,
|
||||
nearMatches,
|
||||
onlyResponse,
|
||||
rawBytes,
|
||||
stateDigest,
|
||||
AUDIT_ALL_ARGV_NEAR_MISSES,
|
||||
HELP_NEAR_MISSES,
|
||||
LIST_ALL_NEAR_MISSES,
|
||||
expectedAuditStdout,
|
||||
type AppEnv,
|
||||
} from '../../harness/personas';
|
||||
|
||||
/** Failing CLI invocations only (the attacker cannot change state through the CLI without a valid token argument). */
|
||||
function garbageCli(r: Rng, model: StateModel): { args: string[]; env: AppEnv; addrValid?: boolean } {
|
||||
for (;;) {
|
||||
const kind = r.pick(['usage', 'usage', 'delete', 'delete', 'config']);
|
||||
if (kind === 'config') {
|
||||
const a = fuzzAddr(r);
|
||||
const env: AppEnv = r.bool() ? { HTTP_ADDR: a.value } : r.bool() ? { HTTP_PORT: fuzzPort(r) } : { LOG_LEVEL: fuzzLogLevel(r) };
|
||||
const cfg = configOutcome(env, env.HTTP_ADDR === undefined ? undefined : a.valid);
|
||||
if (!cfg.ok) return { args: ['token', 'list'], env, addrValid: env.HTTP_ADDR === undefined ? undefined : a.valid };
|
||||
continue;
|
||||
}
|
||||
const args =
|
||||
kind === 'usage'
|
||||
? r.pick([['token'], ['tokens'], ['token', 'nwe'], ['token', 'list', fuzzText(r, { maxLen: 64 }).value], ['token', 'new', 'a', fuzzText(r, { maxLen: 64 }).value], ['token', 'delete'], [fuzzText(r, { maxLen: 64 }).value], [...r.pick(HELP_NEAR_MISSES)], [...r.pick(LIST_ALL_NEAR_MISSES)], [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES)]])
|
||||
: ['token', 'delete', r.pick([...nearMatches(r.pick(model.all()), r, 2).map((m) => m.value), r.pick(model.inactive().length ? model.inactive() : ['x']), fuzzText(r, { maxLen: 128 }).value, r.string(43, TOKEN_CHARS)])];
|
||||
const o = cliOutcome(args, model);
|
||||
if (o.kind === 'error') return { args, env: {} };
|
||||
}
|
||||
}
|
||||
|
||||
test('power-user state attacked by malicious + fuzzer traffic, a write lock and a SIGKILL mid-burst, then normal administration: full recovery and integrity', async ({ cli, ws, startServer, http, fuzz, secrets, e2e }) => {
|
||||
test.setTimeout(180_000);
|
||||
const r = fuzz.rng.fork('cross');
|
||||
const model = new StateModel();
|
||||
e2e.trackedDbs.add(ws.dbPath);
|
||||
|
||||
// ---- 1. power-user state
|
||||
const descs = Array.from({ length: 40 }, (_, i) => (i % 5 === 0 ? fuzzText(r, { maxLen: 512 }).value : `team-${i} ${r.pick(['ci', 'ops', 'dash'])}`));
|
||||
(await mapLimit(descs, 8, (d) => cli.tokenNew(d))).forEach((t, i) => model.add(t, descs[i]));
|
||||
for (const t of r.shuffle(model.all()).slice(0, 10)) {
|
||||
await cli.tokenDelete(t);
|
||||
model.deactivate(t);
|
||||
}
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
const fake = server.fakeDocker!.many(300, r.fork('containers'));
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const users = r.shuffle(model.active()).slice(0, 15);
|
||||
const plan = Array.from({ length: 100 }, (_, i) => users[i % users.length]);
|
||||
await mapLimit(plan, 16, async (t) => {
|
||||
expect(expectContainersResponse(await http.get(server.url(), { token: t }))).toHaveLength(300);
|
||||
});
|
||||
for (const t of plan) model.audit(t);
|
||||
if (round < 2) await server.restart();
|
||||
}
|
||||
model.verifyDb(ws.dbPath, 'power-user state');
|
||||
const lookup = (t: string) => model.lookup(t);
|
||||
|
||||
// ---- 2. attack: everything at once
|
||||
await test.step('concurrent attack: enumeration, near-matches, fuzz, malformed requests, CLI garbage and a 2 s foreign write lock', async () => {
|
||||
const audits: string[] = [];
|
||||
const host = { host: server.connectHost, port: server.port };
|
||||
const enumeration = mapLimit(Array.from({ length: 400 }, () => r.string(43, TOKEN_CHARS)), 8, async (g) => {
|
||||
expectErrorResponse(await http.get(server.url(), { token: g }), 401);
|
||||
});
|
||||
const nearCases = model.all().flatMap((t) => nearMatches(t, r, 6)).filter((m) => !/[\r\n]/.test(m.value));
|
||||
const near = mapLimit(nearCases, 6, async (m) => {
|
||||
const header = `Bearer ${m.value}`;
|
||||
const exp = authOf([header], lookup);
|
||||
const res = onlyResponse(await http.raw({ ...host, data: Buffer.from(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: ${header}\r\nConnection: close\r\n\r\n`, 'utf8'), timeoutMs: 20_000 }), m.kind);
|
||||
if (exp.cls === 'ok') {
|
||||
expectContainersResponse(res);
|
||||
audits.push(exp.token!);
|
||||
} else expectErrorResponse(res, 401);
|
||||
});
|
||||
const fuzzCases = Array.from({ length: 300 }, (_, i) => (i % 8 === 0 ? malformedRequest(r, model) : fuzzRequest(r, model)));
|
||||
const fuzzing = mapLimit(fuzzCases, 6, async (c, i) => {
|
||||
const exp = expectHttp(c, lookup, true);
|
||||
fuzz.record({ phase: 'attack', i, method: c.method, target: c.target.slice(0, 200), headers: c.headers.map(([k, v]) => [k, v.slice(0, 200)]), expected: exp });
|
||||
const res = onlyResponse(await http.raw({ ...host, data: rawBytes(c, { keepAlive: c.body !== undefined }), head: c.method === 'HEAD', timeoutMs: 20_000 }), `fuzz ${i}`);
|
||||
checkResponse(res, exp, { head: c.method === 'HEAD', containers: 300, secrets, label: `fuzz ${i}` });
|
||||
if (exp.audited && res.status === 200) audits.push(exp.audited);
|
||||
});
|
||||
// every 10th garbage invocation is a near miss of `audit all` (`audit ALL`, `audit all x`, ...)
|
||||
const garbageCases = Array.from({ length: 60 }, (_, i) => (i % 10 === 3 ? { args: [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES.filter((a) => /^audit$/i.test(a[0])))], env: {} as AppEnv, addrValid: undefined } : garbageCli(r, model)));
|
||||
const auditAllMisses = garbageCases.filter((g) => !Object.keys(g.env).length && g.args.some((a) => /[a\u0430]ll/i.test(a)) && /^audit$/i.test(g.args[0])).length;
|
||||
expect(auditAllMisses, 'the garbage includes near misses of audit all').toBeGreaterThanOrEqual(6);
|
||||
const garbage = mapLimit(garbageCases, 4, async (g, i) => {
|
||||
fuzz.record({ phase: 'cli-garbage', i, args: g.args.map((a) => a.slice(0, 120)), env: g.env });
|
||||
const res: CliResult = await cli.run(g.args, { env: g.env });
|
||||
if (Object.keys(g.env).length) {
|
||||
const cfg = configOutcome(g.env, g.addrValid);
|
||||
expect(cfg.ok).toBe(false);
|
||||
expectCliFailure(res, { exitCode: 2, usage: false });
|
||||
expect(matchesConfigError(errorMessage(res), cfg as Exclude<typeof cfg, { ok: true }>), `cli garbage ${i}: ${errorMessage(res)}`).toBe(true);
|
||||
} else {
|
||||
const o = cliOutcome(g.args, model);
|
||||
if (o.kind !== 'error') throw new Error('garbage generator produced a successful command');
|
||||
expectCliFailure(res, { exitCode: o.exitCode, message: o.message, usage: o.usage });
|
||||
}
|
||||
});
|
||||
const lock = (async () => {
|
||||
await pollUntil('attack traffic under way', () => (fuzz.cases.length > 100 ? true : undefined), { timeoutMs: 30_000, intervalMs: 5 });
|
||||
const db = new DatabaseSync(ws.dbPath, { timeout: 3000 });
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
await new Promise((res) => setTimeout(res, 2000));
|
||||
db.exec('ROLLBACK');
|
||||
db.close();
|
||||
})();
|
||||
await Promise.all([enumeration, near, fuzzing, garbage, lock]);
|
||||
for (const t of audits) model.audit(t);
|
||||
model.verifyDb(ws.dbPath, 'after the attack: only legitimately authenticated requests left a trace');
|
||||
});
|
||||
|
||||
// ---- 3. crash in the middle of a burst, restart on the same database
|
||||
await test.step('SIGKILL during a burst of 80 authenticated requests, then restart: committed rows survive, nothing is half-written', async () => {
|
||||
const victim = model.active()[0];
|
||||
const before = readOperations(ws.dbPath).filter((o) => o.token === victim).length;
|
||||
fake.delay(5); // keep requests in flight
|
||||
let done = 0;
|
||||
const burst = mapLimit(Array.from({ length: 80 }, () => 0), 10, async () => {
|
||||
const res = await http.raw({ host: server.connectHost, port: server.port, data: rawBytes({ cls: 'burst', method: 'GET', target: '/api/v1/containers', headers: [['Authorization', `Bearer ${victim}`]] }), timeoutMs: 20_000 });
|
||||
done++;
|
||||
return res;
|
||||
});
|
||||
await pollUntil('a third of the burst answered', () => (done >= 25 ? true : undefined), { timeoutMs: 30_000, intervalMs: 2 });
|
||||
const killed = await server.kill();
|
||||
expect(killed.signal).toBe('SIGKILL');
|
||||
const results = await burst;
|
||||
const ok = results.filter((x) => x.outcome === 'response' && x.response!.status === 200).length;
|
||||
const lost = results.filter((x) => x.outcome !== 'response').length;
|
||||
expect(results.every((x) => x.outcome !== 'response' || x.response!.status === 200), 'answered requests all succeeded').toBe(true);
|
||||
expect(ok + lost).toBe(80);
|
||||
expect(lost, 'the kill really interrupted the burst').toBeGreaterThan(0);
|
||||
fake.delay(0);
|
||||
await server.restart(); // new process, same DB/port/backend: WAL recovery
|
||||
const after = readOperations(ws.dbPath).filter((o) => o.token === victim).length - before;
|
||||
// every answered request is durably audited; interrupted ones may or may not have committed their row
|
||||
expect(after).toBeGreaterThanOrEqual(ok);
|
||||
expect(after).toBeLessThanOrEqual(ok + lost);
|
||||
model.audit(victim, after);
|
||||
model.verifyDb(ws.dbPath, 'after crash recovery');
|
||||
// `audit all` on the recovered database: every committed row, oldest first, byte-exact
|
||||
const all = await cli.audit('all');
|
||||
expect(all, 'audit all after recovery: every row').toHaveLength(model.auditTotal);
|
||||
expect(formatAuditOutput(all)).toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true }));
|
||||
test.info().annotations.push({ type: 'crash', description: `${ok} answered, ${lost} interrupted, ${after - ok} interrupted request(s) had committed their audit row` });
|
||||
});
|
||||
|
||||
// ---- 4. normal administration on the recovered instance
|
||||
await test.step('normal administration: list, create, rotate, revoke, use with every client, graceful restart', async () => {
|
||||
model.verifyList(await cli.tokenList());
|
||||
model.verifyList(await cli.tokenListAll(), 'token list all after the incident', 'all');
|
||||
for (let i = 0; i < 5; i++) model.add(await cli.tokenNew(`after incident ${i}`), `after incident ${i}`);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const old = r.pick(model.active());
|
||||
const next = model.add(await cli.tokenNew(`rotated ${i}`), `rotated ${i}`).token;
|
||||
await cli.tokenDelete(old);
|
||||
model.deactivate(old);
|
||||
expectErrorResponse(await http.get(server.url(), { token: old }), 401);
|
||||
expectContainersResponse(await http.curl({ url: server.url(), token: next }));
|
||||
model.audit(next);
|
||||
}
|
||||
for (const t of model.active()) {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
const listing = (await cli.run(['token', 'list', 'all'])).stdout;
|
||||
const history = (await cli.run(['audit', 'all'])).stdout;
|
||||
expect(history, 'audit all: the whole history').toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true }));
|
||||
await server.restart();
|
||||
expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(listing);
|
||||
expect((await cli.run(['audit', 'all'])).stdout, 'audit all survives a graceful restart').toBe(history);
|
||||
for (const t of model.inactive()) expectErrorResponse(await http.get(server.url(), { token: t }), 401);
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
for (const g of server.allOutput()) expectNoTokenFragments(g.stderr, secrets, `server generation ${g.generation}`);
|
||||
expect(server.logs().count({ level: ['WARN', 'ERROR'] }), 'the final generation saw no errors').toBe(0);
|
||||
model.verifyDb(ws.dbPath, 'final');
|
||||
expect(stateDigest(ws.dbPath)).not.toBe('absent');
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 persona "Fuzzer user": seeded, reproducible high-cardinality input across CLI argv,
|
||||
* descriptions, tokens, configuration variables (CLI and server mode), methods, targets, query
|
||||
* strings, headers, Authorization values, token populations, container populations and
|
||||
* concurrent/pipelined/fragmented request patterns.
|
||||
*
|
||||
* Every case is generated from the test's seed (run seed DPSAPI_E2E_SEED + title path, annotated
|
||||
* as `seed`) and recorded with `fuzz.record`; on failure the cases are attached as fuzz-cases.json.
|
||||
* Outcomes are checked against exact oracles (harness/personas.ts) derived from the README
|
||||
* contract: exit code + error line for the CLI, status + exact body for HTTP, audit rows per token,
|
||||
* logged request/auth-class multisets. Malformed-by-construction requests have explicit allowed
|
||||
* Go-level status sets. After every batch the database is checked against the model and the
|
||||
* structural invariants, so the process must survive, stay bounded and keep serving valid traffic.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
errorMessage,
|
||||
expectCliFailure,
|
||||
expectCliSuccess,
|
||||
expectContainersResponse,
|
||||
goUnquoteBytes,
|
||||
makeContainerSummary,
|
||||
mapLimit,
|
||||
msToNanos,
|
||||
parseAuditOutput,
|
||||
parseLogs,
|
||||
parseTokenList,
|
||||
readOperations,
|
||||
readToken,
|
||||
safeStringify,
|
||||
USAGE_TEXT,
|
||||
tokenId,
|
||||
type CliResult,
|
||||
type ContainerSummary,
|
||||
type CurlResult,
|
||||
type HttpResult,
|
||||
type RawHttpResponse,
|
||||
type Rng,
|
||||
} from '../../harness';
|
||||
import {
|
||||
StateModel,
|
||||
TOKEN_CHARS,
|
||||
checkResponse,
|
||||
cliOutcome,
|
||||
configOutcome,
|
||||
expectAuditWindow,
|
||||
expectHttp,
|
||||
expectedAuditStdout,
|
||||
expectedTokenListStdout,
|
||||
fuzzAddr,
|
||||
fuzzAuditArgs,
|
||||
fuzzLogLevel,
|
||||
fuzzPort,
|
||||
fuzzRequest,
|
||||
fuzzText,
|
||||
isTokenChars,
|
||||
isValidLogLevel,
|
||||
isValidPort,
|
||||
malformedRequest,
|
||||
matchesConfigError,
|
||||
nearMatches,
|
||||
onlyResponse,
|
||||
rawBytes,
|
||||
routeOf,
|
||||
stateDigest,
|
||||
type AppEnv,
|
||||
type HttpCase,
|
||||
type HttpExpectation,
|
||||
AUDIT_ALL_ARGV_NEAR_MISSES,
|
||||
HELP_NEAR_MISSES,
|
||||
LIST_ALL_NEAR_MISSES,
|
||||
} from '../../harness/personas';
|
||||
|
||||
/** Compact, artifact-friendly rendering of a fuzz value (long values are summarized). */
|
||||
function show(v: string): string | { length: number; head: string } {
|
||||
return v.length <= 160 ? v : { length: v.length, head: v.slice(0, 80) };
|
||||
}
|
||||
|
||||
const MISSPELLED_COMMANDS = ['Token', 'tokens', 'TOKEN', 'tokn', 'list', 'new', 'delete', 'help', '-h', '--help', '', 'token ', ' token', 'tok\u0435n', 'token\n', 'AUDIT', 'Audit', 'audits', 'audit ', ' audit', 'aud\u0456t', 'log'];
|
||||
const MISSPELLED_SUBCOMMANDS = ['lst', 'ls', 'neww', 'nwe', 'delet', 'del', 'rm', 'remove', 'LIST', 'New', 'DELETE', '', ' list', 'list ', 'new\t', 'create', 'revoke'];
|
||||
|
||||
/** Seeded argv for the CLI fuzzer. */
|
||||
function genArgs(r: Rng, model: StateModel): string[] {
|
||||
if (r.bool(0.15)) return ['audit', ...fuzzAuditArgs(r).args];
|
||||
// `help`, `token list all` and `audit all`, their near misses (case, blanks, look-alikes, extra arguments)
|
||||
if (r.bool(0.14)) {
|
||||
const q = r.next();
|
||||
if (q < 0.3) return ['help'];
|
||||
if (q < 0.45) return ['token', 'list', 'all'];
|
||||
if (q < 0.6) return ['audit', 'all'];
|
||||
return [...r.pick([...HELP_NEAR_MISSES, ...LIST_ALL_NEAR_MISSES, ...AUDIT_ALL_ARGV_NEAR_MISSES])];
|
||||
}
|
||||
const p = r.next();
|
||||
const tail = () => Array.from({ length: r.int(0, 2) }, () => fuzzText(r, { maxLen: 512 }).value);
|
||||
if (p < 0.07) return [r.pick(MISSPELLED_COMMANDS), ...tail()];
|
||||
if (p < 0.1) return [fuzzText(r, { maxLen: 512 }).value, ...tail()];
|
||||
if (p < 0.13) return ['token'];
|
||||
if (p < 0.2) return ['token', r.bool(0.6) ? r.pick(MISSPELLED_SUBCOMMANDS) : fuzzText(r, { maxLen: 256 }).value, ...tail()];
|
||||
const sub = r.pick(['list', 'new', 'new', 'new', 'delete', 'delete', 'delete']);
|
||||
if (sub === 'list') {
|
||||
const q = r.next();
|
||||
if (q < 0.6) return ['token', 'list'];
|
||||
if (q < 0.8) return ['token', 'list', 'all'];
|
||||
return ['token', 'list', ...Array.from({ length: r.int(1, 2) }, () => fuzzText(r, { maxLen: 64 }).value)];
|
||||
}
|
||||
if (sub === 'new') {
|
||||
const q = r.next();
|
||||
if (q < 0.1) return ['token', 'new'];
|
||||
if (q < 0.9) return ['token', 'new', fuzzText(r, { maxLen: r.bool(0.05) ? 131_071 : 4096 }).value];
|
||||
return ['token', 'new', ...Array.from({ length: r.int(2, 3) }, () => fuzzText(r, { maxLen: 64 }).value)];
|
||||
}
|
||||
const q = r.next();
|
||||
if (q < 0.08) return ['token', 'delete'];
|
||||
if (q < 0.15) return ['token', 'delete', ...Array.from({ length: r.int(2, 3) }, () => (model.active().length && r.bool() ? r.pick(model.active()) : fuzzText(r, { maxLen: 64 }).value))];
|
||||
const active = model.active();
|
||||
const inactive = model.inactive();
|
||||
const v = r.next();
|
||||
if (v < 0.35 && active.length) return ['token', 'delete', r.pick(active)];
|
||||
if (v < 0.5 && inactive.length) return ['token', 'delete', r.pick(inactive)];
|
||||
if (v < 0.75 && model.all().length) return ['token', 'delete', nearMatches(r.pick(model.all()), r, 1)[0]?.value ?? 'x'];
|
||||
return ['token', 'delete', r.bool(0.3) ? r.string(43, TOKEN_CHARS) : fuzzText(r, { maxLen: 256 }).value];
|
||||
}
|
||||
|
||||
/** argv mentioning the word `all` in any case / look-alike form (`audit ALL`, `token list all x`...). */
|
||||
const mentionsAll = (args: string[]) => args.some((a) => /[a\u0430]ll/i.test(a));
|
||||
|
||||
/** Seeded configuration variables (each independently set with some probability). */
|
||||
function genEnv(r: Rng): { env: AppEnv; addrValid?: boolean } {
|
||||
if (r.bool(0.65)) return { env: {} };
|
||||
const env: AppEnv = {};
|
||||
let addrValid: boolean | undefined;
|
||||
if (r.bool(0.4)) {
|
||||
const a = fuzzAddr(r);
|
||||
env.HTTP_ADDR = a.value;
|
||||
addrValid = a.valid;
|
||||
}
|
||||
if (r.bool(0.5)) env.HTTP_PORT = fuzzPort(r);
|
||||
if (r.bool(0.6)) env.LOG_LEVEL = fuzzLogLevel(r);
|
||||
return { env, addrValid };
|
||||
}
|
||||
|
||||
/** Stderr of a successful verbose run must consist of well-formed slog records only. */
|
||||
function expectOnlyLogRecords(r: CliResult, label: string): void {
|
||||
const logs = parseLogs(r.stderr);
|
||||
expect(logs.unparsed.map((u) => u.text.slice(0, 200)), `${label}: every stderr line is a slog record`).toEqual([]);
|
||||
}
|
||||
|
||||
test('CLI: 300 seeded argv/environment cases with an exact outcome oracle; database always matches the model', async ({ cli, ws, fuzz, secrets, startServer, http }) => {
|
||||
test.setTimeout(150_000);
|
||||
const r = fuzz.rng.fork('cli');
|
||||
const model = new StateModel();
|
||||
// starting population so delete/near-match cases have targets
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = fuzzText(r, { maxLen: 256 }).value;
|
||||
model.add(await cli.tokenNew(d), d);
|
||||
}
|
||||
// audit history (real authenticated requests) so `audit` selections are non-trivial
|
||||
const server = await startServer();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const t = r.pick(model.active());
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
let auditRuns = 0;
|
||||
let auditUsageErrors = 0;
|
||||
let helpRuns = 0;
|
||||
let helpListUsageErrors = 0;
|
||||
let auditAllRuns = 0;
|
||||
let auditAllUsageErrors = 0;
|
||||
const listRuns = { active: 0, all: 0 };
|
||||
const N = 300;
|
||||
for (let i = 0; i < N; i++) {
|
||||
// every 30th case is `audit all` or one of its near misses, so both are always exercised
|
||||
const forced = i % 30 === 5 ? ['audit', 'all'] : i % 30 === 20 ? [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES.filter((a) => a[0] === 'audit'))] : undefined;
|
||||
const viaShell = !forced && r.bool(0.06);
|
||||
if (viaShell) {
|
||||
// Invalid UTF-8 can only reach argv/env through raw bytes: a real shell with printf octal escapes.
|
||||
const bytes = Buffer.from(r.bytes(r.int(1, 24)).map((b) => (b === 0 ? 0xff : b)));
|
||||
if (bytes[bytes.length - 1] === 0x0a) bytes[bytes.length - 1] = 0xc0; // $(...) strips trailing newlines
|
||||
const fmt = [...bytes].map((b) => '\\' + b.toString(8).padStart(3, '0')).join('');
|
||||
const intoEnv = r.bool(0.4);
|
||||
const c = fuzz.record({ i, via: 'shell', intoEnv, bytesHex: bytes.toString('hex') });
|
||||
const before = stateDigest(ws.dbPath);
|
||||
if (intoEnv) {
|
||||
const res = await cli.shell(`HTTP_PORT="$(printf '${fmt}')" "$DPSAPI_BIN" token list`);
|
||||
expectCliFailure(res, { exitCode: 2, usage: false });
|
||||
const msg = errorMessage(res)!;
|
||||
const m = /^configuration error: invalid HTTP_PORT (".*"): must be an integer between 1 and 65535$/.exec(msg);
|
||||
expect(m, `case ${i}: ${JSON.stringify(c)} → ${msg}`).not.toBeNull();
|
||||
expect(goUnquoteBytes(m![1]).equals(bytes), `case ${i}: value quoted byte-exact`).toBe(true);
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
} else {
|
||||
const res = await cli.shell(`"$DPSAPI_BIN" token new "$(printf '${fmt}')"`);
|
||||
expectCliSuccess(res, { stderr: '' });
|
||||
expect(res.stdout).toMatch(/^[A-Za-z0-9_-]{43}\n$/);
|
||||
const tok = res.stdout.trim();
|
||||
model.add(tok, bytes);
|
||||
expect(readToken(ws.dbPath, tok)!.descriptionHex, `case ${i}: invalid UTF-8 stored byte-exact`).toBe(bytes.toString('hex').toUpperCase());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const args = forced ?? genArgs(r, model);
|
||||
const { env, addrValid } = genEnv(r);
|
||||
const cfg = configOutcome(env, addrValid);
|
||||
const outcome = cfg.ok ? cliOutcome(args, model) : undefined;
|
||||
const c = fuzz.record({ i, args: args.map(show), env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, show(v!)])), expected: cfg.ok ? outcome : cfg });
|
||||
const label = `case ${i} ${safeStringify(c).slice(0, 400)}`;
|
||||
const verbose = cfg.ok && env.LOG_LEVEL !== undefined && /^(debug|trace)$/i.test(env.LOG_LEVEL);
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const t0 = msToNanos(Date.now() - 1);
|
||||
const res = await cli.run(args, { env });
|
||||
const t1 = msToNanos(Date.now() + 1);
|
||||
|
||||
if (!cfg.ok) {
|
||||
// configuration errors are reported before the logger exists: exactly one stderr line
|
||||
expectCliFailure(res, { exitCode: 2, usage: false });
|
||||
expect(matchesConfigError(errorMessage(res), cfg), `${label}: got ${JSON.stringify(errorMessage(res))}`).toBe(true);
|
||||
expect(stateDigest(ws.dbPath), `${label}: state unchanged`).toBe(before);
|
||||
continue;
|
||||
}
|
||||
switch (outcome!.kind) {
|
||||
case 'list': {
|
||||
expectCliSuccess(res);
|
||||
if (!verbose) expect(res.stderr, label).toBe('');
|
||||
else expectOnlyLogRecords(res, label);
|
||||
model.verifyList(parseTokenList(res.stdout), label, outcome!.scope);
|
||||
expect(res.stdout, `${label}: exact padded table of the stored rows`).toBe(expectedTokenListStdout(ws.dbPath, outcome!.scope));
|
||||
expect(stateDigest(ws.dbPath), `${label}: list is read-only`).toBe(before);
|
||||
listRuns[outcome!.scope]++;
|
||||
break;
|
||||
}
|
||||
case 'help': {
|
||||
expectCliSuccess(res, { stdout: USAGE_TEXT });
|
||||
if (!verbose) expect(res.stderr, label).toBe('');
|
||||
else expectOnlyLogRecords(res, label);
|
||||
expect(stateDigest(ws.dbPath), `${label}: help is read-only`).toBe(before);
|
||||
helpRuns++;
|
||||
break;
|
||||
}
|
||||
case 'new': {
|
||||
expectCliSuccess(res);
|
||||
expect(res.stdout, label).toMatch(/^[A-Za-z0-9_-]{43}\n$/);
|
||||
if (!verbose) expect(res.stderr, label).toBe('');
|
||||
else expectOnlyLogRecords(res, label);
|
||||
const tok = secrets.register(res.stdout.trim());
|
||||
model.add(tok, outcome!.description);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
expectCliSuccess(res, { stdout: '' });
|
||||
if (!verbose) expect(res.stderr, label).toBe('');
|
||||
else expectOnlyLogRecords(res, label);
|
||||
model.deactivate(outcome!.token);
|
||||
break;
|
||||
}
|
||||
case 'audit': {
|
||||
expectCliSuccess(res);
|
||||
if (!verbose) expect(res.stderr, label).toBe('');
|
||||
else expectOnlyLogRecords(res, label);
|
||||
const sel = outcome!.selection;
|
||||
const ops = readOperations(ws.dbPath);
|
||||
if ('windowNs' in sel) expectAuditWindow(parseAuditOutput(res.stdout), ops, sel.windowNs, t0, t1, label);
|
||||
else expect(res.stdout, label).toBe(expectedAuditStdout(ops, sel));
|
||||
expect(stateDigest(ws.dbPath), `${label}: audit is read-only`).toBe(before);
|
||||
auditRuns++;
|
||||
if ('all' in sel) {
|
||||
expect(parseAuditOutput(res.stdout), `${label}: audit all prints every row`).toHaveLength(model.auditTotal);
|
||||
auditAllRuns++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
expectCliFailure(res, { exitCode: outcome!.exitCode, message: outcome!.message, usage: outcome!.usage, exactStderr: !verbose });
|
||||
if (verbose) {
|
||||
// nothing but log records before the error line (no echo of any argument)
|
||||
const head = outcome!.usage ? res.stderr.slice(0, res.stderr.length - USAGE_TEXT.length) : res.stderr;
|
||||
expect(parseLogs(head).unparsed.map((u) => u.text.slice(0, 120)), `${label}: only the error line is not a log record`).toEqual([`dpsapi: ${outcome!.message}`]);
|
||||
}
|
||||
if (args[0] === 'audit') auditUsageErrors++;
|
||||
if (args[0] === 'help' || (args[0] === 'token' && args[1] === 'list')) helpListUsageErrors++;
|
||||
if (args[0] === 'audit' && mentionsAll(args.slice(1))) auditAllUsageErrors++;
|
||||
expect(stateDigest(ws.dbPath), `${label}: failed command left the state unchanged`).toBe(before);
|
||||
const deleted = args[1] === 'delete' ? args[2] : undefined;
|
||||
if (deleted && /^[A-Za-z0-9_-]{40,}$/.test(deleted)) expect(res.stderr.includes(deleted), `${label}: supplied token never echoed`).toBe(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (i % 20 === 19) model.verifyDb(ws.dbPath, `after case ${i}`);
|
||||
}
|
||||
model.verifyDb(ws.dbPath, 'final state');
|
||||
model.verifyList(await cli.tokenList(), 'final token list');
|
||||
model.verifyList(await cli.tokenListAll(), 'final token list all', 'all');
|
||||
expect(model.tokens.size, 'the fuzzer actually created tokens').toBeGreaterThan(20);
|
||||
expect(model.inactive().length, 'the fuzzer actually deleted tokens').toBeGreaterThan(3);
|
||||
expect(auditRuns, 'the fuzzer ran valid audit selections').toBeGreaterThan(8);
|
||||
expect(auditUsageErrors, 'the fuzzer produced invalid audit arguments').toBeGreaterThan(8);
|
||||
expect(helpRuns, 'the fuzzer ran help').toBeGreaterThan(2);
|
||||
expect(listRuns.active, 'the fuzzer ran token list').toBeGreaterThan(5);
|
||||
expect(listRuns.all, 'the fuzzer ran token list all').toBeGreaterThan(2);
|
||||
expect(helpListUsageErrors, 'the fuzzer produced help / token list usage errors').toBeGreaterThan(5);
|
||||
expect(auditAllRuns, 'the fuzzer ran audit all').toBeGreaterThan(3);
|
||||
expect(auditAllUsageErrors, 'the fuzzer produced audit all near misses').toBeGreaterThan(3);
|
||||
test.info().annotations.push({ type: 'audit fuzz', description: `${auditRuns} valid audit invocations (${auditAllRuns} audit all), ${auditUsageErrors} audit usage errors (${auditAllUsageErrors} near misses of audit all)` });
|
||||
test.info().annotations.push({ type: 'help/list fuzz', description: `${helpRuns} help, ${listRuns.active} token list, ${listRuns.all} token list all, ${helpListUsageErrors} help/list usage errors` });
|
||||
});
|
||||
|
||||
type Transport = 'raw' | 'raw-fragmented' | 'playwright' | 'curl';
|
||||
|
||||
function playwrightEligible(c: HttpCase, base: string): boolean {
|
||||
if (!['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(c.method) || c.rawLines || c.goReject || routeOf(c.target) === 'bad') return false;
|
||||
const names = c.headers.map(([n]) => n.toLowerCase());
|
||||
if (new Set(names).size !== names.length) return false;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!c.headers.every(([, v]) => /^[\x20-\x7e]*$/.test(v) && v === v.trim())) return false;
|
||||
try {
|
||||
const u = new URL(base + c.target);
|
||||
return u.pathname + u.search === c.target; // WHATWG URL parsing must not rewrite the target
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function curlEligible(c: HttpCase): boolean {
|
||||
if (c.rawLines || c.goReject || c.target.length > 2000) return false;
|
||||
// curl cannot combine -I with a body; Go-rejected targets close the connection with the body unread (RST)
|
||||
if ((c.method === 'HEAD' && c.body !== undefined) || routeOf(c.target) === 'bad' || !isTokenChars(c.method)) return false;
|
||||
if (!/^[A-Za-z0-9\-._~%/?=&;:@!$'()*+,]+$/.test(c.target)) return false;
|
||||
return c.headers.every(([n, v]) => /^[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?$/.test(v) && !/^(accept|user-agent)$/i.test(n));
|
||||
}
|
||||
|
||||
test('HTTP: 1000+ seeded requests across methods, targets, headers and Authorization with an exact oracle, evolving token and container populations', async ({ cli, startServer, http, fuzz, secrets }) => {
|
||||
test.setTimeout(240_000);
|
||||
const r = fuzz.rng.fork('http');
|
||||
const model = new StateModel();
|
||||
const descs = Array.from({ length: 24 }, () => fuzzText(r, { maxLen: 512 }).value);
|
||||
const toks = await mapLimit(descs, 8, (d) => cli.tokenNew(d));
|
||||
toks.forEach((t, i) => model.add(t, descs[i]));
|
||||
for (const t of r.shuffle(toks).slice(0, 7)) {
|
||||
await cli.tokenDelete(t);
|
||||
model.deactivate(t);
|
||||
}
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
const fake = server.fakeDocker!;
|
||||
const host = { host: server.connectHost, port: server.port };
|
||||
|
||||
let reached = 0;
|
||||
let dockerErrors = 0;
|
||||
const expectedClasses: string[] = [];
|
||||
const statusesReached: number[] = [];
|
||||
let sent = 0;
|
||||
const seen = new Map<number, number>();
|
||||
|
||||
const BATCHES = 20;
|
||||
for (let b = 0; b < BATCHES; b++) {
|
||||
// container population and backend health for this batch
|
||||
const n = r.pick([0, 0, 1, 2, r.int(3, 50), r.int(50, 400)]);
|
||||
const containers: ContainerSummary[] = Array.from({ length: n }, (_, i) =>
|
||||
makeContainerSummary(i, r, { Labels: { 'fuzz.batch': String(b), 'fuzz.text': fuzzText(r, { maxLen: 64 }).value }, Names: [`/fuzz-${b}-${i}`] }),
|
||||
);
|
||||
const mode = r.pick(['ok', 'ok', 'ok', 'ok', 'ok', 'error', 'destroy', 'badjson'] as const);
|
||||
fake.setContainers(containers);
|
||||
if (mode === 'error') fake.error500();
|
||||
else if (mode === 'destroy') fake.destroy();
|
||||
else if (mode === 'badjson') fake.badJson(r.pick(['{', '{"a":1}', '[{"Id":', '<html>', '"text"', '42']));
|
||||
const backendOk = mode === 'ok';
|
||||
const concurrency = r.int(1, 16);
|
||||
|
||||
// the batch: well-formed singles, malformed singles, pipelines
|
||||
type Unit = { kind: 'single'; c: HttpCase; t: Transport } | { kind: 'pipeline'; cs: HttpCase[] };
|
||||
const units: Unit[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const p = r.next();
|
||||
if (p < 0.06) {
|
||||
const cs: HttpCase[] = [];
|
||||
while (cs.length < r.int(2, 5)) {
|
||||
const c = fuzzRequest(r, model);
|
||||
const e = expectHttp(c, (t) => model.lookup(t), backendOk);
|
||||
if (e.reachesApp && c.method !== 'HEAD') cs.push(c);
|
||||
}
|
||||
units.push({ kind: 'pipeline', cs });
|
||||
continue;
|
||||
}
|
||||
const c = p < 0.16 ? malformedRequest(r, model) : fuzzRequest(r, model);
|
||||
const options: Transport[] = ['raw', 'raw', 'raw', 'raw-fragmented'];
|
||||
if (playwrightEligible(c, server.baseURL)) options.push('playwright', 'playwright');
|
||||
if (curlEligible(c)) options.push('curl');
|
||||
units.push({ kind: 'single', c, t: r.pick(options) });
|
||||
}
|
||||
|
||||
const audits: string[] = [];
|
||||
const tally = (exp: HttpExpectation, status: number): void => {
|
||||
seen.set(status, (seen.get(status) ?? 0) + 1);
|
||||
if (exp.reachesApp) {
|
||||
reached++;
|
||||
statusesReached.push(status);
|
||||
}
|
||||
if (exp.authClass) expectedClasses.push(exp.authClass);
|
||||
if (exp.audited && (status === 200 || status === 500)) audits.push(exp.audited);
|
||||
if (status === 500) dockerErrors++;
|
||||
};
|
||||
await mapLimit(units, concurrency, async (u, ui) => {
|
||||
const label = `batch ${b} unit ${ui}`;
|
||||
if (u.kind === 'pipeline') {
|
||||
const exps = u.cs.map((c) => expectHttp(c, (t) => model.lookup(t), backendOk));
|
||||
fuzz.record({ b, ui, transport: 'pipeline', cases: u.cs.map((c) => ({ method: c.method, target: show(c.target), headers: c.headers.map(([k, v]) => [k, show(v)]) })), expected: exps });
|
||||
const data = Buffer.concat(u.cs.map((c, i) => rawBytes(c, { keepAlive: i < u.cs.length - 1 })));
|
||||
const res = await http.raw({ ...host, data, expectResponses: u.cs.length, timeoutMs: 20_000 });
|
||||
expect(res.responses.length, `${label}: one response per pipelined request`).toBe(u.cs.length);
|
||||
res.responses.forEach((resp, i) => {
|
||||
checkResponse(resp, exps[i], { containers: n, secrets, label: `${label}#${i}` });
|
||||
tally(exps[i], resp.status);
|
||||
});
|
||||
sent += u.cs.length;
|
||||
return;
|
||||
}
|
||||
const { c, t } = u;
|
||||
const exp = expectHttp(c, (tok) => model.lookup(tok), backendOk);
|
||||
fuzz.record({ b, ui, transport: t, cls: c.cls, method: show(c.method), target: show(c.target), headers: c.headers.map(([k, v]) => [k, show(v)]), rawLines: c.rawLines?.slice(0, 3).map(show), body: c.body === undefined ? undefined : show(c.body), expected: exp });
|
||||
let res: RawHttpResponse | HttpResult | CurlResult;
|
||||
const head = c.method === 'HEAD';
|
||||
if (t === 'raw' || t === 'raw-fragmented') {
|
||||
// Requests with a body are sent keep-alive: with `Connection: close` Go answers before
|
||||
// reading the body and closes with unread bytes (RST), which can destroy the response in
|
||||
// flight on the client side — a transport artefact, not an application outcome.
|
||||
const bytes = rawBytes(c, { keepAlive: c.body !== undefined });
|
||||
let data: Buffer | Buffer[] = bytes;
|
||||
if (t === 'raw-fragmented') {
|
||||
const cuts = [...new Set(Array.from({ length: r.int(1, 6) }, () => r.int(1, bytes.length - 1)))].sort((a, z) => a - z);
|
||||
data = [0, ...cuts].map((s, k) => bytes.subarray(s, cuts[k] ?? bytes.length));
|
||||
}
|
||||
res = onlyResponse(await http.raw({ ...host, data, chunkDelayMs: t === 'raw-fragmented' ? 3 : undefined, head, timeoutMs: 20_000 }), label);
|
||||
} else if (t === 'playwright') {
|
||||
res = await http.fetch(server.url(c.target), { method: c.method, headers: Object.fromEntries(c.headers), data: c.body });
|
||||
} else {
|
||||
res = await http.curl({ url: server.url(c.target), method: head ? undefined : c.method, head, headers: c.headers.map(([k, v]) => `${k}: ${v}`), data: c.body, pathAsIs: true });
|
||||
expect((res as CurlResult).exitCode, `${label}: curl exit`).toBe(0);
|
||||
}
|
||||
checkResponse(res, exp, { head, containers: n, secrets, label });
|
||||
tally(exp, res.status);
|
||||
sent++;
|
||||
});
|
||||
|
||||
for (const t of audits) model.audit(t);
|
||||
model.verifyDb(server.dbPath, `after batch ${b} (${mode}, ${n} containers, concurrency ${concurrency})`);
|
||||
|
||||
// token population evolves between batches
|
||||
if (r.bool(0.6) && model.active().length > 3) {
|
||||
const victim = r.pick(model.active());
|
||||
await cli.tokenDelete(victim);
|
||||
model.deactivate(victim);
|
||||
}
|
||||
if (r.bool(0.6)) {
|
||||
const d = fuzzText(r, { maxLen: 256 }).value;
|
||||
model.add(await cli.tokenNew(d), d);
|
||||
}
|
||||
}
|
||||
|
||||
// the server is still fully functional
|
||||
fake.many(3);
|
||||
const last = model.active()[0];
|
||||
expect(expectContainersResponse(await http.get(server.url(), { token: last }))).toHaveLength(3);
|
||||
model.audit(last);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
|
||||
const logs = server.logs();
|
||||
expect(sent, 'requests sent').toBeGreaterThanOrEqual(1000);
|
||||
for (const st of [200, 400, 401, 404, 405, 500]) expect(seen.get(st) ?? 0, `the fuzzer produced status ${st}`).toBeGreaterThan(0);
|
||||
test.info().annotations.push({ type: 'status distribution', description: JSON.stringify(Object.fromEntries([...seen].sort((a, z) => a[0] - z[0]))) });
|
||||
expect(logs.requests().length, 'every request that reached the app was logged exactly once').toBe(reached + 1);
|
||||
expect(
|
||||
logs.requests().map((x) => Number(x.attrs.status)).sort(),
|
||||
'logged statuses equal the observed ones',
|
||||
).toEqual([...statusesReached, 200].sort());
|
||||
expect(logs.filter({ msg: 'authentication result' }).map((x) => x.attrs.result).sort(), 'authentication classes equal the oracle').toEqual([...expectedClasses, 'ok'].sort());
|
||||
expect(logs.count({ level: 'ERROR', msg: 'docker request failed' }), 'one ERROR per Docker failure').toBe(dockerErrors);
|
||||
expect(logs.count({ level: 'ERROR' })).toBe(dockerErrors);
|
||||
expect(logs.count({ level: 'WARN' })).toBe(0);
|
||||
model.verifyDb(server.dbPath, 'final state');
|
||||
// every token id in the auth log belongs to a known token: nothing unknown was ever authenticated
|
||||
const known = new Set(model.all().map(tokenId));
|
||||
expect(logs.filter({ msg: 'authentication result', attrs: { result: 'ok' } }).every((x) => known.has(x.attrs.token_id))).toBe(true);
|
||||
});
|
||||
|
||||
test('environment: 60 seeded HTTP_ADDR/HTTP_PORT/LOG_LEVEL combinations in server mode — exit 2 with the exact message, or a working server', async ({ cli, ws, startServer, http, e2e, fuzz }) => {
|
||||
test.setTimeout(150_000);
|
||||
const r = fuzz.rng.fork('env');
|
||||
const token = await cli.tokenNew('env fuzz');
|
||||
const loopbacks = ['127.0.0.1', '::1', '127.0.0.2', '127.255.255.254', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1'];
|
||||
let started = 0;
|
||||
let rejected = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const port = await e2e.allocatePort();
|
||||
const env: AppEnv = {};
|
||||
let addrValid = true;
|
||||
// addresses: valid loopbacks only when they would be bound; otherwise invalid values
|
||||
if (r.bool(0.55)) env.HTTP_ADDR = r.pick(loopbacks);
|
||||
else {
|
||||
const a = fuzzAddr(r);
|
||||
env.HTTP_ADDR = a.valid ? r.pick(loopbacks) : a.value;
|
||||
addrValid = a.valid;
|
||||
}
|
||||
env.HTTP_PORT = r.bool(0.55) ? '0'.repeat(r.pick([0, 0, 1, 5])) + String(port) : fuzzPort(r);
|
||||
if (isValidPort(env.HTTP_PORT) && Number(env.HTTP_PORT) !== port) env.HTTP_PORT = String(port); // never bind a port we did not allocate
|
||||
if (r.bool(0.8)) env.LOG_LEVEL = r.bool(0.6) ? r.pick(['error', 'Warn', 'INFO', 'debug', 'TrAcE']) : fuzzLogLevel(r);
|
||||
const cfg = configOutcome(env, addrValid);
|
||||
fuzz.record({ i, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, show(v!)])), expected: cfg });
|
||||
const label = `case ${i} ${JSON.stringify(env).slice(0, 300)}`;
|
||||
if (!cfg.ok) {
|
||||
const res = await cli.run([], { env, timeoutMs: 10_000 });
|
||||
expectCliFailure(res, { exitCode: 2, usage: false });
|
||||
expect(matchesConfigError(errorMessage(res), cfg), `${label}: got ${JSON.stringify(errorMessage(res))}`).toBe(true);
|
||||
expect(stateDigest(ws.dbPath), `${label}: database untouched`).toBe(digest);
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
const server = await startServer({ addr: env.HTTP_ADDR, port: env.HTTP_PORT, logLevel: env.LOG_LEVEL ?? null, label: `env${i}` });
|
||||
const res = await http.get(server.url(), { token });
|
||||
expectContainersResponse(res);
|
||||
const exit = await server.stop();
|
||||
expect(exit.exitCode, label).toBe(0);
|
||||
const level = (env.LOG_LEVEL ?? 'INFO').toUpperCase();
|
||||
expect(isValidLogLevel(level)).toBe(true);
|
||||
const logs = server.logs();
|
||||
expect(logs.unparsed, `${label}: stderr is only slog records`).toEqual([]);
|
||||
const rank = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].indexOf(level);
|
||||
expect(logs.requests().length, `${label}: request record visible iff level <= INFO`).toBe(rank <= 2 ? 1 : 0);
|
||||
expect(logs.levels().every((l) => ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].indexOf(l) >= rank), `${label}: nothing below ${level}`).toBe(true);
|
||||
if (rank <= 2) expect(logs.find({ msg: 'server listening' })!.attrs.addr).toMatch(new RegExp(`:${port}$`));
|
||||
started++;
|
||||
}
|
||||
expect(started, 'some combinations were valid').toBeGreaterThan(5);
|
||||
expect(rejected, 'some combinations were invalid').toBeGreaterThan(5);
|
||||
});
|
||||
@@ -0,0 +1,935 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 persona "Malicious user": attempts to bypass authentication, obtain information,
|
||||
* corrupt state or cause unsafe behaviour through every interface that exists (HTTP via
|
||||
* Playwright / curl / raw socket, the CLI, the shared SQLite file, the Docker socket).
|
||||
*
|
||||
* Every test asserts: the exact status/exit outcome; no token secret (or 12-char fragment of
|
||||
* one) in any response or log; no internal detail in HTTP bodies; exact audit rows; structurally
|
||||
* valid database (assertDbInvariants) and, where the attack must not change anything, a
|
||||
* byte-identical logical state (stateDigest).
|
||||
*/
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
assertDbInvariants,
|
||||
expectCliFailure,
|
||||
expectCliSuccess,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
expectNoInternalDetails,
|
||||
fileDigest,
|
||||
goQuote,
|
||||
mapLimit,
|
||||
msToNanos,
|
||||
parseAuditOutput,
|
||||
parseLogs,
|
||||
parseTokenList,
|
||||
pollUntil,
|
||||
readOperations,
|
||||
readSchema,
|
||||
readToken,
|
||||
rfc3339ToNanos,
|
||||
shellQuote,
|
||||
tokenId,
|
||||
USAGE_TEXT,
|
||||
type HttpClient,
|
||||
type RawResult,
|
||||
type ServerHandle,
|
||||
} from '../../harness';
|
||||
import {
|
||||
ENDPOINT,
|
||||
SQL_PAYLOADS,
|
||||
StateModel,
|
||||
TOKEN_CHARS,
|
||||
auditArgsOutcome,
|
||||
authOf,
|
||||
expectedAuditStdout,
|
||||
expectNoTokenFragments,
|
||||
nearMatches,
|
||||
onlyResponse,
|
||||
routeOf,
|
||||
stateDigest,
|
||||
} from '../../harness/personas';
|
||||
|
||||
/** Raw request bytes (UTF-8): request line, Host, given header lines, optional Connection: close. */
|
||||
function req(lines: string[], { target = ENDPOINT, method = 'GET', version = 'HTTP/1.1', close = true, body }: { target?: string; method?: string; version?: string; close?: boolean; body?: string } = {}): Buffer {
|
||||
const all = [`${method} ${target} ${version}`, 'Host: localhost', ...lines];
|
||||
if (body !== undefined) all.push(`Content-Length: ${Buffer.byteLength(body)}`);
|
||||
if (close) all.push('Connection: close');
|
||||
return Buffer.from(all.join('\r\n') + '\r\n\r\n' + (body ?? ''), 'utf8');
|
||||
}
|
||||
|
||||
function raw(http: HttpClient, s: ServerHandle, data: Buffer | string, extra: { expectResponses?: number; timeoutMs?: number } = {}): Promise<RawResult> {
|
||||
return http.raw({ host: s.connectHost, port: s.port, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data, timeoutMs: 20_000, ...extra });
|
||||
}
|
||||
|
||||
test('token enumeration: 1500 random guesses, near-matches, case/whitespace/confusable mutations and inactive tokens — only the documented OWS trim authenticates, responses are indistinguishable', async ({ cli, startServer, http, secrets, rng }) => {
|
||||
test.setTimeout(120_000);
|
||||
const model = new StateModel();
|
||||
for (let i = 0; i < 5; i++) model.add(await cli.tokenNew(`victim ${i}`), `victim ${i}`);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = model.add(await cli.tokenNew(`revoked ${i}`), `revoked ${i}`).token;
|
||||
await cli.tokenDelete(t);
|
||||
model.deactivate(t);
|
||||
}
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(2);
|
||||
const lookup = (t: string) => model.lookup(t);
|
||||
const expectedClasses: string[] = [];
|
||||
|
||||
await test.step('1500 random 43-character guesses, 24 concurrent (Playwright)', async () => {
|
||||
const guesses = Array.from({ length: 1500 }, () => rng.string(43, TOKEN_CHARS));
|
||||
const res = await mapLimit(guesses, 24, (g) => http.get(server.url(), { token: g }));
|
||||
for (const r of res) expectErrorResponse(r, 401);
|
||||
expectedClasses.push(...guesses.map(() => 'unknown_token'));
|
||||
});
|
||||
|
||||
await test.step('near-match mutations of every active and inactive token (raw socket, UTF-8 bytes)', async () => {
|
||||
const cases: Array<{ kind: string; header: string }> = [];
|
||||
for (const t of model.all()) {
|
||||
for (const m of nearMatches(t, rng, 30)) if (!/[\r\n]/.test(m.value)) cases.push({ kind: m.kind, header: `Bearer ${m.value}` });
|
||||
for (const v of [`bearer ${t}`, `BEARER ${t}`, ` Bearer ${t}`, `Bearer ${t}\t`, `Bearer ${t} `, `Bearer\u{a0}${t}`, `Bearer ${t}\u{200b}`, `B\u{435}arer ${t}`]) cases.push({ kind: 'scheme/whitespace', header: v });
|
||||
}
|
||||
let oks = 0;
|
||||
await mapLimit(cases, 16, async (c) => {
|
||||
const exp = authOf([c.header], lookup);
|
||||
const r = onlyResponse(await raw(http, server, req([`Authorization: ${c.header}`])), c.kind);
|
||||
if (exp.cls === 'ok') {
|
||||
// documented: HTTP itself trims leading/trailing whitespace of the header value (RFC 9110)
|
||||
expectContainersResponse(r);
|
||||
model.audit(exp.token!);
|
||||
oks++;
|
||||
} else expectErrorResponse(r, 401);
|
||||
expectedClasses.push(exp.cls);
|
||||
});
|
||||
expect(oks, 'only OWS-trimmed exact tokens of active tokens authenticated').toBe(cases.filter((c) => authOf([c.header], lookup).cls === 'ok').length);
|
||||
expect(cases.length).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
await test.step('inactive tokens hammered: 4 × 25 concurrent requests → 401 every time', async () => {
|
||||
const attempts = model.inactive().flatMap((t) => Array.from({ length: 25 }, () => t));
|
||||
for (const r of await mapLimit(attempts, 20, (t) => http.get(server.url(), { token: t }))) expectErrorResponse(r, 401);
|
||||
expectedClasses.push(...attempts.map(() => 'inactive_token'));
|
||||
});
|
||||
|
||||
await test.step('unknown, inactive, malformed and missing credentials are indistinguishable to the client', async () => {
|
||||
const shape = (r: RawResult) => {
|
||||
const res = onlyResponse(r, 'shape');
|
||||
return { status: res.status, headers: res.headers.filter(([k]) => k.toLowerCase() !== 'date'), body: res.bodyText };
|
||||
};
|
||||
const variants = [
|
||||
req([`Authorization: Bearer ${rng.string(43, TOKEN_CHARS)}`]),
|
||||
req([`Authorization: Bearer ${model.inactive()[0]}`]),
|
||||
req(['Authorization: Basic Zm9vOmJhcg==']),
|
||||
req([]),
|
||||
req([`Authorization: Bearer ${model.active()[0]}`, `Authorization: Bearer ${model.active()[0]}`]),
|
||||
];
|
||||
const shapes = [];
|
||||
for (const v of variants) shapes.push(shape(await raw(http, server, v)));
|
||||
for (const s of shapes.slice(1)) expect(s).toEqual(shapes[0]);
|
||||
expectedClasses.push('unknown_token', 'inactive_token', 'bad_scheme', 'missing_header', 'multiple_headers');
|
||||
});
|
||||
|
||||
// the legitimate users are unaffected
|
||||
for (const t of model.active()) {
|
||||
expect(expectContainersResponse(await http.get(server.url(), { token: t }))).toHaveLength(2);
|
||||
model.audit(t);
|
||||
expectedClasses.push('ok');
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const logs = server.logs();
|
||||
expect(logs.filter({ msg: 'authentication result' }).map((r) => r.attrs.result).sort(), 'every attempt was classified as the oracle predicts').toEqual([...expectedClasses].sort());
|
||||
expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
||||
expectNoTokenFragments(server.stderr, secrets, 'server log');
|
||||
model.verifyDb(server.dbPath);
|
||||
});
|
||||
|
||||
interface InjectionCase {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
/** Expected status of each response, in order; `go` marks responses produced by Go (not the app). */
|
||||
responses: Array<{ status: number; go?: boolean }>;
|
||||
/** Tokens that must get one audit row each. */
|
||||
audits: string[];
|
||||
}
|
||||
|
||||
test('header injection, smuggling, duplicate/conflicting/oversized headers and malformed HTTP syntax (raw socket)', async ({ cli, startServer, http, secrets }) => {
|
||||
test.setTimeout(90_000);
|
||||
const T = await cli.tokenNew('valid');
|
||||
const U = await cli.tokenNew('other');
|
||||
const I = await cli.tokenNew('revoked');
|
||||
await cli.tokenDelete(I);
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(1);
|
||||
const ok = { status: 200 };
|
||||
const c401 = { status: 401 };
|
||||
const go400 = { status: 400, go: true };
|
||||
const cases: InjectionCase[] = [
|
||||
{ name: 'CR inside a value (attempted header split) → Go 400', data: req([`Authorization: Bearer junk\rAuthorization: Bearer ${T}`]), responses: [go400], audits: [] },
|
||||
{ name: 'bare LF inside a value becomes a second Authorization header → 401 multiple', data: req([`Authorization: Bearer junk\nAuthorization: Bearer ${T}`]), responses: [c401], audits: [] },
|
||||
{ name: 'obs-fold hides "Authorization:" inside X-A → 401 missing', data: req(['X-A: x', ` Authorization: Bearer ${T}`]), responses: [c401], audits: [] },
|
||||
{ name: 'NUL inside the Authorization value → Go 400', data: req([`Authorization: Bearer ${T}\x00`]), responses: [go400], audits: [] },
|
||||
{ name: 'NUL inside a header name → Go 400', data: req([`X-\x00: 1`, `Authorization: Bearer ${T}`]), responses: [go400], audits: [] },
|
||||
{ name: 'space before the colon → Go 400', data: req([`Authorization : Bearer ${T}`]), responses: [go400], audits: [] },
|
||||
{ name: 'valid + revoked under different name case → 401', data: req([`authorization: Bearer ${T}`, `AUTHORIZATION: Bearer ${I}`]), responses: [c401], audits: [] },
|
||||
{ name: 'two different valid tokens → 401', data: req([`Authorization: Bearer ${T}`, `Authorization: Bearer ${U}`]), responses: [c401], audits: [] },
|
||||
{ name: 'valid then empty Authorization → 401', data: req([`Authorization: Bearer ${T}`, 'Authorization:']), responses: [c401], audits: [] },
|
||||
{ name: 'Authorization only in a chunked trailer → 401 missing', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n1\r\na\r\n0\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [c401], audits: [] },
|
||||
{ name: 'Authorization smuggled in a GET body → 401 missing', data: req([], { body: `Authorization: Bearer ${T}\r\n`, close: false }), responses: [c401], audits: [] },
|
||||
{ name: 'pipelined: valid, no auth, revoked on one connection → auth never carries over', data: Buffer.concat([req([`Authorization: Bearer ${T}`], { close: false }), req([], { close: false }), req([`Authorization: Bearer ${I}`])]), responses: [ok, c401, c401], audits: [T] },
|
||||
{
|
||||
name: 'CL+TE request smuggling: the request after the chunk terminator is a separate, unauthenticated request',
|
||||
data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${T}\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nGET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`),
|
||||
responses: [ok, c401],
|
||||
audits: [T],
|
||||
},
|
||||
{ name: 'PROXY protocol preamble spoofing the source → Go 400', data: Buffer.concat([Buffer.from('PROXY TCP4 6.6.6.6 127.0.0.1 1111 2222\r\n'), req([`Authorization: Bearer ${T}`])]), responses: [go400], audits: [] },
|
||||
{ name: 'foreign Host header → served normally', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: evil.example:443\r\nAuthorization: Bearer ${T}\r\nConnection: close\r\n\r\n`), responses: [ok], audits: [T] },
|
||||
{ name: 'absolute-form target to another host with a valid token → same endpoint, 200', data: req([`Authorization: Bearer ${T}`], { target: `http://evil.example${ENDPOINT}` }), responses: [ok], audits: [T] },
|
||||
{ name: 'absolute-form target without credentials → 401', data: req([], { target: `http://evil.example${ENDPOINT}` }), responses: [c401], audits: [] },
|
||||
{ name: 'Expect: 100-continue with a body → final response only, authenticated', data: req([`Authorization: Bearer ${T}`, 'Expect: 100-continue'], { body: 'ab', close: false }), responses: [ok], audits: [T] },
|
||||
{ name: '1000 KiB extra header alongside a valid token (under the limit) → 200', data: req([`X-Pad: ${'p'.repeat(1000 * 1024)}`, `Authorization: Bearer ${T}`]), responses: [ok], audits: [T] },
|
||||
{ name: 'headers over the 1 MiB limit → Go 431, never audited', data: req([`Authorization: Bearer ${T}`, `X-Pad: ${'p'.repeat(1100 * 1024)}`]), responses: [{ status: 431, go: true }], audits: [] },
|
||||
{ name: '900 KiB bearer value → 401, value never logged', data: req([`Authorization: Bearer ${'Q'.repeat(900 * 1024)}`]), responses: [c401], audits: [] },
|
||||
{ name: '490 header lines + valid token → 200', data: req([...Array.from({ length: 490 }, (_, i) => `X-H${i}: v`), `Authorization: Bearer ${T}`]), responses: [ok], audits: [T] },
|
||||
{ name: '600 header lines (over Go\'s header count limit) → Go 431', data: req([`Authorization: Bearer ${T}`, ...Array.from({ length: 600 }, (_, i) => `X-H${i}: v`)]), responses: [{ status: 431, go: true }], audits: [] },
|
||||
{ name: 'HTTP/1.0 without Host → 200', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.0\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [ok], audits: [T] },
|
||||
{ name: 'HTTP/0.9-style request line → Go 400', data: Buffer.from(`GET ${ENDPOINT}\r\n\r\n`), responses: [go400], audits: [] },
|
||||
{ name: 'unsupported HTTP version → Go 505', data: req([`Authorization: Bearer ${T}`], { version: 'HTTP/9.9' }), responses: [{ status: 505, go: true }], audits: [] },
|
||||
{ name: 'lower-case method → 405 before authentication', data: req([`Authorization: Bearer ${T}`], { method: 'get' }), responses: [{ status: 405 }], audits: [] },
|
||||
{ name: 'method with a separator character → Go 400', data: req([`Authorization: Bearer ${T}`], { method: 'G(ET' }), responses: [go400], audits: [] },
|
||||
{ name: 'tab-separated request line → Go 400', data: Buffer.from(`GET\t${ENDPOINT}\tHTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${T}\r\n\r\n`), responses: [go400], audits: [] },
|
||||
{ name: 'missing request target → Go 400', data: Buffer.from(`GET HTTP/1.1\r\nHost: x\r\n\r\n`), responses: [go400], audits: [] },
|
||||
{ name: 'bare-LF line endings (tolerated by Go) → 200', data: Buffer.from(`GET ${ENDPOINT} HTTP/1.1\nHost: x\nAuthorization: Bearer ${T}\nConnection: close\n\n`), responses: [ok], audits: [T] },
|
||||
{ name: '64 KiB request target → 404', data: req([`Authorization: Bearer ${T}`], { target: '/' + 'z'.repeat(64 * 1024) }), responses: [{ status: 404 }], audits: [] },
|
||||
{ name: 'UTF-8 and Latin-1 bytes in unrelated header values → 200', data: Buffer.concat([Buffer.from(`GET ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nX-U: \u{65e5}\u{672c}\r\nX-L: `), Buffer.from([0xe9, 0xff]), Buffer.from(`\r\nAuthorization: Bearer ${T}\r\nConnection: close\r\n\r\n`)]), responses: [ok], audits: [T] },
|
||||
];
|
||||
const audited: string[] = [];
|
||||
let appResponses = 0;
|
||||
for (const c of cases) {
|
||||
await test.step(c.name, async () => {
|
||||
const r = await raw(http, server, c.data, { expectResponses: c.responses.length });
|
||||
expect(r.outcome, c.name).toBe('response');
|
||||
expect(r.responses.map((x) => x.status), c.name).toEqual(c.responses.map((x) => x.status));
|
||||
r.responses.forEach((res, i) => {
|
||||
const e = c.responses[i];
|
||||
if (e.go) expect(res.header('content-type'), `${c.name}: Go plain-text response`).toBe('text/plain; charset=utf-8');
|
||||
else if (e.status === 200) expectContainersResponse(res);
|
||||
else expectErrorResponse(res, e.status as 401 | 404 | 405);
|
||||
if (!e.go) appResponses++;
|
||||
expectNoTokenFragments(res.bodyText + JSON.stringify(res.headers), secrets, `${c.name} response`);
|
||||
});
|
||||
audited.push(...c.audits);
|
||||
});
|
||||
}
|
||||
// the server survived everything
|
||||
expectContainersResponse(await http.get(server.url(), { token: U }));
|
||||
audited.push(U);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const logs = server.logs();
|
||||
expect(logs.requests().length, 'Go-rejected requests never reach the app log').toBe(appResponses + 1);
|
||||
expect(readOperations(server.dbPath).map((o) => o.token), 'exactly the authenticated requests were audited, in order').toEqual(audited);
|
||||
expect(server.stderr).not.toContain('QQQQQQQQQQQQQQQQ');
|
||||
expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
||||
expectNoTokenFragments(server.stderr, secrets, 'server log');
|
||||
assertDbInvariants(server.dbPath, { tokenCount: 3, operationCount: audited.length });
|
||||
});
|
||||
|
||||
test('path normalization tricks and URL-encoding variations: only the exact endpoint after percent-decoding is served, nothing redirects', async ({ cli, startServer, http, secrets }) => {
|
||||
const T = await cli.tokenNew('paths');
|
||||
const server = await startServer();
|
||||
server.fakeDocker!.many(1);
|
||||
const targets: Array<[string, 200 | 404 | 400]> = [
|
||||
[ENDPOINT, 200],
|
||||
['/api/v1/%63ontainers', 200],
|
||||
['/%61%70%69/%76%31/%63%6f%6e%74%61%69%6e%65%72%73', 200],
|
||||
['/api/v1/containers?x=../../etc/passwd', 200],
|
||||
['/api/v1/containers?%00', 200],
|
||||
['/api/v1/containers?', 200],
|
||||
['/api/v1/containers?a=%zz', 200],
|
||||
['http://localhost/api/v1/containers', 200],
|
||||
['http://evil.example:1/api/v1/containers?x=1', 200],
|
||||
['/api/v1/containers/', 404],
|
||||
['/api/v1/containers/.', 404],
|
||||
['/api/v1/./containers', 404],
|
||||
['/api/v1/x/../containers', 404],
|
||||
['/api/v1/x/%2e%2e/containers', 404],
|
||||
['/api/v1/x/%2E%2E%2Fcontainers', 404],
|
||||
['/api/v1%2Fcontainers', 404],
|
||||
['/api%2Fv1%2Fcontainers', 404],
|
||||
['/api/v1/%2563ontainers', 404],
|
||||
['/api/v1/%c0%aecontainers', 404],
|
||||
['/api/v1/containers%00', 404],
|
||||
['/api/v1/containers%3F', 404],
|
||||
['/api/v1/containers%23', 404],
|
||||
['/api/v1/containers;jsessionid=1', 404],
|
||||
['/api/v1/containers..;/', 404],
|
||||
['/api/v1/containers#frag', 404],
|
||||
['//api/v1/containers', 404],
|
||||
['/api//v1/containers', 404],
|
||||
['/API/v1/containers', 404],
|
||||
['/api/v1/CONTAINERS', 404],
|
||||
['/api/v1/%43ontainers', 404],
|
||||
['/api/v1/\u{ff43}ontainers', 404],
|
||||
['/api/v1/%EF%BD%83ontainers', 404],
|
||||
['/api\\v1\\containers', 404],
|
||||
['/api/v1/containers\\', 404],
|
||||
['/api/v1/containers%20', 404],
|
||||
['/api/v1/containers.', 404],
|
||||
['*', 404],
|
||||
['/api/v1/%u0063ontainers', 400],
|
||||
['/api/v1/containers%', 400],
|
||||
['/api/v1/containers%zz', 400],
|
||||
];
|
||||
let audits = 0;
|
||||
let reached = 0;
|
||||
for (const [target, status] of targets) {
|
||||
await test.step(`${target} → ${status} (and 405 / 401 variants)`, async () => {
|
||||
if (target.startsWith('/')) expect(routeOf(target), 'the oracle agrees with the table').toBe(status === 200 ? 'endpoint' : status === 404 ? 'other' : 'bad');
|
||||
const variants: Array<[string, Buffer, number]> = [
|
||||
['GET + token', req([`Authorization: Bearer ${T}`], { target }), status],
|
||||
['POST + token', req([`Authorization: Bearer ${T}`], { target, method: 'POST' }), status === 200 ? 405 : status],
|
||||
['GET, no token', req([], { target }), status === 200 ? 401 : status],
|
||||
];
|
||||
for (const [name, data, st] of variants) {
|
||||
const res = onlyResponse(await raw(http, server, data), `${target} ${name}`);
|
||||
expect(res.status, `${target} ${name}`).toBe(st);
|
||||
expect(res.header('location'), 'never redirected').toBeUndefined();
|
||||
if (st === 400) expect(res.header('content-type')).toBe('text/plain; charset=utf-8');
|
||||
else {
|
||||
reached++;
|
||||
if (st === 200) expectContainersResponse(res);
|
||||
else expectErrorResponse(res, st as 401 | 404 | 405);
|
||||
}
|
||||
if (st === 200) audits++;
|
||||
expectNoTokenFragments(res.bodyText, secrets, 'response');
|
||||
}
|
||||
// curl --path-as-is agrees for targets curl can carry verbatim
|
||||
if (/^\/[A-Za-z0-9\-._~%/?=&;:@!$'()*+,]*$/.test(target) && !target.includes('%00') && status !== 400) {
|
||||
const c = await http.curl({ url: server.url(target), token: T, pathAsIs: true });
|
||||
expect(c.exitCode).toBe(0);
|
||||
expect(c.status, `curl ${target}`).toBe(status);
|
||||
expect(c.headers['location']).toBeUndefined();
|
||||
reached++;
|
||||
if (status === 200) audits++;
|
||||
}
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests().length).toBe(reached);
|
||||
assertDbInvariants(server.dbPath, { tokenCount: 1, operationCount: audits });
|
||||
});
|
||||
|
||||
test('SQL injection in every user-controlled field (descriptions, delete argument, bearer token, query): schema and rows intact, payloads stored verbatim', async ({ cli, ws, startServer, http, secrets }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
const keep = model.add(await cli.tokenNew('keep'), 'keep').token;
|
||||
const revoked = model.add(await cli.tokenNew('revoked'), 'revoked').token;
|
||||
await cli.tokenDelete(revoked);
|
||||
model.deactivate(revoked);
|
||||
const attachTarget = ws.path('attached.db');
|
||||
const payloads = [...SQL_PAYLOADS, `'; ATTACH DATABASE '${attachTarget}' AS x; CREATE TABLE x.t(a); --`, `${keep}' OR '1'='1`, `${keep}'--`, `' OR token LIKE '%`, `x'); UPDATE tokens SET active=1 WHERE token='${revoked}'; --`];
|
||||
|
||||
await test.step('descriptions: stored byte-exact, listed Go-quoted, nothing executed', async () => {
|
||||
for (const p of payloads) model.add(await cli.tokenNew(p), p);
|
||||
model.verifyDb(ws.dbPath);
|
||||
const rows = await cli.tokenList();
|
||||
model.verifyList(rows);
|
||||
for (const p of payloads) expect(rows.some((r) => r.descriptionQuoted === goQuote(p)), `listed: ${p.slice(0, 40)}`).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('delete argument: every payload is "token not found", state byte-identical', async () => {
|
||||
const before = stateDigest(ws.dbPath);
|
||||
for (const p of payloads) {
|
||||
const r = await cli.run(['token', 'delete', p]);
|
||||
expectCliFailure(r, { exitCode: 1, message: 'token not found' });
|
||||
expect(r.stderr.includes(keep), 'token never echoed').toBe(false);
|
||||
}
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
});
|
||||
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(1);
|
||||
await test.step('bearer token and query string: unknown token, never a match', async () => {
|
||||
for (const p of payloads) {
|
||||
const r = onlyResponse(await raw(http, server, req([`Authorization: Bearer ${p}`])), p);
|
||||
expectErrorResponse(r, 401);
|
||||
expectNoTokenFragments(r.bodyText, secrets, 'response');
|
||||
const q = await http.get(server.url(`${ENDPOINT}?token=${encodeURIComponent(p)}&filter=${encodeURIComponent(p)}`), { token: keep });
|
||||
expectContainersResponse(q);
|
||||
model.audit(keep);
|
||||
}
|
||||
// the revoked token stays revoked despite the UPDATE payloads
|
||||
expectErrorResponse(await http.get(server.url(), { token: revoked }), 401);
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const classes = server.logs().filter({ msg: 'authentication result' }).map((r) => r.attrs.result);
|
||||
expect(classes.filter((c) => c === 'unknown_token')).toHaveLength(payloads.length);
|
||||
expect(classes.filter((c) => c === 'inactive_token')).toHaveLength(1);
|
||||
expect(fs.existsSync(attachTarget), 'ATTACH payload was never executed').toBe(false);
|
||||
expect(readSchema(ws.dbPath).tables).toEqual(['operations', 'tokens']);
|
||||
expect(readToken(ws.dbPath, revoked)!.active).toBe(0);
|
||||
model.verifyDb(ws.dbPath, 'all payloads stored verbatim, no row altered');
|
||||
});
|
||||
|
||||
test('shell metacharacters, control characters, log/list forging and Unicode confusables in descriptions; NUL where the platform permits it', async ({ cli, ws, startServer, http, secrets, rng }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
const canary = ws.path('pwned');
|
||||
const shellish = [`$(touch ${canary})`, `\`touch ${canary}\``, `; touch ${canary}`, `| touch ${canary}`, `&& touch ${canary}`, `\ntouch ${canary}`, `'; touch ${canary}; '`, `"; touch ${canary}; "`, `\${IFS}touch\${IFS}${canary}`];
|
||||
const forging = [
|
||||
'x\ntime=2026-01-01T00:00:00.000Z level=ERROR msg="forged record" origin=6.6.6.6',
|
||||
'\r\nlevel=ERROR msg=forged',
|
||||
'" level=ERROR msg="forged',
|
||||
'ok\x1b[2K\x1b[1Glevel=ERROR msg=forged\x1b[0m',
|
||||
'a\tfalse\t2020-01-01T00:00:00Z\t2020-01-02T00:00:00Z',
|
||||
'row\nFAKE.TOKEN\t""\ttrue\t2020-01-01T00:00:00Z\t-',
|
||||
// forging the padded table: a closing quote and padded columns inside the description
|
||||
'a" false 2020-01-01T00:00:00Z 2020-01-02T00:00:00Z',
|
||||
'"' + ' '.repeat(40) + 'true 2020-01-01T00:00:00Z -',
|
||||
'\x08\x08\x08\x08\x7f\x07\x0c\x0b',
|
||||
'tab\there\u{2028}ls\u{2029}ps\u{85}nel',
|
||||
];
|
||||
const confusables = ['\u{430}dmin', 'admin', 'p\u{430}yp\u{430}l', '\u{202e}lanimret\u{202c}', '\u{ff21}\u{ff24}\u{ff2d}\u{ff29}\u{ff2e}', 'e\u{301}', '\u{e9}'];
|
||||
|
||||
await test.step('created through argv and through a real shell with quoting: stored verbatim, never executed', async () => {
|
||||
for (const d of [...shellish, ...forging, ...confusables]) model.add(await cli.tokenNew(d), d);
|
||||
for (const d of shellish) {
|
||||
const r = await cli.shell(`"$DPSAPI_BIN" token new ${shellQuote(d)}`);
|
||||
expectCliSuccess(r, { stderr: '' });
|
||||
model.add(secrets.register(r.stdout.trim()), d);
|
||||
}
|
||||
expect(fs.existsSync(canary), 'no description was ever executed').toBe(false);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
await test.step('token list stays one strictly parseable row per token at INFO and at TRACE', async () => {
|
||||
model.verifyList(await cli.tokenList());
|
||||
model.verifyList(await cli.tokenListAll(), 'token list all', 'all');
|
||||
// plain `token list` filters in SQL: one `row read` per active token only
|
||||
const active = await cli.run(['token', 'list'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||
expectCliSuccess(active);
|
||||
model.verifyList(parseTokenList(active.stdout), 'token list at TRACE');
|
||||
expect(parseLogs(active.stderr).filter({ msg: 'row read' }), 'token list reads only the active rows').toHaveLength(model.active().length);
|
||||
const r = await cli.run(['token', 'list', 'all'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||
expectCliSuccess(r);
|
||||
model.verifyList(parseTokenList(r.stdout), 'token list all at TRACE', 'all');
|
||||
const logs = parseLogs(r.stderr);
|
||||
expect(logs.unparsed, 'every TRACE line is a single well-formed record').toEqual([]);
|
||||
expect(logs.count({ level: 'ERROR' }), 'no forged ERROR record').toBe(0);
|
||||
expect(logs.has({ msg: /forged/ })).toBe(false);
|
||||
const read = logs.filter({ msg: 'row read' });
|
||||
expect(read).toHaveLength(model.tokens.size);
|
||||
for (const d of forging) expect(read.some((x) => x.attrs.description === d), `description logged as one quoted attribute: ${JSON.stringify(d)}`).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('TRACE server: authenticating with every token logs its description safely', async () => {
|
||||
const server = await startServer({ logLevel: 'TRACE' });
|
||||
for (const t of model.active()) {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const logs = server.logs();
|
||||
expect(logs.unparsed).toEqual([]);
|
||||
expect(logs.count({ level: 'ERROR' })).toBe(0);
|
||||
expect(logs.has({ msg: /forged/ })).toBe(false);
|
||||
expect(logs.requests().every((x) => x.attrs.origin === '127.0.0.1')).toBe(true);
|
||||
expect(fs.existsSync(canary)).toBe(false);
|
||||
});
|
||||
|
||||
await test.step('Unicode confusables are distinct tokens/descriptions: confusable token variants never match', async () => {
|
||||
const target = model.active()[0];
|
||||
const before = stateDigest(ws.dbPath);
|
||||
for (const m of nearMatches(target, rng, 400).filter((x) => x.kind === 'confusable' || x.kind === 'zero-width').slice(0, 10)) {
|
||||
expectCliFailure(await cli.run(['token', 'delete', m.value]), { exitCode: 1, message: 'token not found' });
|
||||
}
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
const rows = await cli.tokenList();
|
||||
expect(rows.filter((r) => r.description === 'admin')).toHaveLength(1);
|
||||
expect(rows.filter((r) => r.description === '\u{430}dmin')).toHaveLength(1);
|
||||
});
|
||||
|
||||
await test.step('NUL: argv/env cannot carry it (platform), HTTP rejects or does not match it', async () => {
|
||||
const before = stateDigest(ws.dbPath);
|
||||
// execve(2) takes NUL-terminated strings: Node refuses to spawn, the binary never runs
|
||||
await expect(cli.run(['token', 'new', 'a\x00b'])).rejects.toThrow(/harness:spawn|ERR_INVALID_ARG_VALUE|null bytes/);
|
||||
await expect(cli.run(['token', 'list'], { env: { LOG_LEVEL: 'INFO\x00' } })).rejects.toThrow(/harness:spawn|ERR_INVALID_ARG_VALUE|null bytes/);
|
||||
// a shell drops NUL from command substitution: the stored description has no NUL
|
||||
const sh = await cli.shell(`"$DPSAPI_BIN" token new "$(printf 'a\\000b')"`);
|
||||
expectCliSuccess(sh);
|
||||
const tok = sh.stdout.trim();
|
||||
expect(readToken(ws.dbPath, tok)!.descriptionHex).toBe('6162');
|
||||
model.add(tok, 'ab');
|
||||
expect(stateDigest(ws.dbPath)).not.toBe(before);
|
||||
const server = await startServer();
|
||||
const t = model.active()[0];
|
||||
expect(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}\x00`])), 'nul header').status).toBe(400);
|
||||
expect(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t.slice(0, 20)}%00${t.slice(20)}`])), 'pct nul').status).toBe(401);
|
||||
expectErrorResponse(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}`], { target: `${ENDPOINT}%00` })), 'nul path'), 404);
|
||||
expectContainersResponse(onlyResponse(await raw(http, server, req([`Authorization: Bearer ${t}`], { target: `${ENDPOINT}?a=%00` })), 'nul query'));
|
||||
model.audit(t);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
});
|
||||
model.verifyDb(ws.dbPath, 'final state');
|
||||
});
|
||||
|
||||
test('forwarding headers cannot spoof the audited origin (IPv4 and IPv6 listeners, 40 spoof variants each)', async ({ cli, ws, startServer, http, rng }) => {
|
||||
const T = await cli.tokenNew('origin');
|
||||
const spoofs = ['X-Forwarded-For', 'Forwarded', 'X-Real-IP', 'True-Client-IP', 'CF-Connecting-IP', 'X-Client-IP', 'X-Originating-IP', 'X-Remote-Addr', 'X-Remote-IP', 'Client-IP', 'X-Cluster-Client-IP', 'Fastly-Client-IP', 'X-ProxyUser-Ip', 'Via'];
|
||||
const values = ['6.6.6.6', '6.6.6.6, 127.0.0.1', 'for=6.6.6.6;proto=https', 'for="[2001:db8::6]:1"', 'unknown', '::1', '127.0.0.1', '10.0.0.1, 10.0.0.2, 10.0.0.3', '<script>', `'; DROP TABLE operations; --`];
|
||||
let total = 0;
|
||||
for (const [addr, origin] of [['127.0.0.1', '127.0.0.1'], ['::1', '::1']] as const) {
|
||||
const server = await startServer({ addr });
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const lines = Array.from({ length: rng.int(1, 4) }, () => `${rng.pick(spoofs)}: ${rng.pick(values)}`);
|
||||
expectContainersResponse(onlyResponse(await raw(http, server, req([...lines, `Authorization: Bearer ${T}`])), lines.join('; ')));
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
total += 40;
|
||||
const ops = readOperations(server.dbPath);
|
||||
expect(ops).toHaveLength(total);
|
||||
expect(ops.slice(-40).every((o) => o.origin === origin), `every audited origin is the TCP peer ${origin}`).toBe(true);
|
||||
expect(server.logs().requests().every((r) => r.attrs.origin === origin)).toBe(true);
|
||||
}
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 80 });
|
||||
});
|
||||
|
||||
test('concurrent token deletion versus authenticated requests: no request started after a deletion succeeds; audit rows equal the 200s exactly', async ({ cli, ws, startServer, http, rng }) => {
|
||||
test.setTimeout(90_000);
|
||||
const victims = await Promise.all(Array.from({ length: 8 }, (_, i) => cli.tokenNew(`victim ${i}`)));
|
||||
const bystanders = await Promise.all(Array.from({ length: 2 }, (_, i) => cli.tokenNew(`bystander ${i}`)));
|
||||
const server = await startServer();
|
||||
server.fakeDocker!.many(1);
|
||||
interface Sample { token: string; start: number; end: number; endWall: number; status: number }
|
||||
const samples: Sample[] = [];
|
||||
const deletedAt = new Map<string, number>(); // performance.now() when `token delete` exited
|
||||
const stopAfter = new Map<string, number>(); // requests still to send after deletion
|
||||
let stopping = false;
|
||||
|
||||
const loop = async (token: string) => {
|
||||
let after = 0;
|
||||
while (!stopping) {
|
||||
const start = performance.now();
|
||||
const r = await http.get(server.url(), { token });
|
||||
const end = performance.now();
|
||||
samples.push({ token, start, end, endWall: Date.now(), status: r.status });
|
||||
if (r.status === 200) expectContainersResponse(r);
|
||||
else expectErrorResponse(r, 401);
|
||||
if (deletedAt.has(token) && start > deletedAt.get(token)! && ++after >= (stopAfter.get(token) ?? 5)) return;
|
||||
}
|
||||
};
|
||||
const guard = <T,>(p: Promise<T>) => p.catch((e) => {
|
||||
stopping = true; // a failed worker must not leave the others running until the test timeout
|
||||
throw e;
|
||||
});
|
||||
const loops = [...victims.flatMap((v) => Array.from({ length: 6 }, () => loop(v))), ...bystanders.map((b) => loop(b))].map(guard);
|
||||
const deleter = (async () => {
|
||||
for (const v of rng.shuffle(victims)) {
|
||||
const warmup = rng.int(5, 30);
|
||||
await pollUntil('some traffic for the victim', () => (stopping || samples.filter((s) => s.token === v).length >= warmup ? true : undefined), { timeoutMs: 20_000 });
|
||||
if (stopping) return;
|
||||
stopAfter.set(v, rng.int(3, 8));
|
||||
await cli.tokenDelete(v);
|
||||
deletedAt.set(v, performance.now());
|
||||
}
|
||||
await pollUntil('every victim loop finished', () => (stopping || victims.every((v) => samples.filter((s) => s.token === v && s.start > deletedAt.get(v)!).length >= 18) ? true : undefined), { timeoutMs: 30_000 });
|
||||
stopping = true;
|
||||
})();
|
||||
const settled = await Promise.allSettled([guard(deleter), ...loops]);
|
||||
const failed = settled.find((x) => x.status === 'rejected');
|
||||
if (failed) throw (failed as PromiseRejectedResult).reason;
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
|
||||
const ops = readOperations(ws.dbPath);
|
||||
let racing = 0;
|
||||
for (const v of victims) {
|
||||
const mine = samples.filter((s) => s.token === v);
|
||||
const del = deletedAt.get(v)!;
|
||||
const row = readToken(ws.dbPath, v)!;
|
||||
expect(row.active).toBe(0);
|
||||
expect(mine.filter((s) => s.start > del && s.status !== 401), `${tokenId(v)}: requests started after the deletion returned must all be 401`).toEqual([]);
|
||||
const oks = mine.filter((s) => s.status === 200);
|
||||
expect(oks.every((s) => s.start < del), 'every 200 was started before the deletion completed').toBe(true);
|
||||
const audits = ops.filter((o) => o.token === v);
|
||||
expect(audits.length, `${tokenId(v)}: one audit row per 200, none for 401s`).toBe(oks.length);
|
||||
// A request authenticated (read active=1) before the deactivation commit may insert its audit row
|
||||
// after deleted_at: the app checks then inserts without a common transaction (README: authentication,
|
||||
// then audit insert). The exact invariant is therefore bounded by the request itself: each audit row
|
||||
// precedes the end of the last successful response.
|
||||
const lastOkEnd = Math.max(...oks.map((s) => s.endWall), 0);
|
||||
for (const a of audits) expect(rfc3339ToNanos(a.time) <= msToNanos(lastOkEnd + 1), 'audit row written before its response completed').toBe(true);
|
||||
racing += audits.filter((a) => rfc3339ToNanos(a.time) > rfc3339ToNanos(row.deleted_at!)).length;
|
||||
}
|
||||
for (const b of bystanders) {
|
||||
const mine = samples.filter((s) => s.token === b);
|
||||
expect(mine.every((s) => s.status === 200), 'bystanders were never refused').toBe(true);
|
||||
expect(ops.filter((o) => o.token === b)).toHaveLength(mine.length);
|
||||
}
|
||||
test.info().annotations.push({ type: 'race', description: `${samples.length} requests; ${racing} audit row(s) written after their token's deleted_at by requests authenticated before the commit` });
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 10, operationCount: samples.filter((s) => s.status === 200).length });
|
||||
});
|
||||
|
||||
test('forced interleaving: a request authenticated while a deactivation is queued is still answered 200 and audited exactly once (the row may postdate deleted_at)', async ({ cli, ws, startServer, http }) => {
|
||||
test.setTimeout(60_000);
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(1);
|
||||
let afterDeletion = 0;
|
||||
let deleteReturnedFirst = 0;
|
||||
const N = 6;
|
||||
for (let i = 0; i < N; i++) {
|
||||
await test.step(`iteration ${i}`, async () => {
|
||||
const t = await cli.tokenNew(`interleave ${i}`);
|
||||
// a test-owned write lock parks the request between its token lookup and its audit insert
|
||||
const db = new DatabaseSync(ws.dbPath, { timeout: 2000 });
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
let released = false;
|
||||
try {
|
||||
let reqEnd = 0;
|
||||
let delEnd = 0;
|
||||
const reqP = http.get(server.url(), { token: t, timeoutMs: 20_000 }).then((x) => ((reqEnd = performance.now()), x));
|
||||
await server.waitForLog({ msg: 'authentication result', attrs: { result: 'ok', token_id: tokenId(t) } });
|
||||
// let the parked insert reach SQLite's long busy back-off (~100 ms sleeps), then queue the
|
||||
// deletion just before releasing: its short early polls usually win the lock
|
||||
await new Promise((res) => setTimeout(res, 1200));
|
||||
const delP = cli.run(['token', 'delete', t]).then((x) => ((delEnd = performance.now()), x)); // BEGIN IMMEDIATE: queued behind the same lock
|
||||
await new Promise((res) => setTimeout(res, 40));
|
||||
db.exec('ROLLBACK');
|
||||
db.close();
|
||||
released = true;
|
||||
const [res, del] = await Promise.all([reqP, delP]);
|
||||
// the authentication decision was taken while the token was active: it stands
|
||||
expectContainersResponse(res);
|
||||
expect(del.exitCode).toBe(0);
|
||||
if (delEnd < reqEnd) deleteReturnedFirst++;
|
||||
} finally {
|
||||
if (!released) {
|
||||
db.exec('ROLLBACK');
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
const row = readToken(ws.dbPath, t)!;
|
||||
expect(row.active).toBe(0);
|
||||
const audit = readOperations(ws.dbPath).filter((o) => o.token === t);
|
||||
expect(audit, 'exactly one audit row for the one 200').toHaveLength(1);
|
||||
if (rfc3339ToNanos(audit[0].time) > rfc3339ToNanos(row.deleted_at!)) afterDeletion++;
|
||||
// and from now on the token is refused
|
||||
expectErrorResponse(await http.get(server.url(), { token: t }), 401);
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
// Spec ambiguity (reported): authentication and the audit insert are not one transaction. Observed:
|
||||
// `token delete` can return (exit 0) before an already-authenticated request gets its 200, and that
|
||||
// request's audit row is committed after the deactivation — yet its `time` is stamped before the
|
||||
// insert waits for the lock, so time <= deleted_at still holds here. Structural invariants hold either way.
|
||||
test.info().annotations.push({ type: 'interleaving', description: `${afterDeletion}/${N} audit rows written after deleted_at; ${deleteReturnedFirst}/${N} times \`token delete\` returned before the 200 arrived` });
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: N, operationCount: N });
|
||||
});
|
||||
|
||||
test('audit argument attacks: SQL injection, shell metacharacters, huge, control-character and confusable arguments are usage errors — nothing echoed, executed or written; audit reveals only the stored rows', async ({ cli, ws, startServer, http, secrets }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
// descriptions that try to forge audit lines / extra columns (audit never prints descriptions)
|
||||
const forged = `x\t127.0.0.1\tGET containers\n2026-01-01T00:00:00Z\tFAKE-TOKEN\t6.6.6.6\tGET containers`;
|
||||
const T = model.add(await cli.tokenNew(forged), forged).token;
|
||||
const R = model.add(await cli.tokenNew('revoked\nTIME\tTOKEN\tORIGIN\tOPERATION'), 'revoked\nTIME\tTOKEN\tORIGIN\tOPERATION').token;
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
for (const t of [T, R, T, R, T]) {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
await cli.tokenDelete(R);
|
||||
model.deactivate(R);
|
||||
const canary = ws.path('pwned');
|
||||
const attachTarget = ws.path('attached.db');
|
||||
const payloads = [
|
||||
...SQL_PAYLOADS,
|
||||
'10; DROP TABLE operations',
|
||||
'10 OR 1=1',
|
||||
`10' OR '1'='1`,
|
||||
'1); DELETE FROM operations; --',
|
||||
'10 UNION SELECT token, description, active, created_at FROM tokens',
|
||||
`1h'; ATTACH DATABASE '${attachTarget}' AS x; --`,
|
||||
'10)--',
|
||||
'(SELECT 10)',
|
||||
`$(touch ${canary})`,
|
||||
`\`touch ${canary}\``,
|
||||
`10; touch ${canary}`,
|
||||
`10 | touch ${canary}`,
|
||||
`10 && touch ${canary}`,
|
||||
'10\n11',
|
||||
'10\r',
|
||||
'10\x1b[2K\x1b[1G',
|
||||
'\x1b]0;title\x07',
|
||||
'10\x7f',
|
||||
'\x0810',
|
||||
'10',
|
||||
'10',
|
||||
'10s',
|
||||
'10ѕ',
|
||||
'1O',
|
||||
'l0',
|
||||
'10',
|
||||
'%s',
|
||||
'%n',
|
||||
'${jndi:ldap://x}',
|
||||
'../../etc/passwd',
|
||||
'-1',
|
||||
'-9223372036854775808',
|
||||
'9223372036854775808',
|
||||
'18446744073709551615w',
|
||||
'1' + '0'.repeat(200) + 's',
|
||||
'9'.repeat(100_000),
|
||||
'9'.repeat(131_071),
|
||||
'w'.repeat(10_000),
|
||||
];
|
||||
for (const p of payloads) expect(auditArgsOutcome([p]), JSON.stringify(p.slice(0, 40))).toEqual({ ok: false, message: 'invalid audit argument' });
|
||||
const usage = `dpsapi: usage error: invalid audit argument\n${USAGE_TEXT}`;
|
||||
const wal = `${ws.dbPath}-wal`;
|
||||
const before = { db: fileDigest(ws.dbPath), wal: fileDigest(wal), state: stateDigest(ws.dbPath) };
|
||||
|
||||
await test.step(`${payloads.length} hostile arguments through argv (server running)`, async () => {
|
||||
await mapLimit(payloads, 8, async (p) => {
|
||||
const r = await cli.run(['audit', p]);
|
||||
expectCliFailure(r, { exitCode: 2, message: 'usage error: invalid audit argument' });
|
||||
expect(r.stderr, 'exact stderr: the argument is never echoed').toBe(usage);
|
||||
});
|
||||
// more than one argument, also hostile
|
||||
for (const extra of [['10', `; touch ${canary}`], ['1', "' OR '1'='1"], ['', ''], ['10', 'h', 'x']]) {
|
||||
const r = await cli.run(['audit', ...extra]);
|
||||
expect(r.stderr).toBe(`dpsapi: usage error: audit takes at most one argument\n${USAGE_TEXT}`);
|
||||
expect(r.exitCode).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('through a real shell (quoted payloads, invalid UTF-8 bytes); NUL cannot reach argv', async () => {
|
||||
for (const p of [`$(touch ${canary})`, `\`touch ${canary}\``, `10; touch ${canary}`, "10' OR '1'='1"]) {
|
||||
const r = await cli.shell(`"$DPSAPI_BIN" audit ${shellQuote(p)}`);
|
||||
expectCliFailure(r, { exitCode: 2, message: 'usage error: invalid audit argument' });
|
||||
}
|
||||
const bytes = await cli.shell(`"$DPSAPI_BIN" audit "$(printf '1\\377h')"`);
|
||||
expect(bytes.stderr).toBe(usage);
|
||||
expect(bytes.exitCode).toBe(2);
|
||||
await expect(cli.run(['audit', '1\x00'])).rejects.toThrow(/harness:spawn|ERR_INVALID_ARG_VALUE|null bytes/);
|
||||
});
|
||||
|
||||
await test.step('nothing executed, attached or written', async () => {
|
||||
expect(fs.existsSync(canary), 'no payload was executed').toBe(false);
|
||||
expect(fs.existsSync(attachTarget), 'no ATTACH was executed').toBe(false);
|
||||
expect(fileDigest(ws.dbPath), 'database file bytes').toBe(before.db);
|
||||
expect(fileDigest(wal), 'WAL bytes').toBe(before.wal);
|
||||
expect(stateDigest(ws.dbPath)).toBe(before.state);
|
||||
expect(readSchema(ws.dbPath).tables).toEqual(['operations', 'tokens']);
|
||||
});
|
||||
|
||||
await test.step('a valid audit reveals exactly the stored rows: four columns, no descriptions, no forged lines; tokens only on stdout', async () => {
|
||||
const r = await cli.run(['audit', '100'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||
expectCliSuccess(r);
|
||||
expect(r.stdout).toBe(expectedAuditStdout(readOperations(ws.dbPath), { limit: 100n }));
|
||||
const rows = parseAuditOutput(r.stdout);
|
||||
expect(rows.map((x) => x.token)).toEqual([T, R, T, R, T]);
|
||||
expect(r.stdout.includes('FAKE-TOKEN') || r.stdout.includes('6.6.6.6') || r.stdout.includes('revoked'), 'descriptions never printed').toBe(false);
|
||||
expectNoTokenFragments(r.stderr, secrets, 'audit TRACE stderr');
|
||||
expect(parseLogs(r.stderr).unparsed).toEqual([]);
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests(), 'no CLI invocation reached the server').toHaveLength(5);
|
||||
model.verifyDb(ws.dbPath, 'after the audit attacks');
|
||||
});
|
||||
|
||||
test('database lock contention: a foreign writer holding the write lock — writes wait up to the 5 s busy timeout then fail cleanly, readers never block, nothing is corrupted', async ({ cli, ws, startServer, http, secrets, e2e }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
const T = model.add(await cli.tokenNew('lock'), 'lock').token;
|
||||
const V = model.add(await cli.tokenNew('victim'), 'victim').token;
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(1);
|
||||
expectContainersResponse(await http.get(server.url(), { token: T }));
|
||||
model.audit(T);
|
||||
e2e.trackedDbs.add(ws.dbPath);
|
||||
|
||||
// Test-owned writer connection (the only scenario allowed to open the app DB for writing).
|
||||
// It never modifies anything: it only holds the RESERVED lock of a BEGIN IMMEDIATE transaction.
|
||||
const holdLock = (ms: number): Promise<void> => {
|
||||
const db = new DatabaseSync(ws.dbPath, { timeout: 2000 });
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
db.exec('ROLLBACK');
|
||||
db.close();
|
||||
resolve();
|
||||
}, ms),
|
||||
);
|
||||
};
|
||||
|
||||
await test.step('short lock (1.5 s): the request and a CLI write wait for it and then succeed', async () => {
|
||||
const released = holdLock(1500);
|
||||
const [r, created] = await Promise.all([http.get(server.url(), { token: T }), cli.run(['token', 'new', 'during short lock'])]);
|
||||
await released;
|
||||
expectContainersResponse(r);
|
||||
expect(r.durationMs, 'the audit insert waited for the lock').toBeGreaterThan(1200);
|
||||
model.audit(T);
|
||||
expectCliSuccess(created, { stderr: '' });
|
||||
expect(created.durationMs).toBeGreaterThan(1200);
|
||||
model.add(created.stdout.trim(), 'during short lock');
|
||||
});
|
||||
|
||||
await test.step('long lock (7 s): writes fail after ~5 s with clean errors; reads and unauthenticated requests are not blocked', async () => {
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const released = holdLock(7000);
|
||||
const t0 = performance.now();
|
||||
const opsBefore = readOperations(ws.dbPath);
|
||||
const [authed, created, deleted, listed, audited, unauth, unknown] = await Promise.all([
|
||||
http.get(server.url(), { token: T, timeoutMs: 20_000 }),
|
||||
cli.run(['token', 'new', 'during long lock']),
|
||||
cli.run(['token', 'delete', V]),
|
||||
cli.run(['token', 'list']),
|
||||
cli.run(['audit', '100']),
|
||||
http.get(server.url()),
|
||||
http.get(server.url('/elsewhere'), { token: T }),
|
||||
]);
|
||||
const elapsed = performance.now() - t0;
|
||||
await released;
|
||||
// authenticated request: token lookup (a read) succeeds, the audit insert times out → 500
|
||||
expectErrorResponse(authed, 500);
|
||||
expectNoInternalDetails(authed.body);
|
||||
expect(authed.durationMs, 'bounded by the 5 s busy timeout').toBeGreaterThan(4500);
|
||||
expect(authed.durationMs).toBeLessThan(6900);
|
||||
// CLI writes: exit 1 with a single clean error line, no token printed
|
||||
expectCliFailure(created, { exitCode: 1, message: /^insert token: database is locked/ });
|
||||
expectCliFailure(deleted, { exitCode: 1, message: /^begin transaction: database is locked|^query token: database is locked|^update token: database is locked/ });
|
||||
for (const c of [created, deleted]) expect(c.durationMs).toBeGreaterThan(4500);
|
||||
// readers: WAL lets them through immediately
|
||||
expectCliSuccess(listed);
|
||||
expect(listed.durationMs).toBeLessThan(2000);
|
||||
model.verifyList(parseTokenList(listed.stdout));
|
||||
expectCliSuccess(audited, { stdout: expectedAuditStdout(opsBefore, { limit: 100n }), stderr: '' });
|
||||
expect(audited.durationMs, 'audit is a reader: not blocked by the write lock').toBeLessThan(2000);
|
||||
expectErrorResponse(unauth, 401);
|
||||
expect(unauth.durationMs).toBeLessThan(2000);
|
||||
expectErrorResponse(unknown, 404);
|
||||
expect(elapsed).toBeLessThan(9000);
|
||||
expect(stateDigest(ws.dbPath), 'nothing was written while the lock was held').toBe(before);
|
||||
expect(readToken(ws.dbPath, V)!.active, 'the failed deletion left the token active').toBe(1);
|
||||
expectNoTokenFragments(authed.body + created.stderr + deleted.stderr, secrets, 'errors');
|
||||
});
|
||||
|
||||
await test.step('after release everything works again', async () => {
|
||||
expectContainersResponse(await http.get(server.url(), { token: T }));
|
||||
model.audit(T);
|
||||
await cli.tokenDelete(V);
|
||||
model.deactivate(V);
|
||||
expectErrorResponse(await http.get(server.url(), { token: V }), 401);
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const errors = server.logs().filter({ level: 'ERROR' });
|
||||
expect(errors.map((e) => e.msg)).toEqual(['audit insert failed']);
|
||||
expect(errors[0].attrs.error).toMatch(/database is locked/);
|
||||
expect(errors[0].attrs.token_id).toBe(tokenId(T));
|
||||
model.verifyDb(ws.dbPath, 'after contention');
|
||||
});
|
||||
|
||||
test('Docker socket unavailable and deliberate backend failures under concurrent attack traffic: generic 500s, audit still written, exact failure counts, recovery', async ({ cli, ws, startServer, http, secrets }) => {
|
||||
test.setTimeout(90_000);
|
||||
const T = await cli.tokenNew('backend');
|
||||
let audits = 0;
|
||||
const burst = async (server: ServerHandle, n: number) => {
|
||||
const reqs = Array.from({ length: n }, (_, i) => (i % 2 === 0 ? { token: T } : { token: i % 4 === 1 ? 'x'.repeat(43) : undefined }));
|
||||
return mapLimit(reqs, 16, (o) => http.get(server.url(), o));
|
||||
};
|
||||
for (const docker of ['missing', 'inaccessible'] as const) {
|
||||
await test.step(`socket ${docker}: 60 concurrent requests`, async () => {
|
||||
const server = await startServer({ docker });
|
||||
const res = await burst(server, 60);
|
||||
res.forEach((r, i) => {
|
||||
expectErrorResponse(r, i % 2 === 0 ? 500 : 401);
|
||||
expectNoInternalDetails(r.body);
|
||||
});
|
||||
audits += 30;
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().count({ level: 'ERROR', msg: 'docker request failed' })).toBe(30);
|
||||
});
|
||||
}
|
||||
const server = await startServer();
|
||||
const f = server.fakeDocker!;
|
||||
const leaky = `boom at ${ws.dbPath} /var/run/docker.sock SELECT token FROM tokens WHERE token='${'Z'.repeat(43)}' goroutine 1 [running]`;
|
||||
const cases: Array<[string, () => void, number]> = [
|
||||
['Engine 500 carrying paths, SQL and a stack trace', () => f.error500(leaky), 500],
|
||||
['Engine 401', () => f.error(401, 'unauthorized engine'), 500],
|
||||
['Engine 404 (API version mismatch)', () => f.error(404, 'page not found'), 500],
|
||||
['connection dropped', () => f.destroy(), 500],
|
||||
['truncated JSON', () => f.badJson('[{"Id":"abc"'), 500],
|
||||
['object instead of array', () => f.badJson('{"message":"x"}'), 500],
|
||||
['wrong field types', () => f.badJson('[{"Id":1}]'), 500],
|
||||
['empty body', () => f.badJson(''), 500],
|
||||
// Observed, undefined by the spec: the Docker SDK decodes only the first JSON value of the body,
|
||||
// so `null` and trailing garbage after a valid value are not client errors and yield 200 [].
|
||||
['null body (observed: SDK treats it as no containers)', () => f.badJson('null'), 200],
|
||||
['valid array followed by garbage (observed: trailing bytes ignored by the SDK)', () => f.badJson('[] trailing-garbage'), 200],
|
||||
['slow Engine within the 10 s bound', () => f.empty().delay(1500), 200],
|
||||
];
|
||||
for (const [name, apply, status] of cases) {
|
||||
await test.step(name, async () => {
|
||||
apply();
|
||||
const res = await burst(server, 20);
|
||||
res.forEach((r, i) => {
|
||||
if (i % 2 === 0) {
|
||||
if (status === 200) expect(expectContainersResponse(r)).toEqual([]);
|
||||
else expectErrorResponse(r, 500);
|
||||
} else expectErrorResponse(r, 401);
|
||||
expectNoInternalDetails(r.body);
|
||||
expect(r.body).not.toContain('ZZZZZZZZ');
|
||||
expect(Object.keys(r.headers).filter((h) => /^(api-version|docker-|ostype|server)$/i.test(h))).toEqual([]);
|
||||
expectNoTokenFragments(r.body, secrets, name);
|
||||
});
|
||||
audits += 10;
|
||||
f.delay(0);
|
||||
});
|
||||
}
|
||||
await test.step('temporarily failing Engine under 16-way concurrency: exactly k failures', async () => {
|
||||
f.many(2).flaky(7);
|
||||
const res = await mapLimit(Array.from({ length: 24 }, () => 0), 16, () => http.get(server.url(), { token: T }));
|
||||
expect(res.filter((r) => r.status === 500)).toHaveLength(7);
|
||||
expect(res.filter((r) => r.status === 200)).toHaveLength(17);
|
||||
audits += 24;
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const errs = server.logs().filter({ level: 'ERROR' });
|
||||
expect(errs.every((e) => e.msg === 'docker request failed')).toBe(true);
|
||||
expect(errs).toHaveLength(8 * 10 + 7);
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: audits });
|
||||
});
|
||||
|
||||
test('slow-body client pins a connection during shutdown: SIGTERM waits the documented 15 s, then exits 1 with the shutdown error; new connections are refused meanwhile; the database stays valid', async ({ cli, ws, startServer, http }) => {
|
||||
test.setTimeout(60_000);
|
||||
const T = await cli.tokenNew('slow');
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
expectContainersResponse(await http.get(server.url(), { token: T }));
|
||||
// unauthenticated attacker: declares a 100 kB body, sends 10 bytes and keeps the connection open.
|
||||
// The app answers 405, but before writing it Go drains the declared body of a keep-alive request
|
||||
// (up to 256 KiB, bounded by the 30 s read timeout), so the connection stays active.
|
||||
const stuck = http.raw({ host: server.connectHost, port: server.port, data: `POST ${ENDPOINT} HTTP/1.1\r\nHost: x\r\nContent-Length: 100000\r\n\r\n0123456789`, timeoutMs: 45_000, allowTimeout: true, waitForClose: true });
|
||||
await server.waitForLog({ msg: 'this request has been made', attrs: { method: 'POST', status: 405 } });
|
||||
const t0 = performance.now();
|
||||
const stopping = server.stop('SIGTERM', { timeoutMs: 30_000 });
|
||||
await pollUntil('listener closed', async () => ((await http.raw({ host: server.connectHost, port: server.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n', timeoutMs: 2000, allowTimeout: true })).outcome === 'refused' ? true : undefined), { timeoutMs: 5000 });
|
||||
const exit = await stopping;
|
||||
const elapsed = performance.now() - t0;
|
||||
expect(exit.exitCode, 'documented: in-flight requests not finished within 15 s → exit 1').toBe(1);
|
||||
expect(exit.stderr.trimEnd().split('\n').pop()).toBe('dpsapi: http server shutdown: context deadline exceeded');
|
||||
expect(elapsed).toBeGreaterThan(14_500);
|
||||
expect(elapsed).toBeLessThan(20_000);
|
||||
const s = await stuck;
|
||||
expect(['closed', 'reset', 'response'], 'the stuck connection was torn down by the shutdown').toContain(s.outcome);
|
||||
if (s.outcome === 'response') expect(s.response!.status).toBe(405);
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 1 });
|
||||
// a new server on the same database works normally
|
||||
const again = await startServer({ dbPath: ws.dbPath });
|
||||
expectContainersResponse(await http.get(again.url(), { token: T }));
|
||||
expect((await again.stop()).exitCode).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 "long-running simulated operator session": one live instance, a large seeded sequence
|
||||
* of valid, mistaken, adversarial and randomized actions (CLI incl. `audit`, Playwright, curl, raw
|
||||
* socket, Engine faults, restarts, crashes, short foreign write locks), an in-test model of the expected
|
||||
* state (tokens active/inactive, audit rows per token), and invariant checks every 50 steps and at
|
||||
* the end.
|
||||
*
|
||||
* The session never stops at an expected failure: every step's outcome is checked, application
|
||||
* deviations are collected (with the step number and seed) and the session goes on, so a bad
|
||||
* request or command cannot mask later corruption. Periodic invariant checks, and any harness
|
||||
* error (timeout, signal, hang, transport failure), stop the session immediately.
|
||||
*/
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
errorMessage,
|
||||
expectCliFailure,
|
||||
expectCliSuccess,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
formatAuditOutput,
|
||||
isHarnessError,
|
||||
msToNanos,
|
||||
parseAuditOutput,
|
||||
parseLogs,
|
||||
parseTokenList,
|
||||
readOperations,
|
||||
tokenId,
|
||||
type CurlResult,
|
||||
type HttpResult,
|
||||
type RawHttpResponse,
|
||||
type Rng,
|
||||
} from '../../harness';
|
||||
import {
|
||||
StateModel,
|
||||
TOKEN_CHARS,
|
||||
checkResponse,
|
||||
cliOutcome,
|
||||
configOutcome,
|
||||
expectAuditWindow,
|
||||
expectHttp,
|
||||
expectNoTokenFragments,
|
||||
expectedAuditStdout,
|
||||
fuzzAddr,
|
||||
fuzzAuditArgs,
|
||||
fuzzLogLevel,
|
||||
fuzzPort,
|
||||
fuzzRequest,
|
||||
fuzzText,
|
||||
malformedRequest,
|
||||
matchesConfigError,
|
||||
nearMatches,
|
||||
onlyResponse,
|
||||
rawBytes,
|
||||
stateDigest,
|
||||
AUDIT_ALL_ARGV_NEAR_MISSES,
|
||||
type AppEnv,
|
||||
} from '../../harness/personas';
|
||||
|
||||
type Action =
|
||||
| 'create'
|
||||
| 'use'
|
||||
| 'use-revoked'
|
||||
| 'delete'
|
||||
| 'delete-again'
|
||||
| 'delete-unknown'
|
||||
| 'list'
|
||||
| 'bad-auth'
|
||||
| 'wrong-route'
|
||||
| 'cli-mistake'
|
||||
| 'config-mistake'
|
||||
| 'fuzz-request'
|
||||
| 'malformed-request'
|
||||
| 'backend'
|
||||
| 'containers'
|
||||
| 'restart'
|
||||
| 'crash'
|
||||
| 'short-lock'
|
||||
| 'trace-list'
|
||||
| 'audit'
|
||||
| 'audit-all';
|
||||
|
||||
const WEIGHTS: Array<[Action, number]> = [
|
||||
['create', 9],
|
||||
['use', 24],
|
||||
['use-revoked', 5],
|
||||
['delete', 5],
|
||||
['delete-again', 3],
|
||||
['delete-unknown', 3],
|
||||
['list', 4],
|
||||
['bad-auth', 6],
|
||||
['wrong-route', 4],
|
||||
['cli-mistake', 5],
|
||||
['config-mistake', 3],
|
||||
['fuzz-request', 12],
|
||||
['malformed-request', 4],
|
||||
['backend', 3],
|
||||
['containers', 2],
|
||||
['restart', 1],
|
||||
['crash', 0.7],
|
||||
['short-lock', 1],
|
||||
['trace-list', 1],
|
||||
['audit', 3],
|
||||
['audit-all', 1.5],
|
||||
];
|
||||
|
||||
function pickAction(r: Rng): Action {
|
||||
const total = WEIGHTS.reduce((n, [, w]) => n + w, 0);
|
||||
let x = r.next() * total;
|
||||
for (const [a, w] of WEIGHTS) if ((x -= w) < 0) return a;
|
||||
return 'use';
|
||||
}
|
||||
|
||||
test('operator session: 1000 seeded steps of valid, mistaken, adversarial and random actions on one live instance; invariants every 50 steps', async ({ cli, ws, startServer, http, fuzz, secrets }) => {
|
||||
test.setTimeout(240_000);
|
||||
const r = fuzz.rng.fork('session');
|
||||
const model = new StateModel();
|
||||
const server = await startServer({ logLevel: 'INFO' });
|
||||
const fake = server.fakeDocker!.many(5, r.fork('initial'));
|
||||
let backendOk = true;
|
||||
const deviations: string[] = [];
|
||||
const counts = new Map<Action, number>();
|
||||
let auditAllMistakes = 0;
|
||||
const STEPS = 1000;
|
||||
|
||||
const use = async (token: string, label: string): Promise<void> => {
|
||||
const kind = r.pick(['playwright', 'playwright', 'curl', 'raw']);
|
||||
let res: HttpResult | CurlResult | RawHttpResponse;
|
||||
if (kind === 'playwright') res = await http.get(server.url(), { token });
|
||||
else if (kind === 'curl') res = await http.curl({ url: server.url(), token });
|
||||
else res = onlyResponse(await http.raw({ host: server.connectHost, port: server.port, data: rawBytes({ cls: 'use', method: 'GET', target: '/api/v1/containers', headers: [['Authorization', `Bearer ${token}`]] }) }), label);
|
||||
const exp = expectHttp({ cls: 'use', method: 'GET', target: '/api/v1/containers', headers: [['Authorization', `Bearer ${token}`]] }, (t) => model.lookup(t), backendOk);
|
||||
checkResponse(res, exp, { containers: fake.containers.length, secrets, label });
|
||||
if (exp.audited) model.audit(exp.audited);
|
||||
};
|
||||
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
let action = pickAction(r);
|
||||
if ((action === 'use' || action === 'delete') && model.active().length === 0) action = 'create';
|
||||
if ((action === 'use-revoked' || action === 'delete-again') && model.inactive().length === 0) action = 'create';
|
||||
counts.set(action, (counts.get(action) ?? 0) + 1);
|
||||
const label = `step ${i} (${action})`;
|
||||
const rec: Record<string, unknown> = { i, action };
|
||||
fuzz.recordLive(rec); // filled in by the step below
|
||||
try {
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const d = fuzzText(r, { maxLen: r.bool(0.05) ? 60_000 : 512 }).value;
|
||||
rec.description = d.slice(0, 100);
|
||||
model.add(await cli.tokenNew(d), d);
|
||||
break;
|
||||
}
|
||||
case 'use':
|
||||
await use(r.pick(model.active()), label);
|
||||
break;
|
||||
case 'use-revoked':
|
||||
await use(r.pick(model.inactive()), label);
|
||||
break;
|
||||
case 'delete': {
|
||||
const t = r.pick(model.active());
|
||||
await cli.tokenDelete(t);
|
||||
model.deactivate(t);
|
||||
break;
|
||||
}
|
||||
case 'delete-again':
|
||||
case 'delete-unknown': {
|
||||
const arg = action === 'delete-again' ? r.pick(model.inactive()) : r.bool() && model.all().length ? nearMatches(r.pick(model.all()), r, 1)[0]?.value ?? 'x' : r.string(43, TOKEN_CHARS);
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const o = cliOutcome(['token', 'delete', arg], model);
|
||||
const res = await cli.run(['token', 'delete', arg]);
|
||||
if (o.kind === 'error') expectCliFailure(res, { exitCode: o.exitCode, message: o.message });
|
||||
else throw new Error(`generator produced a valid deletion at ${label}`);
|
||||
expect(stateDigest(ws.dbPath), `${label}: state unchanged`).toBe(before);
|
||||
break;
|
||||
}
|
||||
case 'list':
|
||||
if (r.bool(0.5)) model.verifyList(await cli.tokenList(), label);
|
||||
else model.verifyList(await cli.tokenListAll(), `${label} (all)`, 'all');
|
||||
if (r.bool(0.1)) await cli.help();
|
||||
break;
|
||||
case 'bad-auth': {
|
||||
const t = model.all().length ? r.pick(model.all()) : 'x';
|
||||
const header = r.pick([`Bearer ${r.string(43, TOKEN_CHARS)}`, `Basic ${Buffer.from(`u:${t}`).toString('base64')}`, t, `Bearer ${t}`, `Bearer "${t}"`, 'Bearer', '', `Bearer ${t.toUpperCase()}x`]);
|
||||
rec.header = header.slice(0, 60);
|
||||
const res = onlyResponse(await http.raw({ host: server.connectHost, port: server.port, data: Buffer.from(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: ${header}\r\nConnection: close\r\n\r\n`) }), label);
|
||||
expectErrorResponse(res, 401);
|
||||
break;
|
||||
}
|
||||
case 'wrong-route': {
|
||||
const t = model.active()[0] ?? 'x';
|
||||
if (r.bool()) expectErrorResponse(await http.get(server.url(r.pick(['/', '/api/v1/containers/', '/api/v1/container', '/api/v1/containers/x', '//api/v1/containers'])), { token: t }), 404);
|
||||
else expectErrorResponse(await http.fetch(server.url(), { method: r.pick(['POST', 'PUT', 'DELETE', 'PATCH']), token: t, data: '{}' }), 405);
|
||||
break;
|
||||
}
|
||||
case 'cli-mistake': {
|
||||
const allMiss = r.bool(0.3); // near misses of `audit all`: `audit ALL`, `audit all x`, `audit --all`...
|
||||
const args = allMiss
|
||||
? [...r.pick(AUDIT_ALL_ARGV_NEAR_MISSES)]
|
||||
: r.pick([['token'], ['token', 'lst'], ['tokens'], ['token', 'new', 'a', 'b'], ['token', 'list', 'x'], ['token', 'list', 'ALL'], ['token', 'list', 'all', 'all'], ['help', 'token'], ['Help'], ['token', 'delete'], ['--help'], ['token', 'delete', 'a', 'b'], ['audit', '0'], ['audit', '1', 'h'], ['audit', '1H'], ['audits'], ['audit', 'last']]);
|
||||
rec.args = args;
|
||||
if (allMiss) auditAllMistakes++;
|
||||
const o = cliOutcome(args, model);
|
||||
if (o.kind !== 'error') throw new Error('cli-mistake must fail');
|
||||
const before = stateDigest(ws.dbPath);
|
||||
expectCliFailure(await cli.run(args), { exitCode: o.exitCode, message: o.message, usage: o.usage });
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
break;
|
||||
}
|
||||
case 'config-mistake': {
|
||||
let env: AppEnv = {};
|
||||
let addrValid: boolean | undefined;
|
||||
for (;;) {
|
||||
const a = fuzzAddr(r);
|
||||
env = r.bool() ? { HTTP_ADDR: a.value } : r.bool() ? { HTTP_PORT: fuzzPort(r) } : { LOG_LEVEL: fuzzLogLevel(r) };
|
||||
addrValid = env.HTTP_ADDR === undefined ? undefined : a.valid;
|
||||
if (!configOutcome(env, addrValid).ok) break;
|
||||
}
|
||||
rec.env = env;
|
||||
const cfg = configOutcome(env, addrValid);
|
||||
const res = await cli.run(r.bool() ? [] : ['token', 'new', 'never'], { env, timeoutMs: 10_000 });
|
||||
expectCliFailure(res, { exitCode: 2, usage: false });
|
||||
expect(matchesConfigError(errorMessage(res), cfg as Exclude<typeof cfg, { ok: true }>), `${label}: ${errorMessage(res)}`).toBe(true);
|
||||
break;
|
||||
}
|
||||
case 'fuzz-request':
|
||||
case 'malformed-request': {
|
||||
const c = action === 'fuzz-request' ? fuzzRequest(r, model) : malformedRequest(r, model);
|
||||
const exp = expectHttp(c, (t) => model.lookup(t), backendOk);
|
||||
Object.assign(rec, { method: c.method, target: c.target.slice(0, 200), headers: c.headers.map(([k, v]) => [k, v.slice(0, 100)]), expected: exp });
|
||||
const res = onlyResponse(await http.raw({ host: server.connectHost, port: server.port, data: rawBytes(c, { keepAlive: c.body !== undefined }), head: c.method === 'HEAD', timeoutMs: 20_000 }), label);
|
||||
checkResponse(res, exp, { head: c.method === 'HEAD', containers: fake.containers.length, secrets, label });
|
||||
if (exp.audited && (res.status === 200 || res.status === 500)) model.audit(exp.audited);
|
||||
break;
|
||||
}
|
||||
case 'backend': {
|
||||
backendOk = !backendOk || r.bool(0.3);
|
||||
rec.backendOk = backendOk;
|
||||
if (backendOk) fake.setContainers(fake.containers);
|
||||
else r.pick([() => fake.error500(), () => fake.destroy(), () => fake.badJson('{')])();
|
||||
break;
|
||||
}
|
||||
case 'containers':
|
||||
if (backendOk) fake.many(r.pick([0, 1, 3, 50, 400]), r);
|
||||
break;
|
||||
case 'restart': {
|
||||
const listing = (await cli.run(['token', 'list', 'all'])).stdout;
|
||||
await server.restart();
|
||||
expect((await cli.run(['token', 'list', 'all'])).stdout, `${label}: listing survives`).toBe(listing);
|
||||
break;
|
||||
}
|
||||
case 'crash': {
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const k = await server.kill();
|
||||
expect(k.signal).toBe('SIGKILL');
|
||||
await server.restart();
|
||||
expect(stateDigest(ws.dbPath), `${label}: committed state survives SIGKILL`).toBe(before);
|
||||
break;
|
||||
}
|
||||
case 'short-lock': {
|
||||
const db = new DatabaseSync(ws.dbPath, { timeout: 3000 });
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const release = new Promise<void>((res) =>
|
||||
setTimeout(() => {
|
||||
db.exec('ROLLBACK');
|
||||
db.close();
|
||||
res();
|
||||
}, 300),
|
||||
);
|
||||
try {
|
||||
if (model.active().length) await use(r.pick(model.active()), label);
|
||||
} finally {
|
||||
await release;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'audit': {
|
||||
// mostly the forms an operator types, sometimes fuzzed arguments; compared with SQLite and the model
|
||||
const args = r.bool(0.7) ? r.pick([[], ['all'], ['1'], ['5'], ['10'], ['25'], ['100000'], ['0010'], ['1h'], ['1d'], ['1w']]) : fuzzAuditArgs(r).args;
|
||||
rec.args = args.map((a) => a.slice(0, 60));
|
||||
const o = cliOutcome(['audit', ...args], model);
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const t0 = msToNanos(Date.now() - 1);
|
||||
const res = await cli.run(['audit', ...args]);
|
||||
const t1 = msToNanos(Date.now() + 1);
|
||||
if (o.kind === 'audit') {
|
||||
expectCliSuccess(res, { stderr: '' });
|
||||
const ops = readOperations(ws.dbPath);
|
||||
const rows = parseAuditOutput(res.stdout);
|
||||
if ('windowNs' in o.selection) expectAuditWindow(rows, ops, o.selection.windowNs, t0, t1, label);
|
||||
else expect(res.stdout, label).toBe(expectedAuditStdout(ops, o.selection));
|
||||
if (rows.length === ops.length) {
|
||||
// a complete listing: rows per token equal the model's audit counts
|
||||
const per = new Map<string, number>();
|
||||
for (const x of rows) per.set(x.token, (per.get(x.token) ?? 0) + 1);
|
||||
for (const t of model.tokens.values()) expect(per.get(t.token) ?? 0, `${label}: rows of ${tokenId(t.token)}`).toBe(t.audits);
|
||||
}
|
||||
} else if (o.kind === 'error') expectCliFailure(res, { exitCode: o.exitCode, message: o.message, usage: o.usage });
|
||||
else throw new Error(`unexpected audit outcome ${o.kind}`);
|
||||
expect(stateDigest(ws.dbPath), `${label}: audit is read-only`).toBe(before);
|
||||
break;
|
||||
}
|
||||
case 'audit-all': {
|
||||
// `audit all`: the whole history, oldest first, byte-exact against SQLite and the model's per-token counts
|
||||
const o = cliOutcome(['audit', 'all'], model);
|
||||
if (o.kind !== 'audit' || !('all' in o.selection)) throw new Error('audit all must select every row');
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const res = await cli.run(['audit', 'all']);
|
||||
expectCliSuccess(res, { stderr: '' });
|
||||
const ops = readOperations(ws.dbPath);
|
||||
expect(res.stdout, `${label}: every stored row`).toBe(expectedAuditStdout(ops, o.selection));
|
||||
const rows = parseAuditOutput(res.stdout);
|
||||
expect(rows.length, `${label}: one row per audited request`).toBe(model.auditTotal);
|
||||
const per = new Map<string, number>();
|
||||
for (const x of rows) per.set(x.token, (per.get(x.token) ?? 0) + 1);
|
||||
for (const t of model.tokens.values()) expect(per.get(t.token) ?? 0, `${label}: rows of ${tokenId(t.token)}`).toBe(t.audits);
|
||||
expect(stateDigest(ws.dbPath), `${label}: audit all is read-only`).toBe(before);
|
||||
break;
|
||||
}
|
||||
case 'trace-list': {
|
||||
const res = await cli.run(['token', 'list'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||
expectCliSuccess(res);
|
||||
expect(parseLogs(res.stderr).unparsed, `${label}: TRACE output is well-formed`).toEqual([]);
|
||||
model.verifyList(parseTokenList(res.stdout), label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isHarnessError(e)) throw e; // infrastructure problems are never "expected"
|
||||
deviations.push(`${label}: ${(e as Error).message.split('\n').slice(0, 8).join(' | ')}`);
|
||||
if (deviations.length > 25) break;
|
||||
}
|
||||
if (i % 50 === 49) {
|
||||
// hard checks: corruption must stop the session immediately
|
||||
model.verifyDb(ws.dbPath, `invariants after step ${i}`, { auditBeforeDeletion: true });
|
||||
expect(server.isRunning(), `server alive after step ${i}`).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
expect(deviations, 'steps whose outcome deviated from the model').toEqual([]);
|
||||
if (!backendOk) fake.setContainers(fake.containers);
|
||||
for (const t of model.active().slice(0, 5)) {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
model.verifyDb(ws.dbPath, 'end of session', { auditBeforeDeletion: true });
|
||||
expect(formatAuditOutput(await cli.audit('100000')), 'end of session: audit lists the whole history').toBe(expectedAuditStdout(readOperations(ws.dbPath), { limit: 100000n }));
|
||||
expect(formatAuditOutput(await cli.audit('all')), 'end of session: audit all lists the whole history').toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true }));
|
||||
model.verifyList(await cli.tokenList(), 'end of session');
|
||||
model.verifyList(await cli.tokenListAll(), 'end of session (all)', 'all');
|
||||
for (const g of server.allOutput()) expectNoTokenFragments(g.stderr, secrets, `server generation ${g.generation}`);
|
||||
test.info().annotations.push({ type: 'session', description: JSON.stringify({ steps: STEPS, generations: server.generation, tokens: model.tokens.size, inactive: model.inactive().length, audits: model.auditTotal, actions: Object.fromEntries(counts) }) });
|
||||
for (const a of ['create', 'use', 'delete', 'fuzz-request', 'restart', 'crash', 'short-lock', 'audit'] as const) expect(counts.get(a) ?? 0, `the session exercised ${a}`).toBeGreaterThan(0);
|
||||
expect(counts.get('audit-all') ?? 0, 'the session ran audit all').toBeGreaterThan(3);
|
||||
expect(auditAllMistakes, 'the session produced near misses of audit all').toBeGreaterThan(2);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 persona "Power user": realistic administrative missions at high volume with
|
||||
* deliberate state transitions — many tokens used simultaneously, repeated list/delete,
|
||||
* credential rotation, thousands of authenticated requests, real Docker containers with varied
|
||||
* names/labels/commands, large FakeDocker result sets, concurrent API traffic, repeated server
|
||||
* restarts (graceful and with a different log level) against the same long-lived database.
|
||||
*
|
||||
* After every transition the database is compared with the test's model (StateModel: tokens,
|
||||
* active flags, exact description bytes, audit rows per token) and the structural invariants,
|
||||
* `token list all` is compared byte-for-byte across restarts, `token list` / `token list all` with
|
||||
* the model and the stored rows, and audit history must be append-only.
|
||||
*/
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
assertDbInvariants,
|
||||
expectCliFailure,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
EMPTY_TOKEN_LIST,
|
||||
formatAuditOutput,
|
||||
mapLimit,
|
||||
readOperations,
|
||||
type ContainerSummary,
|
||||
type CurlResult,
|
||||
type HttpResult,
|
||||
type HttpClient,
|
||||
type RealDocker,
|
||||
type ServerHandle,
|
||||
type TestContainer,
|
||||
} from '../../harness';
|
||||
import { StateModel, expectedAuditStdout, expectedTokenListStdout, stateDigest } from '../../harness/personas';
|
||||
|
||||
interface ApiContainer {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
Image: string;
|
||||
Command: string;
|
||||
Labels: Record<string, string>;
|
||||
State: string;
|
||||
}
|
||||
|
||||
/** n authenticated GETs spread over `tokens`, `concurrency` at a time, half Playwright / some curl; returns per-token counts. */
|
||||
async function useSimultaneously(http: HttpClient, server: ServerHandle, tokens: string[], n: number, concurrency: number, expectCount: number): Promise<Map<string, number>> {
|
||||
const plan = Array.from({ length: n }, (_, i) => tokens[i % tokens.length]);
|
||||
const counts = new Map<string, number>();
|
||||
await mapLimit(plan, concurrency, async (t, i) => {
|
||||
const r = i % 10 === 9 ? await http.curl({ url: server.url(), token: t }) : await http.get(server.url(), { token: t });
|
||||
const list = expectContainersResponse(r);
|
||||
expect(list.length).toBe(expectCount);
|
||||
counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
test('fleet mission: 60 tokens, simultaneous use, repeated list/delete, rotations and 6 restarts against one long-lived database', async ({ cli, ws, startServer, http, rng }) => {
|
||||
test.setTimeout(180_000);
|
||||
const model = new StateModel();
|
||||
const boundary: Array<string | undefined> = [undefined, '', 'x', ' ', 'm'.repeat(131_071), '\u{65e5}'.repeat(20_000), 'ci \u{2192} prod \u{1f680}', 'tab\tnew\nline', `quote ' " \\ end`];
|
||||
// boundary descriptions first, then a realistic population; half before the server starts
|
||||
for (const d of boundary) model.add(await cli.tokenNew(d), d ?? '');
|
||||
const descs = Array.from({ length: 51 }, (_, i) => `svc-${String(i).padStart(2, '0')} ${rng.pick(['ci', 'deploy', 'monitoring', 'backup', 'dashboard'])}`);
|
||||
for (const [i, t] of (await mapLimit(descs.slice(0, 25), 8, (d) => cli.tokenNew(d))).entries()) model.add(t, descs[i]);
|
||||
model.verifyDb(ws.dbPath, 'initial population');
|
||||
|
||||
let server = await startServer({ logLevel: 'INFO', label: 'gen' });
|
||||
const fake = server.fakeDocker!;
|
||||
for (const [i, t] of (await mapLimit(descs.slice(25), 8, (d) => cli.tokenNew(d))).entries()) model.add(t, descs[25 + i]);
|
||||
expect(model.tokens.size).toBe(60);
|
||||
|
||||
const levels = ['DEBUG', 'TRACE', 'WARN', 'ERROR', 'INFO', 'DEBUG'];
|
||||
let rotation = 0;
|
||||
let listing = (await cli.run(['token', 'list', 'all'])).stdout;
|
||||
let opsBefore = readOperations(ws.dbPath);
|
||||
for (let phase = 0; phase < 6; phase++) {
|
||||
await test.step(`phase ${phase}`, async () => {
|
||||
// container population of this phase: empty, one, many, large
|
||||
const sizes = [0, 1, 40, 1500, 3000, 7];
|
||||
fake.many(sizes[phase], rng.fork(`containers-${phase}`));
|
||||
const ids = fake.containers.map((c: ContainerSummary) => c.Id);
|
||||
|
||||
// 1. simultaneous use of 20 tokens: 400 requests, 20 concurrent
|
||||
const users = rng.shuffle(model.active()).slice(0, 20);
|
||||
const counts = await useSimultaneously(http, server, users, phase === 3 || phase === 4 ? 60 : 400, 20, ids.length);
|
||||
for (const [t, n] of counts) model.audit(t, n);
|
||||
if (ids.length) {
|
||||
const sample = expectContainersResponse<ContainerSummary>(await http.get(server.url(), { token: users[0] }));
|
||||
expect(sample.map((c) => c.Id), 'complete and in Engine order').toEqual(ids);
|
||||
model.audit(users[0]);
|
||||
}
|
||||
model.verifyDb(ws.dbPath, `phase ${phase}: after simultaneous use`);
|
||||
// the operator's view of the history equals the stored rows (full listing and the default 10)
|
||||
const ops = readOperations(ws.dbPath);
|
||||
expect(formatAuditOutput(await cli.audit('100000')), `phase ${phase}: audit 100000`).toBe(expectedAuditStdout(ops, { limit: 100000n }));
|
||||
expect(formatAuditOutput(await cli.audit()), `phase ${phase}: audit`).toBe(expectedAuditStdout(ops, { limit: 10n }));
|
||||
|
||||
// 2. repeated listings are byte-identical and match the model and the stored rows
|
||||
for (const scope of ['active', 'all'] as const) {
|
||||
const args = scope === 'all' ? ['token', 'list', 'all'] : ['token', 'list'];
|
||||
const a = await cli.run(args);
|
||||
const b = await cli.run(args);
|
||||
expect(b.stdout).toBe(a.stdout);
|
||||
expect(a.stdout, `phase ${phase}: ${args.join(' ')} equals the stored rows`).toBe(expectedTokenListStdout(ws.dbPath, scope));
|
||||
model.verifyList(await cli.tokenList(scope), `phase ${phase}: ${args.join(' ')}`, scope);
|
||||
}
|
||||
|
||||
// 3. credential rotation: replacement first, then revoke the old one
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const old = rng.pick(model.active());
|
||||
const desc = `${model.tokens.get(old)!.description.toString('utf8').slice(0, 40)} (rotation ${++rotation})`;
|
||||
const next = await cli.tokenNew(desc);
|
||||
model.add(next, desc);
|
||||
expectContainersResponse(await http.get(server.url(), { token: next }));
|
||||
model.audit(next);
|
||||
await cli.tokenDelete(old);
|
||||
model.deactivate(old);
|
||||
expectErrorResponse(await http.get(server.url(), { token: old }), 401);
|
||||
expectContainersResponse(await http.get(server.url(), { token: next }));
|
||||
model.audit(next);
|
||||
}
|
||||
|
||||
// 4. repeated deletions of revoked tokens fail and change nothing
|
||||
const before = stateDigest(ws.dbPath);
|
||||
for (const t of rng.shuffle(model.inactive()).slice(0, 4)) expectCliFailure(await cli.run(['token', 'delete', t]), { exitCode: 1, message: 'token already inactive' });
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
model.verifyDb(ws.dbPath, `phase ${phase}: after rotation`, { auditBeforeDeletion: true });
|
||||
|
||||
// 5. restart against the same database (graceful restart, or a new process with another log level)
|
||||
listing = (await cli.run(['token', 'list', 'all'])).stdout;
|
||||
opsBefore = readOperations(ws.dbPath);
|
||||
if (phase % 2 === 0) {
|
||||
await server.restart();
|
||||
} else {
|
||||
const exit = await server.stop();
|
||||
expect(exit.exitCode).toBe(0);
|
||||
server = await startServer({ logLevel: levels[phase], docker: fake, dbPath: ws.dbPath, label: `gen-${levels[phase]}` });
|
||||
}
|
||||
expect((await cli.run(['token', 'list', 'all'])).stdout, 'listing byte-identical across the restart').toBe(listing);
|
||||
expect(readOperations(ws.dbPath), 'audit history unchanged by the restart').toEqual(opsBefore);
|
||||
for (const t of model.inactive().slice(0, 3)) expectErrorResponse(await http.get(server.url(), { token: t }), 401);
|
||||
for (const t of model.active().slice(0, 3)) {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
}
|
||||
model.verifyDb(ws.dbPath, `phase ${phase}: after restart`, { auditBeforeDeletion: true });
|
||||
});
|
||||
}
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
const ops = readOperations(ws.dbPath);
|
||||
expect(ops.slice(0, opsBefore.length), 'audit history is append-only').toEqual(opsBefore);
|
||||
expect(ops.length).toBeGreaterThan(1500);
|
||||
const report = model.verifyDb(ws.dbPath, 'final', { auditBeforeDeletion: true });
|
||||
expect(report).toEqual({ tokens: 60 + rotation, activeTokens: 60, inactiveTokens: rotation, operations: model.auditTotal });
|
||||
});
|
||||
|
||||
/** Our containers in a container listing. */
|
||||
const own = (list: ApiContainer[], docker: RealDocker) => list.filter((c) => c.Labels?.['dpsapi.e2e.test'] === docker.testKey);
|
||||
|
||||
test('real Docker fleet: varied containers, concurrent clients with several tokens, lifecycle transitions and restarts — API equals docker ps after every transition', async ({ cli, ws, startServer, http, docker, rng }) => {
|
||||
test.setTimeout(180_000);
|
||||
const model = new StateModel();
|
||||
for (const d of ['ops', 'ci', 'grafana', 'backup']) model.add(await cli.tokenNew(d), d);
|
||||
const commands = [['sleep', '3600'], ['tail', '-f', '/dev/null'], ['sh', '-c', 'trap "exit 0" TERM; while :; do sleep 1; done'], ['top', '-b', '-d', '30'], ['sh', '-c', 'echo "quoted \\"arg\\" $HOME"; exec sleep 3600']];
|
||||
const spec = (i: number, gen: string) => ({
|
||||
suffix: `${gen}${String(i).padStart(2, '0')}${['', '.dot', '_under', '-dash'][i % 4]}`,
|
||||
command: commands[i % commands.length],
|
||||
labels: {
|
||||
'com.example.index': String(i),
|
||||
'com.example.gen': gen,
|
||||
[`com.example.only-${gen}-${i}`]: i % 3 === 0 ? '' : i % 3 === 1 ? 'caf\u{e9} \u{2603} \u{65e5}\u{672c}' : 'x'.repeat(500),
|
||||
},
|
||||
env: { ROLE: `role-${i}` },
|
||||
});
|
||||
const created = new Map<string, TestContainer>();
|
||||
for (const c of await docker.runMany(12, (i) => spec(i, 'a'), 6)) created.set(c.id, c);
|
||||
let server = await startServer({ docker: 'real', logLevel: 'DEBUG' });
|
||||
|
||||
const checkpoint = async (label: string) => {
|
||||
const running = (await docker.runningIds()).sort();
|
||||
// every token, concurrently, through Playwright (2 each) and curl (1 each)
|
||||
const jobs: Array<() => Promise<HttpResult | CurlResult>> = model.active().flatMap((t) => [() => http.get(server.url(), { token: t }), () => http.get(server.url(), { token: t }), () => http.curl({ url: server.url(), token: t })]);
|
||||
const results = await mapLimit(jobs, 8, (j) => j());
|
||||
for (const r of results) {
|
||||
const list = expectContainersResponse<ApiContainer>(r);
|
||||
expect(list.every((c) => c.State === 'running'), `${label}: running containers only`).toBe(true);
|
||||
const mine = own(list, docker);
|
||||
expect(mine.map((c) => c.Id).sort(), `${label}: API equals docker ps for our label`).toEqual(running);
|
||||
for (const c of mine) {
|
||||
const exp = created.get(c.Id)!;
|
||||
expect(c.Names).toEqual([`/${exp.name}`]);
|
||||
expect(c.Image).toBe('alpine:3.22');
|
||||
for (const [k, v] of Object.entries(exp.labels)) expect(c.Labels[k], `${label}: label ${k}`).toBe(v);
|
||||
}
|
||||
}
|
||||
for (const t of model.active()) model.audit(t, 3);
|
||||
model.verifyDb(ws.dbPath, label);
|
||||
return running.length;
|
||||
};
|
||||
|
||||
expect(await checkpoint('12 started')).toBe(12);
|
||||
const ids = [...created.keys()];
|
||||
const stopped = rng.shuffle(ids).slice(0, 3);
|
||||
await Promise.all(stopped.map((id) => docker.stop(created.get(id)!, 1)));
|
||||
expect(await checkpoint('3 stopped')).toBe(9);
|
||||
const removable = ids.filter((id) => !stopped.includes(id)).slice(0, 2);
|
||||
await Promise.all(removable.map((id) => docker.remove(created.get(id)!)));
|
||||
expect(await checkpoint('2 removed')).toBe(7);
|
||||
await docker.start(created.get(stopped[0])!);
|
||||
expect(await checkpoint('1 restarted')).toBe(8);
|
||||
for (const c of await docker.runMany(3, (i) => spec(i, 'b'), 3)) created.set(c.id, c);
|
||||
expect(await checkpoint('3 more started')).toBe(11);
|
||||
|
||||
// rotate a credential and restart the server twice against the same database
|
||||
const old = model.active()[0];
|
||||
const next = await cli.tokenNew('ops v2');
|
||||
model.add(next, 'ops v2');
|
||||
await cli.tokenDelete(old);
|
||||
model.deactivate(old);
|
||||
expectErrorResponse(await http.get(server.url(), { token: old }), 401);
|
||||
const opsBefore = readOperations(ws.dbPath);
|
||||
await server.restart();
|
||||
expect(await checkpoint('after graceful restart')).toBe(11);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
server = await startServer({ docker: 'real', logLevel: 'INFO', dbPath: ws.dbPath });
|
||||
expect(await checkpoint('after a new server process')).toBe(11);
|
||||
expect(readOperations(ws.dbPath).slice(0, opsBefore.length)).toEqual(opsBefore);
|
||||
|
||||
await docker.cleanup();
|
||||
expect(await checkpoint('all removed')).toBe(0);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
model.verifyDb(ws.dbPath, 'final', { auditBeforeDeletion: true });
|
||||
});
|
||||
|
||||
test('boundary populations on one database: zero tokens, one token, 1000 tokens from 16 concurrent CLI processes, 3000 audited requests, bulk deletion', async ({ cli, ws, startServer, http, rng }) => {
|
||||
test.setTimeout(180_000);
|
||||
const model = new StateModel();
|
||||
const server = await startServer({ logLevel: 'WARN' });
|
||||
server.fakeDocker!.many(3);
|
||||
|
||||
await test.step('zero tokens: header-only listing, every credential refused', async () => {
|
||||
expect((await cli.run(['token', 'list'])).stdout).toBe(EMPTY_TOKEN_LIST);
|
||||
expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(EMPTY_TOKEN_LIST);
|
||||
for (const t of ['', 'x', rng.string(43, 'abcdefghijklmnopqrstuvwxyz')]) expectErrorResponse(await http.get(server.url(), { headers: { Authorization: `Bearer ${t}` } }), 401);
|
||||
assertDbInvariants(ws.dbPath, { tokenCount: 0, operationCount: 0 });
|
||||
});
|
||||
|
||||
await test.step('one token: usable immediately, by a running server', async () => {
|
||||
const t = model.add(await cli.tokenNew('the one'), 'the one').token;
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
await test.step('1000 tokens created by 16 concurrent CLI processes while the server serves traffic', async () => {
|
||||
const descs = Array.from({ length: 999 }, (_, i) => `bulk ${i}`);
|
||||
const traffic = mapLimit(Array.from({ length: 200 }, () => model.active()[0]), 8, async (t) => {
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
});
|
||||
const toks = await mapLimit(descs, 16, (d) => cli.tokenNew(d));
|
||||
await traffic;
|
||||
model.audit(model.active()[0], 200);
|
||||
toks.forEach((t, i) => model.add(t, descs[i]));
|
||||
expect(new Set(toks).size).toBe(999);
|
||||
model.verifyDb(ws.dbPath, '1000 tokens');
|
||||
model.verifyList(await cli.tokenList());
|
||||
});
|
||||
|
||||
await test.step('3000 authenticated requests spread over 300 tokens, 32 concurrent', async () => {
|
||||
const users = rng.shuffle(model.active()).slice(0, 300);
|
||||
const plan = Array.from({ length: 3000 }, (_, i) => users[i % users.length]);
|
||||
await mapLimit(plan, 32, async (t) => {
|
||||
const r = await http.get(server.url(), { token: t });
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
for (const t of plan) model.audit(t);
|
||||
model.verifyDb(ws.dbPath, '3000 requests');
|
||||
const ops = readOperations(ws.dbPath);
|
||||
expect(formatAuditOutput(await cli.audit('5000')), 'audit 5000 lists all 3201 rows').toBe(expectedAuditStdout(ops, { limit: 5000n }));
|
||||
expect(formatAuditOutput(await cli.audit('1h')), 'audit 1h: the whole mission').toBe(expectedAuditStdout(ops, { limit: 5000n }));
|
||||
expect(formatAuditOutput(await cli.audit('300')), 'audit 300').toBe(expectedAuditStdout(ops, { limit: 300n }));
|
||||
});
|
||||
|
||||
await test.step('bulk deletion of 500 tokens (12 concurrent) and a second pass that must fail for each', async () => {
|
||||
const victims = rng.shuffle(model.active()).slice(0, 500);
|
||||
await mapLimit(victims, 12, (t) => cli.tokenDelete(t));
|
||||
for (const t of victims) model.deactivate(t);
|
||||
const before = stateDigest(ws.dbPath);
|
||||
const again = await mapLimit(victims.slice(0, 100), 12, (t) => cli.run(['token', 'delete', t]));
|
||||
for (const r of again) expectCliFailure(r, { exitCode: 1, message: 'token already inactive' });
|
||||
expect(stateDigest(ws.dbPath)).toBe(before);
|
||||
const probes = rng.shuffle([...victims.slice(0, 100), ...rng.shuffle(model.active()).slice(0, 100)]);
|
||||
await mapLimit(probes, 16, async (t) => {
|
||||
const r = await http.get(server.url(), { token: t });
|
||||
if (model.lookup(t) === 'active') {
|
||||
expectContainersResponse(r);
|
||||
} else expectErrorResponse(r, 401);
|
||||
});
|
||||
for (const t of probes) if (model.lookup(t) === 'active') model.audit(t);
|
||||
model.verifyList(await cli.tokenList(), 'after bulk deletion: 500 active');
|
||||
model.verifyList(await cli.tokenListAll(), 'after bulk deletion: all 1000', 'all');
|
||||
expect((await cli.run(['token', 'list'])).stdout, 'token list equals the stored active rows').toBe(expectedTokenListStdout(ws.dbPath, 'active'));
|
||||
});
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().records, 'nothing at WARN or above during the whole mission').toEqual([]);
|
||||
const report = model.verifyDb(ws.dbPath, 'final', { auditBeforeDeletion: true });
|
||||
expect(report.tokens).toBe(1000);
|
||||
expect(report.inactiveTokens).toBe(500);
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* PLAN §12 persona "Stupid user": common human mistakes against the CLI, the environment
|
||||
* configuration and the HTTP API — omitted/extra arguments, misspelled commands, copy/paste
|
||||
* whitespace around tokens, empty and absurdly large descriptions, malformed Authorization
|
||||
* headers, wrong methods and paths, repeated/nonexistent deletions, bad environment variables,
|
||||
* wrong content types and irrelevant bodies, operational blunders (second server on the same
|
||||
* port, Ctrl-C twice, requests after stopping), and command sequencing errors.
|
||||
*
|
||||
* Unlike the core config/usage tables (fresh workspaces), every mistake here is made against a
|
||||
* populated, live database: each must fail safely with the exact status / exit code, leave the
|
||||
* logical state (stateDigest) and, for usage/config errors, the database file bytes unchanged —
|
||||
* and the instance must keep working for the next valid operation.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
expectCliFailure,
|
||||
expectContainersResponse,
|
||||
expectErrorResponse,
|
||||
fileDigest,
|
||||
formatAuditOutput,
|
||||
goQuote,
|
||||
readOperations,
|
||||
readToken,
|
||||
type CliRunner,
|
||||
type CurlResult,
|
||||
type HttpResult,
|
||||
type ServerHandle,
|
||||
} from '../../harness';
|
||||
import { ENDPOINT, StateModel, expectedAuditStdout, expectedTokenListStdout, stateDigest } from '../../harness/personas';
|
||||
|
||||
/** Populated database + running server; returns the model. */
|
||||
async function populated(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>): Promise<{ model: StateModel; server: ServerHandle; active: string; inactive: string }> {
|
||||
const model = new StateModel();
|
||||
const active = model.add(await cli.tokenNew('my laptop'), 'my laptop').token;
|
||||
const inactive = model.add(await cli.tokenNew('old laptop'), 'old laptop').token;
|
||||
model.add(await cli.tokenNew(), '');
|
||||
await cli.tokenDelete(inactive);
|
||||
model.deactivate(inactive);
|
||||
const server = await startServer();
|
||||
server.fakeDocker!.many(2);
|
||||
return { model, server, active, inactive };
|
||||
}
|
||||
|
||||
test('CLI mistakes against a live, populated database: exact exit code and message, state and file untouched, next command works', async ({ cli, ws, startServer, http }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { model, server, active, inactive } = await populated(cli, startServer);
|
||||
const line = (await cli.tokenList()).find((r) => r.token === active)!.line;
|
||||
const usage = (m: string) => ({ exitCode: 2, message: `usage error: ${m}` });
|
||||
const notFound = { exitCode: 1, message: 'token not found' };
|
||||
const mistakes: Array<[string, string[], { exitCode: number; message: string }]> = [
|
||||
['no subcommand', ['token'], usage('missing token subcommand')],
|
||||
['typo in command', ['tokne', 'list'], usage('unknown command')],
|
||||
['plural command', ['tokens', 'list'], usage('unknown command')],
|
||||
['capitalized command', ['Token', 'list'], usage('unknown command')],
|
||||
['asking for help', ['--help'], usage('unknown command')],
|
||||
['help subcommand', ['token', 'help'], usage('unknown token subcommand')],
|
||||
['typo in subcommand', ['token', 'lsit'], usage('unknown token subcommand')],
|
||||
['abbreviated subcommand', ['token', 'ls'], usage('unknown token subcommand')],
|
||||
['upper-case subcommand', ['token', 'LIST'], usage('unknown token subcommand')],
|
||||
['remove instead of delete', ['token', 'remove', active], usage('unknown token subcommand')],
|
||||
['revoke instead of delete', ['token', 'revoke', active], usage('unknown token subcommand')],
|
||||
['subcommand with a stray space', ['token', 'list '], usage('unknown token subcommand')],
|
||||
['list with a filter argument', ['token', 'list', 'active'], usage('unknown token list argument')],
|
||||
['list --all', ['token', 'list', '--all'], usage('unknown token list argument')],
|
||||
['list -a', ['token', 'list', '-a'], usage('unknown token list argument')],
|
||||
['list ALL (caps lock)', ['token', 'list', 'ALL'], usage('unknown token list argument')],
|
||||
['list All', ['token', 'list', 'All'], usage('unknown token list argument')],
|
||||
['list with an empty argument', ['token', 'list', ''], usage('unknown token list argument')],
|
||||
['list all with a pasted trailing space', ['token', 'list', 'all '], usage('unknown token list argument')],
|
||||
['list all twice', ['token', 'list', 'all', 'all'], usage('token list takes at most one argument')],
|
||||
['list all and a filter', ['token', 'list', 'all', 'inactive'], usage('token list takes at most one argument')],
|
||||
['help in the wrong place', ['token', 'list', 'help'], usage('unknown token list argument')],
|
||||
['help with a topic', ['help', 'token'], usage('help takes no arguments')],
|
||||
['help help', ['help', 'help'], usage('help takes no arguments')],
|
||||
['help with an empty argument', ['help', ''], usage('help takes no arguments')],
|
||||
['capitalized Help', ['Help'], usage('unknown command')],
|
||||
['-h instead of help', ['-h'], usage('unknown command')],
|
||||
['help with a stray space', ['help '], usage('unknown command')],
|
||||
['unquoted multi-word description', ['token', 'new', 'my', 'new', 'laptop'], usage('token new takes at most one argument')],
|
||||
['description and a flag', ['token', 'new', 'ci', '--expires=30d'], usage('token new takes at most one argument')],
|
||||
['delete without the token', ['token', 'delete'], usage('token delete requires exactly one argument')],
|
||||
['delete two tokens at once', ['token', 'delete', active, inactive], usage('token delete requires exactly one argument')],
|
||||
['delete the whole pasted list line', ['token', 'delete', ...line.split(/ +/)], usage('token delete requires exactly one argument')],
|
||||
['pasted with a leading space', ['token', 'delete', ` ${active}`], notFound],
|
||||
['pasted with a trailing space', ['token', 'delete', `${active} `], notFound],
|
||||
['pasted with a trailing newline', ['token', 'delete', `${active}\n`], notFound],
|
||||
['pasted with CRLF', ['token', 'delete', `${active}\r\n`], notFound],
|
||||
['pasted with a leading tab', ['token', 'delete', `\t${active}`], notFound],
|
||||
['pasted with literal quotes', ['token', 'delete', `"${active}"`], notFound],
|
||||
['pasted with single quotes', ['token', 'delete', `'${active}'`], notFound],
|
||||
['pasted with the Bearer prefix', ['token', 'delete', `Bearer ${active}`], notFound],
|
||||
['pasted the whole list line as one argument', ['token', 'delete', line], notFound],
|
||||
['truncated copy', ['token', 'delete', active.slice(0, 40)], notFound],
|
||||
['description instead of token', ['token', 'delete', 'my laptop'], notFound],
|
||||
['the documentation placeholder', ['token', 'delete', '<token>'], notFound],
|
||||
['an unexpanded variable', ['token', 'delete', '$TOKEN'], notFound],
|
||||
['empty token', ['token', 'delete', ''], notFound],
|
||||
['upper-cased token', ['token', 'delete', active.toUpperCase()], notFound],
|
||||
['delete the already deleted token', ['token', 'delete', inactive], { exitCode: 1, message: 'token already inactive' }],
|
||||
// audit: wrong argument shapes
|
||||
['audit with a spelled-out count', ['audit', 'ten'], usage('invalid audit argument')],
|
||||
['audit with count and unit as two words', ['audit', '10', 'hours'], usage('audit takes at most one argument')],
|
||||
['audit with count and unit letter separated', ['audit', '10', 'h'], usage('audit takes at most one argument')],
|
||||
['audit "10 hours" quoted as one argument', ['audit', '10 hours'], usage('invalid audit argument')],
|
||||
['audit with a negative count', ['audit', '-5'], usage('invalid audit argument')],
|
||||
['audit with an upper-case unit', ['audit', '1D'], usage('invalid audit argument')],
|
||||
['audit zero', ['audit', '0'], usage('invalid audit argument')],
|
||||
['audit zero hours', ['audit', '0h'], usage('invalid audit argument')],
|
||||
['audit with a decimal window', ['audit', '1.5h'], usage('invalid audit argument')],
|
||||
['audit with a unit but no number', ['audit', 'h'], usage('invalid audit argument')],
|
||||
['audit with minutes spelled "min"', ['audit', '30min'], usage('invalid audit argument')],
|
||||
['audit with years', ['audit', '1y'], usage('invalid audit argument')],
|
||||
['audit like tail -n', ['audit', '-n', '10'], usage('audit takes at most one argument')],
|
||||
['audit --tail=10', ['audit', '--tail=10'], usage('invalid audit argument')],
|
||||
['audit with a pasted trailing space', ['audit', '10 '], usage('invalid audit argument')],
|
||||
['audit with an empty argument', ['audit', ''], usage('invalid audit argument')],
|
||||
['plural audits', ['audits'], usage('unknown command')],
|
||||
['capitalized Audit', ['Audit', '10'], usage('unknown command')],
|
||||
['audit as a token subcommand', ['token', 'audit'], usage('unknown token subcommand')],
|
||||
// audit all: exact lowercase word, nothing after it
|
||||
['audit ALL (caps lock)', ['audit', 'ALL'], usage('invalid audit argument')],
|
||||
['audit All', ['audit', 'All'], usage('invalid audit argument')],
|
||||
['audit --all like a flag', ['audit', '--all'], usage('invalid audit argument')],
|
||||
['audit "all " with a pasted trailing space', ['audit', 'all '], usage('invalid audit argument')],
|
||||
['audit all with a count', ['audit', 'all', '10'], usage('audit takes at most one argument')],
|
||||
['audit all logs', ['audit', 'all', 'logs'], usage('audit takes at most one argument')],
|
||||
];
|
||||
for (const [name, args, exp] of mistakes) {
|
||||
await test.step(name, async () => {
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const file = fileDigest(ws.dbPath);
|
||||
const r = await cli.run(args);
|
||||
expectCliFailure(r, exp);
|
||||
if (args.length >= 3 && args[1] === 'delete' && args[2].length > 20) expect(r.stderr.includes(active), 'the token is never echoed').toBe(false);
|
||||
expect(stateDigest(ws.dbPath), 'logical state unchanged').toBe(digest);
|
||||
if (exp.exitCode === 2) expect(fileDigest(ws.dbPath), 'usage errors never open the database').toBe(file);
|
||||
});
|
||||
}
|
||||
// the mistakes did not hurt the instance: the right commands work
|
||||
model.verifyList(await cli.tokenList());
|
||||
model.verifyList(await cli.tokenListAll(), 'token list all', 'all');
|
||||
await cli.help();
|
||||
for (let i = 0; i < 12; i++) expectContainersResponse(await http.get(server.url(), { token: active }));
|
||||
model.audit(active, 12);
|
||||
const ops = readOperations(ws.dbPath);
|
||||
expect(formatAuditOutput(await cli.audit()), 'audit: the latest 10 rows').toBe(expectedAuditStdout(ops, { limit: 10n }));
|
||||
expect(formatAuditOutput(await cli.audit('10')), 'audit 10').toBe(expectedAuditStdout(ops, { limit: 10n }));
|
||||
expect(formatAuditOutput(await cli.audit('1h')), 'audit 1h: every row of this session').toBe(expectedAuditStdout(ops, { limit: BigInt(ops.length) }));
|
||||
expect(formatAuditOutput(await cli.audit('all')), 'audit all: every row (more than the default 10)').toBe(expectedAuditStdout(ops, { all: true }));
|
||||
expect(await cli.audit('all')).toHaveLength(ops.length);
|
||||
const t = await cli.tokenNew('my new laptop');
|
||||
model.add(t, 'my new laptop');
|
||||
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||
model.audit(t);
|
||||
expectContainersResponse(await http.get(server.url(), { token: active }));
|
||||
model.audit(active);
|
||||
await cli.tokenDelete(active);
|
||||
model.deactivate(active);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
model.verifyDb(ws.dbPath, 'final');
|
||||
});
|
||||
|
||||
test('description mistakes: empty, whitespace-only, accidental token, absurdly large (argv limit, shell E2BIG) — accepted verbatim or refused by the OS without side effects', async ({ cli, ws }) => {
|
||||
const model = new StateModel();
|
||||
const t0 = model.add(await cli.tokenNew('existing'), 'existing').token;
|
||||
for (const d of ['', ' ', '\t', '\n', 'TODO', 'description', '"', `${t0.slice(0, 10)}...`]) model.add(await cli.tokenNew(d), d);
|
||||
// the largest single argument the kernel accepts (MAX_ARG_STRLEN - 1)
|
||||
const huge = 'z'.repeat(131_071);
|
||||
model.add(await cli.tokenNew(huge), huge);
|
||||
const rows = await cli.tokenList();
|
||||
model.verifyList(rows);
|
||||
expect(rows.find((r) => r.description === huge)!.descriptionQuoted).toBe(goQuote(huge));
|
||||
// the huge description sets the DESCRIPTION column width: every other row is padded to it
|
||||
// (all quoted descriptions are ASCII here, so string offsets are rune offsets)
|
||||
const activeAt = 45 + goQuote(huge).length + 2;
|
||||
expect(rows.filter((r) => r.line.slice(activeAt - 1, activeAt + 5) !== ' true ').map((r) => r.descriptionQuoted.slice(0, 20)), 'ACTIVE column aligned after the huge description').toEqual([]);
|
||||
expect((await cli.run(['token', 'list'])).stdout).toBe(expectedTokenListStdout(ws.dbPath));
|
||||
// larger than the kernel limit: the shell cannot even exec the binary (126), nothing changes
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const r = await cli.shell(`"$DPSAPI_BIN" token new "$(head -c 200000 /dev/zero | tr '\\0' 'y')"`);
|
||||
expect(r.exitCode, 'the shell reports E2BIG; dpsapi never ran').toBe(126);
|
||||
expect(r.stderr).toMatch(/Argument list too long/);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(stateDigest(ws.dbPath)).toBe(digest);
|
||||
// the same mistake through argv is refused by the OS before dpsapi runs (spawn error, a harness-level refusal)
|
||||
await expect(cli.run(['token', 'new', 'z'.repeat(200_000)])).rejects.toThrow(/harness:spawn|E2BIG/);
|
||||
expect(stateDigest(ws.dbPath)).toBe(digest);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
test('environment mistakes when starting the server against an existing database: exit 2/1 with the exact message, database untouched, a correct start works', async ({ cli, ws, startServer, http, e2e }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
const tok = model.add(await cli.tokenNew('env'), 'env').token;
|
||||
const port = String(await e2e.allocatePort());
|
||||
const ok = { HTTP_ADDR: '127.0.0.1', HTTP_PORT: port, LOG_LEVEL: 'INFO' };
|
||||
const cfg = (v: string, val: string, tail: string) => `configuration error: invalid ${v} ${goQuote(val)}: ${tail}`;
|
||||
const ADDR = 'must be an IPv4 or IPv6 address';
|
||||
const PORT = 'must be an integer between 1 and 65535';
|
||||
const LEVEL = 'must be one of ERROR, WARN, INFO, DEBUG, TRACE';
|
||||
const mistakes: Array<[string, Record<string, string | undefined>, number, string | RegExp]> = [
|
||||
['forgot DB_PATH', { DB_PATH: undefined }, 2, 'configuration error: DB_PATH is required'],
|
||||
['DB_PATH exported empty', { DB_PATH: '' }, 2, 'configuration error: DB_PATH must not be empty'],
|
||||
['DB_PATH with an unexpanded ~ (relative "~" directory does not exist)', { DB_PATH: '~/dpsapi.db' }, 1, 'initialize database schema: unable to open database file (14)'],
|
||||
['DB_PATH pointing at the directory', { DB_PATH: ws.dir }, 1, 'initialize database schema: unable to open database file (14)'],
|
||||
['port with a trailing space', { HTTP_PORT: `${port} ` }, 2, cfg('HTTP_PORT', `${port} `, PORT)],
|
||||
['port written like a listen address', { HTTP_PORT: `:${port}` }, 2, cfg('HTTP_PORT', `:${port}`, PORT)],
|
||||
['port given as a URL', { HTTP_PORT: `http://127.0.0.1:${port}` }, 2, cfg('HTTP_PORT', `http://127.0.0.1:${port}`, PORT)],
|
||||
['port zero', { HTTP_PORT: '0' }, 2, cfg('HTTP_PORT', '0', PORT)],
|
||||
['port 70000', { HTTP_PORT: '70000' }, 2, cfg('HTTP_PORT', '70000', PORT)],
|
||||
['host name instead of an address', { HTTP_ADDR: 'localhost' }, 2, cfg('HTTP_ADDR', 'localhost', ADDR)],
|
||||
['address with the port', { HTTP_ADDR: `127.0.0.1:${port}` }, 2, cfg('HTTP_ADDR', `127.0.0.1:${port}`, ADDR)],
|
||||
['address as a URL', { HTTP_ADDR: 'http://127.0.0.1' }, 2, cfg('HTTP_ADDR', 'http://127.0.0.1', ADDR)],
|
||||
['bracketed IPv6', { HTTP_ADDR: '[::1]' }, 2, cfg('HTTP_ADDR', '[::1]', ADDR)],
|
||||
['log level verbose', { LOG_LEVEL: 'verbose' }, 2, cfg('LOG_LEVEL', 'verbose', LEVEL)],
|
||||
['log level with a trailing space', { LOG_LEVEL: 'debug ' }, 2, cfg('LOG_LEVEL', 'debug ', LEVEL)],
|
||||
['log level warning', { LOG_LEVEL: 'warning' }, 2, cfg('LOG_LEVEL', 'warning', LEVEL)],
|
||||
['log level exported empty', { LOG_LEVEL: '' }, 2, 'configuration error: LOG_LEVEL must not be empty'],
|
||||
];
|
||||
const homeDb = `${process.env.HOME ?? '/nonexistent'}/dpsapi.db`;
|
||||
const homeHad = fs.existsSync(homeDb);
|
||||
for (const [name, over, code, message] of mistakes) {
|
||||
await test.step(name, async () => {
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const file = fileDigest(ws.dbPath);
|
||||
const r = await cli.run([], { env: { ...ok, ...over }, timeoutMs: 10_000 });
|
||||
expectCliFailure(r, { exitCode: code, message, usage: false });
|
||||
expect(stateDigest(ws.dbPath)).toBe(digest);
|
||||
if (code === 2) expect(fileDigest(ws.dbPath), 'configuration errors never open the database').toBe(file);
|
||||
});
|
||||
}
|
||||
expect(fs.existsSync(ws.path('~')), 'no "~" directory was created').toBe(false);
|
||||
expect(fs.existsSync(homeDb), 'nothing was created in $HOME').toBe(homeHad);
|
||||
// lower-case level and leading zeros are not mistakes
|
||||
const server = await startServer({ port: `000${port}`, logLevel: 'info' });
|
||||
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||
model.audit(tok);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
test('HTTP mistakes: wrong methods, wrong paths, malformed Authorization, wrong content types and irrelevant bodies — exact status, nothing audited, valid request still works', async ({ cli, ws, startServer, http }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
const tok = model.add(await cli.tokenNew('http'), 'http').token;
|
||||
const old = model.add(await cli.tokenNew('old'), 'old').token;
|
||||
await cli.tokenDelete(old);
|
||||
model.deactivate(old);
|
||||
const server = await startServer({ logLevel: 'DEBUG' });
|
||||
server.fakeDocker!.many(2);
|
||||
const json = { 'Content-Type': 'application/json' };
|
||||
type Case = [string, () => Promise<HttpResult | CurlResult>, 200 | 401 | 404 | 405];
|
||||
const cases: Case[] = [
|
||||
['POST with a JSON body', () => http.fetch(server.url(), { method: 'POST', token: tok, data: '{"all":true}', headers: json }), 405],
|
||||
['PUT with a form body', () => http.fetch(server.url(), { method: 'PUT', token: tok, data: 'name=x', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 405],
|
||||
['DELETE a container', () => http.fetch(server.url(`${ENDPOINT}/abc`), { method: 'DELETE', token: tok }), 404],
|
||||
['DELETE the collection', () => http.fetch(server.url(), { method: 'DELETE', token: tok }), 405],
|
||||
['PATCH', () => http.fetch(server.url(), { method: 'PATCH', token: tok, data: '[]', headers: json }), 405],
|
||||
['curl -d (implicit POST)', () => http.curl({ url: server.url(), token: tok, data: 'all=1' }), 405],
|
||||
['curl -I (HEAD)', () => http.curl({ url: server.url(), token: tok, head: true }), 405],
|
||||
['missing /api prefix', () => http.get(server.url('/v1/containers'), { token: tok }), 404],
|
||||
['missing version', () => http.get(server.url('/api/containers'), { token: tok }), 404],
|
||||
['wrong version', () => http.get(server.url('/api/v2/containers'), { token: tok }), 404],
|
||||
['singular resource', () => http.get(server.url('/api/v1/container'), { token: tok }), 404],
|
||||
['trailing slash', () => http.get(server.url(`${ENDPOINT}/`), { token: tok }), 404],
|
||||
['docker-style path', () => http.get(server.url('/containers/json'), { token: tok }), 404],
|
||||
['root', () => http.get(server.url('/'), { token: tok }), 404],
|
||||
['upper-case path', () => http.get(server.url('/API/V1/CONTAINERS'), { token: tok }), 404],
|
||||
['.json suffix', () => http.get(server.url(`${ENDPOINT}.json`), { token: tok }), 404],
|
||||
['no Authorization at all', () => http.get(server.url()), 401],
|
||||
['token without scheme', () => http.get(server.url(), { headers: { Authorization: tok } }), 401],
|
||||
['Token scheme', () => http.get(server.url(), { headers: { Authorization: `Token ${tok}` } }), 401],
|
||||
['Basic with the token as password', () => http.get(server.url(), { headers: { Authorization: `Basic ${Buffer.from(`:${tok}`).toString('base64')}` } }), 401],
|
||||
['colon after Bearer', () => http.get(server.url(), { headers: { Authorization: `Bearer: ${tok}` } }), 401],
|
||||
['quoted token', () => http.get(server.url(), { headers: { Authorization: `Bearer "${tok}"` } }), 401],
|
||||
['angle brackets from the docs', () => http.get(server.url(), { headers: { Authorization: `Bearer <${tok}>` } }), 401],
|
||||
['unexpanded $TOKEN', () => http.get(server.url(), { headers: { Authorization: 'Bearer $TOKEN' } }), 401],
|
||||
['two spaces after Bearer', () => http.get(server.url(), { headers: { Authorization: `Bearer ${tok}` } }), 401],
|
||||
['description instead of token', () => http.get(server.url(), { headers: { Authorization: 'Bearer http' } }), 401],
|
||||
['old (deleted) token', () => http.get(server.url(), { token: old }), 401],
|
||||
['token in the query string', () => http.get(server.url(`${ENDPOINT}?token=${tok}`)), 401],
|
||||
['token in X-Api-Key', () => http.get(server.url(), { headers: { 'X-Api-Key': tok } }), 401],
|
||||
['token in a cookie', () => http.get(server.url(), { headers: { Cookie: `token=${tok}` } }), 401],
|
||||
['token in a JSON body', () => http.fetch(server.url(), { method: 'GET', data: JSON.stringify({ token: tok }), headers: json }), 401],
|
||||
['token pasted with a trailing newline (curl splits it off)', () => http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok}\n`] }), 200],
|
||||
['copy/paste spaces around the value (trimmed by HTTP)', () => http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok} `] }), 200],
|
||||
['XML content type on a GET', () => http.get(server.url(), { token: tok, headers: { 'Content-Type': 'application/xml' } }), 200],
|
||||
['GET with a multipart body', () => http.fetch(server.url(), { method: 'GET', token: tok, data: '--b\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--b--\r\n', headers: { 'Content-Type': 'multipart/form-data; boundary=b' } }), 200],
|
||||
['asking for HTML', () => http.get(server.url(), { token: tok, headers: { Accept: 'text/html' } }), 200],
|
||||
['asking for XML', () => http.get(server.url(), { token: tok, headers: { Accept: 'application/xml' } }), 200],
|
||||
];
|
||||
let audited = 0;
|
||||
for (const [name, send, status] of cases) {
|
||||
await test.step(`${name} → ${status}`, async () => {
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const r = await send();
|
||||
if (status === 200) {
|
||||
expect(expectContainersResponse(r)).toHaveLength(2);
|
||||
expect(r.headers['content-type'], 'always JSON, whatever was asked for').toBe('application/json');
|
||||
model.audit(tok);
|
||||
audited++;
|
||||
} else {
|
||||
expectErrorResponse(r, status, { head: name.includes('HEAD') });
|
||||
expect(stateDigest(ws.dbPath), 'refused requests change nothing').toBe(digest);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(expectContainersResponse(await http.get(server.url(), { token: tok }))).toHaveLength(2);
|
||||
model.audit(tok);
|
||||
expect((await server.stop()).exitCode).toBe(0);
|
||||
expect(server.logs().requests()).toHaveLength(cases.length + 1);
|
||||
expect(server.logs().count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
||||
expect(audited).toBe(6);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
test('operational blunders and sequencing errors: using the API before creating a token, a second server on the same port, Ctrl-C twice, requests after stopping, deleting before creating', async ({ cli, ws, startServer, http }) => {
|
||||
test.setTimeout(90_000);
|
||||
const model = new StateModel();
|
||||
|
||||
await test.step('deleting and listing before anything exists (fresh database)', async () => {
|
||||
expectCliFailure(await cli.run(['token', 'delete', 'whatever']), { exitCode: 1, message: 'token not found' });
|
||||
expect(fs.existsSync(ws.dbPath), 'the CLI initialized the database').toBe(true);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
const server = await startServer();
|
||||
server.fakeDocker!.many(1);
|
||||
await test.step('calling the API before creating any token: 401, no audit', async () => {
|
||||
expectErrorResponse(await http.get(server.url(), { headers: { Authorization: 'Bearer ' } }), 401);
|
||||
expectErrorResponse(await http.get(server.url()), 401);
|
||||
model.verifyDb(ws.dbPath);
|
||||
});
|
||||
|
||||
const tok = model.add(await cli.tokenNew('finally'), 'finally').token;
|
||||
await test.step('the token created afterwards works without restarting the server', async () => {
|
||||
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||
model.audit(tok);
|
||||
});
|
||||
|
||||
await test.step('starting a second server on the same port: exit 1, the first keeps serving', async () => {
|
||||
const digest = stateDigest(ws.dbPath);
|
||||
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: String(server.port), LOG_LEVEL: 'INFO' }, timeoutMs: 10_000 });
|
||||
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 127.0.0.1:${server.port}: bind: address already in use`, exactStderr: true });
|
||||
expect(stateDigest(ws.dbPath)).toBe(digest);
|
||||
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||
model.audit(tok);
|
||||
});
|
||||
|
||||
await test.step('Ctrl-C pressed twice: still a graceful exit 0', async () => {
|
||||
server.signal('SIGINT');
|
||||
server.signal('SIGINT');
|
||||
const exit = await server.waitForExit(20_000);
|
||||
// README documents SIGINT/SIGTERM → graceful exit 0; a second SIGINT during shutdown is
|
||||
// undefined by the spec — observed: it is absorbed and the exit is still 0.
|
||||
expect(exit.exitCode).toBe(0);
|
||||
expect(server.logs().has({ msg: 'server stopped' })).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('requests after stopping: connection refused, nothing recorded', async () => {
|
||||
const c = await http.curl({ url: server.url(), token: tok });
|
||||
expect(c.exitCode, 'curl: connection refused').toBe(7);
|
||||
expect(c.status).toBe(0);
|
||||
const raw = await http.raw({ host: server.connectHost, port: server.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
|
||||
expect(raw.outcome).toBe('refused');
|
||||
});
|
||||
|
||||
await test.step('deleting twice, then using the deleted token, then listing', async () => {
|
||||
await cli.tokenDelete(tok);
|
||||
model.deactivate(tok);
|
||||
expectCliFailure(await cli.run(['token', 'delete', tok]), { exitCode: 1, message: 'token already inactive' });
|
||||
const s2 = await startServer({ dbPath: ws.dbPath });
|
||||
expectErrorResponse(await http.get(s2.url(), { token: tok }), 401);
|
||||
expect((await s2.stop()).exitCode).toBe(0);
|
||||
model.verifyList(await cli.tokenList());
|
||||
model.verifyList(await cli.tokenListAll(), 'token list all', 'all');
|
||||
expect(readToken(ws.dbPath, tok)!.deleted_at).not.toBeNull();
|
||||
});
|
||||
model.verifyDb(ws.dbPath, 'final', { auditBeforeDeletion: true });
|
||||
});
|
||||
Reference in new issue
Block a user