@ultimat3/testing 29.3.2 → 30.0.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/CLAUDE.md +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +82 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +448 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +68 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// `seedGraph(entities, { tenants, perTable, seed })`: rows for every table, in an order every foreign
|
|
2
|
+
// key accepts, each child pointing at a parent of its OWN tenant, every value storable and every
|
|
3
|
+
// declared rule kept. Pure: it returns the plan, `insertSeedGraph` writes it. Same seed, same bytes.
|
|
4
|
+
|
|
5
|
+
import { finiteCount, finiteOption, fnv1a } from '@ultimat3/core';
|
|
6
|
+
import type { ReferenceDescription } from '@ultimat3/entity';
|
|
7
|
+
import { DEFAULT_SEED, seededUuid } from './determinism';
|
|
8
|
+
import { seedGraphUnsatisfied } from './seed-graph-error';
|
|
9
|
+
import { edgeKey, insertOrder } from './seed-graph-order';
|
|
10
|
+
import type {
|
|
11
|
+
SeedCanary,
|
|
12
|
+
SeedGraph,
|
|
13
|
+
SeedGraphOptions,
|
|
14
|
+
SeedRow,
|
|
15
|
+
SeedUpdate,
|
|
16
|
+
} from './seed-graph-plan';
|
|
17
|
+
import { type RowProblem, solveRow, uniqueKey } from './seed-graph-row';
|
|
18
|
+
import { type EntityShape, type SeedEntity, shapeOf } from './seed-graph-shape';
|
|
19
|
+
import { candidatesFor, storable } from './seed-graph-values';
|
|
20
|
+
|
|
21
|
+
interface Placed {
|
|
22
|
+
readonly tenant: string | null;
|
|
23
|
+
readonly row: Record<string, unknown>;
|
|
24
|
+
readonly index: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const streamSeed = (seed: number, name: string): number => fnv1a(`${seed}:${name}`) || 1;
|
|
28
|
+
|
|
29
|
+
const rotate = <T>(values: readonly T[], by: number): T[] =>
|
|
30
|
+
values.map((_, i) => values[(i + by) % values.length] as T);
|
|
31
|
+
|
|
32
|
+
const keyOf = (entity: SeedEntity, row: Readonly<Record<string, unknown>>) =>
|
|
33
|
+
Object.fromEntries(entity.$primaryKey.map((property) => [property, row[property]]));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every entity whose rows belong to one tenant: a tenant column, a tenant root, or — with neither —
|
|
37
|
+
* a reference path to one that is not cut by `shared`. The last kind is seeded per tenant under
|
|
38
|
+
* its own tenant's parent, so the tenant proof has a row of each instance to attack it through.
|
|
39
|
+
*/
|
|
40
|
+
function ownedByTenant(
|
|
41
|
+
shapes: ReadonlyMap<string, EntityShape>,
|
|
42
|
+
roots: ReadonlyMap<string, string>,
|
|
43
|
+
): ReadonlySet<string> {
|
|
44
|
+
const owned = new Set<string>();
|
|
45
|
+
for (const shape of shapes.values()) {
|
|
46
|
+
if (roots.has(shape.name) || shape.entity.$tenantColumn !== null) owned.add(shape.name);
|
|
47
|
+
}
|
|
48
|
+
// A fixpoint over references: a grandchild is owned through its owned child.
|
|
49
|
+
for (let grew = true; grew; ) {
|
|
50
|
+
grew = false;
|
|
51
|
+
for (const shape of shapes.values()) {
|
|
52
|
+
if (owned.has(shape.name) || shape.entity.$describe().shared !== undefined) continue;
|
|
53
|
+
const reaches = [...shape.references.values()].some(
|
|
54
|
+
(reference) => reference.targetEntity !== shape.name && owned.has(reference.targetEntity),
|
|
55
|
+
);
|
|
56
|
+
if (reaches) {
|
|
57
|
+
owned.add(shape.name);
|
|
58
|
+
grew = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return owned;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function seedGraph(
|
|
66
|
+
entities: Readonly<Record<string, SeedEntity>>,
|
|
67
|
+
options: SeedGraphOptions = {},
|
|
68
|
+
): SeedGraph {
|
|
69
|
+
const seed = finiteOption('seedGraph', 'seed', options.seed ?? DEFAULT_SEED);
|
|
70
|
+
const tenantCount = finiteCount('seedGraph', 'tenants', options.tenants ?? 2, 1);
|
|
71
|
+
const perTable = finiteCount('seedGraph', 'perTable', options.perTable ?? 3, 1);
|
|
72
|
+
// A view entity is a relation Postgres derives: no row is ever written to it, so it is no part of
|
|
73
|
+
// the graph (`X_ENTITY_VIEW_READ_ONLY` otherwise) — the tables it reads from carry the rows. A
|
|
74
|
+
// history table is the same from this side: its trigger writes every row, from the parent's.
|
|
75
|
+
const tables = Object.values(entities).filter((entity) => {
|
|
76
|
+
const described = entity.$describe();
|
|
77
|
+
return described.view === undefined && described.historyOf === undefined;
|
|
78
|
+
});
|
|
79
|
+
const shapes = new Map(tables.map((entity) => [entity.$name, shapeOf(entity)]));
|
|
80
|
+
const { order, deferred } = insertOrder(
|
|
81
|
+
[...shapes.values()].map((shape) => ({
|
|
82
|
+
name: shape.name,
|
|
83
|
+
references: [...shape.references.values()],
|
|
84
|
+
})),
|
|
85
|
+
);
|
|
86
|
+
const tenantStream = seededUuid(streamSeed(seed, '$tenants'));
|
|
87
|
+
const tenants = Array.from({ length: tenantCount }, () => tenantStream());
|
|
88
|
+
// The entity a tenant column points at holds ONE row per tenant, keyed by the tenant itself.
|
|
89
|
+
const roots = new Map<string, string>();
|
|
90
|
+
for (const shape of shapes.values()) {
|
|
91
|
+
const tenantColumn = shape.entity.$tenantColumn;
|
|
92
|
+
const reference = tenantColumn === null ? undefined : shape.references.get(tenantColumn);
|
|
93
|
+
if (reference !== undefined && reference.targetEntity !== shape.name) {
|
|
94
|
+
roots.set(reference.targetEntity, reference.targetProperty);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const owned = ownedByTenant(shapes, roots);
|
|
98
|
+
const scoped = (name: string): boolean => owned.has(name);
|
|
99
|
+
for (const factory of Object.values(options.factories ?? {})) factory.reset();
|
|
100
|
+
|
|
101
|
+
const placed = new Map<string, Placed[]>();
|
|
102
|
+
const parentValues = (
|
|
103
|
+
reference: ReferenceDescription,
|
|
104
|
+
tenant: string | null,
|
|
105
|
+
index: number,
|
|
106
|
+
): unknown[] => {
|
|
107
|
+
if (!shapes.has(reference.targetEntity)) return reference.nullable ? [null] : [];
|
|
108
|
+
const pool = (placed.get(reference.targetEntity) ?? []).filter(
|
|
109
|
+
(parent) => tenant === null || !scoped(reference.targetEntity) || parent.tenant === tenant,
|
|
110
|
+
);
|
|
111
|
+
const values =
|
|
112
|
+
pool.length === 0 ? [] : rotate(pool, index).map((p) => p.row[reference.targetProperty]);
|
|
113
|
+
return reference.nullable ? [...values, null] : values;
|
|
114
|
+
};
|
|
115
|
+
const refuseEmpty = (shape: EntityShape, property: string, tenant: string | null): never => {
|
|
116
|
+
const reference = shape.references.get(property);
|
|
117
|
+
throw seedGraphUnsatisfied({
|
|
118
|
+
entity: shape.name,
|
|
119
|
+
what:
|
|
120
|
+
reference === undefined
|
|
121
|
+
? `no generated value is storable in ${property} — its column refuses every one`
|
|
122
|
+
: !shapes.has(reference.targetEntity)
|
|
123
|
+
? `${property} references ${reference.targetEntity}, which is not in the set seeded — pass it too`
|
|
124
|
+
: `${property} references ${reference.targetEntity}, which has no row${tenant === null ? '' : ' in the same tenant'} to point at`,
|
|
125
|
+
columns: [property],
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const inserts: SeedRow[] = [];
|
|
130
|
+
const canaries: SeedCanary[] = [];
|
|
131
|
+
for (const name of order) {
|
|
132
|
+
const shape = shapes.get(name) as EntityShape;
|
|
133
|
+
const entity = shape.entity;
|
|
134
|
+
const stream = seededUuid(streamSeed(seed, name));
|
|
135
|
+
const factory = options.factories?.[name];
|
|
136
|
+
const rows: Placed[] = [];
|
|
137
|
+
placed.set(name, rows);
|
|
138
|
+
const taken = shape.uniques.map(() => new Set<string>());
|
|
139
|
+
const rootProperty = roots.get(name);
|
|
140
|
+
const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
|
|
141
|
+
let ordinal = 0;
|
|
142
|
+
for (const [bucket, tenant] of buckets.entries()) {
|
|
143
|
+
const size = rootProperty === undefined ? perTable : 1;
|
|
144
|
+
for (let index = 0; index < size; index += 1) {
|
|
145
|
+
ordinal += 1;
|
|
146
|
+
const canary = stream().replace(/-/g, '').slice(0, 10);
|
|
147
|
+
const built: Readonly<Record<string, unknown>> =
|
|
148
|
+
factory === undefined ? {} : { ...factory.build() };
|
|
149
|
+
const columns = shape.columns.map(([property, column], position) => {
|
|
150
|
+
const reference = shape.references.get(property);
|
|
151
|
+
// The tenant key points at a root row only when the root is in the set: refused like any
|
|
152
|
+
// other reference to an entity this graph does not seed.
|
|
153
|
+
const candidates =
|
|
154
|
+
property === entity.$tenantColumn &&
|
|
155
|
+
reference !== undefined &&
|
|
156
|
+
!shapes.has(reference.targetEntity)
|
|
157
|
+
? []
|
|
158
|
+
: property === entity.$tenantColumn || property === rootProperty
|
|
159
|
+
? [tenant]
|
|
160
|
+
: property === shape.softDelete && !Object.hasOwn(built, property)
|
|
161
|
+
? [null]
|
|
162
|
+
: reference !== undefined
|
|
163
|
+
? deferred.has(edgeKey(name, property))
|
|
164
|
+
? [null]
|
|
165
|
+
: parentValues(reference, tenant, index)
|
|
166
|
+
: Object.hasOwn(built, property)
|
|
167
|
+
? storable(column, [built[property]])
|
|
168
|
+
: candidatesFor(property, column, {
|
|
169
|
+
ordinal,
|
|
170
|
+
canary,
|
|
171
|
+
position,
|
|
172
|
+
uuid: stream,
|
|
173
|
+
});
|
|
174
|
+
return [
|
|
175
|
+
property,
|
|
176
|
+
candidates.length === 0 ? refuseEmpty(shape, property, tenant) : candidates,
|
|
177
|
+
] as const;
|
|
178
|
+
});
|
|
179
|
+
const problem: RowProblem = {
|
|
180
|
+
columns,
|
|
181
|
+
checks: shape.checks,
|
|
182
|
+
uniques: shape.uniques,
|
|
183
|
+
taken,
|
|
184
|
+
};
|
|
185
|
+
const solved = solveRow(problem);
|
|
186
|
+
if (!solved.ok) {
|
|
187
|
+
// A table whose unique values run out is full, not broken — as long as EVERY tenant holds a
|
|
188
|
+
// row: a tenant left empty would leave its own children no parent to point at.
|
|
189
|
+
if (solved.capacity && index > 0) break;
|
|
190
|
+
throw seedGraphUnsatisfied({
|
|
191
|
+
entity: name,
|
|
192
|
+
what:
|
|
193
|
+
solved.capacity && rows.length > 0
|
|
194
|
+
? `${solved.broken.join(', ')} runs out of values before tenant ${bucket + 1} of ${buckets.length} holds a row — it is unique across the table, not per tenant`
|
|
195
|
+
: `no generated row keeps ${solved.broken.join(', ')}`,
|
|
196
|
+
columns: solved.properties,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const row = { ...solved.row };
|
|
200
|
+
shape.uniques.forEach((set, i) => {
|
|
201
|
+
const key = uniqueKey(row, set);
|
|
202
|
+
if (key !== undefined) taken[i]?.add(key);
|
|
203
|
+
});
|
|
204
|
+
rows.push({ tenant, row, index });
|
|
205
|
+
inserts.push({ entity: name, table: entity.$table, tenant, row: { ...row } });
|
|
206
|
+
canaries.push({
|
|
207
|
+
entity: name,
|
|
208
|
+
tenant,
|
|
209
|
+
key: keyOf(entity, row),
|
|
210
|
+
marker: canary,
|
|
211
|
+
columns: Object.keys(row).filter(
|
|
212
|
+
(p) => typeof row[p] === 'string' && String(row[p]).includes(canary),
|
|
213
|
+
),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const updates: SeedUpdate[] = [];
|
|
220
|
+
for (const name of order) {
|
|
221
|
+
const shape = shapes.get(name) as EntityShape;
|
|
222
|
+
const later = shape.columns.filter(([property]) => deferred.has(edgeKey(name, property)));
|
|
223
|
+
if (later.length === 0) continue;
|
|
224
|
+
for (const { tenant, row, index } of placed.get(name) ?? []) {
|
|
225
|
+
const columns = shape.columns.map(([property]) => {
|
|
226
|
+
const reference = shape.references.get(property);
|
|
227
|
+
return reference !== undefined && deferred.has(edgeKey(name, property))
|
|
228
|
+
? ([property, parentValues(reference, tenant, index)] as const)
|
|
229
|
+
: ([property, [row[property]]] as const);
|
|
230
|
+
});
|
|
231
|
+
const solved = solveRow({ columns, checks: shape.checks, uniques: [], taken: [] });
|
|
232
|
+
if (!solved.ok) {
|
|
233
|
+
throw seedGraphUnsatisfied({
|
|
234
|
+
entity: name,
|
|
235
|
+
what: `the key set after insert to close a cycle breaks ${solved.broken.join(', ')}`,
|
|
236
|
+
columns: solved.properties,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const patch = Object.fromEntries(
|
|
240
|
+
later.flatMap(([property]) =>
|
|
241
|
+
solved.row[property] === null ? [] : [[property, solved.row[property]]],
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
if (Object.keys(patch).length === 0) continue;
|
|
245
|
+
Object.assign(row, patch);
|
|
246
|
+
updates.push({ entity: name, tenant, key: keyOf(shape.entity, row), patch });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { seed, tenants, order, inserts, updates, canaries };
|
|
250
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `entitySeedWriter`: the `SeedWriter` that writes a seed graph through each entity's own
|
|
2
|
+
// repository, so a seed takes the same writes the app makes — Postgres through a pinned client, or
|
|
3
|
+
// the driver the app reads through (memory, `testDriver()`). It reads rows back too, as digests by
|
|
4
|
+
// primary key: the tenant proof's write half (`snapshot`).
|
|
5
|
+
//
|
|
6
|
+
// `@ultimat3/entity` loads on the first write, never on import: this module is on the barrel, and
|
|
7
|
+
// the barrel stays entity-free.
|
|
8
|
+
|
|
9
|
+
import type { DbClient, SqlFragment } from '@ultimat3/db';
|
|
10
|
+
import { identifier, join, sql } from '@ultimat3/db';
|
|
11
|
+
import type { Driver, EntityCore, Repo } from '@ultimat3/entity';
|
|
12
|
+
import { SeedGraphEntityUnknownError } from './seed-graph-error';
|
|
13
|
+
import type { SeedRow, SeedUpdate, SeedWriter } from './seed-graph-plan';
|
|
14
|
+
|
|
15
|
+
type SeedRepo = Repo<Record<string, unknown>>;
|
|
16
|
+
|
|
17
|
+
/** Stable text for one row: keys sorted, an instant as ISO, bytes as hex — never insertion order. */
|
|
18
|
+
const stable = (value: unknown): unknown => {
|
|
19
|
+
if (value instanceof Date) return value.toISOString();
|
|
20
|
+
if (value instanceof Uint8Array) {
|
|
21
|
+
return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === 'bigint') return value.toString();
|
|
24
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
25
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
26
|
+
const record = value as Record<string, unknown>;
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.keys(record)
|
|
29
|
+
.sort()
|
|
30
|
+
.map((key) => [key, stable(record[key])]),
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const digestRow = (row: unknown): string =>
|
|
35
|
+
new Bun.CryptoHasher('md5').update(JSON.stringify(stable(row))).digest('hex');
|
|
36
|
+
|
|
37
|
+
/** The physical columns of an entity's primary key, in key order. */
|
|
38
|
+
const keyColumns = (entity: EntityCore): readonly string[] => {
|
|
39
|
+
const columns = entity.$describe().columns;
|
|
40
|
+
return entity.$primaryKey.map(
|
|
41
|
+
(property) => columns.find((column) => column.property === property)?.column ?? property,
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `md5(t::text)` per row, by primary key, through the client — the whole stored row, columns no
|
|
47
|
+
* entity declares included. One statement per table for a one-column key, one per row for a
|
|
48
|
+
* composite one. Unscoped on purpose: the question is whether the row changed, not who may read it.
|
|
49
|
+
*/
|
|
50
|
+
async function snapshotOnClient(
|
|
51
|
+
client: DbClient,
|
|
52
|
+
rows: readonly SeedRow[],
|
|
53
|
+
entities: readonly EntityCore[],
|
|
54
|
+
): Promise<readonly (string | null)[]> {
|
|
55
|
+
const out: (string | null)[] = rows.map(() => null);
|
|
56
|
+
const byEntity = new Map<EntityCore, number[]>();
|
|
57
|
+
entities.forEach((entity, i) => {
|
|
58
|
+
byEntity.set(entity, [...(byEntity.get(entity) ?? []), i]);
|
|
59
|
+
});
|
|
60
|
+
for (const [entity, indexes] of byEntity) {
|
|
61
|
+
const table = identifier(entity.$table);
|
|
62
|
+
const columns = keyColumns(entity);
|
|
63
|
+
const keyAt = (i: number, at: number) => String(rows[i]?.row[entity.$primaryKey[at] as string]);
|
|
64
|
+
const [single] = columns;
|
|
65
|
+
if (columns.length === 1 && single !== undefined) {
|
|
66
|
+
const key = identifier(single);
|
|
67
|
+
const found = await client.query<{ k: string; h: string }>(
|
|
68
|
+
sql`select ${key}::text as k, md5(t::text) as h from ${table} t where ${key}::text = any(${indexes.map((i) => keyAt(i, 0))}::text[])`,
|
|
69
|
+
);
|
|
70
|
+
const digests = new Map(found.map((one) => [one.k, one.h]));
|
|
71
|
+
for (const i of indexes) out[i] = digests.get(keyAt(i, 0)) ?? null;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const i of indexes) {
|
|
75
|
+
const terms: SqlFragment[] = columns.map(
|
|
76
|
+
(column, at) => sql`${identifier(column)}::text = ${keyAt(i, at)}`,
|
|
77
|
+
);
|
|
78
|
+
const one = await client.one<{ h: string }>(
|
|
79
|
+
sql`select md5(t::text) as h from ${table} t where ${join(terms, ' and ')}`,
|
|
80
|
+
);
|
|
81
|
+
out[i] = one?.h ?? null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Where the rows go. One or the other, never both: two answers to one question is a compile error. */
|
|
88
|
+
export type SeedWriterTarget =
|
|
89
|
+
/** Postgres (or PGlite) through this client: `postgresRepo(entity, { client })`. */
|
|
90
|
+
| { readonly client: DbClient; readonly driver?: never }
|
|
91
|
+
/** This driver's repositories. Left out: `defaultDriver()`, the one `database()` names by default. */
|
|
92
|
+
| { readonly driver?: Driver; readonly client?: never };
|
|
93
|
+
|
|
94
|
+
export type EntitySeedWriterOptions = SeedWriterTarget & {
|
|
95
|
+
/** The set the plan came from — the same object handed to `seedGraph`. Keyed by anything. */
|
|
96
|
+
readonly entities: Readonly<Record<string, EntityCore>>;
|
|
97
|
+
/**
|
|
98
|
+
* The `orgId` a write runs under. Default: the row's seeded tenant on a tenant-scoped entity, and
|
|
99
|
+
* none on a tenantless one (a tenant root such as `orgs` carries its tenant but no tenant column).
|
|
100
|
+
*/
|
|
101
|
+
readonly tenantOf?: (write: SeedRow | SeedUpdate) => string | undefined;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function entitySeedWriter(options: EntitySeedWriterOptions): SeedWriter {
|
|
105
|
+
const byName = new Map(Object.values(options.entities).map((one) => [one.$name, one]));
|
|
106
|
+
const repos = new Map<string, SeedRepo>();
|
|
107
|
+
let resolve: Promise<(entity: EntityCore) => SeedRepo> | undefined;
|
|
108
|
+
|
|
109
|
+
const resolver = async (): Promise<(entity: EntityCore) => SeedRepo> => {
|
|
110
|
+
const { defaultDriver, postgresRepo } = await import('@ultimat3/entity');
|
|
111
|
+
const { client } = options;
|
|
112
|
+
if (client !== undefined) {
|
|
113
|
+
return (entity) => postgresRepo(entity as EntityCore<Record<string, unknown>>, { client });
|
|
114
|
+
}
|
|
115
|
+
const driver = options.driver ?? defaultDriver();
|
|
116
|
+
return (entity) => driver.repo(entity as EntityCore<Record<string, unknown>>);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const repoFor = async (name: string): Promise<{ entity: EntityCore; repo: SeedRepo }> => {
|
|
120
|
+
const entity = byName.get(name);
|
|
121
|
+
if (entity === undefined) {
|
|
122
|
+
throw new SeedGraphEntityUnknownError({ entity: name, known: [...byName.keys()].sort() });
|
|
123
|
+
}
|
|
124
|
+
resolve ??= resolver();
|
|
125
|
+
const make = await resolve;
|
|
126
|
+
let repo = repos.get(name);
|
|
127
|
+
if (repo === undefined) {
|
|
128
|
+
repo = make(entity);
|
|
129
|
+
repos.set(name, repo);
|
|
130
|
+
}
|
|
131
|
+
return { entity, repo };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const scope = (entity: EntityCore, write: SeedRow | SeedUpdate): { orgId?: string } => {
|
|
135
|
+
const orgId =
|
|
136
|
+
options.tenantOf !== undefined
|
|
137
|
+
? options.tenantOf(write)
|
|
138
|
+
: entity.$tenantColumn === null
|
|
139
|
+
? undefined
|
|
140
|
+
: (write.tenant ?? undefined);
|
|
141
|
+
return orgId === undefined ? {} : { orgId };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
async insert(row) {
|
|
146
|
+
const { entity, repo } = await repoFor(row.entity);
|
|
147
|
+
await repo.insert({ ...row.row }, scope(entity, row));
|
|
148
|
+
},
|
|
149
|
+
async update(update) {
|
|
150
|
+
const { entity, repo } = await repoFor(update.entity);
|
|
151
|
+
await repo.updateWhere({ ...update.key }, { ...update.patch }, scope(entity, update));
|
|
152
|
+
},
|
|
153
|
+
async snapshot(rows) {
|
|
154
|
+
const entities = await Promise.all(
|
|
155
|
+
rows.map(async (row) => (await repoFor(row.entity)).entity),
|
|
156
|
+
);
|
|
157
|
+
// Postgres hashes the whole stored row (`md5(t::text)`), columns no entity declares included.
|
|
158
|
+
if (options.client !== undefined) return snapshotOnClient(options.client, rows, entities);
|
|
159
|
+
return Promise.all(
|
|
160
|
+
rows.map(async (row, i) => {
|
|
161
|
+
const entity = entities[i] as EntityCore;
|
|
162
|
+
const { repo } = await repoFor(row.entity);
|
|
163
|
+
const where = entity.$primaryKey.map((property) => ({
|
|
164
|
+
column: property,
|
|
165
|
+
op: 'eq' as const,
|
|
166
|
+
value: row.row[property],
|
|
167
|
+
}));
|
|
168
|
+
const page = await repo.findMany({
|
|
169
|
+
where,
|
|
170
|
+
limit: 1,
|
|
171
|
+
includeDeleted: true,
|
|
172
|
+
...scope(entity, row),
|
|
173
|
+
});
|
|
174
|
+
const found = page.rows[0];
|
|
175
|
+
return found === undefined ? null : digestRow(found);
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// `X_SUBJECT_ERASE_INCOMPLETE`: the subject proof found the person's seeded value after the erase.
|
|
2
|
+
// Apart from ./errors, which owns the code, its title and the one registration, because that
|
|
3
|
+
// catalogue is at its ceiling. Names the table and the column, never the value.
|
|
4
|
+
|
|
5
|
+
import { renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import './errors';
|
|
7
|
+
|
|
8
|
+
export class SubjectEraseIncompleteError extends UltimateError {
|
|
9
|
+
constructor(input: {
|
|
10
|
+
readonly subject: string;
|
|
11
|
+
readonly table: string;
|
|
12
|
+
readonly column: string;
|
|
13
|
+
readonly what: string;
|
|
14
|
+
}) {
|
|
15
|
+
const table = renderFixLiteral(input.table, '<table>');
|
|
16
|
+
super({
|
|
17
|
+
code: 'X_SUBJECT_ERASE_INCOMPLETE',
|
|
18
|
+
cause: `after subjectErase of one ${input.subject}, ${input.table}.${input.column} ${input.what}: the table is untreated, an anonymize left this column, or it holds a copy of the value with no key to the subject`,
|
|
19
|
+
fix: `x exercise --json # after tables: { ${table}: 'delete' } (or anonymize: { ${input.column}: <value> }) in defineSubject(${input.subject}, …) — or columns: { ${table}: '<column holding the key>' } for a copy with no foreign key`,
|
|
20
|
+
meta: { subject: input.subject, table: input.table, column: input.column },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `subjectProof(subject)`: the subject erase PROVED on generated data. Seed every table with
|
|
2
|
+
// canaries, pick one person, run the erase's table passes as a worker in that person's tenant,
|
|
3
|
+
// then search every table's stored rows for that person's canaries and deleted keys. Anything
|
|
4
|
+
// left outside a declared `retain` is `X_SUBJECT_ERASE_INCOMPLETE`, naming the table and column.
|
|
5
|
+
|
|
6
|
+
import type { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { ctxOf, finiteOption, runWithContext, serviceActor } from '@ultimat3/core';
|
|
8
|
+
import type { Subject, SubjectPlan } from '@ultimat3/jobs';
|
|
9
|
+
import { memoryStepStore, planSubject, stepRunner, subjectEraseTables } from '@ultimat3/jobs';
|
|
10
|
+
import { seedGraph } from './seed-graph';
|
|
11
|
+
import type { SeedGraph, SeedWriter } from './seed-graph-plan';
|
|
12
|
+
import { insertSeedGraph } from './seed-graph-plan';
|
|
13
|
+
import type { SeedEntity } from './seed-graph-shape';
|
|
14
|
+
import { SubjectEraseIncompleteError } from './subject-proof-error';
|
|
15
|
+
|
|
16
|
+
type Stored = Readonly<Record<string, unknown>>;
|
|
17
|
+
|
|
18
|
+
export interface SubjectProofOptions {
|
|
19
|
+
/** Every entity to seed and search — the same set `seedGraph` takes. */
|
|
20
|
+
readonly entities: readonly SeedEntity[];
|
|
21
|
+
readonly writer: SeedWriter;
|
|
22
|
+
/** Every stored row of one table, as the database holds it, unscoped. */
|
|
23
|
+
readonly read: (entity: SeedEntity) => Promise<readonly Stored[]>;
|
|
24
|
+
readonly seed?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SubjectProofResult {
|
|
28
|
+
readonly subject: string;
|
|
29
|
+
/** The person erased, by key; absent when the plan refused before a row was seeded. */
|
|
30
|
+
readonly key?: string;
|
|
31
|
+
readonly problems: readonly UltimateError[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Reached {
|
|
35
|
+
readonly entity: string;
|
|
36
|
+
readonly row: Stored;
|
|
37
|
+
readonly marker: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ownKey = (entity: string, row: Stored, primaryKey: readonly string[]): string =>
|
|
41
|
+
`${entity}|${JSON.stringify(primaryKey.map((property) => row[property]))}`;
|
|
42
|
+
|
|
43
|
+
/** The seeded rows of the person, table by table, walked as the plan walks them: parents first. */
|
|
44
|
+
function reachedRows(
|
|
45
|
+
graph: SeedGraph,
|
|
46
|
+
plan: SubjectPlan,
|
|
47
|
+
entities: ReadonlyMap<string, SeedEntity>,
|
|
48
|
+
subjectRow: Stored,
|
|
49
|
+
) {
|
|
50
|
+
const markers = new Map<string, string>();
|
|
51
|
+
graph.inserts.forEach((insert, i) => {
|
|
52
|
+
const entity = entities.get(insert.entity);
|
|
53
|
+
const marker = graph.canaries[i]?.marker;
|
|
54
|
+
if (entity !== undefined && marker !== undefined) {
|
|
55
|
+
markers.set(ownKey(insert.entity, insert.row, entity.$primaryKey), marker);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const rowsOf = (entity: string) =>
|
|
59
|
+
graph.inserts.filter((i) => i.entity === entity).map((i) => i.row);
|
|
60
|
+
const selected = new Map<string, readonly Stored[]>([[plan.subject, [subjectRow]]]);
|
|
61
|
+
for (const step of [...plan.steps].reverse()) {
|
|
62
|
+
if (step.entity === plan.subject) continue;
|
|
63
|
+
selected.set(
|
|
64
|
+
step.entity,
|
|
65
|
+
rowsOf(step.entity).filter((row) =>
|
|
66
|
+
step.via.some((via) =>
|
|
67
|
+
(selected.get(via.parent) ?? []).some(
|
|
68
|
+
(parent) => parent[via.parentKey] === row[via.property],
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const reached: Reached[] = [];
|
|
75
|
+
for (const [entity, rows] of selected) {
|
|
76
|
+
const core = entities.get(entity);
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
reached.push({
|
|
79
|
+
entity,
|
|
80
|
+
row,
|
|
81
|
+
marker: core === undefined ? undefined : markers.get(ownKey(entity, row, core.$primaryKey)),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return reached;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const asText = (value: unknown): string =>
|
|
89
|
+
typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
|
|
90
|
+
|
|
91
|
+
const physical = (entity: SeedEntity, property: string): string =>
|
|
92
|
+
entity.$describe().columns.find((column) => column.property === property)?.column ?? property;
|
|
93
|
+
|
|
94
|
+
export async function subjectProof<I>(
|
|
95
|
+
subject: Subject<I>,
|
|
96
|
+
options: SubjectProofOptions,
|
|
97
|
+
): Promise<SubjectProofResult> {
|
|
98
|
+
const { registeredEntities } = await import('@ultimat3/entity');
|
|
99
|
+
const { plan, problems } = planSubject({
|
|
100
|
+
subject: subject.entity,
|
|
101
|
+
tables: subject.init.tables,
|
|
102
|
+
columns: subject.init.columns,
|
|
103
|
+
entries: registeredEntities(),
|
|
104
|
+
});
|
|
105
|
+
if (problems.length > 0) return { subject: subject.entity, problems };
|
|
106
|
+
const entities = new Map(options.entities.map((entity) => [entity.$name, entity]));
|
|
107
|
+
const graph = seedGraph(Object.fromEntries(entities), {
|
|
108
|
+
seed: finiteOption('subjectProof', 'seed', options.seed ?? 1),
|
|
109
|
+
tenants: 2,
|
|
110
|
+
});
|
|
111
|
+
await insertSeedGraph(graph, options.writer);
|
|
112
|
+
const tenant = graph.tenants[0] ?? null;
|
|
113
|
+
const person = graph.inserts.find(
|
|
114
|
+
(i) => i.entity === subject.entity && (i.tenant === tenant || i.tenant === null),
|
|
115
|
+
);
|
|
116
|
+
const root = entities.get(subject.entity);
|
|
117
|
+
if (person === undefined || root === undefined) return { subject: subject.entity, problems: [] };
|
|
118
|
+
const key = String(person.row[plan.key]);
|
|
119
|
+
const reached = reachedRows(graph, plan, entities, person.row);
|
|
120
|
+
|
|
121
|
+
const treatments = new Map(Object.entries(subject.init.tables));
|
|
122
|
+
const kept = (entity: string) => {
|
|
123
|
+
const treatment = treatments.get(entity);
|
|
124
|
+
return typeof treatment === 'object' && treatment !== null && 'retain' in treatment;
|
|
125
|
+
};
|
|
126
|
+
const step = stepRunner({
|
|
127
|
+
runId: `subject-proof:${key}`,
|
|
128
|
+
jobName: 'subject-proof',
|
|
129
|
+
store: memoryStepStore(),
|
|
130
|
+
}).step;
|
|
131
|
+
const actor = serviceActor({
|
|
132
|
+
id: 'subject-proof',
|
|
133
|
+
...(tenant === null ? {} : { orgId: tenant }),
|
|
134
|
+
});
|
|
135
|
+
await runWithContext(ctxOf({ role: 'worker', actor }), () =>
|
|
136
|
+
subjectEraseTables(subject, key, step),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const markers = new Set(
|
|
140
|
+
reached.flatMap((r) => (kept(r.entity) || r.marker === undefined ? [] : [r.marker])),
|
|
141
|
+
);
|
|
142
|
+
const gone = reached.filter((r) => treatments.get(r.entity) === 'delete');
|
|
143
|
+
const found: UltimateError[] = [];
|
|
144
|
+
for (const entity of options.entities) {
|
|
145
|
+
if (kept(entity.$name) || entity.$describe().view !== undefined) continue;
|
|
146
|
+
const rows = await options.read(entity);
|
|
147
|
+
const keyColumns = entity.$primaryKey.map((property) => physical(entity, property));
|
|
148
|
+
const deleted = new Set(
|
|
149
|
+
gone
|
|
150
|
+
.filter((r) => r.entity === entity.$name)
|
|
151
|
+
.map((r) => JSON.stringify(entity.$primaryKey.map((p) => String(r.row[p])))),
|
|
152
|
+
);
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
if (deleted.has(JSON.stringify(keyColumns.map((column) => String(row[column]))))) {
|
|
155
|
+
found.push(
|
|
156
|
+
new SubjectEraseIncompleteError({
|
|
157
|
+
subject: subject.entity,
|
|
158
|
+
table: entity.$name,
|
|
159
|
+
column: keyColumns.join(', '),
|
|
160
|
+
what: 'a row the erase deletes is still stored',
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
for (const [column, value] of Object.entries(row)) {
|
|
165
|
+
const text = asText(value);
|
|
166
|
+
if ([...markers].some((marker) => text.includes(marker))) {
|
|
167
|
+
found.push(
|
|
168
|
+
new SubjectEraseIncompleteError({
|
|
169
|
+
subject: subject.entity,
|
|
170
|
+
table: entity.$name,
|
|
171
|
+
column,
|
|
172
|
+
what: 'still holds a seeded value of the subject',
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { subject: subject.entity, key, problems: found };
|
|
180
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Single responsibility: the template database of a `database.rowSecurity` app — the two roles
|
|
2
|
+
// `@ultimat3/db` declares, made able to log in, the migrator migrating and the app role serving. The
|
|
3
|
+
// bootstrap user (`ultimate`, a superuser) would see every tenant's rows, so a suite never serves as it.
|
|
4
|
+
|
|
5
|
+
import { APP_ROLE, MIGRATOR_ROLE, rowSecurityRolesSql } from '@ultimat3/db';
|
|
6
|
+
import type { SqlRunner } from './template-db';
|
|
7
|
+
|
|
8
|
+
type RowSecurityRole = typeof APP_ROLE | typeof MIGRATOR_ROLE;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Test-services credentials only — the role's own name as its password, as `ultimate:ultimate` is.
|
|
12
|
+
* A production role's credentials are the operator's and never written by the framework.
|
|
13
|
+
*/
|
|
14
|
+
export const loginSql = (role: RowSecurityRole): string =>
|
|
15
|
+
`ALTER ROLE "${role}" LOGIN PASSWORD '${role}'`;
|
|
16
|
+
|
|
17
|
+
/** `url` with its credentials swapped for `role`'s, database and parameters kept. */
|
|
18
|
+
export function asRole(url: string, role: RowSecurityRole): string {
|
|
19
|
+
const next = new URL(url);
|
|
20
|
+
next.username = role;
|
|
21
|
+
next.password = role;
|
|
22
|
+
return next.toString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run in the TEMPLATE, before it is migrated: roles are the cluster's, but the schema grants and
|
|
27
|
+
* the default privileges that make a migrator's later table reachable live in the database, and
|
|
28
|
+
* every clone inherits them.
|
|
29
|
+
*/
|
|
30
|
+
export async function prepareRowSecurity(
|
|
31
|
+
connect: (url: string) => SqlRunner,
|
|
32
|
+
templateUrl: string,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const runner = connect(templateUrl);
|
|
35
|
+
try {
|
|
36
|
+
for (const statement of rowSecurityRolesSql()) await runner.exec(statement);
|
|
37
|
+
await runner.exec(loginSql(APP_ROLE));
|
|
38
|
+
await runner.exec(loginSql(MIGRATOR_ROLE));
|
|
39
|
+
} finally {
|
|
40
|
+
await runner.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where `migrate` runs and where a suite connects, with row security on and off. */
|
|
45
|
+
export const rowSecurityUrls = (
|
|
46
|
+
enabled: boolean,
|
|
47
|
+
url: string,
|
|
48
|
+
): { readonly migrate: string; readonly serve: string } =>
|
|
49
|
+
enabled
|
|
50
|
+
? { migrate: asRole(url, MIGRATOR_ROLE), serve: asRole(url, APP_ROLE) }
|
|
51
|
+
: { migrate: url, serve: url };
|