@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.
- package/dist/concurrency-suite.d.ts +3 -0
- package/dist/concurrency-suite.d.ts.map +1 -0
- package/dist/concurrency-suite.js +228 -0
- package/dist/concurrency-suite.js.map +1 -0
- package/dist/conformance.d.ts +85 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +17 -0
- package/dist/conformance.js.map +1 -0
- package/dist/entity-check-plan.d.ts +138 -0
- package/dist/entity-check-plan.d.ts.map +1 -0
- package/dist/entity-check-plan.js +181 -0
- package/dist/entity-check-plan.js.map +1 -0
- package/dist/entity-check-suite.d.ts +3 -90
- package/dist/entity-check-suite.d.ts.map +1 -1
- package/dist/entity-check-suite.js +42 -71
- package/dist/entity-check-suite.js.map +1 -1
- package/dist/entity-version-suite.d.ts +3 -0
- package/dist/entity-version-suite.d.ts.map +1 -0
- package/dist/entity-version-suite.js +160 -0
- package/dist/entity-version-suite.js.map +1 -0
- package/dist/idempotency-suite.d.ts +3 -0
- package/dist/idempotency-suite.d.ts.map +1 -0
- package/dist/idempotency-suite.js +243 -0
- package/dist/idempotency-suite.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/input-parse-suite.d.ts +3 -0
- package/dist/input-parse-suite.d.ts.map +1 -0
- package/dist/input-parse-suite.js +149 -0
- package/dist/input-parse-suite.js.map +1 -0
- package/dist/modules.d.ts +482 -160
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +367 -2
- package/dist/modules.js.map +1 -1
- package/dist/node-only-suite.d.ts +28 -0
- package/dist/node-only-suite.d.ts.map +1 -1
- package/dist/node-only-suite.js +43 -1
- package/dist/node-only-suite.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +125 -0
- package/dist/permission-suite.js.map +1 -1
- package/dist/timeline-suite.d.ts +3 -0
- package/dist/timeline-suite.d.ts.map +1 -0
- package/dist/timeline-suite.js +223 -0
- package/dist/timeline-suite.js.map +1 -0
- 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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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":"
|
|
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
|
-
|
|
64
|
-
|
|
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,
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
192
|
-
await grantAllOn(fixture,
|
|
193
|
-
const outcome = await denialFrom(fixture,
|
|
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,
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"entity-check-suite.js","sourceRoot":"","sources":["../src/entity-check-suite.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|