@ultimat3/testing 29.4.0 → 30.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CLAUDE.md +56 -63
  2. package/README.md +281 -6
  3. package/package.json +27 -16
  4. package/src/ax-node.ts +96 -0
  5. package/src/cassette-errors.ts +91 -0
  6. package/src/cassette-format.ts +175 -0
  7. package/src/cassette-record.ts +120 -0
  8. package/src/cassette-secret-scan.ts +77 -0
  9. package/src/cassette.ts +166 -0
  10. package/src/cdp-browser.ts +5 -3
  11. package/src/cdp-e2e-page.ts +91 -3
  12. package/src/cdp-e2e-session.ts +18 -1
  13. package/src/define-exercise.ts +92 -0
  14. package/src/drill-budgets.ts +54 -0
  15. package/src/drill-errors.ts +45 -0
  16. package/src/drill-fleet.ts +251 -0
  17. package/src/drill-jobs.ts +79 -0
  18. package/src/drill-load.ts +166 -0
  19. package/src/drill-probe.ts +68 -0
  20. package/src/drill-run.ts +144 -0
  21. package/src/drill-services.ts +70 -0
  22. package/src/drill-sync.ts +108 -0
  23. package/src/drill-verdict.ts +256 -0
  24. package/src/drill-writes.ts +123 -0
  25. package/src/drills.ts +109 -0
  26. package/src/e2e-a11y-run.ts +189 -0
  27. package/src/e2e-a11y-tabs.ts +113 -0
  28. package/src/e2e-a11y.ts +153 -0
  29. package/src/e2e-driver.ts +46 -10
  30. package/src/e2e-page.ts +28 -3
  31. package/src/e2e-run.ts +1 -0
  32. package/src/e2e-spawn.ts +3 -2
  33. package/src/errors.ts +43 -0
  34. package/src/exercise-actor.ts +124 -0
  35. package/src/exercise-crash.ts +46 -0
  36. package/src/exercise-input.ts +182 -0
  37. package/src/exercise-override-input.ts +34 -0
  38. package/src/exercise-report.ts +282 -0
  39. package/src/exercise-request.ts +38 -0
  40. package/src/exercise-sweeps.ts +100 -0
  41. package/src/exercise.ts +451 -0
  42. package/src/factories.ts +10 -7
  43. package/src/factory-registry.ts +49 -15
  44. package/src/fake-identity-provider.ts +191 -0
  45. package/src/fault-proxy.ts +342 -0
  46. package/src/file-boundary.ts +4 -1
  47. package/src/fixture-jobs.ts +3 -1
  48. package/src/fixture-statements.ts +4 -1
  49. package/src/free-port.ts +59 -0
  50. package/src/index.ts +148 -3
  51. package/src/island-dom.ts +5 -0
  52. package/src/isolated-plugins.ts +1 -1
  53. package/src/job-crash-proof.ts +249 -0
  54. package/src/job-sim-driver.ts +164 -0
  55. package/src/job-sim-effects.ts +142 -0
  56. package/src/job-sim-errors.ts +72 -0
  57. package/src/job-sim-schedule.ts +154 -0
  58. package/src/job-sim-world.ts +198 -0
  59. package/src/job-simulate.ts +179 -0
  60. package/src/mock-route.ts +81 -0
  61. package/src/module-mock-boundary.ts +85 -0
  62. package/src/module-mock-error.ts +28 -0
  63. package/src/plan-oracle.ts +164 -0
  64. package/src/plan-sweep-fix.ts +123 -0
  65. package/src/plan-sweep-report.ts +84 -0
  66. package/src/plan-sweep.ts +148 -0
  67. package/src/preload.ts +24 -4
  68. package/src/registry-snapshot.ts +1 -1
  69. package/src/repro-stand-in.ts +67 -0
  70. package/src/repro.ts +158 -0
  71. package/src/request-log.ts +64 -0
  72. package/src/reusable-database.ts +1 -1
  73. package/src/scale-sweep-graph.ts +82 -0
  74. package/src/scale-sweep-run.ts +134 -0
  75. package/src/scale-sweep.ts +74 -0
  76. package/src/sealed-network.ts +63 -46
  77. package/src/seed-graph-error.ts +50 -0
  78. package/src/seed-graph-order.ts +98 -0
  79. package/src/seed-graph-pass.ts +85 -0
  80. package/src/seed-graph-plan.ts +81 -0
  81. package/src/seed-graph-row.ts +134 -0
  82. package/src/seed-graph-shape.ts +104 -0
  83. package/src/seed-graph-values.ts +135 -0
  84. package/src/seed-graph.ts +250 -0
  85. package/src/seed-writer.ts +180 -0
  86. package/src/subject-proof-error.ts +23 -0
  87. package/src/subject-proof.ts +180 -0
  88. package/src/template-db-roles.ts +51 -0
  89. package/src/template-db.ts +8 -2
  90. package/src/tenant-canary.ts +120 -0
  91. package/src/tenant-proof-report.ts +62 -0
  92. package/src/tenant-proof.ts +177 -0
  93. package/src/test-types.ts +12 -3
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { renderThrowable } from '@ultimat3/core';
7
7
  import { TestDatabaseUnavailableError } from './errors';
8
+ import { prepareRowSecurity, rowSecurityUrls } from './template-db-roles';
8
9
 
9
10
  export type DbKind = 'postgres' | 'pglite';
10
11
 
@@ -20,6 +21,8 @@ export interface TemplateDbConfig {
20
21
  readonly templateName?: string;
21
22
  /** Applied into the template under an advisory lock, which every worker's clone also holds. */
22
23
  readonly migrate?: (url: string) => Promise<void>;
24
+ /** `database.rowSecurity`: migrate as `x_migrator`, hand the worker a URL that serves as `x_app`. */
25
+ readonly rowSecurity?: boolean | undefined;
23
26
  }
24
27
 
25
28
  export interface WorkerDatabase {
@@ -150,7 +153,10 @@ export async function acquireWorkerDatabase(
150
153
  // server that is not a fresh CI container. Migrations are idempotent and the advisory lock
151
154
  // serialises them, so a failure here is a real failure and `alreadyExists` (a substring match
152
155
  // on the message) would read `relation "x_jobs" already exists` as success.
153
- if (config.migrate !== undefined) await config.migrate(urlFor(adminUrl, template));
156
+ const roles = config.rowSecurity === true;
157
+ if (roles) await prepareRowSecurity(connect, urlFor(adminUrl, template));
158
+ const migrateUrl = rowSecurityUrls(roles, urlFor(adminUrl, template)).migrate;
159
+ if (config.migrate !== undefined) await config.migrate(migrateUrl);
154
160
  // INSIDE the lock: `CREATE DATABASE … TEMPLATE` waits 5 s for every session on the template
155
161
  // to leave, then refuses — and the next worker's migration opens one the moment the lock is
156
162
  // free. Measured: a clone outside it failed whenever that migration ran past 5 s.
@@ -171,7 +177,7 @@ export async function acquireWorkerDatabase(
171
177
  kind: 'postgres',
172
178
  worker,
173
179
  database,
174
- url: urlFor(adminUrl, database),
180
+ url: rowSecurityUrls(config.rowSecurity === true, urlFor(adminUrl, database)).serve,
175
181
  drop: async () => {
176
182
  const cleanup = connect(adminUrl);
177
183
  await cleanup.exec(dropSql(database));
@@ -0,0 +1,120 @@
1
+ // The read half of the tenant proof: what of tenant A a call by B must never hand back — every A
2
+ // row's canary and every A row's id, off the pass's one seed graph — and the walk that finds one in
3
+ // an output or an effect, by path. Taint, not heuristics: only values the seed itself wrote count.
4
+
5
+ import type { SeedGraph, SeedRow } from './seed-graph-plan';
6
+
7
+ /**
8
+ * An id shorter than this is no taint: a serial `1` matches every count in every output. A seeded
9
+ * uuid is 36; a short key is still caught through its row's canary.
10
+ */
11
+ export const MIN_TAINT_ID_LENGTH = 8;
12
+
13
+ /** One tenant instance's taint: its rows, for the write half, and its markers, for the read half. */
14
+ export interface TenantTaint {
15
+ readonly tenant: string;
16
+ /** Every row the seed wrote for the instance, a child with no tenant column included. */
17
+ readonly rows: readonly SeedRow[];
18
+ /** Per row, its primary key as printed: the parts in key order, joined by `,`. */
19
+ readonly ids: readonly string[];
20
+ /** Lower-case: each row's canary, and each key part no row of anyone else shares. */
21
+ readonly marks: readonly string[];
22
+ }
23
+
24
+ const keyParts = (key: Readonly<Record<string, unknown>>): readonly string[] =>
25
+ Object.values(key).flatMap((part) =>
26
+ typeof part === 'string' && part.length >= MIN_TAINT_ID_LENGTH ? [part.toLowerCase()] : [],
27
+ );
28
+
29
+ /**
30
+ * The taint of `tenant`: canaries of its rows that carry one in some column, and its key parts —
31
+ * minus any key part a row outside the instance also has (a composite key naming a shared `plans`
32
+ * row is no proof of whose row came back).
33
+ */
34
+ export function tenantTaint(graph: SeedGraph, tenant: string): TenantTaint {
35
+ // `canaries[i]` is `inserts[i]`'s: the graph writes the two together.
36
+ const mine = graph.inserts.flatMap((row, i) => {
37
+ const canary = graph.canaries[i];
38
+ return row.tenant === tenant && canary?.entity === row.entity ? [{ row, canary }] : [];
39
+ });
40
+ const rows = mine.map((one) => one.row);
41
+ const ids = mine.map((one) => Object.values(one.canary.key).map(String).join(','));
42
+ const ours = mine.map((one) => one.canary);
43
+ const elsewhere = new Set(
44
+ graph.canaries.filter((canary) => canary.tenant !== tenant).flatMap((c) => keyParts(c.key)),
45
+ );
46
+ const marks = new Set<string>();
47
+ for (const canary of ours) {
48
+ if (canary.columns.length > 0) marks.add(canary.marker.toLowerCase());
49
+ for (const part of keyParts(canary.key)) if (!elsewhere.has(part)) marks.add(part);
50
+ }
51
+ return { tenant, rows, ids, marks: [...marks] };
52
+ }
53
+
54
+ /** Every string a value holds, at any depth: what a caller HANDED in, so never a find. */
55
+ const stringsOf = (value: unknown, into: string[] = [], seen = new WeakSet<object>()): string[] => {
56
+ if (typeof value === 'string') into.push(value.toLowerCase());
57
+ else if (typeof value === 'object' && value !== null && !seen.has(value)) {
58
+ seen.add(value);
59
+ const entries = value instanceof Map ? [...value] : Object.entries(value);
60
+ for (const [key, item] of entries) {
61
+ stringsOf(key, into, seen);
62
+ stringsOf(item, into, seen);
63
+ }
64
+ if (value instanceof Set) for (const item of value) stringsOf(item, into, seen);
65
+ }
66
+ return into;
67
+ };
68
+
69
+ /**
70
+ * The marks a call by B can be caught by: the taint, minus every mark B's own input carried.
71
+ * B was handed A's ids on purpose, so an echo of one (`{ id: input.postId, ok: false }`) proves
72
+ * nothing; an id B was NOT handed, or any canary, proves the handler read A's row.
73
+ */
74
+ export function huntedMarks(taint: TenantTaint, input: unknown): readonly string[] {
75
+ const handed = stringsOf(input);
76
+ return taint.marks.filter((mark) => !handed.some((text) => text.includes(mark)));
77
+ }
78
+
79
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
80
+ /** A key that is no identifier is data — an id, an address — so the path says `[*]`, never it. */
81
+ const step = (path: string, key: string): string =>
82
+ IDENTIFIER.test(key) ? `${path}.${key}` : `${path}[*]`;
83
+
84
+ /**
85
+ * Every path under `root` where a string holds a hunted mark — `output.items[0].url`. Object
86
+ * keys are searched too (a record keyed by A's ids is a leak, `output[*]:key`); a path never holds
87
+ * the value found, and the walk stops at a value seen twice.
88
+ */
89
+ export function findTaint(value: unknown, root: string, marks: readonly string[]): string[] {
90
+ const found: string[] = [];
91
+ if (marks.length === 0) return found;
92
+ const tainted = (text: string): boolean => {
93
+ const lower = text.toLowerCase();
94
+ return marks.some((mark) => lower.includes(mark));
95
+ };
96
+ const seen = new WeakSet<object>();
97
+ const walk = (item: unknown, path: string): void => {
98
+ if (typeof item === 'string') {
99
+ if (tainted(item)) found.push(path);
100
+ return;
101
+ }
102
+ if (typeof item !== 'object' || item === null || seen.has(item)) return;
103
+ if (item instanceof Date || ArrayBuffer.isView(item)) return;
104
+ seen.add(item);
105
+ if (Array.isArray(item) || item instanceof Set) {
106
+ for (const [i, one] of [...item].entries()) walk(one, `${path}[${i}]`);
107
+ return;
108
+ }
109
+ const entries: [unknown, unknown][] =
110
+ item instanceof Map ? [...item] : Object.entries(item as Record<string, unknown>);
111
+ for (const [key, one] of entries) {
112
+ const name = String(key);
113
+ const here = step(path, name);
114
+ if (tainted(name)) found.push(`${here}:key`);
115
+ walk(one, here);
116
+ }
117
+ };
118
+ walk(value, root);
119
+ return found;
120
+ }
@@ -0,0 +1,62 @@
1
+ // The tenant proof, as `x exercise` reports it: one line per finding — primitive, leg, and the path
2
+ // of A's canary in B's answer or the table and id of A's changed row — and one coded finding each
3
+ // for the gate to fail on. Only paths, tables and seeded ids are printed: never a bound value.
4
+
5
+ import { renderFixShellArg } from '@ultimat3/core';
6
+ import type { ExerciseRecord, TenantFinding } from './exercise-report';
7
+
8
+ /** One coded finding, the shape `x exercise` hands the gate. */
9
+ export interface TenantProofFinding {
10
+ readonly code: TenantFinding['code'];
11
+ readonly cause: string;
12
+ readonly fix: string;
13
+ /** The primitive, by kind and name: the registry records no declaring file. */
14
+ readonly at: string;
15
+ }
16
+
17
+ const LEG = { before: 'before A ran', after: 'after A ran' } as const;
18
+
19
+ /** `B with A's ids, before A ran: output.items[0].url` — where, never what. */
20
+ const where = (finding: TenantFinding): string =>
21
+ finding.code === 'X_TENANT_LEAK'
22
+ ? `${finding.path ?? 'output'}`
23
+ : `${finding.table ?? '?'} row ${finding.id ?? '?'}`;
24
+
25
+ /** Every finding of every crossed record, one line each: `action readPost · before · output.url`. */
26
+ export function tenantFindingLines(records: readonly ExerciseRecord[]): readonly string[] {
27
+ return records.flatMap((record) =>
28
+ record.tenant?.verdict === 'crossed'
29
+ ? record.tenant.findings.map(
30
+ (finding) =>
31
+ `${record.kind} ${record.name} · ${finding.leg} · ${finding.code} ${where(finding)}`,
32
+ )
33
+ : [],
34
+ );
35
+ }
36
+
37
+ const causeOf = (record: ExerciseRecord, finding: TenantFinding): string =>
38
+ finding.code === 'X_TENANT_LEAK'
39
+ ? `${record.kind} ${record.name}, called by tenant B with tenant A's ids ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row by an id the caller chose, past the tenant guard`
40
+ : `${record.kind} ${record.name}, called by tenant B with tenant A's ids ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row by an id the caller chose, past the tenant guard`;
41
+
42
+ /**
43
+ * One coded finding per tenant finding. The fix reruns the one primitive: what it reads or writes
44
+ * by input id goes through the repository scoped to `ctx.actor` (or `crossTenant()` with a public
45
+ * policy, when it truly spans tenants) — which read, only the handler shows.
46
+ */
47
+ export function tenantProofFindings(
48
+ records: readonly ExerciseRecord[],
49
+ ): readonly TenantProofFinding[] {
50
+ return records.flatMap((record) => {
51
+ if (record.tenant?.verdict !== 'crossed') return [];
52
+ const name = renderFixShellArg(record.name, '<primitive>');
53
+ return record.tenant.findings.map(
54
+ (finding): TenantProofFinding => ({
55
+ code: finding.code,
56
+ cause: causeOf(record, finding),
57
+ fix: `x exercise ${name} --json — then read ${finding.code === 'X_TENANT_LEAK' ? 'the row behind ' : ''}${where(finding)} through the repository scoped to ctx.actor, never by the input id alone`,
58
+ at: `${record.kind} ${record.name}`,
59
+ }),
60
+ );
61
+ });
62
+ }
@@ -0,0 +1,177 @@
1
+ // The tenant proof, one primitive at a time: tenant B calls with A's ids before A's own call and
2
+ // again after it, and the call is crossed if anything of A's — a canary, an id B was not handed —
3
+ // came back in the output or an effect, or if one of A's rows changed. Order B, A, B: a deleting
4
+ // action would leave B nothing to reach, and the second B catches a cache entry A filled.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import type { Policy } from '@ultimat3/policy';
8
+ import { policyMatrix } from '@ultimat3/policy';
9
+ import type {
10
+ EffectPayload,
11
+ ExerciseRecord,
12
+ TenantFinding,
13
+ TenantLeg,
14
+ TenantProof,
15
+ } from './exercise-report';
16
+ import type { SeedRow } from './seed-graph-plan';
17
+ import type { TenantTaint } from './tenant-canary';
18
+ import { findTaint, huntedMarks } from './tenant-canary';
19
+
20
+ export type { TenantFinding, TenantLeg, TenantProof } from './exercise-report';
21
+
22
+ /**
23
+ * `@ultimat3/entity`'s `CROSS_TENANT_SCOPE`, restated: this module stays off entity at run time.
24
+ * `tenant-proof-legs.test.ts` holds the two equal, so a rename there fails here.
25
+ */
26
+ export const CROSS_TENANT_SCOPE = 'tenancy:cross';
27
+
28
+ /**
29
+ * B's actor: the role that grants the policy's permission, never the capability that lifts the
30
+ * tenant guard. An A actor holding `tenancy:cross` legitimately reads across; B attacking with it
31
+ * would prove only that the escape works.
32
+ */
33
+ export const withoutCrossTenant = (actor: Actor | null): Actor | null =>
34
+ actor === null || !actor.scopes.includes(CROSS_TENANT_SCOPE)
35
+ ? actor
36
+ : Object.freeze({ ...actor, scopes: actor.scopes.filter((s) => s !== CROSS_TENANT_SCOPE) });
37
+
38
+ /**
39
+ * Why the read half is skipped, or `undefined`: a policy that admits an anonymous caller with an
40
+ * empty input is public by its own declaration — what it returns, anyone may read. A job has no
41
+ * policy and a predicate the empty input cannot answer is not an admission: both are proven.
42
+ */
43
+ export function publicReason(policy: Policy | undefined): string | undefined {
44
+ if (policy === undefined) return undefined;
45
+ try {
46
+ const row = policyMatrix(policy, { actors: [{ name: 'anonymous', actor: null }], input: {} })
47
+ .rows[0];
48
+ return row?.allowed === true
49
+ ? `${policy.label} admits an anonymous caller, so what it returns is public: read half skipped, write half kept`
50
+ : undefined;
51
+ } catch {
52
+ // A predicate that reads the input throws on the empty one: undecided, so not public.
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /** What one B call answered and what the room saw it do. */
58
+ export interface LegAnswer {
59
+ readonly output: unknown;
60
+ readonly payloads: readonly EffectPayload[];
61
+ /** Every `crossTenant()` reason the leg opened, as `@ultimat3/entity` witnessed it. */
62
+ readonly crossings?: readonly string[] | undefined;
63
+ }
64
+
65
+ export interface TenantAttack {
66
+ readonly taint: TenantTaint;
67
+ /** B's input: A's own first input, A's ids bound — both legs send exactly this. */
68
+ readonly input: unknown;
69
+ /** From `publicReason`: set, the read half is skipped and the verdict at best `public`. */
70
+ readonly public: string | undefined;
71
+ /**
72
+ * A read (a query): A's own answer must hold one of A's rows, or B's empty answer proves
73
+ * nothing — a cache shared across tenants that B filled first hands A the same nothing.
74
+ */
75
+ readonly read: boolean;
76
+ /**
77
+ * A job: its answer goes to no caller, so a leg that opened `crossTenant()` and was handed none
78
+ * of A's marks is a declared sweep — `sendDigest` fanning out one delivery per org — and its read
79
+ * half is skipped. An action or a query answers B directly, so a declared scope excuses nothing.
80
+ */
81
+ readonly sweep: boolean;
82
+ attack(leg: TenantLeg): Promise<LegAnswer>;
83
+ /** A's positive control: today's exercise of the primitive, and what A's last answer was. */
84
+ control(): Promise<{ readonly record: ExerciseRecord; readonly output: unknown }>;
85
+ snapshot(rows: readonly SeedRow[]): Promise<readonly (string | null)[]>;
86
+ }
87
+
88
+ /** A's rows whose digest moved across one B leg — changed, or gone. */
89
+ export function crossedWrites(
90
+ leg: TenantLeg,
91
+ taint: Pick<TenantTaint, 'rows' | 'ids'>,
92
+ before: readonly (string | null)[],
93
+ after: readonly (string | null)[],
94
+ ): readonly TenantFinding[] {
95
+ return taint.rows.flatMap((row, i): TenantFinding[] =>
96
+ before[i] === after[i]
97
+ ? []
98
+ : [{ code: 'X_TENANT_WRITE_CROSSED', leg, table: row.table, id: taint.ids[i] ?? '' }],
99
+ );
100
+ }
101
+
102
+ /** Every path in B's answer holding a mark of A's that B was not handed. */
103
+ export function leaksIn(
104
+ leg: TenantLeg,
105
+ answer: LegAnswer,
106
+ marks: readonly string[],
107
+ ): readonly TenantFinding[] {
108
+ const paths = [
109
+ ...findTaint(answer.output, 'output', marks),
110
+ ...answer.payloads.flatMap((payload) => findTaint(payload.value, payload.path, marks)),
111
+ ];
112
+ return paths.map((path): TenantFinding => ({ code: 'X_TENANT_LEAK', leg, path }));
113
+ }
114
+
115
+ /**
116
+ * The reason a leg's read half is skipped as a declared sweep, or `undefined`: a job, a scope the
117
+ * leg opened, and an input carrying nothing of A's — so no id B chose pointed at A, and what came
118
+ * back is what the sweep hands every caller. Handed one of A's ids, the scope is `tpCrossRead`'s.
119
+ */
120
+ export function sweepReason(
121
+ plan: Pick<TenantAttack, 'sweep' | 'taint'>,
122
+ answer: LegAnswer,
123
+ hunted: readonly string[],
124
+ ): string | undefined {
125
+ const reason = answer.crossings?.[0];
126
+ if (!plan.sweep || reason === undefined || hunted.length !== plan.taint.marks.length) {
127
+ return undefined;
128
+ }
129
+ return `crossTenant(${JSON.stringify(reason)}) declares this job reads across tenants, and B's input named nothing of A's: read half skipped, write half kept`;
130
+ }
131
+
132
+ const verdictOf = (
133
+ plan: TenantAttack,
134
+ control: { readonly record: ExerciseRecord; readonly output: unknown },
135
+ findings: readonly TenantFinding[],
136
+ swept: string | undefined,
137
+ ): TenantProof => {
138
+ const { record } = control;
139
+ if (findings.length > 0) return { verdict: 'crossed', findings };
140
+ if (record.outcome !== 'reached') {
141
+ return {
142
+ verdict: 'unproven',
143
+ reason: `A's own call was not reached (${record.code ?? record.outcome}), so B's clean answers prove nothing`,
144
+ };
145
+ }
146
+ if (plan.public !== undefined) return { verdict: 'public', reason: plan.public };
147
+ if (swept !== undefined) return { verdict: 'public', reason: swept };
148
+ // Every mark of A's, handed ones included: A was handed its own ids, and reading them is the point.
149
+ if (plan.read && findTaint(control.output, 'output', plan.taint.marks).length === 0) {
150
+ return {
151
+ verdict: 'unproven',
152
+ reason: "A's control returned none of A's rows, so B's answers had nothing to leak",
153
+ };
154
+ }
155
+ return { verdict: 'proven' };
156
+ };
157
+
158
+ /** B, A, B — each B leg between two snapshots of A's rows — and A's record with the verdict on it. */
159
+ export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
160
+ const marks = huntedMarks(plan.taint, plan.input);
161
+ const findings: TenantFinding[] = [];
162
+ let swept: string | undefined;
163
+ const leg = async (name: TenantLeg): Promise<void> => {
164
+ const before = await plan.snapshot(plan.taint.rows);
165
+ const answer = await plan.attack(name);
166
+ const after = await plan.snapshot(plan.taint.rows);
167
+ findings.push(...crossedWrites(name, plan.taint, before, after));
168
+ if (plan.public !== undefined) return;
169
+ const sweep = sweepReason(plan, answer, marks);
170
+ if (sweep === undefined) findings.push(...leaksIn(name, answer, marks));
171
+ else swept = sweep;
172
+ };
173
+ await leg('before');
174
+ const control = await plan.control();
175
+ await leg('after');
176
+ return { ...control.record, tenant: verdictOf(plan, control, findings, swept) };
177
+ }
package/src/test-types.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // failure line say which of the six steps it belongs to. See packages/cli/src/verify-tests.ts.
4
4
 
5
5
  import { test } from 'bun:test';
6
+ import type { A11yOptions } from './e2e-a11y-run';
6
7
  import { TestEvalThresholdError } from './errors';
7
8
  import type { FixtureBody } from './fixtures';
8
9
  import { runWithFixtures } from './fixtures';
@@ -116,8 +117,16 @@ export interface E2eFixtures {
116
117
 
117
118
  export type E2eBody = (fixtures: E2eFixtures) => Promise<void>;
118
119
 
119
- /** What registers one e2e test with a browser behind it. `options` is the test's own (`timeoutMs`). */
120
- export type E2eDriver = (name: string, body: E2eBody, options?: TestOptions) => void;
120
+ /**
121
+ * An e2e test's own options. `a11y` is not a switch: every `e2eTest` audits the browser's
122
+ * accessibility tree, and the only exception is a reasoned `allow` entry for one element.
123
+ */
124
+ export interface E2eTestOptions extends TestOptions {
125
+ readonly a11y?: A11yOptions | undefined;
126
+ }
127
+
128
+ /** What registers one e2e test with a browser behind it. `options` is the test's own. */
129
+ export type E2eDriver = (name: string, body: E2eBody, options?: E2eTestOptions) => void;
121
130
 
122
131
  let e2eDriver: E2eDriver | undefined;
123
132
 
@@ -152,7 +161,7 @@ export const resetE2eDriver = (): void => {
152
161
  e2eDriver = undefined;
153
162
  };
154
163
 
155
- export const e2eTest = (name: string, body: E2eBody, options?: TestOptions): void => {
164
+ export const e2eTest = (name: string, body: E2eBody, options?: E2eTestOptions): void => {
156
165
  if (e2eDriver === undefined) {
157
166
  test.skip(
158
167
  testName('e2e', `${name} (no browser driver: x build --target static && x e2e)`),