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