@cerbos/orm-prisma 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +598 -603
- package/lib/collections.d.ts +30 -0
- package/lib/collections.d.ts.map +1 -0
- package/lib/collections.js +421 -0
- package/lib/collections.js.map +1 -0
- package/lib/comparison.d.ts +24 -0
- package/lib/comparison.d.ts.map +1 -0
- package/lib/comparison.js +731 -0
- package/lib/comparison.js.map +1 -0
- package/lib/errors.d.ts +13 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +17 -0
- package/lib/errors.js.map +1 -0
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.d.ts.map +1 -0
- package/lib/evaluate.js +118 -0
- package/lib/evaluate.js.map +1 -0
- package/lib/fields.d.ts +34 -0
- package/lib/fields.d.ts.map +1 -0
- package/lib/fields.js +253 -0
- package/lib/fields.js.map +1 -0
- package/lib/functions.d.ts +5 -0
- package/lib/functions.d.ts.map +1 -0
- package/lib/functions.js +408 -0
- package/lib/functions.js.map +1 -0
- package/lib/hierarchy.d.ts +6 -0
- package/lib/hierarchy.d.ts.map +1 -0
- package/lib/hierarchy.js +266 -0
- package/lib/hierarchy.js.map +1 -0
- package/lib/index.d.ts +23 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2555
- package/lib/index.js.map +1 -1
- package/lib/literals.d.ts +20 -0
- package/lib/literals.d.ts.map +1 -0
- package/lib/literals.js +290 -0
- package/lib/literals.js.map +1 -0
- package/lib/mapping.d.ts +95 -0
- package/lib/mapping.d.ts.map +1 -0
- package/lib/mapping.js +316 -0
- package/lib/mapping.js.map +1 -0
- package/lib/narrowing.d.ts +12 -0
- package/lib/narrowing.d.ts.map +1 -0
- package/lib/narrowing.js +32 -0
- package/lib/narrowing.js.map +1 -0
- package/lib/plan.d.ts +49 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +86 -0
- package/lib/plan.js.map +1 -0
- package/lib/presence.d.ts +25 -0
- package/lib/presence.d.ts.map +1 -0
- package/lib/presence.js +82 -0
- package/lib/presence.js.map +1 -0
- package/lib/regex.d.ts +33 -0
- package/lib/regex.d.ts.map +1 -0
- package/lib/regex.js +480 -0
- package/lib/regex.js.map +1 -0
- package/lib/relations.d.ts +58 -0
- package/lib/relations.d.ts.map +1 -0
- package/lib/relations.js +175 -0
- package/lib/relations.js.map +1 -0
- package/lib/rewrite.d.ts +36 -0
- package/lib/rewrite.d.ts.map +1 -0
- package/lib/rewrite.js +366 -0
- package/lib/rewrite.js.map +1 -0
- package/lib/solve.d.ts +20 -0
- package/lib/solve.d.ts.map +1 -0
- package/lib/solve.js +77 -0
- package/lib/solve.js.map +1 -0
- package/lib/strings.d.ts +12 -0
- package/lib/strings.d.ts.map +1 -0
- package/lib/strings.js +110 -0
- package/lib/strings.js.map +1 -0
- package/lib/ternary.d.ts +12 -0
- package/lib/ternary.d.ts.map +1 -0
- package/lib/ternary.js +192 -0
- package/lib/ternary.js.map +1 -0
- package/lib/timestamp.d.ts +45 -0
- package/lib/timestamp.d.ts.map +1 -0
- package/lib/timestamp.js +225 -0
- package/lib/timestamp.js.map +1 -0
- package/lib/translate.d.ts +20 -0
- package/lib/translate.d.ts.map +1 -0
- package/lib/translate.js +287 -0
- package/lib/translate.js.map +1 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +980 -0
- package/lib/types.js.map +1 -0
- package/lib/value.d.ts +8 -0
- package/lib/value.d.ts.map +1 -0
- package/lib/value.js +7 -0
- package/lib/value.js.map +1 -0
- package/package.json +27 -17
- package/src/adversarial.test.ts +376 -1071
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +433 -250
- package/src/errors.ts +12 -0
- package/src/evaluate.ts +170 -0
- package/src/fields.ts +373 -0
- package/src/functions.ts +548 -0
- package/src/generated/prisma-adversarial/browser.ts +10 -0
- package/src/generated/prisma-adversarial/client.ts +10 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial/models.ts +2 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
- package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
- package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
- package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
- package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
- package/src/generated/prisma-adversarial-mysql-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
- package/src/generated/prisma-adversarial-pg/client.ts +10 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
- package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial-pg/models.ts +2 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
- package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +66 -3646
- package/src/literals.ts +363 -0
- package/src/mapping.ts +474 -0
- package/src/narrowing.ts +33 -0
- package/src/plan.ts +125 -0
- package/src/presence.ts +114 -0
- package/src/regex.ts +596 -0
- package/src/relations.ts +226 -0
- package/src/rewrite.ts +524 -0
- package/src/solve.ts +96 -0
- package/src/strings.ts +152 -0
- package/src/ternary.ts +359 -0
- package/src/test-setup.adversarial-url.ts +6 -3
- package/src/test-setup.adversarial.mysql.v6.ts +13 -0
- package/src/test-setup.adversarial.mysql.v7.ts +12 -0
- package/src/test-setup.adversarial.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +497 -1317
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
package/src/adversarial.test.ts
CHANGED
|
@@ -1,110 +1,48 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "@jest/globals";
|
|
5
5
|
import {
|
|
6
6
|
PlanExpression,
|
|
7
7
|
PlanExpressionValue,
|
|
8
8
|
PlanExpressionVariable,
|
|
9
9
|
} from "@cerbos/core";
|
|
10
|
-
import type {
|
|
11
|
-
PlanExpressionOperand,
|
|
12
|
-
Principal,
|
|
13
|
-
Resource,
|
|
14
|
-
Value,
|
|
15
|
-
} from "@cerbos/core";
|
|
10
|
+
import type { PlanExpressionOperand, PlanResourcesResponse } from "@cerbos/core";
|
|
16
11
|
|
|
17
|
-
import { queryPlanToPrisma, PlanKind,
|
|
12
|
+
import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
|
|
13
|
+
import type { PrismaFilter } from ".";
|
|
18
14
|
import {
|
|
19
|
-
|
|
15
|
+
assertFuzzVerdict,
|
|
16
|
+
FUZZ_DIR,
|
|
17
|
+
fuzzThrown,
|
|
18
|
+
fuzzVerdict,
|
|
20
19
|
MAPPER,
|
|
21
20
|
MODEL,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ThrowingAction,
|
|
21
|
+
pdpTags,
|
|
22
|
+
planOf,
|
|
23
|
+
readCorpusJson,
|
|
24
|
+
readGolden,
|
|
25
|
+
readGoldens,
|
|
26
|
+
writeFuzzResults,
|
|
29
27
|
} from "./corpus";
|
|
28
|
+
import type { FuzzVerdict, Golden } from "./corpus";
|
|
30
29
|
import { prisma } from "./test-setup.adversarial";
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
32
|
+
* The conformance harness (conformance/README.md, "The harness contract"). It loads the corpus
|
|
33
|
+
* dataset into a real store, translates every recorded golden plan for both pinned PDPs, runs the
|
|
34
|
+
* query through a generated Prisma client, and compares the returned ids with the `allowed` ids the
|
|
35
|
+
* PDP's check() recorded. No PDP runs here. Exceptions live in `conformance-ledger.json`.
|
|
78
36
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
37
|
+
* The store is chosen with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` and `mysql` via
|
|
38
|
+
* testcontainers started in `jest.globalSetup.adversarial.js`), and the Prisma major with
|
|
39
|
+
* `PRISMA_VERSION`. The two are independent dimensions: v6/v7 is an ENGINE matrix, and an engine
|
|
40
|
+
* matrix says nothing about how a provider coerces a fractional threshold against an `Int` column,
|
|
41
|
+
* escapes a LIKE metacharacter, compares a real `timestamp` (cerbos/query-plan-adapters#320), or
|
|
42
|
+
* decides with its COLLATION whether `=` is case-sensitive at all (#340).
|
|
84
43
|
*/
|
|
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
44
|
|
|
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
|
-
}
|
|
45
|
+
// -- the dataset: conformance/seeds.json + derived-fields.json ----------------------------------
|
|
108
46
|
|
|
109
47
|
interface Tag {
|
|
110
48
|
id: string;
|
|
@@ -113,405 +51,114 @@ interface Tag {
|
|
|
113
51
|
|
|
114
52
|
interface Seed {
|
|
115
53
|
id: string;
|
|
116
|
-
aBool: boolean;
|
|
117
|
-
aString: string;
|
|
118
|
-
aNumber: number;
|
|
54
|
+
aBool: boolean | null;
|
|
55
|
+
aString: string | null;
|
|
56
|
+
aNumber: number | null;
|
|
119
57
|
aOptionalString: string | null;
|
|
58
|
+
aNumberList: (number | null)[];
|
|
59
|
+
aBoolList: (boolean | null)[];
|
|
120
60
|
tags: Tag[];
|
|
121
61
|
subCategoryNames: string[];
|
|
122
62
|
/** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
|
|
123
63
|
parentSeedId: string | null;
|
|
64
|
+
/** The store capabilities the row needs (conformance/README.md, "The harness contract"). */
|
|
65
|
+
requires?: string[];
|
|
124
66
|
}
|
|
125
67
|
|
|
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
68
|
interface DerivedEntry {
|
|
189
69
|
createdBy: string;
|
|
190
70
|
aDouble: number | null;
|
|
191
71
|
createdAt: string | null;
|
|
72
|
+
updatedAt: string | null;
|
|
192
73
|
scope: string | null;
|
|
193
74
|
labels: (string | null)[];
|
|
194
75
|
}
|
|
195
76
|
|
|
196
|
-
|
|
197
|
-
fields: string[];
|
|
198
|
-
derived: Record<string, DerivedEntry>;
|
|
199
|
-
}
|
|
77
|
+
const STORE_NAMES = ["sqlite", "postgres", "mysql"] as const;
|
|
200
78
|
|
|
201
|
-
|
|
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
|
-
}
|
|
79
|
+
type StoreName = (typeof STORE_NAMES)[number];
|
|
224
80
|
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
}
|
|
81
|
+
function selectedStoreName(): StoreName {
|
|
82
|
+
const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
|
|
239
83
|
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
});
|
|
84
|
+
// A typo must fail rather than silently fall back to SQLite (jest.adversarial.config.js rejects
|
|
85
|
+
// it first; the repeat keeps this file reading against a value it trusts).
|
|
86
|
+
const store = STORE_NAMES.find((name) => name === requested);
|
|
261
87
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
}
|
|
88
|
+
if (store === undefined) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
280
93
|
|
|
281
|
-
|
|
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
|
-
);
|
|
94
|
+
return store;
|
|
294
95
|
}
|
|
295
96
|
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
|
|
97
|
+
const STORE_NAME = selectedStoreName();
|
|
98
|
+
|
|
99
|
+
// A seed requiring a capability the ledger's `lacks` names for this store is a resource it cannot
|
|
100
|
+
// hold: it is not loaded, and its id is struck from every allowed set.
|
|
101
|
+
const LACKS = Object.entries(
|
|
102
|
+
(
|
|
103
|
+
// SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
|
|
104
|
+
JSON.parse(
|
|
105
|
+
fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
|
|
106
|
+
) as { lacks?: Record<string, { reason: string; stores?: string[] }> }
|
|
107
|
+
).lacks ?? {}
|
|
108
|
+
)
|
|
109
|
+
.filter(([, entry]) => entry.stores?.includes(STORE_NAME) ?? true)
|
|
110
|
+
.map(([capability]) => capability);
|
|
111
|
+
|
|
112
|
+
const UNHELD = new Set(
|
|
113
|
+
(readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds
|
|
114
|
+
.filter((seed) => seed.requires?.some((capability) => LACKS.includes(capability)))
|
|
115
|
+
.map((seed) => seed.id)
|
|
308
116
|
);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
]
|
|
117
|
+
|
|
118
|
+
const SEEDS = (readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds.filter(
|
|
119
|
+
(seed) => !UNHELD.has(seed.id)
|
|
321
120
|
);
|
|
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
121
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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;
|
|
122
|
+
function held(allowed: string[]): string[] {
|
|
123
|
+
return allowed.filter((id) => !UNHELD.has(id));
|
|
124
|
+
}
|
|
447
125
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
126
|
+
const DERIVED = (
|
|
127
|
+
readCorpusJson<{ derived: Record<string, DerivedEntry> }>("derived-fields.json")
|
|
128
|
+
).derived;
|
|
453
129
|
|
|
454
130
|
function derivedFor(seed: Seed): DerivedEntry {
|
|
455
|
-
const entry =
|
|
131
|
+
const entry = DERIVED[seed.id];
|
|
132
|
+
|
|
456
133
|
if (entry === undefined) {
|
|
457
134
|
throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
|
|
458
135
|
}
|
|
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
136
|
|
|
480
|
-
|
|
481
|
-
return derivedFor(seed).scope;
|
|
137
|
+
return entry;
|
|
482
138
|
}
|
|
483
139
|
|
|
484
|
-
//
|
|
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
|
-
|
|
140
|
+
// The real to-one relation (conformance/README.md, "The dataset"): every resource owns a FRESH
|
|
141
|
+
// parent (and inner) row carrying the named seed's scalars, so no two resources share one.
|
|
492
142
|
const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
|
|
493
143
|
|
|
494
144
|
function parentSeedOf(seed: Seed | undefined): Seed | undefined {
|
|
495
145
|
const id = seed?.parentSeedId;
|
|
146
|
+
|
|
496
147
|
if (id === undefined || id === null) {
|
|
497
148
|
return undefined;
|
|
498
149
|
}
|
|
150
|
+
|
|
499
151
|
const parent = SEEDS_BY_ID.get(id);
|
|
152
|
+
|
|
500
153
|
if (parent === undefined) {
|
|
501
|
-
throw new Error(
|
|
502
|
-
`seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`
|
|
503
|
-
);
|
|
154
|
+
throw new Error(`seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`);
|
|
504
155
|
}
|
|
156
|
+
|
|
505
157
|
return parent;
|
|
506
158
|
}
|
|
507
159
|
|
|
508
160
|
/** 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
|
-
} {
|
|
161
|
+
function relationColumns(seed: Seed) {
|
|
515
162
|
return {
|
|
516
163
|
aBool: seed.aBool,
|
|
517
164
|
aString: seed.aString,
|
|
@@ -520,63 +167,20 @@ function relationColumns(seed: Seed): {
|
|
|
520
167
|
};
|
|
521
168
|
}
|
|
522
169
|
|
|
523
|
-
|
|
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
|
-
}
|
|
170
|
+
async function seedStore(): Promise<void> {
|
|
570
171
|
await prisma.adversarialInner.deleteMany();
|
|
571
172
|
await prisma.adversarialParent.deleteMany();
|
|
572
173
|
await prisma.adversarialLabel.deleteMany();
|
|
573
174
|
await prisma.adversarialSubCategory.deleteMany();
|
|
574
175
|
await prisma.adversarialCategory.deleteMany();
|
|
575
176
|
await prisma.adversarialTag.deleteMany();
|
|
177
|
+
await prisma.adversarialNumberListElement.deleteMany();
|
|
178
|
+
await prisma.adversarialBoolListElement.deleteMany();
|
|
576
179
|
await prisma.adversarialResource.deleteMany();
|
|
577
180
|
|
|
578
181
|
// Distinct sub-category/category graphs per seed so no rows share relations by accident.
|
|
579
182
|
for (const seed of SEEDS) {
|
|
183
|
+
const derived = derivedFor(seed);
|
|
580
184
|
const parentSeed = parentSeedOf(seed);
|
|
581
185
|
const innerSeed = parentSeedOf(parentSeed);
|
|
582
186
|
await prisma.adversarialResource.create({
|
|
@@ -585,626 +189,389 @@ beforeAll(async () => {
|
|
|
585
189
|
aBool: seed.aBool,
|
|
586
190
|
aString: seed.aString,
|
|
587
191
|
aNumber: seed.aNumber,
|
|
588
|
-
aDouble:
|
|
192
|
+
aDouble: derived.aDouble,
|
|
589
193
|
aOptionalString: seed.aOptionalString,
|
|
590
|
-
createdBy:
|
|
591
|
-
scope:
|
|
592
|
-
createdAt:
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
194
|
+
createdBy: derived.createdBy,
|
|
195
|
+
scope: derived.scope,
|
|
196
|
+
createdAt: derived.createdAt,
|
|
197
|
+
updatedAt: derived.updatedAt,
|
|
198
|
+
// One row per element, null elements included: membership is all a filter asks of
|
|
199
|
+
// either list, so the element's position is not stored.
|
|
200
|
+
numberList: { create: seed.aNumberList.map((value) => ({ value })) },
|
|
201
|
+
boolList: { create: seed.aBoolList.map((value) => ({ value })) },
|
|
202
|
+
tags: { create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })) },
|
|
203
|
+
// The to-one chain, one owned row per level. A seed with no parent gets no row at all:
|
|
204
|
+
// Prisma reads an undefined relation as no write.
|
|
205
|
+
parent:
|
|
206
|
+
parentSeed === undefined
|
|
207
|
+
? undefined
|
|
208
|
+
: {
|
|
603
209
|
create: {
|
|
604
210
|
...relationColumns(parentSeed),
|
|
605
|
-
|
|
606
|
-
? {}
|
|
607
|
-
: { inner: { create: relationColumns(innerSeed) } }),
|
|
211
|
+
inner: innerSeed === undefined ? undefined : { create: relationColumns(innerSeed) },
|
|
608
212
|
},
|
|
609
213
|
},
|
|
610
|
-
|
|
214
|
+
// One category holding every subcategory name (conformance/README.md, "The dataset").
|
|
611
215
|
categories: {
|
|
612
|
-
create:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
216
|
+
create:
|
|
217
|
+
seed.subCategoryNames.length === 0
|
|
218
|
+
? []
|
|
219
|
+
: [
|
|
220
|
+
{
|
|
221
|
+
name: "business",
|
|
222
|
+
subCategories: {
|
|
223
|
+
create: seed.subCategoryNames.map((subName) => ({
|
|
224
|
+
name: subName,
|
|
225
|
+
labels: { create: derived.labels.map((name) => ({ name })) },
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
620
228
|
},
|
|
621
|
-
|
|
622
|
-
],
|
|
623
|
-
},
|
|
624
|
-
})),
|
|
229
|
+
],
|
|
625
230
|
},
|
|
626
231
|
},
|
|
627
232
|
});
|
|
628
233
|
}
|
|
629
|
-
}
|
|
234
|
+
}
|
|
630
235
|
|
|
631
|
-
|
|
632
|
-
await prisma.$disconnect();
|
|
633
|
-
});
|
|
236
|
+
// -- the store ------------------------------------------------------------------------------------
|
|
634
237
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
238
|
+
/**
|
|
239
|
+
* How each leg asks its own connection which engine answered, and the first word of the answer.
|
|
240
|
+
* Each spelling is a syntax error on the other two engines, so the anti-vacuity test fails in every
|
|
241
|
+
* direction. MySQL's `version()` answers a bare `8.4.x`; `@@version_comment` names the engine.
|
|
242
|
+
*/
|
|
243
|
+
const STORE_BANNERS: Record<StoreName, { query: string; engine: string }> = {
|
|
244
|
+
sqlite: { query: "select 'SQLite ' || sqlite_version() as banner", engine: "SQLite" },
|
|
245
|
+
postgres: { query: "select version() as banner", engine: "PostgreSQL" },
|
|
246
|
+
mysql: { query: "select @@version_comment as banner", engine: "MySQL" },
|
|
247
|
+
};
|
|
638
248
|
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
249
|
+
/**
|
|
250
|
+
* The six adversarial schemas — (Prisma 6, Prisma 7) x (SQLite, PostgreSQL, MySQL) — must hold one
|
|
251
|
+
* data model between them: a column that drifts in one would seed a different row shape on that leg
|
|
252
|
+
* while every assertion here stayed identical.
|
|
253
|
+
*/
|
|
254
|
+
const ADVERSARIAL_SCHEMAS = [
|
|
255
|
+
"schema.adversarial.prisma",
|
|
256
|
+
"schema.adversarial.v6.prisma",
|
|
257
|
+
"schema.adversarial.pg.prisma",
|
|
258
|
+
"schema.adversarial.pg.v6.prisma",
|
|
259
|
+
"schema.adversarial.mysql.prisma",
|
|
260
|
+
"schema.adversarial.mysql.v6.prisma",
|
|
261
|
+
] as const;
|
|
647
262
|
|
|
648
|
-
/**
|
|
649
|
-
function
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
}
|
|
263
|
+
/** A schema's `model` blocks, without comments, generator/datasource blocks or incidental space. */
|
|
264
|
+
function modelBlocks(schema: string): string {
|
|
265
|
+
const withoutComments = schema
|
|
266
|
+
.split("\n")
|
|
267
|
+
.filter((line) => !line.trimStart().startsWith("//"))
|
|
268
|
+
.join("\n");
|
|
717
269
|
|
|
718
|
-
|
|
270
|
+
const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
|
|
719
271
|
|
|
720
|
-
|
|
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();
|
|
272
|
+
return models.join("\n").replace(/[ \t]+/g, " ").replace(/\n\s*\n/g, "\n").trim();
|
|
733
273
|
}
|
|
734
274
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
action,
|
|
740
|
-
nonEmpty: ids.length > 0,
|
|
741
|
-
nonTotal: ids.length < SEEDS.length,
|
|
742
|
-
}).toEqual({ action, nonEmpty: true, nonTotal: true });
|
|
275
|
+
async function selectIds(where: PrismaFilter): Promise<string[]> {
|
|
276
|
+
const rows = await prisma.adversarialResource.findMany({ where, select: { id: true } });
|
|
277
|
+
|
|
278
|
+
return rows.map((row) => row.id).sort();
|
|
743
279
|
}
|
|
744
280
|
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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({
|
|
281
|
+
// Every attribute the dataset omits when NULL is declared `omitted` in MAPPER. The call-level
|
|
282
|
+
// option covers what is left, the list and relation elements, which the dataset sends as values;
|
|
283
|
+
// the adapter's own default is pinned in translator.test.ts.
|
|
284
|
+
const translatePlan = (queryPlan: PlanResourcesResponse) =>
|
|
285
|
+
queryPlanToPrisma({
|
|
758
286
|
queryPlan,
|
|
759
|
-
mapper,
|
|
287
|
+
mapper: MAPPER,
|
|
760
288
|
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 },
|
|
289
|
+
nullAttributeRepresentation: "explicit",
|
|
770
290
|
});
|
|
771
|
-
|
|
291
|
+
|
|
292
|
+
async function selectAllowed(queryPlan: PlanResourcesResponse): Promise<string[]> {
|
|
293
|
+
const result = translatePlan(queryPlan);
|
|
294
|
+
|
|
295
|
+
if (result.kind === PlanKind.ALWAYS_DENIED) return [];
|
|
296
|
+
|
|
297
|
+
return selectIds(result.kind === PlanKind.CONDITIONAL ? result.filters : {});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// -- the ledger -----------------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
interface LedgerEntry {
|
|
303
|
+
status: "unsupported" | "divergent";
|
|
304
|
+
reason: string;
|
|
305
|
+
issue?: string;
|
|
306
|
+
pdp?: string[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const LEDGER = (
|
|
310
|
+
// SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
|
|
311
|
+
JSON.parse(
|
|
312
|
+
fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
|
|
313
|
+
) as { cases: Record<string, LedgerEntry> }
|
|
314
|
+
).cases;
|
|
315
|
+
|
|
316
|
+
function ledgerEntry(golden: Golden, tag: string): LedgerEntry | undefined {
|
|
317
|
+
const entry = LEDGER[golden.id];
|
|
318
|
+
|
|
319
|
+
return entry && (entry.pdp === undefined || entry.pdp.includes(tag)) ? entry : undefined;
|
|
772
320
|
}
|
|
773
321
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
322
|
+
// -- the replay -----------------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
const TAGS = pdpTags();
|
|
325
|
+
const GOLDENS = new Map(TAGS.map((tag) => [tag, readGoldens(tag)]));
|
|
326
|
+
const passed: Record<string, Record<string, number>> = {};
|
|
327
|
+
const totals: Record<string, Record<string, number>> = {};
|
|
328
|
+
const fuzzResults: FuzzVerdict[] = [];
|
|
329
|
+
|
|
330
|
+
/** A fuzz case judged on this store (conformance/README.md, "Fuzzing"). */
|
|
331
|
+
async function fuzzReplay(golden: Golden): Promise<FuzzVerdict> {
|
|
332
|
+
try {
|
|
333
|
+
return fuzzVerdict(golden, held(golden.allowed).sort(), await selectAllowed(await planOf(golden)));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return fuzzThrown(golden, error, UnsupportedQueryPlanError);
|
|
781
336
|
}
|
|
782
|
-
const operands = node["operands"];
|
|
783
|
-
return Array.isArray(operands) && operands.some(planCarriesNullLiteral);
|
|
784
337
|
}
|
|
785
338
|
|
|
786
|
-
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
339
|
+
beforeAll(async () => {
|
|
340
|
+
// CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith to
|
|
341
|
+
// LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII whatever the column collation; only this
|
|
342
|
+
// pragma changes it (conformance/README.md, "Store configuration is part of conformance").
|
|
343
|
+
if (STORE_NAME === "sqlite") {
|
|
344
|
+
await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await seedStore();
|
|
348
|
+
}, 120_000);
|
|
349
|
+
|
|
350
|
+
afterAll(async () => {
|
|
351
|
+
await prisma.$disconnect();
|
|
352
|
+
writeFuzzResults("prisma", `${STORE_NAME}-v${process.env["PRISMA_VERSION"] ?? "7"}`, fuzzResults);
|
|
353
|
+
|
|
354
|
+
const lines = TAGS.map((tag) => {
|
|
355
|
+
const tiers = Object.keys(totals[tag] ?? {}).sort();
|
|
356
|
+
const skipped = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence !== null);
|
|
357
|
+
|
|
358
|
+
return `${tag}: ${tiers.map((tier) => `${tier} ${passed[tag]?.[tier] ?? 0}/${totals[tag]?.[tier]}`).join(", ")} (planner divergences skipped: ${skipped.map((golden) => golden.id).join(", ") || "none"})`;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
console.log(`conformance (${STORE_NAME}) passed per tier\n${lines.join("\n")}`);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe(`conformance (${STORE_NAME})`, () => {
|
|
365
|
+
// Every assertion below is identical on every leg, so a PostgreSQL leg that silently fell back to
|
|
366
|
+
// SQLite would pass while proving nothing about PostgreSQL. Ask the connection which engine it is.
|
|
792
367
|
test("executes against the store ADAPTER_TEST_DB selects", async () => {
|
|
793
368
|
const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
|
|
794
|
-
STORE_NAME
|
|
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"
|
|
369
|
+
STORE_BANNERS[STORE_NAME].query
|
|
800
370
|
);
|
|
371
|
+
|
|
372
|
+
expect(rows[0]?.banner?.split(" ")[0]).toBe(STORE_BANNERS[STORE_NAME].engine);
|
|
801
373
|
});
|
|
802
374
|
|
|
375
|
+
// Store configuration is part of conformance. Prisma's MySQL migration engine hardcodes
|
|
376
|
+
// `utf8mb4_unicode_ci`, so the tables are converted after `db push`
|
|
377
|
+
// (jest.globalSetup.adversarial.js). Under that default these return the case variant, the
|
|
378
|
+
// accent variant and the wrong-case prefix; asserted by name so a failure reads as "the store is
|
|
379
|
+
// misconfigured" rather than as a translation bug.
|
|
380
|
+
(STORE_NAME === "mysql" ? test : test.skip)(
|
|
381
|
+
"the MySQL leg runs under a byte-exact NO PAD collation",
|
|
382
|
+
async () => {
|
|
383
|
+
expect({
|
|
384
|
+
caseVariant: await selectIds({ aString: "one" }),
|
|
385
|
+
accentFolded: await selectIds({ aString: "hello🚀" }),
|
|
386
|
+
wrongCasePrefix: await selectIds({ aString: { startsWith: "ON" } }),
|
|
387
|
+
trailingSpace: await selectIds({ aString: "one " }),
|
|
388
|
+
}).toEqual({ caseVariant: ["a1"], accentFolded: [], wrongCasePrefix: [], trailingSpace: [] });
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// `PRAGMA case_sensitive_like` holds per CONNECTION, so it must reach every connection a query can
|
|
393
|
+
// land on. Prisma 6 pooled SQLite connections until its schema pinned `connection_limit=1`;
|
|
394
|
+
// concurrent queries are what spread onto the rest.
|
|
395
|
+
(STORE_NAME === "sqlite" ? test : test.skip)(
|
|
396
|
+
"the SQLite leg's LIKE is case-sensitive on every connection",
|
|
397
|
+
async () => {
|
|
398
|
+
const results = await Promise.all(
|
|
399
|
+
Array.from({ length: 32 }, () => selectIds({ aString: { contains: "one" } }))
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(results[0]?.length).toBeGreaterThan(0);
|
|
403
|
+
expect(results.filter((ids) => ids.includes("c1"))).toEqual([]);
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
803
407
|
test("every adversarial schema declares the same data model", () => {
|
|
804
408
|
const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
|
|
805
409
|
name,
|
|
806
410
|
models: modelBlocks(
|
|
807
|
-
fs.readFileSync(path.join(
|
|
411
|
+
fs.readFileSync(path.join(__dirname, "..", "prisma", name), "utf8")
|
|
808
412
|
),
|
|
809
413
|
}));
|
|
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
414
|
|
|
824
|
-
|
|
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
|
-
);
|
|
415
|
+
expect(reference?.models).toContain(`model ${MODEL}`);
|
|
843
416
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
).not.toContain(promotedAction);
|
|
417
|
+
for (const schema of rest) {
|
|
418
|
+
expect(schema).toEqual({ name: schema.name, models: reference?.models });
|
|
419
|
+
}
|
|
848
420
|
});
|
|
849
421
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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/);
|
|
422
|
+
(FUZZ_DIR ? test.skip : test)("the ledger names no case without a golden file", () => {
|
|
423
|
+
const ids = new Set(TAGS.flatMap((tag) => GOLDENS.get(tag)!.map((golden) => golden.id)));
|
|
424
|
+
expect(Object.keys(LEDGER).filter((id) => !ids.has(id))).toEqual([]);
|
|
868
425
|
});
|
|
869
426
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
427
|
+
// A golden that does not say which PDP recorded it, or whether that PDP diverged, cannot be
|
|
428
|
+
// routed: fail on it rather than let the filter below read a missing field as "no divergence".
|
|
429
|
+
test("every golden file is recorded for its tag and declares plannerDivergence", () => {
|
|
430
|
+
const malformed = TAGS.flatMap((tag) =>
|
|
431
|
+
GOLDENS.get(tag)!
|
|
432
|
+
.filter((golden) => golden.pdp !== tag || golden.plannerDivergence === undefined)
|
|
433
|
+
.map((golden) => `${tag}/${golden.id}`)
|
|
875
434
|
);
|
|
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
435
|
|
|
886
|
-
expect(
|
|
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([]);
|
|
436
|
+
expect(malformed).toEqual([]);
|
|
896
437
|
});
|
|
897
438
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
oracleAllowedIds(action),
|
|
903
|
-
adapterFilteredIds(action),
|
|
904
|
-
]);
|
|
905
|
-
expect(filtered).toEqual(oracle);
|
|
906
|
-
}
|
|
907
|
-
);
|
|
439
|
+
for (const tag of TAGS) {
|
|
440
|
+
// A plannerDivergence is a PDP bug no adapter can pass. The generator writes it only into the
|
|
441
|
+
// golden files of the tags it applies to, so a non-null value is the whole skip condition.
|
|
442
|
+
const cases = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence === null);
|
|
908
443
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
);
|
|
444
|
+
test.each(cases.map((golden) => [golden.id, golden] as const))(
|
|
445
|
+
`${tag} %s`,
|
|
446
|
+
async (_id, golden) => {
|
|
447
|
+
if (FUZZ_DIR) {
|
|
448
|
+
const verdict = await fuzzReplay(golden);
|
|
449
|
+
fuzzResults.push(verdict);
|
|
450
|
+
assertFuzzVerdict(verdict);
|
|
938
451
|
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
963
454
|
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
);
|
|
455
|
+
const entry = ledgerEntry(golden, tag);
|
|
456
|
+
const queryPlan = await planOf(golden);
|
|
983
457
|
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
);
|
|
458
|
+
if (entry?.status === "unsupported") {
|
|
459
|
+
expect(() => translatePlan(queryPlan)).toThrow(UnsupportedQueryPlanError);
|
|
994
460
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
await expect(
|
|
998
|
-
adapterFilteredIds("null-eq", "omitted", MAPPER_WITHOUT_NULL_CONVENTIONS)
|
|
999
|
-
).rejects.toThrow(NULL_OMITTED_MESSAGE);
|
|
1000
|
-
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
1001
463
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
}
|
|
464
|
+
// Both sides sorted the same way, so a divergent entry cannot pass on ordering alone.
|
|
465
|
+
const ids = await selectAllowed(queryPlan);
|
|
466
|
+
const allowed = held(golden.allowed).sort();
|
|
1021
467
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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)})`);
|
|
468
|
+
if (entry?.status === "divergent") {
|
|
469
|
+
expect(ids).not.toEqual(allowed);
|
|
470
|
+
|
|
471
|
+
return;
|
|
1041
472
|
}
|
|
473
|
+
|
|
474
|
+
expect(ids).toEqual(allowed);
|
|
475
|
+
(passed[tag] ??= {})[golden.tier] = (passed[tag]?.[golden.tier] ?? 0) + 1;
|
|
1042
476
|
}
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// The denominator is every golden file in the tier, skipped planner divergences included.
|
|
480
|
+
for (const golden of GOLDENS.get(tag)!) {
|
|
481
|
+
(totals[tag] ??= {})[golden.tier] = (totals[tag]?.[golden.tier] ?? 0) + 1;
|
|
1043
482
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
483
|
+
}
|
|
484
|
+
});
|
|
1046
485
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
486
|
+
// -- shapes the corpus does not spell -------------------------------------------------------------
|
|
487
|
+
//
|
|
488
|
+
// Synthesised plans over the same seeded store, for guards that must be a property of the
|
|
489
|
+
// `mainCategory` chain rather than of the few spellings the corpus pins. Every expectation is a
|
|
490
|
+
// recorded check() decision from a golden file, never a hand-computed row list.
|
|
491
|
+
|
|
492
|
+
describe(`the absent-parent guard over the chain (${STORE_NAME})`, () => {
|
|
493
|
+
const allowedFor = (id: string) => held(readGolden(TAGS[0]!, id).allowed);
|
|
494
|
+
|
|
495
|
+
async function synthetic(condition: PlanExpressionOperand): Promise<string[]> {
|
|
496
|
+
const queryPlan: PlanResourcesResponse = {
|
|
497
|
+
kind: PlanKind.CONDITIONAL,
|
|
498
|
+
condition,
|
|
499
|
+
cerbosCallId: "synthetic",
|
|
500
|
+
requestId: "synthetic",
|
|
501
|
+
validationErrors: [],
|
|
502
|
+
metadata: undefined,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return selectAllowed(queryPlan);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Every count threshold, not only the two spellings the corpus pins (#316). Each is TRUE for a
|
|
509
|
+
// row with no mainCategory only if the guard leaks: an absent to-one parent is a CEL
|
|
510
|
+
// missing-path error, so the PDP denies it outright.
|
|
511
|
+
test("every count threshold inherits the guard", async () => {
|
|
512
|
+
const size = new PlanExpression("size", [
|
|
513
|
+
new PlanExpressionVariable("request.resource.attr.mainCategory.subCategories"),
|
|
514
|
+
]);
|
|
1064
515
|
|
|
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
516
|
const compare = (operator: string, threshold: number) =>
|
|
1079
517
|
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
518
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const
|
|
519
|
+
const negate = (condition: PlanExpressionOperand) => new PlanExpression("not", [condition]);
|
|
520
|
+
|
|
521
|
+
for (const [label, condition] of [
|
|
1110
522
|
["size(chain) == 0", compare("eq", 0)],
|
|
1111
523
|
["size(chain) <= 0", compare("le", 0)],
|
|
1112
524
|
["size(chain) < 1", compare("lt", 1)],
|
|
1113
525
|
["!(size(chain) >= 1)", negate(compare("ge", 1))],
|
|
1114
526
|
["!(size(chain) > 0)", negate(compare("gt", 0))],
|
|
1115
|
-
]
|
|
1116
|
-
|
|
1117
|
-
for (const [shape, condition] of emptyByConstruction) {
|
|
1118
|
-
expect([shape, await filteredIdsFor(condition)]).toEqual([shape, []]);
|
|
527
|
+
] as const) {
|
|
528
|
+
expect([label, await synthetic(condition)]).toEqual([label, []]);
|
|
1119
529
|
}
|
|
1120
530
|
|
|
1121
|
-
// The mirror image, so the loop above cannot pass by denying everything
|
|
1122
|
-
|
|
1123
|
-
const withParent = await oracleAllowedIds("w1-size-nonneg-chain");
|
|
531
|
+
// The mirror image, so the loop above cannot pass by denying everything.
|
|
532
|
+
const withParent = allowedFor("relation/size/non-negative-to-one-chain");
|
|
1124
533
|
expect(withParent.length).toBeGreaterThan(0);
|
|
1125
|
-
expect(
|
|
1126
|
-
expect(await
|
|
1127
|
-
expect(await filteredIdsFor(negate(compare("lt", 1)))).toEqual(withParent);
|
|
534
|
+
expect(await synthetic(negate(compare("eq", 0)))).toEqual(withParent);
|
|
535
|
+
expect(await synthetic(negate(compare("lt", 1)))).toEqual(withParent);
|
|
1128
536
|
});
|
|
1129
537
|
|
|
1130
|
-
//
|
|
1131
|
-
|
|
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 () => {
|
|
538
|
+
// Every ternary condition position over the chain, not only the one the corpus pins (#334).
|
|
539
|
+
test("every ternary condition inherits the guard", async () => {
|
|
1136
540
|
const chainIn = new PlanExpression("in", [
|
|
1137
541
|
new PlanExpressionValue("finance"),
|
|
1138
542
|
new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
|
|
1139
543
|
]);
|
|
544
|
+
|
|
1140
545
|
const TRUE = new PlanExpressionValue(true);
|
|
1141
546
|
const FALSE = new PlanExpressionValue(false);
|
|
547
|
+
|
|
1142
548
|
const ternary = (
|
|
1143
549
|
condition: PlanExpressionOperand,
|
|
1144
550
|
thenBranch: PlanExpressionOperand,
|
|
1145
551
|
elseBranch: PlanExpressionOperand
|
|
1146
552
|
) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
|
|
1147
553
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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");
|
|
554
|
+
// A row with no mainCategory is neither: the condition errors and selects neither branch.
|
|
555
|
+
const conditionTrue = allowedFor("relation/in/to-one-chain");
|
|
556
|
+
const conditionFalse = allowedFor("relation/in/negated-to-one-chain");
|
|
1177
557
|
expect(conditionTrue.length).toBeGreaterThan(0);
|
|
1178
558
|
expect(conditionFalse.length).toBeGreaterThan(0);
|
|
1179
|
-
expect(conditionTrue.length + conditionFalse.length).toBeLessThan(
|
|
1180
|
-
SEEDS.length
|
|
1181
|
-
);
|
|
559
|
+
expect(conditionTrue.length + conditionFalse.length).toBeLessThan(SEEDS.length);
|
|
1182
560
|
|
|
1183
|
-
|
|
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
|
-
);
|
|
561
|
+
expect(await synthetic(ternary(chainIn, FALSE, TRUE))).toEqual(conditionFalse);
|
|
1188
562
|
expect(
|
|
1189
|
-
await
|
|
1190
|
-
ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE)
|
|
1191
|
-
)
|
|
563
|
+
await synthetic(ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE))
|
|
1192
564
|
).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
565
|
expect(
|
|
1196
|
-
await
|
|
1197
|
-
ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE)
|
|
1198
|
-
)
|
|
566
|
+
await synthetic(ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE))
|
|
1199
567
|
).toEqual(conditionTrue);
|
|
1200
568
|
|
|
1201
|
-
// A conjunction condition needs De Morgan
|
|
1202
|
-
//
|
|
1203
|
-
|
|
1204
|
-
const aBoolFalse = SEEDS.filter((seed) => !seed.aBool).map((seed) => seed.id);
|
|
569
|
+
// A conjunction condition needs De Morgan: CEL's `&&` absorbs an erroring operand when the
|
|
570
|
+
// other is FALSE, so a parentless row with aBool=false DOES select the else-branch.
|
|
571
|
+
const aBoolFalse = SEEDS.filter((seed) => seed.aBool === false).map((seed) => seed.id);
|
|
1205
572
|
expect(aBoolFalse.length).toBeGreaterThan(0);
|
|
1206
573
|
expect(
|
|
1207
|
-
await
|
|
574
|
+
await synthetic(
|
|
1208
575
|
ternary(
|
|
1209
576
|
new PlanExpression("and", [
|
|
1210
577
|
chainIn,
|
|
@@ -1216,66 +583,4 @@ describe(`adversarial conformance corpus (${STORE_NAME})`, () => {
|
|
|
1216
583
|
)
|
|
1217
584
|
).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
|
|
1218
585
|
});
|
|
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
586
|
});
|