@substrat-run/contract-tests 0.87.0 → 0.89.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 (48) hide show
  1. package/dist/concurrency-suite.d.ts +3 -0
  2. package/dist/concurrency-suite.d.ts.map +1 -0
  3. package/dist/concurrency-suite.js +228 -0
  4. package/dist/concurrency-suite.js.map +1 -0
  5. package/dist/conformance.d.ts +85 -0
  6. package/dist/conformance.d.ts.map +1 -0
  7. package/dist/conformance.js +17 -0
  8. package/dist/conformance.js.map +1 -0
  9. package/dist/entity-check-plan.d.ts +138 -0
  10. package/dist/entity-check-plan.d.ts.map +1 -0
  11. package/dist/entity-check-plan.js +181 -0
  12. package/dist/entity-check-plan.js.map +1 -0
  13. package/dist/entity-check-suite.d.ts +3 -90
  14. package/dist/entity-check-suite.d.ts.map +1 -1
  15. package/dist/entity-check-suite.js +42 -71
  16. package/dist/entity-check-suite.js.map +1 -1
  17. package/dist/entity-version-suite.d.ts +3 -0
  18. package/dist/entity-version-suite.d.ts.map +1 -0
  19. package/dist/entity-version-suite.js +160 -0
  20. package/dist/entity-version-suite.js.map +1 -0
  21. package/dist/idempotency-suite.d.ts +3 -0
  22. package/dist/idempotency-suite.d.ts.map +1 -0
  23. package/dist/idempotency-suite.js +243 -0
  24. package/dist/idempotency-suite.js.map +1 -0
  25. package/dist/index.d.ts +9 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/input-parse-suite.d.ts +3 -0
  30. package/dist/input-parse-suite.d.ts.map +1 -0
  31. package/dist/input-parse-suite.js +149 -0
  32. package/dist/input-parse-suite.js.map +1 -0
  33. package/dist/modules.d.ts +482 -160
  34. package/dist/modules.d.ts.map +1 -1
  35. package/dist/modules.js +367 -2
  36. package/dist/modules.js.map +1 -1
  37. package/dist/node-only-suite.d.ts +28 -0
  38. package/dist/node-only-suite.d.ts.map +1 -1
  39. package/dist/node-only-suite.js +43 -1
  40. package/dist/node-only-suite.js.map +1 -1
  41. package/dist/permission-suite.d.ts.map +1 -1
  42. package/dist/permission-suite.js +125 -0
  43. package/dist/permission-suite.js.map +1 -1
  44. package/dist/timeline-suite.d.ts +3 -0
  45. package/dist/timeline-suite.d.ts.map +1 -0
  46. package/dist/timeline-suite.js +223 -0
  47. package/dist/timeline-suite.js.map +1 -0
  48. package/package.json +11 -3
@@ -0,0 +1,181 @@
1
+ /** An operation declaring an entity-narrowed check — the ones in scope here. */
2
+ function entityCheckOf(op) {
3
+ const permission = op.permission;
4
+ if (!permission || typeof permission === 'string')
5
+ return undefined;
6
+ return permission.entity || permission.entityFrom || permission.refFrom ? permission : undefined;
7
+ }
8
+ /**
9
+ * The values a schema admits, when it admits a knowable few.
10
+ *
11
+ * `z.literal('workorder')` and `z.literal(['workorder', 'protocol'])` publish a
12
+ * `values` Set; `z.enum([…])` publishes `options`. An open `z.string()` publishes
13
+ * neither, and that is the answer the caller needs — not a guess.
14
+ */
15
+ function admissibleValues(schema) {
16
+ const values = schema.values;
17
+ if (values instanceof Set)
18
+ return [...values];
19
+ const options = schema.options;
20
+ if (Array.isArray(options))
21
+ return options;
22
+ return undefined;
23
+ }
24
+ /**
25
+ * The fields whose value the SCHEMA already fixes — one admissible value each.
26
+ *
27
+ * #890. A timeline operation declares `entity: 'workorder'` and takes
28
+ * `entityType: z.literal('workorder')`, so the fixture used to be handed
29
+ * `{ entityType: 'workorder' }` by hand: the same constant written twice, in two
30
+ * files, with nothing holding them together. Written as `'customer'` by mistake
31
+ * and case 1 fails claiming the handler checks the node — a false accusation
32
+ * against correct code, which is the kind of red that gets a suite disabled.
33
+ *
34
+ * So the kit reads the constant instead of being told it. A literal with more
35
+ * than one admissible value is deliberately NOT read: driving it means driving
36
+ * once per value, which is a different feature, and `.value` throws there rather
37
+ * than picking one.
38
+ */
39
+ function fixedFields(op, idField) {
40
+ const shape = op.input?.shape;
41
+ if (!shape)
42
+ return {};
43
+ const fixed = {};
44
+ for (const [field, schema] of Object.entries(shape)) {
45
+ if (field === idField)
46
+ continue;
47
+ const values = admissibleValues(schema);
48
+ if (values?.length === 1)
49
+ fixed[field] = values[0];
50
+ }
51
+ return fixed;
52
+ }
53
+ /**
54
+ * The input fields an operation REQUIRES, beyond the one carrying the entity id.
55
+ *
56
+ * Read off the schema rather than asked for: an operation taking only an id
57
+ * needs no fixture entry, and one that needs more says so precisely instead of
58
+ * failing later as a validation error the reader has to decode.
59
+ *
60
+ * A field the schema fixes to one value is not one of them, nor is the type field
61
+ * an `entityFrom` check names — the kit supplies both, so demanding a sample
62
+ * input for either would report a gap that is not there.
63
+ */
64
+ function requiredExtras(op, idField, supplied = []) {
65
+ const shape = op.input?.shape;
66
+ if (!shape)
67
+ return [];
68
+ const kitSupplies = new Set([idField, ...supplied, ...Object.keys(fixedFields(op, idField))]);
69
+ return Object.entries(shape)
70
+ .filter(([field]) => !kitSupplies.has(field))
71
+ .filter(([, schema]) => {
72
+ const parse = schema.safeParse;
73
+ // No `safeParse` means we cannot tell — treat it as required, because
74
+ // guessing "optional" is the answer that silently drops the operation.
75
+ return typeof parse !== 'function' || !parse.call(schema, undefined).success;
76
+ })
77
+ .map(([field]) => field);
78
+ }
79
+ /**
80
+ * Partition an operation set into what this kit can drive and what it cannot.
81
+ *
82
+ * Exported and pure so the classification is testable on its own. It is the part
83
+ * that decides what counts as covered, and a bug here is invisible in the worst
84
+ * way — it would drop an operation from the suite while every remaining test
85
+ * still passed.
86
+ *
87
+ * Out of scope entirely (neither covered nor uncovered): an operation with a
88
+ * bare-key node check, or one declaring `narrows`. Neither claims an entity
89
+ * check, so neither has one to honour.
90
+ *
91
+ * An `entityFrom` operation appears ONCE PER ADMISSIBLE TYPE (#890) — the pair is
92
+ * what tells a correct check from a node check, and it is worth no less for the
93
+ * second type than for the first. So `callout/timeline` is driven twice, over a
94
+ * work order and over a protocol, and a handler that honoured the check for one
95
+ * and not the other has nowhere left to hide.
96
+ */
97
+ export function planEntityCheckCoverage(operations, inputs = {}, refEntityType) {
98
+ const covered = [];
99
+ const uncovered = {};
100
+ for (const [name, raw] of Object.entries(operations).sort(([a], [b]) => a.localeCompare(b))) {
101
+ const op = raw;
102
+ const check = entityCheckOf(op);
103
+ if (!check)
104
+ continue;
105
+ // The ref case first: it names neither a type nor an id field, because the
106
+ // field it names carries both (#896).
107
+ if (check.refFrom) {
108
+ const path = check.refFrom.split('.');
109
+ if (!refEntityType) {
110
+ uncovered[name] =
111
+ `declares 'refFrom: ${check.refFrom}' and the suite named no 'refEntityType' — the ` +
112
+ 'check narrows to a type this module cannot know, so the harness has to say which ' +
113
+ 'one it can create';
114
+ continue;
115
+ }
116
+ if (!(path[0] in (op.input?.shape ?? {}))) {
117
+ uncovered[name] = `declares 'refFrom: ${check.refFrom}', which names no input field`;
118
+ continue;
119
+ }
120
+ const missing = requiredExtras(op, '', [path[0]]).filter((f) => inputs[name]?.[f] === undefined);
121
+ if (missing.length > 0) {
122
+ uncovered[name] = `no sample input for required field(s): ${missing.join(', ')}`;
123
+ continue;
124
+ }
125
+ covered.push({
126
+ name,
127
+ key: check.key,
128
+ entity: refEntityType,
129
+ target: { kind: 'ref', path },
130
+ fixed: fixedFields(op, path[0]),
131
+ });
132
+ continue;
133
+ }
134
+ if (!check.idFrom) {
135
+ uncovered[name] =
136
+ `declares 'resolved' (${check.resolved ?? 'no reason given'}) — the entity id is not ` +
137
+ 'in the input, so the harness cannot reach the entity';
138
+ continue;
139
+ }
140
+ // One fixed type, or the several the type field's schema admits (#890). An
141
+ // `entityFrom` pointing at a field that enumerates nothing is a gap with a
142
+ // name: the kit will not invent a type, because driving one arm of an
143
+ // operation and reporting the operation covered is the overclaim this whole
144
+ // suite exists to avoid.
145
+ let types;
146
+ if (check.entity) {
147
+ types = [check.entity];
148
+ }
149
+ else {
150
+ const field = check.entityFrom;
151
+ const values = admissibleValues(op.input?.shape?.[field]);
152
+ if (!values || values.length === 0) {
153
+ uncovered[name] =
154
+ `declares 'entityFrom: ${field}', whose schema does not enumerate the types it ` +
155
+ 'admits — the kit cannot know which entity to create, and will not guess one';
156
+ continue;
157
+ }
158
+ types = values;
159
+ }
160
+ const typeField = check.entityFrom ? [check.entityFrom] : [];
161
+ const missing = requiredExtras(op, check.idFrom, typeField).filter((f) => inputs[name]?.[f] === undefined);
162
+ if (missing.length > 0) {
163
+ uncovered[name] = `no sample input for required field(s): ${missing.join(', ')}`;
164
+ continue;
165
+ }
166
+ for (const type of types) {
167
+ covered.push({
168
+ name,
169
+ key: check.key,
170
+ entity: String(type),
171
+ target: { kind: 'id', path: [check.idFrom] },
172
+ fixed: {
173
+ ...fixedFields(op, check.idFrom),
174
+ ...(check.entityFrom ? { [check.entityFrom]: type } : {}),
175
+ },
176
+ });
177
+ }
178
+ }
179
+ return { covered, uncovered };
180
+ }
181
+ //# sourceMappingURL=entity-check-plan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-check-plan.js","sourceRoot":"","sources":["../src/entity-check-plan.ts"],"names":[],"mappings":"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"}
@@ -1,93 +1,6 @@
1
- import type { EntityRef } from '@substrat-run/contracts';
2
- /** What a vertical supplies so the kit can drive its operations. */
3
- export interface EntityCheckFixture {
4
- /**
5
- * Create one entity of this declared type and return its id.
6
- *
7
- * Called for each case, so every case gets a world nobody else has touched —
8
- * the operation under test may well delete the thing it is given.
9
- */
10
- createEntity(entityType: string): Promise<string>;
11
- /**
12
- * Grant `permission` to the probe principal, narrowed to exactly this entity.
13
- *
14
- * Deliberately NOT the vertical's own sharing operation: using `share-list` to
15
- * set up the test for `share-list` would prove only that it agrees with
16
- * itself. Reach for the admin grant.
17
- */
18
- grantOnEntity(permission: string, entity: EntityRef): Promise<void>;
19
- /** Invoke as the probe principal — the one holding only narrowed grants. */
20
- invoke(operation: string, input: Record<string, unknown>): Promise<unknown>;
21
- /**
22
- * Is this error a permission denial? Defaults to `PermissionDenied`, which is
23
- * what a stub throws; a fixture driving HTTP would test for its 403 instead.
24
- */
25
- isDenial?(error: unknown): boolean;
26
- }
27
- export interface EntityCheckSuiteOptions {
28
- /**
29
- * Extra input fields per operation, beyond the entity id the kit supplies.
30
- *
31
- * Only needed where the operation's schema has REQUIRED fields besides the id
32
- * — the kit reads the schema to find out, so an operation taking nothing but
33
- * an id needs no entry here.
34
- */
35
- readonly inputs?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
36
- /**
37
- * Permissions an operation needs BEYOND the one it declares, granted on the
38
- * same target entity — with the reason it needs them.
39
- *
40
- * The first vertical this kit ran against produced one immediately.
41
- * `todo/share-list` declares `list:manage` and honours it, then calls
42
- * `ctx.grant` to hand `list:contribute` to the invitee — and delegation only
43
- * works for a permission the caller HOLDS. A principal granted `list:manage`
44
- * alone is refused, correctly, by the second gate.
45
- *
46
- * So an operation's declared permission is the gate it opens with, not
47
- * necessarily the whole authority it exercises. That gap is invisible in
48
- * production here only because todo's bootstrap grant hands every owner both
49
- * keys on their own entity, so nobody ever holds one without the other.
50
- *
51
- * `because` is required rather than a comment: this is the one place the gap
52
- * gets written down, and an entry without a reason is indistinguishable from
53
- * someone widening the grant until the test went green.
54
- */
55
- readonly alsoGrant?: Readonly<Record<string, {
56
- readonly permissions: readonly string[];
57
- readonly because: string;
58
- }>>;
59
- /**
60
- * The in-scope operations this kit cannot generate, each with its reason.
61
- *
62
- * Asserted EXACTLY: an operation that becomes uncoverable, or one that stops
63
- * being, fails until this list is updated. That is the point — it is the
64
- * coverage gap made reviewable rather than invisible.
65
- */
66
- readonly uncovered?: Readonly<Record<string, string>>;
67
- }
68
- /** One operation the kit can drive, with the declaration it was read from. */
69
- export interface PlannedCheck {
70
- readonly name: string;
71
- readonly key: string;
72
- readonly entity: string;
73
- readonly idFrom: string;
74
- }
75
- /**
76
- * Partition an operation set into what this kit can drive and what it cannot.
77
- *
78
- * Exported and pure so the classification is testable on its own. It is the part
79
- * that decides what counts as covered, and a bug here is invisible in the worst
80
- * way — it would drop an operation from the suite while every remaining test
81
- * still passed.
82
- *
83
- * Out of scope entirely (neither covered nor uncovered): an operation with a
84
- * bare-key node check, or one declaring `narrows`. Neither claims an entity
85
- * check, so neither has one to honour.
86
- */
87
- export declare function planEntityCheckCoverage(operations: Readonly<Record<string, object>>, inputs?: Readonly<Record<string, Readonly<Record<string, unknown>>>>): {
88
- covered: PlannedCheck[];
89
- uncovered: Record<string, string>;
90
- };
1
+ export type { EntityCheckFixture, EntityCheckSuiteOptions, PlannedCheck } from './entity-check-plan.js';
2
+ export { planEntityCheckCoverage } from './entity-check-plan.js';
3
+ import type { EntityCheckFixture, EntityCheckSuiteOptions } from './entity-check-plan.js';
91
4
  /**
92
5
  * Generate the conformance pair for every operation declaring an entity check.
93
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"entity-check-suite.d.ts","sourceRoot":"","sources":["../src/entity-check-suite.ts"],"names":[],"mappings":"AA6DA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGzD,oEAAoE;AACpE,MAAM,WAAW,kBAAkB;IACjC;;;;;OAKG;IACH,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD;;;;;;OAMG;IACH,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,4EAA4E;IAC5E,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5E;;;OAGG;IACH,QAAQ,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC;CACpC;AAED,MAAM,WAAW,uBAAuB;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAC3B,MAAM,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CACtF,CAAC;IACF;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACvD;AA0CD,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC5C,MAAM,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAM,GACvE;IAAE,OAAO,EAAE,YAAY,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAuBhE;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC5C,WAAW,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,EAC9C,OAAO,GAAE,uBAA4B,GACpC,IAAI,CAkGN"}
1
+ {"version":3,"file":"entity-check-suite.d.ts","sourceRoot":"","sources":["../src/entity-check-suite.ts"],"names":[],"mappings":"AA+DA,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACxG,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAG1F;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAC5C,WAAW,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,EAC9C,OAAO,GAAE,uBAA4B,GACpC,IAAI,CA2IN"}
@@ -60,69 +60,8 @@
60
60
  */
61
61
  import { describe, expect, it } from 'vitest';
62
62
  import { PermissionDenied } from '@substrat-run/kernel';
63
- /** An operation declaring an entity-narrowed check — the ones in scope here. */
64
- function entityCheckOf(op) {
65
- const permission = op.permission;
66
- if (!permission || typeof permission === 'string')
67
- return undefined;
68
- return permission.entity ? permission : undefined;
69
- }
70
- /**
71
- * The input fields an operation REQUIRES, beyond the one carrying the entity id.
72
- *
73
- * Read off the schema rather than asked for: an operation taking only an id
74
- * needs no fixture entry, and one that needs more says so precisely instead of
75
- * failing later as a validation error the reader has to decode.
76
- */
77
- function requiredExtras(op, idField) {
78
- const shape = op.input?.shape;
79
- if (!shape)
80
- return [];
81
- return Object.entries(shape)
82
- .filter(([field]) => field !== idField)
83
- .filter(([, schema]) => {
84
- const parse = schema.safeParse;
85
- // No `safeParse` means we cannot tell — treat it as required, because
86
- // guessing "optional" is the answer that silently drops the operation.
87
- return typeof parse !== 'function' || !parse.call(schema, undefined).success;
88
- })
89
- .map(([field]) => field);
90
- }
91
- /**
92
- * Partition an operation set into what this kit can drive and what it cannot.
93
- *
94
- * Exported and pure so the classification is testable on its own. It is the part
95
- * that decides what counts as covered, and a bug here is invisible in the worst
96
- * way — it would drop an operation from the suite while every remaining test
97
- * still passed.
98
- *
99
- * Out of scope entirely (neither covered nor uncovered): an operation with a
100
- * bare-key node check, or one declaring `narrows`. Neither claims an entity
101
- * check, so neither has one to honour.
102
- */
103
- export function planEntityCheckCoverage(operations, inputs = {}) {
104
- const covered = [];
105
- const uncovered = {};
106
- for (const [name, raw] of Object.entries(operations).sort(([a], [b]) => a.localeCompare(b))) {
107
- const op = raw;
108
- const check = entityCheckOf(op);
109
- if (!check)
110
- continue;
111
- if (!check.idFrom) {
112
- uncovered[name] =
113
- `declares 'resolved' (${check.resolved ?? 'no reason given'}) — the entity id is not ` +
114
- 'in the input, so the harness cannot reach the entity';
115
- continue;
116
- }
117
- const missing = requiredExtras(op, check.idFrom).filter((f) => inputs[name]?.[f] === undefined);
118
- if (missing.length > 0) {
119
- uncovered[name] = `no sample input for required field(s): ${missing.join(', ')}`;
120
- continue;
121
- }
122
- covered.push({ name, key: check.key, entity: check.entity, idFrom: check.idFrom });
123
- }
124
- return { covered, uncovered };
125
- }
63
+ export { planEntityCheckCoverage } from './entity-check-plan.js';
64
+ import { planEntityCheckCoverage } from './entity-check-plan.js';
126
65
  /**
127
66
  * Generate the conformance pair for every operation declaring an entity check.
128
67
  *
@@ -137,7 +76,7 @@ export function entityCheckConformanceSuite(subjectName, operations, makeFixture
137
76
  const declaredUncovered = options.uncovered ?? {};
138
77
  const supplied = options.inputs ?? {};
139
78
  // Partitioned at collection time so the generated tests can be named.
140
- const { covered, uncovered } = planEntityCheckCoverage(operations, supplied);
79
+ const { covered, uncovered } = planEntityCheckCoverage(operations, supplied, options.refEntityType);
141
80
  describe(`declared entity checks are honoured: ${subjectName}`, () => {
142
81
  it('covers every entity check it can, and names every one it cannot', () => {
143
82
  // The whole partition asserted at once. A new operation with an entity
@@ -157,9 +96,41 @@ export function entityCheckConformanceSuite(subjectName, operations, makeFixture
157
96
  // the zero guard: silence must not read as success.
158
97
  expect(covered.length).toBeGreaterThan(0);
159
98
  });
160
- for (const { name, key, entity, idFrom } of covered) {
161
- describe(`${name} — ${key} on ${entity}, id from '${idFrom}'`, () => {
162
- const extras = supplied[name] ?? {};
99
+ for (const { name, key, entity, target, fixed } of covered) {
100
+ const where = target.kind === 'ref'
101
+ ? `ref from '${target.path.join('.')}'`
102
+ : `id from '${target.path[0]}'`;
103
+ /**
104
+ * The input the pair is driven with. A `refFrom` check is handed the whole
105
+ * ref — which is the assertion, since an engine declaring this shape claims
106
+ * to honour whatever noun arrives.
107
+ */
108
+ const inputFor = (entityId, extras) => {
109
+ const value = target.kind === 'ref' ? { entityType: entity, entityId } : entityId;
110
+ const [head, ...rest] = target.path;
111
+ if (rest.length === 0)
112
+ return { ...extras, [head]: value };
113
+ // One level in: the ref travels beside other fields the caller supplied,
114
+ // so the sibling keys the fixture gave for that object are preserved.
115
+ const outer = (extras[head] ?? {});
116
+ return { ...extras, [head]: { ...outer, [rest.join('.')]: value } };
117
+ };
118
+ describe(`${name} — ${key} on ${entity}, ${where}`, () => {
119
+ /**
120
+ * Schema-fixed constants first, so an explicit fixture entry still wins —
121
+ * it is the reviewable way to say "this one needs something else".
122
+ *
123
+ * Read per CASE, not once per describe. A fixture entry legitimately
124
+ * holds a value that does not exist yet at collect time: rally's spare
125
+ * member is created in `beforeAll` and written into the object the kit
126
+ * was handed, which is the documented way to supply an id the harness
127
+ * must make first. Spreading in the describe body captured the empty
128
+ * placeholder instead, and nothing said so — case 1 only asserts "was
129
+ * not denied", and case 2's permission answer arrived before anything
130
+ * looked at the field. Once the host parses a declared input (#893) the
131
+ * same fixture fails the parse, which is what surfaced this.
132
+ */
133
+ const extrasNow = () => ({ ...fixed, ...(supplied[name] ?? {}) });
163
134
  const extraKeys = options.alsoGrant?.[name]?.permissions ?? [];
164
135
  /**
165
136
  * The declared key on the target, plus anything the operation needs to
@@ -188,9 +159,9 @@ export function entityCheckConformanceSuite(subjectName, operations, makeFixture
188
159
  };
189
160
  it('allows a principal granted on THAT entity (the node-check catcher)', async () => {
190
161
  const fixture = await makeFixture();
191
- const target = await fixture.createEntity(entity);
192
- await grantAllOn(fixture, target);
193
- const outcome = await denialFrom(fixture, { ...extras, [idFrom]: target });
162
+ const targetId = await fixture.createEntity(entity);
163
+ await grantAllOn(fixture, targetId);
164
+ const outcome = await denialFrom(fixture, inputFor(targetId, extrasNow()));
194
165
  const denied = outcome !== undefined && !outcome.notADenial;
195
166
  expect(denied, `${name} denied a principal holding ${key} on the very ${entity} it was ` +
196
167
  'invoked against — the handler is checking the node, not the entity').toBe(false);
@@ -202,7 +173,7 @@ export function entityCheckConformanceSuite(subjectName, operations, makeFixture
202
173
  const granted = await fixture.createEntity(entity);
203
174
  await grantAllOn(fixture, granted);
204
175
  const other = await fixture.createEntity(entity);
205
- const outcome = await denialFrom(fixture, { ...extras, [idFrom]: other });
176
+ const outcome = await denialFrom(fixture, inputFor(other, extrasNow()));
206
177
  const notADenial = outcome?.notADenial;
207
178
  expect(outcome, `${name} allowed a principal to reach a ${entity} they hold no grant on`).toBeDefined();
208
179
  expect(notADenial, `${name} failed against an ungranted ${entity}, but not with a permission ` +
@@ -1 +1 @@
1
- {"version":3,"file":"entity-check-suite.js","sourceRoot":"","sources":["../src/entity-check-suite.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"entity-check-suite.js","sourceRoot":"","sources":["../src/entity-check-suite.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { ScopeHostFixture } from './scope-host-suite.js';
2
+ export declare function entityVersionContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture>): void;
3
+ //# sourceMappingURL=entity-version-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entity-version-suite.d.ts","sourceRoot":"","sources":["../src/entity-version-suite.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAK9D,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CAsJN"}
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Contract suite for `ctx.versionOf` — an entity's version (#901).
3
+ *
4
+ * What it pins: **the version was already being written.** No entity table has a
5
+ * version column and none is going to; `_substrat_outbox` has recorded
6
+ * `entity_type` and `entity_id` against a monotonic ULID since it was written,
7
+ * so every mutation that followed the fat-event rule already versioned the thing
8
+ * it touched. This suite is what holds both adapters to that reading.
9
+ *
10
+ * The assertions are behavioural for the reason the search suite gives: an
11
+ * emitter or a query that reads correctly can still be wrong against a real
12
+ * database, and a string comparison would call it a pass. So every case here
13
+ * provisions a real scope, emits real events, and asks a real question.
14
+ *
15
+ * Two of these cases are the interesting ones, and neither is about the happy
16
+ * path:
17
+ *
18
+ * - **A shred must not take the version with it.** The payload goes, the row
19
+ * stays, and an erased entity can still refuse a stale write. A version that
20
+ * vanished with the data would fail OPEN at exactly the moment the data was
21
+ * most sensitive.
22
+ * - **A silent mutation does not move it.** That is the documented hole, pinned
23
+ * here deliberately so it stays a known property. The fix is a compile-checked
24
+ * `concurrency` against the operation's declared `emits` (#129) — not a change
25
+ * to this behaviour.
26
+ */
27
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
28
+ import { dataSubjectId, permissionKey, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
29
+ import { ulid } from '@substrat-run/kernel';
30
+ import { contractTestBareOps, testMod } from './modules.js';
31
+ const PERM_USE = permissionKey.parse('testmod:use');
32
+ export function entityVersionContractSuite(adapterName, makeFixture) {
33
+ describe(`entity version (ctx.versionOf): ${adapterName}`, () => {
34
+ let fixture;
35
+ let host;
36
+ let stub;
37
+ const t1 = tenantId.parse(ulid());
38
+ const s1 = scopeId.parse(ulid());
39
+ const alice = principalId.parse(ulid());
40
+ const staff = platformActorId.parse(ulid());
41
+ const versionOf = (entityId, entityType = 'test-thing') => stub.invoke('test/version-of', { entityType, entityId });
42
+ const emitAbout = (entityId, subject) => stub.invoke('test/emit-about', { entityId, ...(subject ? { subject } : {}) });
43
+ beforeAll(async () => {
44
+ fixture = await makeFixture();
45
+ host = fixture.host;
46
+ for (const [name, handler] of Object.entries(contractTestBareOps)) {
47
+ host.defineOperation(name, handler);
48
+ }
49
+ host.registerModule(testMod);
50
+ await host.admin.createTenant(staff, { id: t1, slug: 'version-tenant', name: 'Version Tenant' });
51
+ await host.admin.grantEntitlement(staff, t1, 'testmod');
52
+ await host.admin.defineRole(staff, t1, {
53
+ key: 'version-admin',
54
+ permissions: [PERM_USE],
55
+ source: 'vertical',
56
+ });
57
+ await host.admin.assignRole(staff, {
58
+ principalId: alice,
59
+ roleKey: 'version-admin',
60
+ node: { tenantId: t1, scopeId: null },
61
+ });
62
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s1, vertical: 'version-vertical' });
63
+ await host.admin.activateScope(staff, t1, s1);
64
+ stub = await host.getScope(alice, t1, s1);
65
+ });
66
+ afterAll(async () => {
67
+ await fixture.cleanup();
68
+ });
69
+ it('is absent for an entity nothing has emitted about', async () => {
70
+ // Absence, not a throw and not an empty string: `MAX(id)` over no rows is
71
+ // NULL, and the caller distinguishes "never touched" from "touched" on the
72
+ // value alone. A precondition against an entity with no history has nothing
73
+ // to compare and must be able to see that.
74
+ expect(await versionOf('never-touched')).toBeNull();
75
+ });
76
+ it('appears on the first event and moves on every one after', async () => {
77
+ const id = `e-${ulid()}`;
78
+ expect(await versionOf(id)).toBeNull();
79
+ await emitAbout(id);
80
+ const first = await versionOf(id);
81
+ expect(first).not.toBeNull();
82
+ await emitAbout(id);
83
+ const second = await versionOf(id);
84
+ expect(second).not.toBe(first);
85
+ // ULIDs are lexicographically ordered, and `ulid()` uses the monotonic
86
+ // factory — so two events in the SAME millisecond still compare in creation
87
+ // order. Without that this assertion would flake rather than fail, which is
88
+ // the worst way for it to be wrong.
89
+ expect(second > first).toBe(true);
90
+ });
91
+ it('is stable across reads that change nothing', async () => {
92
+ const id = `e-${ulid()}`;
93
+ await emitAbout(id);
94
+ const a = await versionOf(id);
95
+ const b = await versionOf(id);
96
+ const c = await versionOf(id);
97
+ expect(b).toBe(a);
98
+ expect(c).toBe(a);
99
+ });
100
+ it('is per entity — an event about one does not move another', async () => {
101
+ const left = `e-${ulid()}`;
102
+ const right = `e-${ulid()}`;
103
+ await emitAbout(left);
104
+ await emitAbout(right);
105
+ const leftBefore = await versionOf(left);
106
+ await emitAbout(right);
107
+ // The scope's newest event is now about `right`. If the query were reading
108
+ // the outbox head rather than the entity's own last row, this would move.
109
+ expect(await versionOf(left)).toBe(leftBefore);
110
+ expect(await versionOf(right)).not.toBe(leftBefore);
111
+ });
112
+ it('distinguishes two entities that share an id under different types', async () => {
113
+ const shared = `e-${ulid()}`;
114
+ await emitAbout(shared);
115
+ const asThing = await versionOf(shared, 'test-thing');
116
+ // Nothing has emitted about this id under the OTHER type, so the pair is
117
+ // what identifies the row — not the id alone.
118
+ expect(await versionOf(shared, 'other-thing')).toBeNull();
119
+ expect(asThing).not.toBeNull();
120
+ });
121
+ it('reflects an emit made earlier in the same operation', async () => {
122
+ const id = `e-${ulid()}`;
123
+ const { before, after } = await stub.invoke('test/emit-then-version', { entityId: id });
124
+ // Read-after-write within one operation. This holds because `emit` writes
125
+ // the outbox row inline rather than buffering to commit — the same property
126
+ // the search index depends on, and worth pinning where it can regress.
127
+ expect(before).toBeNull();
128
+ expect(after).not.toBeNull();
129
+ });
130
+ it('survives a shred — the payload goes, the version stays', async () => {
131
+ const id = `e-${ulid()}`;
132
+ const subject = dataSubjectId.parse(ulid());
133
+ await emitAbout(id, subject);
134
+ const before = await versionOf(id);
135
+ expect(before).not.toBeNull();
136
+ const receipt = await host.admin.shredSubject(staff, t1, s1, subject);
137
+ expect(receipt.eventsRedacted).toBe(1);
138
+ // The whole point. An erasure nulls the payload and keeps the envelope, so
139
+ // the version token outlives the data it described. Were it to go null here,
140
+ // a stale write against an erased entity would be admitted rather than
141
+ // refused — failing open at the worst possible moment.
142
+ expect(await versionOf(id)).toBe(before);
143
+ });
144
+ it('does NOT move for a mutation that emits nothing', async () => {
145
+ const id = `e-${ulid()}`;
146
+ await emitAbout(id);
147
+ const before = await versionOf(id);
148
+ await stub.invoke('test/mutate-silently', {});
149
+ // The documented hole, pinned so it stays known. "Every mutation emits a fat
150
+ // event" is enforced by review, not by boundary-lint, and this is what that
151
+ // costs: a version that did not move because nobody announced the change.
152
+ // The answer is a `concurrency` declaration compile-checked against the
153
+ // operation's declared `emits` (#129) — strictly more than a version column
154
+ // with a trigger would have given, which guarantees the column moved but
155
+ // never that the operation said what it did.
156
+ expect(await versionOf(id)).toBe(before);
157
+ });
158
+ });
159
+ }
160
+ //# sourceMappingURL=entity-version-suite.js.map