@cerbos/orm-prisma 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +83 -6
  2. package/lib/index.d.ts +29 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1459 -114
  5. package/lib/index.js.map +1 -1
  6. package/package.json +19 -13
  7. package/src/adversarial.test.ts +586 -0
  8. package/src/generated/prisma/commonInputTypes.ts +26 -0
  9. package/src/generated/prisma/internal/class.ts +9 -9
  10. package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
  11. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
  12. package/src/generated/prisma/models/Category.ts +1 -1
  13. package/src/generated/prisma/models/Label.ts +1 -1
  14. package/src/generated/prisma/models/NestedResource.ts +1 -1
  15. package/src/generated/prisma/models/NextLevelNestedResource.ts +1 -1
  16. package/src/generated/prisma/models/Resource.ts +62 -2
  17. package/src/generated/prisma/models/SubCategory.ts +1 -1
  18. package/src/generated/prisma/models/Tag.ts +1 -1
  19. package/src/generated/prisma/models/User.ts +1 -1
  20. package/src/generated/prisma-adversarial/browser.ts +44 -0
  21. package/src/generated/prisma-adversarial/client.ts +66 -0
  22. package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
  23. package/src/generated/prisma-adversarial/enums.ts +15 -0
  24. package/src/generated/prisma-adversarial/internal/class.ts +244 -0
  25. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
  26. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
  27. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  28. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  29. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
  30. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  31. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  32. package/src/generated/prisma-adversarial/models.ts +16 -0
  33. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  34. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  35. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  36. package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
  37. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  38. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  39. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  40. package/src/generated/prisma-v6/index.d.ts +90 -3
  41. package/src/index.test.ts +3199 -636
  42. package/src/index.ts +2055 -148
  43. package/src/test-setup.adversarial.v6.ts +5 -0
  44. package/src/test-setup.adversarial.v7.ts +12 -0
@@ -0,0 +1,586 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ import { GRPC as Cerbos } from "@cerbos/grpc";
5
+ import type { Principal, Resource, Value } from "@cerbos/core";
6
+
7
+ import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
8
+ import { prisma } from "./test-setup.adversarial";
9
+
10
+ /**
11
+ * Adversarial differential suite: every action in the shared `../conformance/` corpus is planned
12
+ * against a REAL Cerbos PDP (the sidecar started by `npm run test:adversarial`, loaded with
13
+ * `conformance/policies/adversarial.yaml`), translated by this adapter, and executed against
14
+ * seeded rows — then the filtered id set is compared against an oracle computed by calling the
15
+ * check API for each row with attributes mirroring that row exactly.
16
+ *
17
+ * No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
18
+ * evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
19
+ * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns the
20
+ * Prisma-specific translation (seeding, field mapping, executing the query).
21
+ */
22
+
23
+ const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
24
+
25
+ const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
26
+
27
+ interface Tag {
28
+ id: string;
29
+ name: string | null;
30
+ }
31
+
32
+ interface Seed {
33
+ id: string;
34
+ aBool: boolean;
35
+ aString: string;
36
+ aNumber: number;
37
+ aOptionalString: string | null;
38
+ tags: Tag[];
39
+ subCategoryNames: string[];
40
+ }
41
+
42
+ interface SeedsFile {
43
+ principal: Principal;
44
+ resourceKind: string;
45
+ seeds: Seed[];
46
+ }
47
+
48
+ interface UnsupportedShape {
49
+ action: string;
50
+ shape: string;
51
+ springDataMessage: string;
52
+ }
53
+
54
+ interface AdapterUnsupportedEntry {
55
+ action: string;
56
+ reason: string;
57
+ }
58
+
59
+ interface KnownDivergence {
60
+ action: string;
61
+ adapters: string[];
62
+ }
63
+
64
+ interface ActionsFile {
65
+ conformance: string[];
66
+ adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
67
+ adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
68
+ expectedUnsupported: UnsupportedShape[];
69
+ knownDivergences?: KnownDivergence[];
70
+ }
71
+
72
+ type ThrowingAction = readonly [action: string, reason: string];
73
+
74
+ interface ActionClassification {
75
+ oracleActions: string[];
76
+ throwingActions: ThrowingAction[];
77
+ supportedExpected: Set<string>;
78
+ }
79
+
80
+ function classifyActionsForAdapter(
81
+ manifest: ActionsFile,
82
+ adapter: string
83
+ ): ActionClassification {
84
+ const unsupported = manifest.adapterUnsupported?.[adapter] ?? [];
85
+ const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
86
+ const supportedExpected = new Set(
87
+ (manifest.adapterSupportedExpected?.[adapter] ?? []).map(
88
+ (entry) => entry.action
89
+ )
90
+ );
91
+ const oracleActions = [
92
+ ...manifest.conformance.filter(
93
+ (action) => !unsupportedActions.has(action)
94
+ ),
95
+ ...supportedExpected,
96
+ ];
97
+ const throwingActions: ThrowingAction[] = [
98
+ ...unsupported.map(
99
+ (entry): ThrowingAction => [entry.action, entry.reason]
100
+ ),
101
+ ...manifest.expectedUnsupported
102
+ .filter((entry) => !supportedExpected.has(entry.action))
103
+ .map((entry): ThrowingAction => [entry.action, entry.shape]),
104
+ ];
105
+
106
+ return {
107
+ oracleActions: [...new Set(oracleActions)].sort(),
108
+ throwingActions: throwingActions.sort(([left], [right]) =>
109
+ left.localeCompare(right)
110
+ ),
111
+ supportedExpected,
112
+ };
113
+ }
114
+
115
+ const seedsFile: SeedsFile = JSON.parse(
116
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "seeds.json"), "utf8")
117
+ );
118
+ const actionsFile: ActionsFile = JSON.parse(
119
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
120
+ );
121
+ const SEEDS = seedsFile.seeds;
122
+
123
+ const {
124
+ oracleActions: ORACLE_ACTIONS,
125
+ throwingActions: THROWING_ACTIONS,
126
+ supportedExpected: PRISMA_SUPPORTED_EXPECTED,
127
+ } = classifyActionsForAdapter(actionsFile, "prisma");
128
+ const PRISMA_KNOWN_DIVERGENCES = new Set(
129
+ (actionsFile.knownDivergences ?? [])
130
+ .filter((entry) => entry.adapters.includes("prisma"))
131
+ .map((entry) => entry.action)
132
+ );
133
+ const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
134
+ actionsFile.expectedUnsupported.map((entry) => entry.action)
135
+ );
136
+ const MANIFEST_ACTIONS = new Set([
137
+ ...actionsFile.conformance,
138
+ ...EXPECTED_UNSUPPORTED_ACTIONS,
139
+ ...PRISMA_KNOWN_DIVERGENCES,
140
+ ]);
141
+
142
+ function doubleFor(seed: Seed): number | null {
143
+ switch (seed.id) {
144
+ case "a1":
145
+ return -0.6;
146
+ case "a2":
147
+ return 0.25;
148
+ case "a3":
149
+ return null;
150
+ default:
151
+ return seed.aNumber + 0.3;
152
+ }
153
+ }
154
+
155
+ function labelsFor(seed: Seed): (string | null)[] {
156
+ switch (seed.id) {
157
+ case "a1":
158
+ return ["gold", "silver"];
159
+ case "a6":
160
+ return [null, "silver"];
161
+ case "a8":
162
+ return ["silver"];
163
+ case "c1":
164
+ return ["Gold"];
165
+ default:
166
+ return [];
167
+ }
168
+ }
169
+
170
+ /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
171
+ function isoFor(seed: Seed): string {
172
+ return seed.aNumber >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z";
173
+ }
174
+
175
+ function timestampFor(seed: Seed): string | null {
176
+ switch (seed.id) {
177
+ case "a1":
178
+ return "2020-03-15T10:30:00Z";
179
+ case "a2":
180
+ return "2037-01-01T00:00:00Z";
181
+ case "a3":
182
+ return null;
183
+ case "a4":
184
+ return "2024-06-01T00:00:00Z";
185
+ case "a5":
186
+ return "2020-03-15T10:30:00.123456Z";
187
+ default:
188
+ return seed.aNumber >= 2
189
+ ? "2036-06-06T06:06:06Z"
190
+ : "2021-05-05T05:05:05Z";
191
+ }
192
+ }
193
+
194
+ function scopeFor(seed: Seed): string | null {
195
+ switch (seed.id) {
196
+ case "a1":
197
+ return "dept";
198
+ case "a2":
199
+ return "dept.eng";
200
+ case "a3":
201
+ return "dept.eng.platform";
202
+ case "a4":
203
+ return "dept.eng.platform.obs";
204
+ case "a5":
205
+ return "dept.engineering";
206
+ case "a6":
207
+ return "dept.sales";
208
+ case "a8":
209
+ return "";
210
+ case "a9":
211
+ return "50%";
212
+ case "b1":
213
+ return "50%:a_b:x";
214
+ case "b2":
215
+ return "50x:a_b:y";
216
+ case "b3":
217
+ return "50%:aXb:y";
218
+ case "b4":
219
+ return "50%:a_b";
220
+ case "b5":
221
+ return "dept.eng.platform2";
222
+ case "b6":
223
+ return "50%.a_b";
224
+ case "c1":
225
+ return "Dept.Eng";
226
+ case "c2":
227
+ return "dept.eng.";
228
+ case "d1":
229
+ return "[env]:prod:eu";
230
+ case "d2":
231
+ return "e:prod:eu";
232
+ default:
233
+ return null;
234
+ }
235
+ }
236
+
237
+ const MAPPER: Record<string, MapperConfig> = {
238
+ "request.resource.attr.aBool": { field: "aBool" },
239
+ "request.resource.attr.aString": { field: "aString" },
240
+ "request.resource.attr.aNumber": { field: "aNumber" },
241
+ "request.resource.attr.aDouble": { field: "aDouble" },
242
+ "request.resource.attr.aOptionalString": { field: "aOptionalString" },
243
+ "request.resource.attr.createdBy": { field: "createdBy" },
244
+ "request.resource.attr.scope": { field: "scope" },
245
+ "request.resource.attr.createdAt": {
246
+ field: "createdAt",
247
+ valueType: "dateTime",
248
+ },
249
+ "request.resource.attr.owner": {
250
+ field: "aOptionalString",
251
+ nullable: true,
252
+ },
253
+ // obj.inner is not a real nested column — mirrors aString, same trick the spring-data
254
+ // reference harness uses for the p-struct probe.
255
+ "request.resource.attr.obj.inner": { field: "aString" },
256
+ "request.resource.attr.tags": {
257
+ relation: {
258
+ name: "tags",
259
+ type: "many",
260
+ // Model name enables field-to-field comparisons between tag columns; the nullable
261
+ // flag on `name` enables the adapter's three-valued-logic guards for collection
262
+ // macros over elements whose name column is NULL (a missing attribute — a CEL
263
+ // error, hence deny — on the check side).
264
+ model: "AdversarialTag",
265
+ fields: {
266
+ id: { field: "tagId" },
267
+ name: { field: "name", nullable: true },
268
+ },
269
+ },
270
+ },
271
+ "request.resource.attr.tagNames": {
272
+ relation: {
273
+ name: "tags",
274
+ type: "many",
275
+ field: "name",
276
+ fields: { name: { field: "name", nullable: true } },
277
+ },
278
+ },
279
+ "request.resource.attr.categories": {
280
+ relation: {
281
+ name: "categories",
282
+ type: "many",
283
+ fields: {
284
+ name: { field: "name" },
285
+ subCategories: {
286
+ relation: {
287
+ name: "subCategories",
288
+ type: "many",
289
+ fields: {
290
+ name: { field: "name" },
291
+ labels: {
292
+ relation: {
293
+ name: "labels",
294
+ type: "many",
295
+ fields: {
296
+ name: { field: "name", nullable: true },
297
+ },
298
+ },
299
+ },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ },
305
+ },
306
+ // Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
307
+ // as a single-object dotted chain on the check side (every seed holds at most one category),
308
+ // pinning that the adapter joins through every intermediate hop, never off the root.
309
+ "request.resource.attr.mainCategory": {
310
+ relation: {
311
+ name: "categories",
312
+ type: "many",
313
+ fields: {
314
+ name: { field: "name" },
315
+ subCategories: {
316
+ relation: {
317
+ name: "subCategories",
318
+ type: "many",
319
+ fields: { name: { field: "name" } },
320
+ },
321
+ },
322
+ // subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
323
+ // compares the flattened tail's name column directly.
324
+ subNames: {
325
+ relation: {
326
+ name: "subCategories",
327
+ type: "many",
328
+ field: "name",
329
+ },
330
+ },
331
+ },
332
+ },
333
+ },
334
+ };
335
+
336
+ beforeAll(async () => {
337
+ await prisma.adversarialLabel.deleteMany();
338
+ await prisma.adversarialSubCategory.deleteMany();
339
+ await prisma.adversarialCategory.deleteMany();
340
+ await prisma.adversarialTag.deleteMany();
341
+ await prisma.adversarialResource.deleteMany();
342
+
343
+ // Distinct sub-category/category graphs per seed so no rows share relations by accident.
344
+ for (const seed of SEEDS) {
345
+ await prisma.adversarialResource.create({
346
+ data: {
347
+ id: seed.id,
348
+ aBool: seed.aBool,
349
+ aString: seed.aString,
350
+ aNumber: seed.aNumber,
351
+ aDouble: doubleFor(seed),
352
+ aOptionalString: seed.aOptionalString,
353
+ createdBy: isoFor(seed),
354
+ scope: scopeFor(seed),
355
+ createdAt: timestampFor(seed),
356
+ tags: {
357
+ create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
358
+ },
359
+ categories: {
360
+ create: seed.subCategoryNames.map((subName) => ({
361
+ name: "business",
362
+ subCategories: {
363
+ create: [
364
+ {
365
+ name: subName,
366
+ labels: {
367
+ create: labelsFor(seed).map((name) => ({ name })),
368
+ },
369
+ },
370
+ ],
371
+ },
372
+ })),
373
+ },
374
+ },
375
+ });
376
+ }
377
+ });
378
+
379
+ afterAll(async () => {
380
+ await prisma.$disconnect();
381
+ });
382
+
383
+ function principal(): Principal {
384
+ return seedsFile.principal;
385
+ }
386
+
387
+ /** A NULL tag name in the DB is a missing element attribute on the check side. */
388
+ function asTagAttribute(tag: Tag): Record<string, Value> {
389
+ const attr: Record<string, Value> = { id: tag.id };
390
+ if (tag.name !== null) {
391
+ attr["name"] = tag.name;
392
+ }
393
+ return attr;
394
+ }
395
+
396
+ /** Cerbos attributes mirroring exactly what the seeded DB row holds. */
397
+ function asCheckResource(seed: Seed): Resource {
398
+ const attr: Record<string, Value> = {
399
+ aBool: seed.aBool,
400
+ aString: seed.aString,
401
+ aNumber: seed.aNumber,
402
+ createdBy: isoFor(seed),
403
+ owner: seed.aOptionalString,
404
+ tagNames: seed.tags.map((tag) => tag.name),
405
+ obj: { inner: seed.aString },
406
+ tags: seed.tags.map(asTagAttribute),
407
+ categories: seed.subCategoryNames.map((subName) => ({
408
+ name: "business",
409
+ subCategories: [
410
+ {
411
+ name: subName,
412
+ labels: labelsFor(seed).map((name): Record<string, Value> =>
413
+ name === null ? {} : { name }
414
+ ),
415
+ },
416
+ ],
417
+ })),
418
+ };
419
+ // A DB NULL is a missing attribute on the check side — conditions touching it must deny
420
+ // (CEL error), matching SQL three-valued logic excluding the row.
421
+ if (seed.aOptionalString !== null) {
422
+ attr["aOptionalString"] = seed.aOptionalString;
423
+ }
424
+ const aDouble = doubleFor(seed);
425
+ if (aDouble !== null) {
426
+ attr["aDouble"] = aDouble;
427
+ }
428
+ const scope = scopeFor(seed);
429
+ if (scope !== null) {
430
+ attr["scope"] = scope;
431
+ }
432
+ const createdAt = timestampFor(seed);
433
+ if (createdAt !== null) {
434
+ attr["createdAt"] = createdAt;
435
+ }
436
+ // mainCategory mirrors the row's single category as ONE nested object (the seeder creates
437
+ // at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
438
+ // rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
439
+ // adapter's empty join chain excluding the row.
440
+ if (seed.subCategoryNames.length > 0) {
441
+ attr["mainCategory"] = {
442
+ name: "business",
443
+ subCategories: seed.subCategoryNames.map((name) => ({ name })),
444
+ subNames: seed.subCategoryNames,
445
+ };
446
+ }
447
+ return { kind: seedsFile.resourceKind, id: seed.id, attr };
448
+ }
449
+
450
+ // -- oracle: ask the PDP itself, row by row --
451
+
452
+ async function oracleAllowedIds(action: string): Promise<string[]> {
453
+ const ids: string[] = [];
454
+ for (const seed of SEEDS) {
455
+ const result = await cerbos.checkResource({
456
+ principal: principal(),
457
+ resource: asCheckResource(seed),
458
+ actions: [action],
459
+ });
460
+ if (result.isAllowed(action)) {
461
+ ids.push(seed.id);
462
+ }
463
+ }
464
+ return ids.sort();
465
+ }
466
+
467
+ // -- adapter execution through the public queryPlanToPrisma path --
468
+
469
+ async function adapterFilteredIds(action: string): Promise<string[]> {
470
+ const queryPlan = await cerbos.planResources({
471
+ principal: principal(),
472
+ resource: { kind: seedsFile.resourceKind },
473
+ action,
474
+ });
475
+ const result = queryPlanToPrisma({
476
+ queryPlan,
477
+ mapper: MAPPER,
478
+ model: "AdversarialResource",
479
+ });
480
+ if (result.kind === PlanKind.ALWAYS_DENIED) {
481
+ return [];
482
+ }
483
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
484
+ const rows = await prisma.adversarialResource.findMany({
485
+ where,
486
+ select: { id: true },
487
+ });
488
+ return rows.map((r) => r.id).sort();
489
+ }
490
+
491
+ describe("adversarial conformance corpus", () => {
492
+ test("adapter-supported expected shapes move from throwing to oracle", () => {
493
+ const promotedAction = "promoted-shape";
494
+ const classification = classifyActionsForAdapter(
495
+ {
496
+ conformance: [],
497
+ adapterSupportedExpected: {
498
+ prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
499
+ },
500
+ expectedUnsupported: [
501
+ {
502
+ action: promotedAction,
503
+ shape: "synthetic globally unsupported shape",
504
+ springDataMessage: "unsupported",
505
+ },
506
+ ],
507
+ },
508
+ "prisma"
509
+ );
510
+
511
+ expect(classification.oracleActions).toContain(promotedAction);
512
+ expect(
513
+ classification.throwingActions.map(([action]) => action)
514
+ ).not.toContain(promotedAction);
515
+ });
516
+
517
+ test("manifest assigns all 118 policy actions exactly one Prisma outcome", () => {
518
+ const oracle = new Set(ORACLE_ACTIONS);
519
+ const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
520
+ const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
521
+ const classificationCount = [
522
+ oracle.has(action),
523
+ throwing.has(action),
524
+ PRISMA_KNOWN_DIVERGENCES.has(action),
525
+ ].filter(Boolean).length;
526
+ return classificationCount !== 1;
527
+ });
528
+
529
+ expect(MANIFEST_ACTIONS.size).toBe(118);
530
+ expect(misclassified).toEqual([]);
531
+ expect(
532
+ [...PRISMA_SUPPORTED_EXPECTED].filter(
533
+ (action) => !EXPECTED_UNSUPPORTED_ACTIONS.has(action)
534
+ )
535
+ ).toEqual([]);
536
+ });
537
+
538
+ test.each(ORACLE_ACTIONS)(
539
+ "%s matches the check() oracle",
540
+ async (action) => {
541
+ const [oracle, filtered] = await Promise.all([
542
+ oracleAllowedIds(action),
543
+ adapterFilteredIds(action),
544
+ ]);
545
+ expect(filtered).toEqual(oracle);
546
+ }
547
+ );
548
+
549
+ // Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
550
+ // declared adapterUnsupported list): translation must fail loudly, never produce a
551
+ // silently-wrong filter.
552
+ test.each(THROWING_ACTIONS)(
553
+ "%s fails loudly instead of silently mistranslating (%s)",
554
+ async (action) => {
555
+ await expect(adapterFilteredIds(action)).rejects.toThrow();
556
+ }
557
+ );
558
+
559
+ test("pins the upstream has() planner over-grant", async () => {
560
+ const action = "p-has";
561
+ expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
562
+ const queryPlan = await cerbos.planResources({
563
+ principal: principal(),
564
+ resource: { kind: seedsFile.resourceKind },
565
+ action,
566
+ });
567
+ const oracle = await oracleAllowedIds(action);
568
+ const allIds = SEEDS.map((seed) => seed.id).sort();
569
+
570
+ expect(queryPlan.kind).toBe(PlanKind.ALWAYS_ALLOWED);
571
+ expect(oracle.length).toBeGreaterThan(0);
572
+ expect(oracle.length).toBeLessThan(allIds.length);
573
+ expect(oracle).toContain("a1");
574
+ expect(await adapterFilteredIds(action)).toEqual(allIds);
575
+ });
576
+
577
+ test("oracle is not degenerate", async () => {
578
+ // Guard the guard: at least one action must produce a non-empty, non-total oracle set,
579
+ // otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
580
+ for (const action of ["vf-le", "like-percent", "all-on-empty"]) {
581
+ const ids = await oracleAllowedIds(action);
582
+ expect(ids.length).toBeGreaterThan(0);
583
+ expect(ids.length).toBeLessThan(SEEDS.length);
584
+ }
585
+ });
586
+ });
@@ -58,6 +58,11 @@ export type StringNullableFilter<$PrismaModel = never> = {
58
58
  not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
59
59
  }
60
60
 
61
+ export type BoolNullableFilter<$PrismaModel = never> = {
62
+ equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
63
+ not?: Prisma.NestedBoolNullableFilter<$PrismaModel> | boolean | null
64
+ }
65
+
61
66
  export type SortOrderInput = {
62
67
  sort: Prisma.SortOrder
63
68
  nulls?: Prisma.NullsOrder
@@ -121,6 +126,14 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
121
126
  _max?: Prisma.NestedStringNullableFilter<$PrismaModel>
122
127
  }
123
128
 
129
+ export type BoolNullableWithAggregatesFilter<$PrismaModel = never> = {
130
+ equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
131
+ not?: Prisma.NestedBoolNullableWithAggregatesFilter<$PrismaModel> | boolean | null
132
+ _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
133
+ _min?: Prisma.NestedBoolNullableFilter<$PrismaModel>
134
+ _max?: Prisma.NestedBoolNullableFilter<$PrismaModel>
135
+ }
136
+
124
137
  export type NestedStringFilter<$PrismaModel = never> = {
125
138
  equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
126
139
  in?: string[]
@@ -165,6 +178,11 @@ export type NestedStringNullableFilter<$PrismaModel = never> = {
165
178
  not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
166
179
  }
167
180
 
181
+ export type NestedBoolNullableFilter<$PrismaModel = never> = {
182
+ equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
183
+ not?: Prisma.NestedBoolNullableFilter<$PrismaModel> | boolean | null
184
+ }
185
+
168
186
  export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
169
187
  equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
170
188
  in?: string[]
@@ -245,4 +263,12 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
245
263
  not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
246
264
  }
247
265
 
266
+ export type NestedBoolNullableWithAggregatesFilter<$PrismaModel = never> = {
267
+ equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel> | null
268
+ not?: Prisma.NestedBoolNullableWithAggregatesFilter<$PrismaModel> | boolean | null
269
+ _count?: Prisma.NestedIntNullableFilter<$PrismaModel>
270
+ _min?: Prisma.NestedBoolNullableFilter<$PrismaModel>
271
+ _max?: Prisma.NestedBoolNullableFilter<$PrismaModel>
272
+ }
273
+
248
274