@cerbos/orm-prisma 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +243 -10
  2. package/lib/index.d.ts +102 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1754 -155
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -15
  7. package/src/adversarial.test.ts +1281 -0
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +54 -0
  10. package/src/generated/prisma-adversarial/client.ts +76 -0
  11. package/src/generated/{prisma → prisma-adversarial}/commonInputTypes.ts +131 -27
  12. package/src/generated/prisma-adversarial/enums.ts +15 -0
  13. package/src/generated/prisma-adversarial/internal/class.ts +264 -0
  14. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1332 -0
  15. package/src/generated/{prisma → prisma-adversarial}/internal/prismaNamespaceBrowser.ts +45 -47
  16. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  17. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  18. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  19. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  20. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1789 -0
  21. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  22. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  23. package/src/generated/prisma-adversarial/models.ts +18 -0
  24. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  25. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  26. package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +356 -0
  27. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  28. package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1371 -0
  29. package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +177 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  31. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  32. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  33. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  34. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  35. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  36. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  37. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  38. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  39. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  40. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  41. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  42. package/src/generated/prisma-adversarial-v6/index.d.ts +11769 -0
  43. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  44. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  45. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  46. package/src/index.ts +2515 -219
  47. package/src/test-setup.adversarial-url.ts +18 -0
  48. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  49. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  50. package/src/test-setup.adversarial.v6.ts +5 -0
  51. package/src/test-setup.adversarial.v7.ts +12 -0
  52. package/src/translator.test.ts +1586 -0
  53. package/src/generated/prisma/internal/class.ts +0 -274
  54. package/src/generated/prisma/internal/prismaNamespace.ts +0 -1333
  55. package/src/generated/prisma/models/Category.ts +0 -1412
  56. package/src/generated/prisma/models/Label.ts +0 -1276
  57. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  58. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  59. package/src/generated/prisma/models/Resource.ts +0 -2136
  60. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  61. package/src/generated/prisma/models/Tag.ts +0 -1276
  62. package/src/generated/prisma/models/User.ts +0 -1503
  63. package/src/generated/prisma/models.ts +0 -19
  64. package/src/generated/prisma-v6/index.d.ts +0 -13639
  65. package/src/index.test.ts +0 -6322
  66. package/src/test-setup.v6.ts +0 -5
  67. package/src/test-setup.v7.ts +0 -7
  68. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  69. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  70. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  71. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  72. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  73. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  74. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -0
@@ -0,0 +1,1281 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ import { GRPC as Cerbos } from "@cerbos/grpc";
5
+ import {
6
+ PlanExpression,
7
+ PlanExpressionValue,
8
+ PlanExpressionVariable,
9
+ } from "@cerbos/core";
10
+ import type {
11
+ PlanExpressionOperand,
12
+ Principal,
13
+ Resource,
14
+ Value,
15
+ } from "@cerbos/core";
16
+
17
+ import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
18
+ import {
19
+ CONFORMANCE_DIR,
20
+ MAPPER,
21
+ MODEL,
22
+ classifyActionsForAdapter,
23
+ requireMessage,
24
+ } from "./corpus";
25
+ import type {
26
+ ActionClassification,
27
+ ActionsFile,
28
+ ThrowingAction,
29
+ } from "./corpus";
30
+ import { prisma } from "./test-setup.adversarial";
31
+
32
+ /**
33
+ * Adversarial differential suite: every action in the shared `../conformance/` corpus is planned
34
+ * against a REAL Cerbos PDP (the sidecar started by `npm run test:adversarial`, loaded with
35
+ * `conformance/policies/adversarial.yaml`), translated by this adapter, and executed against
36
+ * seeded rows — then the filtered id set is compared against an oracle computed by calling the
37
+ * check API for each row with attributes mirroring that row exactly.
38
+ *
39
+ * No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
40
+ * evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
41
+ * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns seeding
42
+ * and executing the query. The mapper and the per-adapter classification live in `./corpus`,
43
+ * shared with `translator.test.ts`, which pins the filter this suite then proves returns the
44
+ * right rows.
45
+ *
46
+ * The whole corpus is replayed against every store this adapter is proved on, one store per run,
47
+ * selected with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` for the container-backed leg —
48
+ * see `jest.globalSetup.adversarial.js`). The Prisma major and the store are independent
49
+ * dimensions: v6/v7 is an ENGINE matrix, and an engine matrix says nothing about how a provider
50
+ * coerces a fractional threshold against an `Int` column, escapes a LIKE metacharacter, or
51
+ * compares a real `timestamp` rather than milliseconds since the epoch
52
+ * (cerbos/query-plan-adapters#320).
53
+ */
54
+
55
+ const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
56
+
57
+ const SCHEMA_DIR = path.join(__dirname, "..", "prisma");
58
+
59
+ const STORE_NAMES = ["sqlite", "postgres"] as const;
60
+ type StoreName = (typeof STORE_NAMES)[number];
61
+
62
+ function selectedStoreName(): StoreName {
63
+ const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
64
+ // jest.adversarial.config.js rejects an unknown value before jest resolves this module; the
65
+ // repeat here is what keeps the assertion below reading against a value this file trusts.
66
+ if (!STORE_NAMES.includes(requested as StoreName)) {
67
+ throw new Error(
68
+ `Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
69
+ );
70
+ }
71
+ return requested as StoreName;
72
+ }
73
+
74
+ const STORE_NAME = selectedStoreName();
75
+
76
+ /**
77
+ * The four adversarial schemas, which must hold one data model between them.
78
+ *
79
+ * A generated Prisma client bakes in its provider and its major, so proving the corpus on
80
+ * (Prisma 6, Prisma 7) x (SQLite, PostgreSQL) needs four schema files. They are only a matrix
81
+ * over the same models — a column that drifts in one of them would seed a different row shape on
82
+ * that leg while every assertion in this file stayed identical, which is the projection trap
83
+ * conformance/README.md describes applied to the schema instead of the seeds.
84
+ */
85
+ const ADVERSARIAL_SCHEMAS = [
86
+ "schema.adversarial.prisma",
87
+ "schema.adversarial.v6.prisma",
88
+ "schema.adversarial.pg.prisma",
89
+ "schema.adversarial.pg.v6.prisma",
90
+ ] as const;
91
+
92
+ /**
93
+ * A schema's `model` blocks, with comments, the generator/datasource blocks and all incidental
94
+ * whitespace removed — everything that legitimately differs between the four.
95
+ */
96
+ function modelBlocks(schema: string): string {
97
+ const withoutComments = schema
98
+ .split("\n")
99
+ .filter((line) => !line.trimStart().startsWith("//"))
100
+ .join("\n");
101
+ const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
102
+ return models
103
+ .join("\n")
104
+ .replace(/[ \t]+/g, " ")
105
+ .replace(/\n\s*\n/g, "\n")
106
+ .trim();
107
+ }
108
+
109
+ interface Tag {
110
+ id: string;
111
+ name: string | null;
112
+ }
113
+
114
+ interface Seed {
115
+ id: string;
116
+ aBool: boolean;
117
+ aString: string;
118
+ aNumber: number;
119
+ aOptionalString: string | null;
120
+ tags: Tag[];
121
+ subCategoryNames: string[];
122
+ /** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
123
+ parentSeedId: string | null;
124
+ }
125
+
126
+ interface SeedsFile {
127
+ principal: Principal;
128
+ resourceKind: string;
129
+ seeds: Seed[];
130
+ }
131
+
132
+ // -- corpus coverage guards ---------------------------------------------------------------------
133
+ //
134
+ // The same parsed seed feeds the stored row AND the check() oracle, so a corpus field this harness
135
+ // does not consume is dropped from both sides at once and the differential agrees for the wrong
136
+ // reason — the projection trap conformance/README.md describes for actions.json, applied to the
137
+ // seeds. Asserting set equality catches both directions: a corpus key nothing here reads, and a key
138
+ // this harness reads that the corpus no longer carries.
139
+
140
+ const SEED_KEYS = [
141
+ "id",
142
+ "aBool",
143
+ "aString",
144
+ "aNumber",
145
+ "aOptionalString",
146
+ "tags",
147
+ "subCategoryNames",
148
+ "parentSeedId",
149
+ ] as const;
150
+
151
+ /** Corpus prose, never read by a harness: the one documented exclusion from SEED_KEYS. */
152
+ const SEED_NOTE_KEY = "note";
153
+
154
+ /** The one nested object array a seed carries. A key added inside an element is dropped from both
155
+ * sides of the differential just as silently as a top-level one, so it is guarded the same way. */
156
+ const TAG_KEYS = ["id", "name"] as const;
157
+
158
+ const DERIVED_KEYS = [
159
+ "createdBy",
160
+ "aDouble",
161
+ "createdAt",
162
+ "scope",
163
+ "labels",
164
+ ] as const;
165
+
166
+ // The corpus principal is guarded the same way and for the same reason. It feeds the PLAN under
167
+ // test AND the check() oracle, so an attribute dropped on the way in vanishes from both sides at
168
+ // once: the plan folds to ALWAYS_DENIED and the oracle, built from the same principal, agrees. That
169
+ // is how langchain-chromadb's hardcoded attribute allowlist let `pv-exists` pass while testing
170
+ // nothing (conformance/README.md, "Adding a new hostile shape", step 7). This harness passes the
171
+ // principal through verbatim, which is correct; the guard is what proves it still does.
172
+ //
173
+ // `id` and `roles` are deliberately IN scope, guarded by PRINCIPAL_KEYS one level above the
174
+ // attributes — the same two-level shape SEED_KEYS and TAG_KEYS use for a row and its `tags[]`
175
+ // elements. A role dropped on the way in changes every policy decision at once; that it is less
176
+ // likely to be projected away than an attribute is a reason to expect the assertion to stay quiet,
177
+ // not a reason to omit it.
178
+ const PRINCIPAL_KEYS = ["id", "roles", "attr"] as const;
179
+
180
+ const PRINCIPAL_ATTR_KEYS = [
181
+ "allowedTags",
182
+ "context",
183
+ "fewTeams",
184
+ "manyTeams",
185
+ ] as const;
186
+
187
+ /** One seed's derived fields, exactly as conformance/derived-fields.json carries them. */
188
+ interface DerivedEntry {
189
+ createdBy: string;
190
+ aDouble: number | null;
191
+ createdAt: string | null;
192
+ scope: string | null;
193
+ labels: (string | null)[];
194
+ }
195
+
196
+ interface DerivedFile {
197
+ fields: string[];
198
+ derived: Record<string, DerivedEntry>;
199
+ }
200
+
201
+ function assertKeys(
202
+ label: string,
203
+ got: string[],
204
+ want: readonly string[],
205
+ optional: readonly string[] = []
206
+ ): void {
207
+ const allowed = new Set<string>([...want, ...optional]);
208
+ for (const key of got) {
209
+ if (!allowed.has(key)) {
210
+ throw new Error(
211
+ `${label} carries "${key}", which this harness does not consume: an unconsumed corpus field is dropped from the stored row and the check() oracle at once`
212
+ );
213
+ }
214
+ }
215
+ const present = new Set(got);
216
+ for (const key of want) {
217
+ if (!present.has(key)) {
218
+ throw new Error(
219
+ `${label} is missing "${key}", which this harness consumes`
220
+ );
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * One principal attribute, checked against the two JSON shapes the corpus carries. A key-set guard
227
+ * says nothing about a change inside a value and three of the four attributes are lists, so the
228
+ * element type is asserted for the same reason the seed guard descends into `tags[]`.
229
+ */
230
+ function assertPrincipalAttrShape(label: string, value: unknown): void {
231
+ if (typeof value === "string") return;
232
+ if (Array.isArray(value) && value.every((el) => typeof el === "string")) {
233
+ return;
234
+ }
235
+ throw new Error(
236
+ `${label} is neither a string nor an array of strings, the only two shapes this harness consumes: a reshaped principal attribute feeds the plan and the check() oracle at once`
237
+ );
238
+ }
239
+
240
+ const seedsFile: SeedsFile = JSON.parse(
241
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "seeds.json"), "utf8")
242
+ );
243
+ const actionsFile: ActionsFile = JSON.parse(
244
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
245
+ );
246
+ const derivedFile: DerivedFile = JSON.parse(
247
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "derived-fields.json"), "utf8")
248
+ );
249
+ const SEEDS = seedsFile.seeds;
250
+
251
+ // SEEDS holds the parsed JSON rows verbatim, so Object.keys reports the corpus key set. Keep it
252
+ // that way: a parser that rebuilt each row field by field could only ever report the keys this
253
+ // harness already names, and the assertion would pass vacuously.
254
+ SEEDS.forEach((seed, index) => {
255
+ const label = `seeds.json seeds[${index}]`;
256
+ assertKeys(label, Object.keys(seed), SEED_KEYS, [SEED_NOTE_KEY]);
257
+ seed.tags.forEach((tag, tagIndex) => {
258
+ assertKeys(`${label}.tags[${tagIndex}]`, Object.keys(tag), TAG_KEYS);
259
+ });
260
+ });
261
+
262
+ // seedsFile.principal is the parsed JSON object, handed to the SDK untouched, so Object.keys
263
+ // reports the corpus key set on both levels.
264
+ assertKeys(
265
+ "seeds.json principal",
266
+ Object.keys(seedsFile.principal),
267
+ PRINCIPAL_KEYS
268
+ );
269
+ // `attr` is optional on the SDK's Principal type; the corpus always carries it, and the assertion
270
+ // above is what proves it rather than this fallback.
271
+ const PRINCIPAL_ATTR = seedsFile.principal.attr ?? {};
272
+ assertKeys(
273
+ "seeds.json principal.attr",
274
+ Object.keys(PRINCIPAL_ATTR),
275
+ PRINCIPAL_ATTR_KEYS
276
+ );
277
+ for (const [key, value] of Object.entries(PRINCIPAL_ATTR)) {
278
+ assertPrincipalAttrShape(`seeds.json principal.attr.${key}`, value);
279
+ }
280
+
281
+ assertKeys("derived-fields.json fields", derivedFile.fields, DERIVED_KEYS);
282
+ const DERIVED_IDS = Object.keys(derivedFile.derived);
283
+ if (DERIVED_IDS.length !== SEEDS.length) {
284
+ throw new Error(
285
+ `derived-fields.json has ${DERIVED_IDS.length} entries for ${SEEDS.length} seeds`
286
+ );
287
+ }
288
+ for (const seed of SEEDS) {
289
+ assertKeys(
290
+ `derived-fields.json derived["${seed.id}"]`,
291
+ Object.keys(derivedFor(seed)),
292
+ DERIVED_KEYS
293
+ );
294
+ }
295
+
296
+ const {
297
+ oracleActions: ORACLE_ACTIONS,
298
+ throwingActions: THROWING_ACTIONS,
299
+ supportedExpected: PRISMA_SUPPORTED_EXPECTED,
300
+ } = classifyActionsForAdapter(actionsFile, "prisma");
301
+ const PRISMA_KNOWN_DIVERGENCES = new Set(
302
+ (actionsFile.knownDivergences ?? [])
303
+ .filter((entry) => entry.adapters.includes("prisma"))
304
+ .map((entry) => entry.action)
305
+ );
306
+ const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
307
+ actionsFile.expectedUnsupported.map((entry) => entry.action)
308
+ );
309
+ // Actions whose `== null` probe targets an attribute the oracle OMITS for NULL columns. They
310
+ // carry no oracle comparison: under the omitted representation check() denies every row, so the
311
+ // adapter must reject the shape rather than emit a filter (#302).
312
+ const NULL_REPRESENTATION_OMITTED = actionsFile.nullRepresentationOmitted.map(
313
+ (entry): ThrowingAction => [
314
+ entry.action,
315
+ entry.reason,
316
+ requireMessage(
317
+ `nullRepresentationOmitted.${entry.action}.messages.prisma`,
318
+ entry.messages?.["prisma"]
319
+ ),
320
+ ]
321
+ );
322
+ /** The one message every null-carrying action must be rejected with under `omitted`. */
323
+ const NULL_OMITTED_MESSAGE = NULL_REPRESENTATION_OMITTED[0]?.[2] ?? "";
324
+ const MANIFEST_ACTIONS = new Set([
325
+ ...actionsFile.conformance,
326
+ ...EXPECTED_UNSUPPORTED_ACTIONS,
327
+ ...NULL_REPRESENTATION_OMITTED.map(([action]) => action),
328
+ // ALL divergences, not just Prisma's: a divergence registered solely for another adapter
329
+ // must still enter this manifest, so the size tripwire and the classified-exactly-once
330
+ // check flag it for triage here instead of letting the action silently vanish from this
331
+ // harness. Classification/skipping still uses the Prisma-filtered set.
332
+ ...(actionsFile.knownDivergences ?? []).map((entry) => entry.action),
333
+ ]);
334
+
335
+ // -- the degeneracy guard (conformance/README.md, "The degeneracy guard") -----------------------
336
+ //
337
+ // A representative sample of the actions this adapter ORACLE-COMPARES, one per hostile group it
338
+ // can express. The two lists are asserted to be complements of `ORACLE_ACTIONS`, so neither can
339
+ // drift into the other unnoticed.
340
+ //
341
+ // w1-size-zero-chain, w1-not-size-chain, w1-size-frac-chain, the two string-cast actions and
342
+ // filter-as-conjunct are deliberately absent from both lists: their oracles are empty by
343
+ // CONSTRUCTION (no seed holds a to-one parent with zero children, nor one with two or more; every
344
+ // seed's aString raises in int()/double(); a filter() in boolean position is not evaluable at
345
+ // all), so they cannot satisfy a non-empty assertion. filter-as-conjunct carries its own
346
+ // anti-vacuity test instead — see "dropping the untranslatable half over-grants" below.
347
+
348
+ const DEGENERACY_GUARD_ACTIONS = [
349
+ "vf-le",
350
+ // Prisma escapes no LIKE metacharacter at all, so every needle-carrying shape in that group is
351
+ // a liveness probe below. `[` is the one metacharacter it can leave alone — it is literal on
352
+ // every provider but SQL Server, which Prisma's own guard only rejects for hierarchy prefixes —
353
+ // so like-bracket is what carries the group's oracle comparison.
354
+ "like-bracket",
355
+ "all-on-empty",
356
+ "pv-exists",
357
+ "pv-all",
358
+ "null-eq",
359
+ "null-ne",
360
+ // The explicit-null convention against a non-null operand (#308). All five are compared rather
361
+ // than thrown, because the mapper declares the convention per attribute; every one of them
362
+ // under-granted by exactly the NULL-column rows before that declaration existed.
363
+ "null-value-ne-const",
364
+ "null-value-not-eq-const",
365
+ "null-value-not-in-const",
366
+ "null-value-f2f",
367
+ "null-value-pv-not-exists",
368
+ // The absent to-one parent (#309/#315/#316/#334): the four discriminating chain shapes Prisma
369
+ // translates. Its unsupported siblings are liveness probes below.
370
+ "w1-not-exists-chain",
371
+ "w1-not-in-chain",
372
+ "w1-not-hasint-chain",
373
+ "w1-ternary-chain-cond",
374
+ // The real to-one join (#375): one per hazard — the negated hop, the null comparison, two-level
375
+ // depth, the root conjunction, and the disjunction, whose failure direction is an under-grant.
376
+ "rel-not-bool-hop",
377
+ "rel-ne-null-hop",
378
+ "rel-bool-hop2",
379
+ "rel-hop-and-root",
380
+ "rel-hop2-or-exists",
381
+ // Case sensitivity in STRING MATCHING (#375 follow-up), a different mechanism from cs-eq:
382
+ // collation governs `=`, and on SQLite nothing but `PRAGMA case_sensitive_like` governs LIKE.
383
+ "cs-contains",
384
+ // The primary key as a filterable attribute (#376): the key against a constant, against a
385
+ // column under negation — the shape that emitted an invalid `not: { _ref }` before this
386
+ // change — the value-first concatenation solved back to a key equality, and the key inside a
387
+ // constructed hierarchy path.
388
+ "id-eq-const",
389
+ "id-f2f-ne",
390
+ "id-concat-vf",
391
+ "hier-list-id",
392
+ // Root position and bare operand forms (#388): one per hazard — the negation over a bare
393
+ // ordering (every other negated ordering in the corpus wraps a size() or a ternary), the bare
394
+ // boolean at the ROOT of the condition, and the collection subquery disjoined with a scalar
395
+ // predicate rather than conjoined with one.
396
+ "not-lt",
397
+ "root-bare-bool",
398
+ "or-eq-exists",
399
+ // Hazard classes the corpus missed (#387): the De Morgan branch over a conjunction, the
400
+ // value-first hasIntersection whose operands are not interchangeable in the emitted query, and
401
+ // the BELOW-cliff unroll of a principal collection — the shape a principal with three teams
402
+ // produces, which nothing planned while pv-exists carried only the value-list form.
403
+ "not-and",
404
+ "vf-hasint",
405
+ "pv-exists-unrolled",
406
+ ] as const;
407
+
408
+ /**
409
+ * Shapes Prisma refuses to translate: they have no oracle comparison to guard, and stay here as
410
+ * PDP/policy liveness probes for a group Prisma's own list cannot cover. See
411
+ * cerbos/query-plan-adapters#324.
412
+ */
413
+ const DEGENERACY_LIVENESS_PROBES = [
414
+ // Prisma emits LIKE with no ESCAPE clause, so a % needle throws — and so does a backslash one,
415
+ // which is the default escape character on PostgreSQL and MySQL and literal on SQLite
416
+ // (cerbos/query-plan-adapters#320).
417
+ "like-percent",
418
+ "like-backslash",
419
+ // every() cannot require the intermediate hop of a chain, and no relation count with an
420
+ // arbitrary threshold — >= 0 or a rounded fractional one — has a none/some spelling.
421
+ "w1-all-chain",
422
+ "w1-size-nonneg-chain",
423
+ "w1-size-frac-le-chain",
424
+ // Prisma filters have no column arithmetic, so the whole cr-div group (#311) throws.
425
+ "cr-div-neg-zero",
426
+ // int() over a numeric column: truncation-versus-rounding, unsupported for every adapter but
427
+ // convex, which promotes it in adapterSupportedExpected.
428
+ "cast-int-double",
429
+ // string() has no Prisma filter form (#376). cast-string-bool carries the group's probe rather
430
+ // than cast-string-double because its oracle is 14 of 21 rather than a single row, so a PDP or
431
+ // policy that went quiet fails the non-total half of the assertion too.
432
+ "cast-string-bool",
433
+ // Concatenation against the key where BOTH operands carry a column — the arithmetic solver
434
+ // needs a value on the other side, so the id-* group's one throwing shape probes here.
435
+ "id-concat",
436
+ // The same missing form with BOTH operands columns, so there is no constant to invert off
437
+ // either side (#391).
438
+ "concat-f2f",
439
+ // #387, one probe per group Prisma cannot compare. The negated LIKE is refused for its COLUMN
440
+ // needle before the negation matters, so the whole not-* group throws; `mod`, a positional read
441
+ // of a scalar list, and list equality over a projection have no Prisma filter form at all.
442
+ "not-contains",
443
+ "arith-mod",
444
+ "index-scalar-list",
445
+ "map-eq-list",
446
+ ] as const;
447
+
448
+ // -- deterministic derived fields (conformance/README.md, "Deterministic derived fields") --------
449
+ //
450
+ // Read from conformance/derived-fields.json rather than restated here. The same value feeds the
451
+ // stored row and the check() oracle, so a transcription error would be self-consistent and
452
+ // invisible to the differential; one machine-readable definition is what makes that impossible.
453
+
454
+ function derivedFor(seed: Seed): DerivedEntry {
455
+ const entry = derivedFile.derived[seed.id];
456
+ if (entry === undefined) {
457
+ throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
458
+ }
459
+ return entry;
460
+ }
461
+
462
+ function doubleFor(seed: Seed): number | null {
463
+ return derivedFor(seed).aDouble;
464
+ }
465
+
466
+ /** Third-level label names. A null element is a NULL label name — a missing element attribute. */
467
+ function labelsFor(seed: Seed): (string | null)[] {
468
+ return derivedFor(seed).labels;
469
+ }
470
+
471
+ /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
472
+ function isoFor(seed: Seed): string {
473
+ return derivedFor(seed).createdBy;
474
+ }
475
+
476
+ function timestampFor(seed: Seed): string | null {
477
+ return derivedFor(seed).createdAt;
478
+ }
479
+
480
+ function scopeFor(seed: Seed): string | null {
481
+ return derivedFor(seed).scope;
482
+ }
483
+
484
+ // -- the real to-one relation (conformance/README.md, "The real to-one relation") ----------------
485
+ //
486
+ // `parentSeedId` names the seed whose four scalars this row's `parent` carries, and that seed's
487
+ // own `parentSeedId` names the ones `parent.inner` carries. The chain is cut at two levels. Every
488
+ // resource owns a FRESH parent (and inner) row rather than pointing at the named seed's own row,
489
+ // so no two resources share one and a filter that returned the parent instead of the child cannot
490
+ // agree with the oracle by accident.
491
+
492
+ const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
493
+
494
+ function parentSeedOf(seed: Seed | undefined): Seed | undefined {
495
+ const id = seed?.parentSeedId;
496
+ if (id === undefined || id === null) {
497
+ return undefined;
498
+ }
499
+ const parent = SEEDS_BY_ID.get(id);
500
+ if (parent === undefined) {
501
+ throw new Error(
502
+ `seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`
503
+ );
504
+ }
505
+ return parent;
506
+ }
507
+
508
+ /** The four scalars one level of the chain stores, as columns. */
509
+ function relationColumns(seed: Seed): {
510
+ aBool: boolean;
511
+ aString: string;
512
+ aNumber: number;
513
+ aOptionalString: string | null;
514
+ } {
515
+ return {
516
+ aBool: seed.aBool,
517
+ aString: seed.aString,
518
+ aNumber: seed.aNumber,
519
+ aOptionalString: seed.aOptionalString,
520
+ };
521
+ }
522
+
523
+ /** The same four as check() attributes: a NULL column is a MISSING attribute, one hop out. */
524
+ function relationAttr(seed: Seed): Record<string, Value> {
525
+ const attr: Record<string, Value> = {
526
+ aBool: seed.aBool,
527
+ aString: seed.aString,
528
+ aNumber: seed.aNumber,
529
+ };
530
+ if (seed.aOptionalString !== null) {
531
+ attr["aOptionalString"] = seed.aOptionalString;
532
+ }
533
+ return attr;
534
+ }
535
+
536
+ /**
537
+ * The same mapper with every per-attribute null convention stripped, so the call-level option is
538
+ * the only thing governing null operands.
539
+ *
540
+ * The #302 completeness guard is a statement about that option: every corpus action carrying a
541
+ * null literal must be rejected under `"omitted"`. Declaring `owner`/`coOwner` as explicit-null
542
+ * (#308) deliberately overrides the option for those two attributes — which would otherwise read
543
+ * as the guard going quiet, when in fact it is the per-attribute declaration doing exactly its
544
+ * job. Stripping the declarations keeps the guard testing what it was written to test.
545
+ */
546
+ function withoutNullConventions(
547
+ mapper: Record<string, MapperConfig>
548
+ ): Record<string, MapperConfig> {
549
+ return Object.fromEntries(
550
+ Object.entries(mapper).map(([reference, config]) => {
551
+ const { nullAttributeRepresentation: _stripped, ...rest } = config;
552
+ return [reference, rest];
553
+ })
554
+ );
555
+ }
556
+
557
+ const MAPPER_WITHOUT_NULL_CONVENTIONS = withoutNullConventions(MAPPER);
558
+
559
+ beforeAll(async () => {
560
+ // CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith
561
+ // to LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII no matter what collation the column
562
+ // was created with — only this pragma changes it — so without it every string predicate
563
+ // over-grants by exactly the case-variant rows (the `cs-contains` group; proved by c1, "One").
564
+ // The column collation the README talks about governs `=`, not LIKE, which is why cs-eq passed
565
+ // here for a long time while the string operators did not. ent and sqlalchemy set the same
566
+ // pragma; drizzle needs none because it lowers to REPLACE rather than LIKE.
567
+ if (STORE_NAME === "sqlite") {
568
+ await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
569
+ }
570
+ await prisma.adversarialInner.deleteMany();
571
+ await prisma.adversarialParent.deleteMany();
572
+ await prisma.adversarialLabel.deleteMany();
573
+ await prisma.adversarialSubCategory.deleteMany();
574
+ await prisma.adversarialCategory.deleteMany();
575
+ await prisma.adversarialTag.deleteMany();
576
+ await prisma.adversarialResource.deleteMany();
577
+
578
+ // Distinct sub-category/category graphs per seed so no rows share relations by accident.
579
+ for (const seed of SEEDS) {
580
+ const parentSeed = parentSeedOf(seed);
581
+ const innerSeed = parentSeedOf(parentSeed);
582
+ await prisma.adversarialResource.create({
583
+ data: {
584
+ id: seed.id,
585
+ aBool: seed.aBool,
586
+ aString: seed.aString,
587
+ aNumber: seed.aNumber,
588
+ aDouble: doubleFor(seed),
589
+ aOptionalString: seed.aOptionalString,
590
+ createdBy: isoFor(seed),
591
+ scope: scopeFor(seed),
592
+ createdAt: timestampFor(seed),
593
+ tags: {
594
+ create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
595
+ },
596
+ // The to-one chain, one owned row per level. A seed with no parent gets no row at all,
597
+ // which is what makes the absent-parent hazard reachable through a SCALAR rather than
598
+ // only through mainCategory's collection.
599
+ ...(parentSeed === undefined
600
+ ? {}
601
+ : {
602
+ parent: {
603
+ create: {
604
+ ...relationColumns(parentSeed),
605
+ ...(innerSeed === undefined
606
+ ? {}
607
+ : { inner: { create: relationColumns(innerSeed) } }),
608
+ },
609
+ },
610
+ }),
611
+ categories: {
612
+ create: seed.subCategoryNames.map((subName) => ({
613
+ name: "business",
614
+ subCategories: {
615
+ create: [
616
+ {
617
+ name: subName,
618
+ labels: {
619
+ create: labelsFor(seed).map((name) => ({ name })),
620
+ },
621
+ },
622
+ ],
623
+ },
624
+ })),
625
+ },
626
+ },
627
+ });
628
+ }
629
+ });
630
+
631
+ afterAll(async () => {
632
+ await prisma.$disconnect();
633
+ });
634
+
635
+ function principal(): Principal {
636
+ return seedsFile.principal;
637
+ }
638
+
639
+ /** A NULL tag name in the DB is a missing element attribute on the check side. */
640
+ function asTagAttribute(tag: Tag): Record<string, Value> {
641
+ const attr: Record<string, Value> = { id: tag.id };
642
+ if (tag.name !== null) {
643
+ attr["name"] = tag.name;
644
+ }
645
+ return attr;
646
+ }
647
+
648
+ /** Cerbos attributes mirroring exactly what the seeded DB row holds. */
649
+ function asCheckResource(seed: Seed): Resource {
650
+ const attr: Record<string, Value> = {
651
+ aBool: seed.aBool,
652
+ aString: seed.aString,
653
+ aNumber: seed.aNumber,
654
+ createdBy: isoFor(seed),
655
+ owner: seed.aOptionalString,
656
+ // The explicit-null alias of the `scope` column, the second half of `null-value-f2f`:
657
+ // `scope` itself is omitted when NULL (below), so the corpus carries the same column under
658
+ // both conventions and the field-to-field probe has two explicit nulls to compare.
659
+ coOwner: scopeFor(seed),
660
+ tagNames: seed.tags.map((tag) => tag.name),
661
+ obj: { inner: seed.aString },
662
+ tags: seed.tags.map(asTagAttribute),
663
+ categories: seed.subCategoryNames.map((subName) => ({
664
+ name: "business",
665
+ subCategories: [
666
+ {
667
+ name: subName,
668
+ labels: labelsFor(seed).map((name): Record<string, Value> =>
669
+ name === null ? {} : { name }
670
+ ),
671
+ },
672
+ ],
673
+ })),
674
+ };
675
+ // A DB NULL is a missing attribute on the check side — conditions touching it must deny
676
+ // (CEL error), matching SQL three-valued logic excluding the row.
677
+ if (seed.aOptionalString !== null) {
678
+ attr["aOptionalString"] = seed.aOptionalString;
679
+ }
680
+ const aDouble = doubleFor(seed);
681
+ if (aDouble !== null) {
682
+ attr["aDouble"] = aDouble;
683
+ }
684
+ const scope = scopeFor(seed);
685
+ if (scope !== null) {
686
+ attr["scope"] = scope;
687
+ }
688
+ const createdAt = timestampFor(seed);
689
+ if (createdAt !== null) {
690
+ attr["createdAt"] = createdAt;
691
+ }
692
+ // mainCategory mirrors the row's single category as ONE nested object (the seeder creates
693
+ // at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
694
+ // rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
695
+ // adapter's empty join chain excluding the row.
696
+ if (seed.subCategoryNames.length > 0) {
697
+ attr["mainCategory"] = {
698
+ name: "business",
699
+ subCategories: seed.subCategoryNames.map((name) => ({ name })),
700
+ subNames: seed.subCategoryNames,
701
+ };
702
+ }
703
+ // The real to-one chain, mirroring the seeded rows exactly. A row with no parent sends NO
704
+ // `parent` attribute — a CEL missing-path error (deny) — matching the adapter's join finding
705
+ // nothing; the same holds one level down for `parent.inner`.
706
+ const parentSeed = parentSeedOf(seed);
707
+ if (parentSeed !== undefined) {
708
+ const parentAttr = relationAttr(parentSeed);
709
+ const innerSeed = parentSeedOf(parentSeed);
710
+ if (innerSeed !== undefined) {
711
+ parentAttr["inner"] = relationAttr(innerSeed);
712
+ }
713
+ attr["parent"] = parentAttr;
714
+ }
715
+ return { kind: seedsFile.resourceKind, id: seed.id, attr };
716
+ }
717
+
718
+ // -- oracle: ask the PDP itself, row by row --
719
+
720
+ async function oracleAllowedIds(action: string): Promise<string[]> {
721
+ const ids: string[] = [];
722
+ for (const seed of SEEDS) {
723
+ const result = await cerbos.checkResource({
724
+ principal: principal(),
725
+ resource: asCheckResource(seed),
726
+ actions: [action],
727
+ });
728
+ if (result.isAllowed(action)) {
729
+ ids.push(seed.id);
730
+ }
731
+ }
732
+ return ids.sort();
733
+ }
734
+
735
+ /** The degeneracy guard's per-action assertion, labelled so a failure names the action. */
736
+ async function expectNonDegenerateOracle(action: string): Promise<void> {
737
+ const ids = await oracleAllowedIds(action);
738
+ expect({
739
+ action,
740
+ nonEmpty: ids.length > 0,
741
+ nonTotal: ids.length < SEEDS.length,
742
+ }).toEqual({ action, nonEmpty: true, nonTotal: true });
743
+ }
744
+
745
+ // -- adapter execution through the public queryPlanToPrisma path --
746
+
747
+ async function adapterFilteredIds(
748
+ action: string,
749
+ nullAttributeRepresentation: "explicit" | "omitted" = "explicit",
750
+ mapper: Record<string, MapperConfig> = MAPPER
751
+ ): Promise<string[]> {
752
+ const queryPlan = await cerbos.planResources({
753
+ principal: principal(),
754
+ resource: { kind: seedsFile.resourceKind },
755
+ action,
756
+ });
757
+ const result = queryPlanToPrisma({
758
+ queryPlan,
759
+ mapper,
760
+ model: MODEL,
761
+ nullAttributeRepresentation,
762
+ });
763
+ if (result.kind === PlanKind.ALWAYS_DENIED) {
764
+ return [];
765
+ }
766
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
767
+ const rows = await prisma.adversarialResource.findMany({
768
+ where,
769
+ select: { id: true },
770
+ });
771
+ return rows.map((r) => r.id).sort();
772
+ }
773
+
774
+ /** Whether any operand anywhere in the plan is a literal null, or a list containing one. */
775
+ function planCarriesNullLiteral(operand: unknown): boolean {
776
+ if (typeof operand !== "object" || operand === null) return false;
777
+ const node = operand as Record<string, unknown>;
778
+ if ("value" in node) {
779
+ const value = node["value"];
780
+ return value === null || (Array.isArray(value) && value.includes(null));
781
+ }
782
+ const operands = node["operands"];
783
+ return Array.isArray(operands) && operands.some(planCarriesNullLiteral);
784
+ }
785
+
786
+ describe(`adversarial conformance corpus (${STORE_NAME})`, () => {
787
+ // Anti-vacuity for the store split: every other assertion in this file is identical on both
788
+ // legs, so a PostgreSQL leg that silently fell back to SQLite would pass the entire suite while
789
+ // proving nothing about PostgreSQL — the exact gap #320 reports. Ask the connection the suite
790
+ // actually queries through which engine it is. Each spelling is rejected by the other engine, so
791
+ // this fails in both directions rather than only when the container is missing.
792
+ test("executes against the store ADAPTER_TEST_DB selects", async () => {
793
+ const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
794
+ STORE_NAME === "postgres"
795
+ ? "select version() as banner"
796
+ : "select 'SQLite ' || sqlite_version() as banner"
797
+ );
798
+ expect(rows[0]?.banner?.split(" ")[0]).toBe(
799
+ STORE_NAME === "postgres" ? "PostgreSQL" : "SQLite"
800
+ );
801
+ });
802
+
803
+ test("every adversarial schema declares the same data model", () => {
804
+ const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
805
+ name,
806
+ models: modelBlocks(
807
+ fs.readFileSync(path.join(SCHEMA_DIR, name), "utf8")
808
+ ),
809
+ }));
810
+ if (!reference) {
811
+ throw new Error("ADVERSARIAL_SCHEMAS is empty");
812
+ }
813
+ // Guard the guard: a regex that stopped matching would make every schema compare equal on an
814
+ // empty string.
815
+ expect(reference.models).toContain(`model ${MODEL}`);
816
+ for (const schema of rest) {
817
+ expect({ name: schema.name, models: schema.models }).toEqual({
818
+ name: schema.name,
819
+ models: reference.models,
820
+ });
821
+ }
822
+ });
823
+
824
+ test("adapter-supported expected shapes move from throwing to oracle", () => {
825
+ const promotedAction = "promoted-shape";
826
+ const classification = classifyActionsForAdapter(
827
+ {
828
+ conformance: [],
829
+ adapterSupportedExpected: {
830
+ prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
831
+ },
832
+ expectedUnsupported: [
833
+ {
834
+ action: promotedAction,
835
+ shape: "synthetic globally unsupported shape",
836
+ messages: { prisma: "unsupported" },
837
+ },
838
+ ],
839
+ nullRepresentationOmitted: [],
840
+ },
841
+ "prisma"
842
+ );
843
+
844
+ expect(classification.oracleActions).toContain(promotedAction);
845
+ expect(
846
+ classification.throwingActions.map(([action]) => action)
847
+ ).not.toContain(promotedAction);
848
+ });
849
+
850
+ // Adding a throwing action without pinning its message must fail this harness rather than
851
+ // silently degrade the throw suite to a bare "it threw" (cerbos/query-plan-adapters#326).
852
+ test("a throwing action with no pinned message fails classification", () => {
853
+ const unpinnedAction = "unpinned-shape";
854
+ const classify = (): ActionClassification =>
855
+ classifyActionsForAdapter(
856
+ {
857
+ conformance: [unpinnedAction],
858
+ adapterUnsupported: {
859
+ prisma: [{ action: unpinnedAction, reason: "synthetic limitation" }],
860
+ },
861
+ expectedUnsupported: [],
862
+ nullRepresentationOmitted: [],
863
+ },
864
+ "prisma"
865
+ );
866
+
867
+ expect(classify).toThrow(/pins no throw message/);
868
+ });
869
+
870
+ test("manifest assigns all 146 policy actions exactly one Prisma outcome", () => {
871
+ const oracle = new Set(ORACLE_ACTIONS);
872
+ const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
873
+ const nullOmitted = new Set(
874
+ NULL_REPRESENTATION_OMITTED.map(([action]) => action)
875
+ );
876
+ const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
877
+ const classificationCount = [
878
+ oracle.has(action),
879
+ throwing.has(action),
880
+ nullOmitted.has(action),
881
+ PRISMA_KNOWN_DIVERGENCES.has(action),
882
+ ].filter(Boolean).length;
883
+ return classificationCount !== 1;
884
+ });
885
+
886
+ expect(MANIFEST_ACTIONS.size).toBe(199);
887
+ // Deliberate tripwire: every one of these carries a pinned message, so a throwing action
888
+ // gained or lost has to be re-triaged here rather than joining the suite unnoticed.
889
+ expect(THROWING_ACTIONS).toHaveLength(61);
890
+ expect(misclassified).toEqual([]);
891
+ expect(
892
+ [...PRISMA_SUPPORTED_EXPECTED].filter(
893
+ (action) => !EXPECTED_UNSUPPORTED_ACTIONS.has(action)
894
+ )
895
+ ).toEqual([]);
896
+ });
897
+
898
+ test.each(ORACLE_ACTIONS)(
899
+ "%s matches the check() oracle",
900
+ async (action) => {
901
+ const [oracle, filtered] = await Promise.all([
902
+ oracleAllowedIds(action),
903
+ adapterFilteredIds(action),
904
+ ]);
905
+ expect(filtered).toEqual(oracle);
906
+ }
907
+ );
908
+
909
+ // Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
910
+ // declared adapterUnsupported list): translation must fail loudly, never produce a
911
+ // silently-wrong filter. The plan is fetched OUTSIDE the assertion so a PDP failure fails
912
+ // the test instead of passing it, and no query executes — the invariant is that the shape
913
+ // throws BEFORE a filter exists, so SQLite rejecting a wrongly emitted filter afterwards
914
+ // must not be able to masquerade as the adapter refusing to translate.
915
+ //
916
+ // The message is asserted, not just the throw: a bare `toThrow()` is satisfied by a mapper
917
+ // typo or an unrelated validation, which would leave the classification resting on a failure
918
+ // that has nothing to do with the limitation it declares (cerbos/query-plan-adapters#326).
919
+ test.each(THROWING_ACTIONS)(
920
+ "%s fails during translation with the declared message, before any filter exists (%s)",
921
+ async (action, _reason, message) => {
922
+ const queryPlan = await cerbos.planResources({
923
+ principal: principal(),
924
+ resource: { kind: seedsFile.resourceKind },
925
+ action,
926
+ });
927
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
928
+ expect(() =>
929
+ queryPlanToPrisma({
930
+ queryPlan,
931
+ mapper: MAPPER,
932
+ model: MODEL,
933
+ nullAttributeRepresentation: "explicit",
934
+ })
935
+ ).toThrow(message);
936
+ }
937
+ );
938
+
939
+ // #387. `filter-as-conjunct` puts a filter() one level below the root, where the guard that
940
+ // refuses `filter-as-condition` does not look. Its oracle is empty BY CONSTRUCTION — check()
941
+ // cannot evaluate a non-boolean conjunction — so it belongs to neither degeneracy-guard list,
942
+ // and a bare "it throws" would say nothing about whether refusing it is REQUIRED.
943
+ //
944
+ // This is that argument. The other conjunct is `R.attr.aBool`, which the adapter certainly can
945
+ // express and which `root-bare-bool` spells on its own; an adapter that dropped the conjunct it
946
+ // could not translate would emit exactly that filter and return every row it selects, all of
947
+ // which the PDP denies for this action.
948
+ test("filter-as-conjunct must be refused: dropping its untranslatable half over-grants", async () => {
949
+ expect(await oracleAllowedIds("filter-as-conjunct")).toEqual([]);
950
+
951
+ const survivingHalf = await adapterFilteredIds("root-bare-bool");
952
+ expect(survivingHalf.length).toBeGreaterThan(0);
953
+ expect(survivingHalf.length).toBeLessThan(SEEDS.length);
954
+
955
+ const message = THROWING_ACTIONS.find(
956
+ ([action]) => action === "filter-as-conjunct"
957
+ )?.[2];
958
+ expect(message).toBeDefined();
959
+ await expect(adapterFilteredIds("filter-as-conjunct")).rejects.toThrow(
960
+ message
961
+ );
962
+ });
963
+
964
+ // #302. `null-eq-missing` probes `aOptionalString == null`, and `aOptionalString` follows the
965
+ // corpus default: a NULL column sends NO attribute. Both halves are asserted because the
966
+ // rejection alone would pass vacuously if the adapter threw for an unrelated reason — the
967
+ // over-grant under the default representation is what makes the rejection necessary.
968
+ test.each(NULL_REPRESENTATION_OMITTED)(
969
+ "%s over-grants under the explicit representation and is rejected under omitted (%s)",
970
+ async (action, _reason, message) => {
971
+ const oracle = await oracleAllowedIds(action);
972
+ expect(oracle).toEqual([]);
973
+
974
+ // The default translation emits IS NULL and returns exactly the rows the PDP denies.
975
+ const overGranted = await adapterFilteredIds(action, "explicit");
976
+ expect(overGranted.length).toBeGreaterThan(0);
977
+
978
+ await expect(adapterFilteredIds(action, "omitted")).rejects.toThrow(
979
+ message
980
+ );
981
+ }
982
+ );
983
+
984
+ // #308. The per-attribute declaration overrides the call-level option, which is the property
985
+ // that makes a suite mixing both conventions expressible at all. Asserted in both directions
986
+ // against the SAME action and the SAME call-level option, varying only whether the mapper
987
+ // declares the convention — so a declaration that did nothing would show up here as the two
988
+ // runs agreeing.
989
+ test("a per-attribute declaration overrides the call-level representation", async () => {
990
+ // `owner` declares "explicit", so the call-level "omitted" does not reach it.
991
+ await expect(adapterFilteredIds("null-eq", "omitted")).resolves.toEqual(
992
+ await oracleAllowedIds("null-eq")
993
+ );
994
+
995
+ // Strip the declaration and the same action under the same option is rejected — so the
996
+ // stripped mapper the completeness guard below uses is not quietly equivalent to MAPPER.
997
+ await expect(
998
+ adapterFilteredIds("null-eq", "omitted", MAPPER_WITHOUT_NULL_CONVENTIONS)
999
+ ).rejects.toThrow(NULL_OMITTED_MESSAGE);
1000
+ });
1001
+
1002
+ // #302 completeness guard. The rejection must key off the null OPERAND, not off a list of
1003
+ // operators: `hasIntersection(tagNames, ["public", null])` carries one in its value list, and
1004
+ // an allowlist of eq/ne/in silently misses it. Enumerating the corpus rather than naming
1005
+ // shapes means a newly added action carrying a null constant is covered automatically.
1006
+ test("every corpus action carrying a null literal is rejected under omitted", async () => {
1007
+ const nullCarrying: string[] = [];
1008
+ for (const action of [...MANIFEST_ACTIONS].sort()) {
1009
+ const queryPlan = await cerbos.planResources({
1010
+ principal: principal(),
1011
+ resource: { kind: seedsFile.resourceKind },
1012
+ action,
1013
+ });
1014
+ if (
1015
+ queryPlan.kind === PlanKind.CONDITIONAL &&
1016
+ planCarriesNullLiteral(queryPlan.condition)
1017
+ ) {
1018
+ nullCarrying.push(action);
1019
+ }
1020
+ }
1021
+
1022
+ // Guard the guard: if the walk stopped finding null operands the loop below is vacuous.
1023
+ expect(nullCarrying).toContain("null-eq-missing");
1024
+ expect(nullCarrying).toContain("in-null-elem-hasint");
1025
+
1026
+ const notRejected: string[] = [];
1027
+ for (const action of nullCarrying) {
1028
+ try {
1029
+ await adapterFilteredIds(
1030
+ action,
1031
+ "omitted",
1032
+ MAPPER_WITHOUT_NULL_CONVENTIONS
1033
+ );
1034
+ notRejected.push(action);
1035
+ } catch (error) {
1036
+ // The rejection must be the null-operand check talking, not an incidental failure — a
1037
+ // transport error or mapper typo counting as the required rejection is the silent pass
1038
+ // the corpus README warns about.
1039
+ if (!String(error).includes(NULL_OMITTED_MESSAGE)) {
1040
+ notRejected.push(`${action} (rejected for the wrong reason: ${String(error)})`);
1041
+ }
1042
+ }
1043
+ }
1044
+ expect(notRejected).toEqual([]);
1045
+ });
1046
+
1047
+ test("pins the upstream has() planner over-grant", async () => {
1048
+ const action = "p-has";
1049
+ expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
1050
+ const queryPlan = await cerbos.planResources({
1051
+ principal: principal(),
1052
+ resource: { kind: seedsFile.resourceKind },
1053
+ action,
1054
+ });
1055
+ const oracle = await oracleAllowedIds(action);
1056
+ const allIds = SEEDS.map((seed) => seed.id).sort();
1057
+
1058
+ expect(queryPlan.kind).toBe(PlanKind.ALWAYS_ALLOWED);
1059
+ expect(oracle.length).toBeGreaterThan(0);
1060
+ expect(oracle.length).toBeLessThan(allIds.length);
1061
+ expect(oracle).toContain("a1");
1062
+ expect(await adapterFilteredIds(action)).toEqual(allIds);
1063
+ });
1064
+
1065
+ // The corpus pins two count spellings over the chain — `size(...) == 0` and
1066
+ // `!(size(...) > 0)` — and CEL's type checker rules out a third that a real policy could
1067
+ // reach through the planner (`>= 1` and `<= 0` are the only other thresholds this adapter
1068
+ // can express, and no policy needs both spellings). The guard must nonetheless be a
1069
+ // property of the chain rather than of the two spellings that happen to be pinned, so
1070
+ // these synthesise the remaining threshold/polarity combinations directly onto the same
1071
+ // seeded store and assert the parentless rows stay out of every one
1072
+ // (cerbos/query-plan-adapters#316).
1073
+ test("every count threshold over the chain inherits the absent-parent guard", async () => {
1074
+ const chain = new PlanExpressionVariable(
1075
+ "request.resource.attr.mainCategory.subCategories"
1076
+ );
1077
+ const size = new PlanExpression("size", [chain]);
1078
+ const compare = (operator: string, threshold: number) =>
1079
+ new PlanExpression(operator, [size, new PlanExpressionValue(threshold)]);
1080
+ const negate = (condition: PlanExpressionOperand) =>
1081
+ new PlanExpression("not", [condition]);
1082
+
1083
+ const filteredIdsFor = async (
1084
+ condition: PlanExpressionOperand
1085
+ ): Promise<string[]> => {
1086
+ const result = queryPlanToPrisma({
1087
+ queryPlan: {
1088
+ kind: PlanKind.CONDITIONAL,
1089
+ condition,
1090
+ cerbosCallId: "synthetic",
1091
+ requestId: "synthetic",
1092
+ validationErrors: [],
1093
+ metadata: undefined,
1094
+ },
1095
+ mapper: MAPPER,
1096
+ model: MODEL,
1097
+ });
1098
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
1099
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1100
+ const rows = await prisma.adversarialResource.findMany({
1101
+ where,
1102
+ select: { id: true },
1103
+ });
1104
+ return rows.map((row) => row.id).sort();
1105
+ };
1106
+
1107
+ // Each of these is TRUE for a row with no mainCategory only if the guard leaks: an
1108
+ // absent to-one parent is a CEL missing-path error, so the PDP denies it outright.
1109
+ const emptyByConstruction: [string, PlanExpressionOperand][] = [
1110
+ ["size(chain) == 0", compare("eq", 0)],
1111
+ ["size(chain) <= 0", compare("le", 0)],
1112
+ ["size(chain) < 1", compare("lt", 1)],
1113
+ ["!(size(chain) >= 1)", negate(compare("ge", 1))],
1114
+ ["!(size(chain) > 0)", negate(compare("gt", 0))],
1115
+ ];
1116
+
1117
+ for (const [shape, condition] of emptyByConstruction) {
1118
+ expect([shape, await filteredIdsFor(condition)]).toEqual([shape, []]);
1119
+ }
1120
+
1121
+ // The mirror image, so the loop above cannot pass by denying everything: the negation of
1122
+ // an emptiness check is TRUE for exactly the rows that HAVE the parent.
1123
+ const withParent = await oracleAllowedIds("w1-size-nonneg-chain");
1124
+ expect(withParent.length).toBeGreaterThan(0);
1125
+ expect(withParent.length).toBeLessThan(SEEDS.length);
1126
+ expect(await filteredIdsFor(negate(compare("eq", 0)))).toEqual(withParent);
1127
+ expect(await filteredIdsFor(negate(compare("lt", 1)))).toEqual(withParent);
1128
+ });
1129
+
1130
+ // The corpus pins ONE ternary whose condition reaches a chain — `w1-ternary-chain-cond`, whose
1131
+ // else-branch is a bare `!aBool`. The guard has to be a property of the ternary's false-branch
1132
+ // rather than of that one spelling, so these synthesise the other condition positions onto the
1133
+ // same seeded store (cerbos/query-plan-adapters#334). Each expectation is a real check() oracle,
1134
+ // never a hand-computed row list.
1135
+ test("every ternary condition over the chain inherits the absent-parent guard", async () => {
1136
+ const chainIn = new PlanExpression("in", [
1137
+ new PlanExpressionValue("finance"),
1138
+ new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
1139
+ ]);
1140
+ const TRUE = new PlanExpressionValue(true);
1141
+ const FALSE = new PlanExpressionValue(false);
1142
+ const ternary = (
1143
+ condition: PlanExpressionOperand,
1144
+ thenBranch: PlanExpressionOperand,
1145
+ elseBranch: PlanExpressionOperand
1146
+ ) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
1147
+
1148
+ const filteredIdsFor = async (
1149
+ condition: PlanExpressionOperand
1150
+ ): Promise<string[]> => {
1151
+ const result = queryPlanToPrisma({
1152
+ queryPlan: {
1153
+ kind: PlanKind.CONDITIONAL,
1154
+ condition,
1155
+ cerbosCallId: "synthetic",
1156
+ requestId: "synthetic",
1157
+ validationErrors: [],
1158
+ metadata: undefined,
1159
+ },
1160
+ mapper: MAPPER,
1161
+ model: MODEL,
1162
+ });
1163
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
1164
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1165
+ const rows = await prisma.adversarialResource.findMany({
1166
+ where,
1167
+ select: { id: true },
1168
+ });
1169
+ return rows.map((row) => row.id).sort();
1170
+ };
1171
+
1172
+ // The rows the chain condition is definitively TRUE for, and the ones it is definitively
1173
+ // FALSE for. Everything else — every row with no mainCategory at all — is a CEL
1174
+ // missing-path error, which selects NEITHER branch.
1175
+ const conditionTrue = await oracleAllowedIds("w1-in-chain");
1176
+ const conditionFalse = await oracleAllowedIds("w1-not-in-chain");
1177
+ expect(conditionTrue.length).toBeGreaterThan(0);
1178
+ expect(conditionFalse.length).toBeGreaterThan(0);
1179
+ expect(conditionTrue.length + conditionFalse.length).toBeLessThan(
1180
+ SEEDS.length
1181
+ );
1182
+
1183
+ // The else-branch is what a bare `NOT` over the chain filter over-grants: it is TRUE for
1184
+ // every parentless row, so each of these returned the 17 missing-parent seeds on top.
1185
+ expect(await filteredIdsFor(ternary(chainIn, FALSE, TRUE))).toEqual(
1186
+ conditionFalse
1187
+ );
1188
+ expect(
1189
+ await filteredIdsFor(
1190
+ ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE)
1191
+ )
1192
+ ).toEqual(conditionFalse);
1193
+ // A `not` condition in false-branch position: the double negation collapses back to the
1194
+ // positive membership, which excludes the parentless rows by itself.
1195
+ expect(
1196
+ await filteredIdsFor(
1197
+ ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE)
1198
+ )
1199
+ ).toEqual(conditionTrue);
1200
+
1201
+ // A conjunction condition needs De Morgan, not an outer NOT with the hops ANDed beside it:
1202
+ // CEL's `&&` absorbs an erroring operand when the other is FALSE, so a parentless row with
1203
+ // aBool=false makes the whole condition definitively FALSE and DOES select the else-branch.
1204
+ const aBoolFalse = SEEDS.filter((seed) => !seed.aBool).map((seed) => seed.id);
1205
+ expect(aBoolFalse.length).toBeGreaterThan(0);
1206
+ expect(
1207
+ await filteredIdsFor(
1208
+ ternary(
1209
+ new PlanExpression("and", [
1210
+ chainIn,
1211
+ new PlanExpressionVariable("request.resource.attr.aBool"),
1212
+ ]),
1213
+ FALSE,
1214
+ TRUE
1215
+ )
1216
+ )
1217
+ ).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
1218
+ });
1219
+
1220
+ // The to-one relation carries no corpus action yet — this is the expand half of
1221
+ // cerbos/query-plan-adapters#372's expand–contract — so nothing else in this file would notice a
1222
+ // seeder that stored no chain at all, or one that attached every parent to the wrong resource.
1223
+ // Read the two hops back through a real join rather than counting rows: a count cannot tell an
1224
+ // inner row carrying the corpus's values from one carrying the root's own columns, which is
1225
+ // exactly the flat-column-alias failure this relation exists to make visible.
1226
+ test("the seeded to-one chain matches the corpus relation", async () => {
1227
+ const withParent = SEEDS.filter((seed) => parentSeedOf(seed) !== undefined);
1228
+ const withInner = SEEDS.filter(
1229
+ (seed) => parentSeedOf(parentSeedOf(seed)) !== undefined
1230
+ );
1231
+ expect(withParent.length).toBeGreaterThan(0);
1232
+ expect(withInner.length).toBeGreaterThan(0);
1233
+ expect(withParent.length).toBeLessThan(SEEDS.length);
1234
+
1235
+ const joined = await prisma.adversarialResource.findMany({
1236
+ select: {
1237
+ id: true,
1238
+ parent: {
1239
+ select: { aString: true, inner: { select: { aString: true } } },
1240
+ },
1241
+ },
1242
+ });
1243
+ const stored = Object.fromEntries(
1244
+ joined.map((row) => [
1245
+ row.id,
1246
+ [row.parent?.aString ?? null, row.parent?.inner?.aString ?? null],
1247
+ ])
1248
+ );
1249
+ const expected = Object.fromEntries(
1250
+ SEEDS.map((seed) => [
1251
+ seed.id,
1252
+ [
1253
+ parentSeedOf(seed)?.aString ?? null,
1254
+ parentSeedOf(parentSeedOf(seed))?.aString ?? null,
1255
+ ],
1256
+ ])
1257
+ );
1258
+ expect(stored).toEqual(expected);
1259
+ });
1260
+
1261
+ test("oracle is not degenerate", async () => {
1262
+ // Guard the guard: each of these actions must produce a non-empty, non-total oracle set,
1263
+ // otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
1264
+ //
1265
+ // Every entry is asserted to be an action Prisma actually oracle-compares. A list copied
1266
+ // from another harness drifts into naming shapes this adapter never compares, which guard
1267
+ // nothing (cerbos/query-plan-adapters#324); the membership assertion turns moving an action
1268
+ // into Prisma's `adapterUnsupported` set into a failure here rather than a silent no-op.
1269
+ for (const action of DEGENERACY_GUARD_ACTIONS) {
1270
+ expect(ORACLE_ACTIONS).toContain(action);
1271
+ await expectNonDegenerateOracle(action);
1272
+ }
1273
+ // Shapes Prisma refuses to translate, so there is no comparison behind them: these carry
1274
+ // PDP/policy liveness for their group only. Asserting the complement keeps the split
1275
+ // honest — an action Prisma gains support for must move up into the guard proper.
1276
+ for (const action of DEGENERACY_LIVENESS_PROBES) {
1277
+ expect(ORACLE_ACTIONS).not.toContain(action);
1278
+ await expectNonDegenerateOracle(action);
1279
+ }
1280
+ });
1281
+ });