@ultimat3/testing 30.5.0 → 30.7.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.
package/src/seed-graph.ts CHANGED
@@ -63,6 +63,33 @@ function ownedByTenant(
63
63
  return owned;
64
64
  }
65
65
 
66
+ /**
67
+ * Every untenanted entity (`shared`, or of no tenant) a tenant-owned table points at through a key
68
+ * unique across the TABLE — a unique set holding the reference and not the tenant column. Each
69
+ * tenant's children then take parent rows no other tenant may reuse, so the parent seeds
70
+ * `perTable` rows per tenant: `perTable` alone ran out before tenant 2 (`X_SEED_GRAPH_UNSATISFIED`).
71
+ */
72
+ function fannedOut(
73
+ shapes: ReadonlyMap<string, EntityShape>,
74
+ scoped: (name: string) => boolean,
75
+ ): ReadonlySet<string> {
76
+ const out = new Set<string>();
77
+ for (const shape of shapes.values()) {
78
+ if (!scoped(shape.name)) continue;
79
+ const tenantColumn = shape.entity.$tenantColumn;
80
+ const tableWide = [...shape.uniques, ...shape.partialUniques].filter(
81
+ (set) => tenantColumn === null || !set.includes(tenantColumn),
82
+ );
83
+ for (const reference of shape.references.values()) {
84
+ if (scoped(reference.targetEntity) || reference.targetEntity === shape.name) continue;
85
+ if (tableWide.some((set) => set.includes(reference.property))) {
86
+ out.add(reference.targetEntity);
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
66
93
  export function seedGraph(
67
94
  entities: Readonly<Record<string, SeedEntity>>,
68
95
  options: SeedGraphOptions = {},
@@ -108,6 +135,7 @@ export function seedGraph(
108
135
  }
109
136
  const owned = ownedByTenant(shapes, roots);
110
137
  const scoped = (name: string): boolean => owned.has(name);
138
+ const fanned = fannedOut(shapes, scoped);
111
139
  for (const factory of Object.values(options.factories ?? {})) factory.reset();
112
140
 
113
141
  const placed = new Map<string, Placed[]>();
@@ -170,7 +198,8 @@ export function seedGraph(
170
198
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
171
199
  let ordinal = 0;
172
200
  for (const [bucket, tenant] of buckets.entries()) {
173
- const size = rootProperty === undefined ? perTable : 1;
201
+ const size =
202
+ rootProperty !== undefined ? 1 : fanned.has(name) ? perTable * tenantCount : perTable;
174
203
  for (let index = 0; index < size; index += 1) {
175
204
  ordinal += 1;
176
205
  const canary = stream().replace(/-/g, '').slice(0, 10);
@@ -34,10 +34,19 @@ export function tenantFindingLines(records: readonly ExerciseRecord[]): readonly
34
34
  );
35
35
  }
36
36
 
37
+ /**
38
+ * B sends A's own first input, A's ids bound in only where it takes any: `{ occurrenceMs }` carries
39
+ * none, and B still crosses when a statement it ran names no tenant. Both readings, always.
40
+ */
37
41
  const causeOf = (record: ExerciseRecord, finding: TenantFinding): string =>
38
42
  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`;
43
+ ? `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`
44
+ : `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`;
45
+
46
+ const fixOf = (name: string, finding: TenantFinding): string =>
47
+ finding.code === 'X_TENANT_LEAK'
48
+ ? `x exercise ${name} --json — then read the row behind ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`
49
+ : `x exercise ${name} --json — then write ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`;
41
50
 
42
51
  /**
43
52
  * One coded finding per tenant finding. The fix reruns the one primitive: what it reads or writes
@@ -54,7 +63,7 @@ export function tenantProofFindings(
54
63
  (finding): TenantProofFinding => ({
55
64
  code: finding.code,
56
65
  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`,
66
+ fix: fixOf(name, finding),
58
67
  at: `${record.kind} ${record.name}`,
59
68
  }),
60
69
  );
@@ -79,6 +79,12 @@ export interface TenantAttack {
79
79
  * half is skipped. An action or a query answers B directly, so a declared scope excuses nothing.
80
80
  */
81
81
  readonly sweep: boolean;
82
+ /**
83
+ * A job declaring `tenant: 'none'` (`bindJobTenant`'s `none`): no org reaches its run. Handed
84
+ * nothing of A's, B's run is A's run — what it writes to A's rows it writes for every enqueuer,
85
+ * so the write half's premise, B choosing A's row, cannot hold: skipped (`tenantlessReason`).
86
+ */
87
+ readonly tenantless: boolean;
82
88
  attack(leg: TenantLeg): Promise<LegAnswer>;
83
89
  /** A's positive control: today's exercise of the primitive, and what A's last answer was. */
84
90
  control(): Promise<{ readonly record: ExerciseRecord; readonly output: unknown }>;
@@ -129,11 +135,25 @@ export function sweepReason(
129
135
  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
136
  }
131
137
 
138
+ /**
139
+ * The reason the write half is skipped, or `undefined`: a `tenant: 'none'` job whose input carries
140
+ * none of A's marks — no org and no id of B's choosing reaches the run. Handed one of A's ids, B
141
+ * chose A's row and the write half stands. An action or a query runs as B's actor, so B's org
142
+ * reaches it: a write to A's row with no id handed is an unscoped statement, crossed as ever.
143
+ */
144
+ export function tenantlessReason(
145
+ plan: Pick<TenantAttack, 'tenantless' | 'taint'>,
146
+ hunted: readonly string[],
147
+ ): string | undefined {
148
+ if (!plan.tenantless || hunted.length !== plan.taint.marks.length) return undefined;
149
+ return "tenant: 'none' runs this job under no org, and B's input named nothing of A's: B's run is A's run, so what it writes it writes for every enqueuer — write half skipped, read half kept";
150
+ }
151
+
132
152
  const verdictOf = (
133
153
  plan: TenantAttack,
134
154
  control: { readonly record: ExerciseRecord; readonly output: unknown },
135
155
  findings: readonly TenantFinding[],
136
- swept: string | undefined,
156
+ skipped: readonly string[],
137
157
  ): TenantProof => {
138
158
  const { record } = control;
139
159
  if (findings.length > 0) return { verdict: 'crossed', findings };
@@ -144,7 +164,7 @@ const verdictOf = (
144
164
  };
145
165
  }
146
166
  if (plan.public !== undefined) return { verdict: 'public', reason: plan.public };
147
- if (swept !== undefined) return { verdict: 'public', reason: swept };
167
+ if (skipped.length > 0) return { verdict: 'public', reason: skipped.join('; ') };
148
168
  // Every mark of A's, handed ones included: A was handed its own ids, and reading them is the point.
149
169
  if (plan.read && findTaint(control.output, 'output', plan.taint.marks).length === 0) {
150
170
  return {
@@ -159,12 +179,19 @@ const verdictOf = (
159
179
  export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
160
180
  const marks = huntedMarks(plan.taint, plan.input);
161
181
  const findings: TenantFinding[] = [];
182
+ const unwritten = tenantlessReason(plan, marks);
162
183
  let swept: string | undefined;
163
- const leg = async (name: TenantLeg): Promise<void> => {
184
+ /** B's call, between two snapshots unless the write half is skipped: then none is read. */
185
+ const watched = async (name: TenantLeg): Promise<LegAnswer> => {
186
+ if (unwritten !== undefined) return plan.attack(name);
164
187
  const before = await plan.snapshot(plan.taint.rows);
165
188
  const answer = await plan.attack(name);
166
189
  const after = await plan.snapshot(plan.taint.rows);
167
190
  findings.push(...crossedWrites(name, plan.taint, before, after));
191
+ return answer;
192
+ };
193
+ const leg = async (name: TenantLeg): Promise<void> => {
194
+ const answer = await watched(name);
168
195
  if (plan.public !== undefined) return;
169
196
  const sweep = sweepReason(plan, answer, marks);
170
197
  if (sweep === undefined) findings.push(...leaksIn(name, answer, marks));
@@ -173,5 +200,6 @@ export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
173
200
  await leg('before');
174
201
  const control = await plan.control();
175
202
  await leg('after');
176
- return { ...control.record, tenant: verdictOf(plan, control, findings, swept) };
203
+ const skipped = [swept, unwritten].filter((reason) => reason !== undefined);
204
+ return { ...control.record, tenant: verdictOf(plan, control, findings, skipped) };
177
205
  }
@@ -1,216 +0,0 @@
1
- // Strings a declared `matches(/…/)` pattern accepts, built by walking the pattern rather than
2
- // guessing shapes. The common subset only — literals, classes, `.`, groups, alternation,
3
- // quantifiers, anchors — and an honest `[]` for anything past it (lookaround, backreference, `\b`,
4
- // `\p{…}`): a pattern this cannot read leaves the column's other candidates to the solver.
5
-
6
- import { fnv1a } from '@ultimat3/core';
7
- import { seededRandom } from './determinism';
8
-
9
- type Node =
10
- | { readonly kind: 'chars'; readonly chars: readonly string[] }
11
- | { readonly kind: 'alt'; readonly branches: readonly (readonly Piece[])[] }
12
- | { readonly kind: 'empty' };
13
-
14
- interface Piece {
15
- readonly node: Node;
16
- readonly min: number;
17
- readonly max: number;
18
- }
19
-
20
- /** How far past its floor an unbounded quantifier draws: enough to vary, short enough to store. */
21
- const UNBOUNDED_SPAN = 6;
22
-
23
- /** The universe a class or `.` picks from: printable ASCII, so every sample is plain text. */
24
- const PRINTABLE = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i));
25
- const DIGITS = PRINTABLE.filter((c) => c >= '0' && c <= '9');
26
- const WORD = PRINTABLE.filter((c) => /\w/.test(c));
27
- const SPACE = [' ', '\t'];
28
-
29
- const SHORTHAND: Readonly<Record<string, readonly string[]>> = {
30
- d: DIGITS,
31
- D: PRINTABLE.filter((c) => !DIGITS.includes(c)),
32
- w: WORD,
33
- W: PRINTABLE.filter((c) => !WORD.includes(c)),
34
- s: SPACE,
35
- S: PRINTABLE.filter((c) => c !== ' '),
36
- };
37
-
38
- const CONTROL: Readonly<Record<string, string>> = { t: '\t', n: '\n', r: '\r', f: '\f', v: '\v' };
39
-
40
- class Parser {
41
- private at = 0;
42
- /** The first construct past the subset; set once, and it ends the walk. */
43
- private refused: string | undefined;
44
- constructor(private readonly source: string) {}
45
-
46
- /** The tree, or `undefined` when any part of the pattern is past the subset. */
47
- parse(): Node | undefined {
48
- const node = this.alternation();
49
- if (this.refused === undefined && this.at < this.source.length) this.refuse('a stray ")"');
50
- return this.refused === undefined ? node : undefined;
51
- }
52
-
53
- /** Records the refusal and moves to the end, so every loop above stops on its next look. */
54
- private refuse(construct: string): readonly string[] {
55
- this.refused ??= construct;
56
- this.at = this.source.length;
57
- return [];
58
- }
59
-
60
- private peek(): string | undefined {
61
- return this.source[this.at];
62
- }
63
-
64
- private alternation(): Node {
65
- const branches: Piece[][] = [this.sequence()];
66
- while (this.peek() === '|') {
67
- this.at += 1;
68
- branches.push(this.sequence());
69
- }
70
- return { kind: 'alt', branches };
71
- }
72
-
73
- private sequence(): Piece[] {
74
- const pieces: Piece[] = [];
75
- for (let c = this.peek(); c !== undefined && c !== '|' && c !== ')'; c = this.peek()) {
76
- const node = this.atom();
77
- const [min, max] = this.quantifier();
78
- pieces.push({ node, min, max });
79
- }
80
- return pieces;
81
- }
82
-
83
- private quantifier(): readonly [number, number] {
84
- const c = this.peek();
85
- const bounds = /^\{(\d+)(,(\d*))?\}/.exec(this.source.slice(this.at));
86
- let range: readonly [number, number] | undefined;
87
- if (c === '*') range = [0, UNBOUNDED_SPAN];
88
- else if (c === '+') range = [1, 1 + UNBOUNDED_SPAN];
89
- else if (c === '?') range = [0, 1];
90
- else if (bounds !== null) {
91
- const low = Number(bounds[1]);
92
- const high =
93
- bounds[2] === undefined ? low : bounds[3] === '' ? low + UNBOUNDED_SPAN : Number(bounds[3]);
94
- range = [low, high];
95
- }
96
- if (range === undefined) return [1, 1];
97
- this.at += bounds !== null && c === '{' ? bounds[0].length : 1;
98
- // A lazy quantifier matches the same strings; only the engine's preference differs.
99
- if (this.peek() === '?') this.at += 1;
100
- return range;
101
- }
102
-
103
- private atom(): Node {
104
- const c = this.peek() as string;
105
- this.at += 1;
106
- if (c === '^' || c === '$') return { kind: 'empty' };
107
- if (c === '.') return { kind: 'chars', chars: PRINTABLE };
108
- if (c === '[') return { kind: 'chars', chars: this.charClass() };
109
- if (c === '(') return this.group();
110
- if (c === '\\') return { kind: 'chars', chars: this.escape(false) };
111
- if (c === '*' || c === '+' || c === '?' || c === '{')
112
- return { kind: 'chars', chars: this.refuse(`a bare "${c}"`) };
113
- return { kind: 'chars', chars: [c] };
114
- }
115
-
116
- private group(): Node {
117
- if (this.peek() === '?') {
118
- const head = this.source.slice(this.at, this.at + 3);
119
- if (head.startsWith('?:')) this.at += 2;
120
- else if (/^\?<[A-Za-z_$]/.test(head) && this.source.includes('>', this.at)) {
121
- this.at = this.source.indexOf('>', this.at) + 1;
122
- } else return { kind: 'chars', chars: this.refuse(`the lookaround "(${head}"`) };
123
- }
124
- const inner = this.alternation();
125
- if (this.peek() !== ')') return { kind: 'chars', chars: this.refuse('an unclosed group') };
126
- this.at += 1;
127
- return inner;
128
- }
129
-
130
- private escape(inClass: boolean): readonly string[] {
131
- const c = this.peek();
132
- if (c === undefined) return this.refuse('a trailing backslash');
133
- this.at += 1;
134
- const shorthand = Object.hasOwn(SHORTHAND, c) ? SHORTHAND[c] : undefined;
135
- if (shorthand !== undefined) return shorthand;
136
- const control = Object.hasOwn(CONTROL, c) ? CONTROL[c] : undefined;
137
- if (control !== undefined) return [control];
138
- const hex = c === 'x' ? 2 : c === 'u' ? 4 : 0;
139
- if (hex > 0) {
140
- const digits = this.source.slice(this.at, this.at + hex);
141
- if (!/^[0-9a-fA-F]+$/.test(digits) || digits.length !== hex) return this.refuse(`\\${c}`);
142
- this.at += hex;
143
- return [String.fromCharCode(Number.parseInt(digits, 16))];
144
- }
145
- // `\b`, `\B`, a backreference, `\p{…}`, `\k<…>`, `\0`: a position or a reference, not a char.
146
- if (/[A-Za-z0-9]/.test(c)) return this.refuse(`\\${c}${inClass ? ' in a class' : ''}`);
147
- return [c];
148
- }
149
-
150
- /** The members of `[…]`, drawn from printable ASCII; a negated class is its complement there. */
151
- private charClass(): readonly string[] {
152
- const negated = this.peek() === '^';
153
- if (negated) this.at += 1;
154
- const members = new Set<string>();
155
- let first = true;
156
- for (let c = this.peek(); c !== ']' || first; c = this.peek()) {
157
- if (c === undefined) return this.refuse('an unclosed class');
158
- first = false;
159
- const low = this.classMember();
160
- if (low.length === 1 && this.peek() === '-' && this.source[this.at + 1] !== ']') {
161
- this.at += 1;
162
- const high = this.classMember();
163
- if (high.length !== 1) return this.refuse('a range ending in a shorthand');
164
- const [from, to] = [(low[0] as string).charCodeAt(0), (high[0] as string).charCodeAt(0)];
165
- // Past printable ASCII the range's own first member stands in, so `[À-ÿ]` still yields one.
166
- const inside = PRINTABLE.filter((ch) => ch.charCodeAt(0) >= from && ch.charCodeAt(0) <= to);
167
- for (const ch of inside.length > 0 ? inside : [String.fromCharCode(from)]) members.add(ch);
168
- } else {
169
- for (const ch of low) members.add(ch);
170
- }
171
- }
172
- this.at += 1;
173
- return negated ? PRINTABLE.filter((ch) => !members.has(ch)) : [...members];
174
- }
175
-
176
- private classMember(): readonly string[] {
177
- const c = this.peek() as string;
178
- this.at += 1;
179
- return c === '\\' ? this.escape(true) : [c];
180
- }
181
- }
182
-
183
- const draw = (node: Node, random: () => number): string => {
184
- const pick = <T>(items: readonly T[]): T | undefined =>
185
- items[Math.floor(random() * items.length)];
186
- if (node.kind === 'empty') return '';
187
- if (node.kind === 'chars') return pick(node.chars) ?? '';
188
- const branch = pick(node.branches) ?? [];
189
- return branch
190
- .map((piece) => {
191
- const count = piece.min + Math.floor(random() * (piece.max - piece.min + 1));
192
- return Array.from({ length: count }, () => draw(piece.node, random)).join('');
193
- })
194
- .join('');
195
- };
196
-
197
- /** Tries per sample: a draw that crosses an anchor (`a^b`) or a flag is retried, never returned. */
198
- const ATTEMPTS = 8;
199
-
200
- /**
201
- * Up to `count` distinct strings `pattern.test` accepts, the same ones for the same `seed` on any
202
- * machine. Empty when the pattern is past the subset, or when no draw lands inside it.
203
- */
204
- export function patternSamples(pattern: RegExp, seed: string, count = 4): readonly string[] {
205
- const tree = new Parser(pattern.source).parse();
206
- if (tree === undefined) return [];
207
- const random = seededRandom(fnv1a(`${pattern.source}/${pattern.flags}:${seed}`) || 1);
208
- // `lastIndex` would make `test` stateful under `g`/`y`; a copy without them judges each draw.
209
- const judge = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
210
- const out: string[] = [];
211
- for (let attempt = 0; attempt < count * ATTEMPTS && out.length < count; attempt += 1) {
212
- const sample = draw(tree, random);
213
- if (judge.test(sample) && !out.includes(sample)) out.push(sample);
214
- }
215
- return out;
216
- }