@opensaas/stack-core 0.39.2 → 0.41.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { buildAccessScopedInclude } from './access-filter.js'
|
|
3
|
-
import {
|
|
2
|
+
import { buildAccessScopedInclude, resolveToOneAccessVisibility } from './access-filter.js'
|
|
3
|
+
import {
|
|
4
|
+
AccessScopeDepthExceededError,
|
|
5
|
+
RelationFilterAccessDeniedError,
|
|
6
|
+
UndeclaredCountKeyError,
|
|
7
|
+
UndeclaredIncludeKeyError,
|
|
8
|
+
} from './errors.js'
|
|
4
9
|
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
10
|
+
import { ValidationError } from '../hooks/index.js'
|
|
5
11
|
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
6
12
|
import type { AccessContext } from './types.js'
|
|
7
13
|
|
|
@@ -22,6 +28,14 @@ import type { AccessContext } from './types.js'
|
|
|
22
28
|
* relation the request doesn't name never has its list's `query` access
|
|
23
29
|
* invoked, and naming a relation fetches its own columns and stops (the "One
|
|
24
30
|
* hop" rule) at every level, not just the root.
|
|
31
|
+
*
|
|
32
|
+
* The scenarios in this file build every chain relation as to-MANY
|
|
33
|
+
* (`rel(ref, true)`), so `where`-in-`include` keeps meaning what it always
|
|
34
|
+
* has here — the walk-depth/caller-directed logic under test is arity-
|
|
35
|
+
* agnostic. The to-one-specific behavior this function grew for issue #974
|
|
36
|
+
* (a `where` on a to-one include is not something Prisma accepts, so it's
|
|
37
|
+
* recorded in `toOneAccessFilters` instead and resolved post-query by
|
|
38
|
+
* `resolveToOneAccessVisibility`) has its own dedicated describe blocks below.
|
|
25
39
|
*/
|
|
26
40
|
|
|
27
41
|
// A relationship field pointing at another list.
|
|
@@ -71,10 +85,10 @@ function chainConfig(count: number): {
|
|
|
71
85
|
const listName = `L${i}`
|
|
72
86
|
const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
|
|
73
87
|
if (i < count - 1) {
|
|
74
|
-
fields.next = rel(`L${i + 1}.prev
|
|
88
|
+
fields.next = rel(`L${i + 1}.prev`, true)
|
|
75
89
|
}
|
|
76
90
|
if (i > 0) {
|
|
77
|
-
fields.prev = rel(`L${i - 1}.next
|
|
91
|
+
fields.prev = rel(`L${i - 1}.next`, true)
|
|
78
92
|
}
|
|
79
93
|
const filter = { ownerId: { equals: listName } }
|
|
80
94
|
const queryFn = vi.fn(i === 0 ? () => true : () => filter)
|
|
@@ -139,7 +153,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
139
153
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
140
154
|
} as any as OpenSaasConfig
|
|
141
155
|
|
|
142
|
-
const include = await buildAccessScopedInclude(
|
|
156
|
+
const { include } = await buildAccessScopedInclude(
|
|
143
157
|
{ b: true },
|
|
144
158
|
config.lists.A.fields,
|
|
145
159
|
{ session: null, context: makeContext() },
|
|
@@ -155,7 +169,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
155
169
|
it("fetches a named relation's own columns and stops — does not auto-expand its subtree (One hop)", async () => {
|
|
156
170
|
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
157
171
|
|
|
158
|
-
const include = await buildAccessScopedInclude(
|
|
172
|
+
const { include } = await buildAccessScopedInclude(
|
|
159
173
|
{ next: true },
|
|
160
174
|
config.lists.L0.fields,
|
|
161
175
|
{ session: null, context: makeContext() },
|
|
@@ -175,7 +189,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
175
189
|
it('scopes a nested path at every level the request names it', async () => {
|
|
176
190
|
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
177
191
|
|
|
178
|
-
const include = await buildAccessScopedInclude(
|
|
192
|
+
const { include } = await buildAccessScopedInclude(
|
|
179
193
|
{ next: { include: { next: { include: { next: true } } } } },
|
|
180
194
|
config.lists.L0.fields,
|
|
181
195
|
{ session: null, context: makeContext() },
|
|
@@ -215,7 +229,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
215
229
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
216
230
|
} as any as OpenSaasConfig
|
|
217
231
|
|
|
218
|
-
const include = await buildAccessScopedInclude(
|
|
232
|
+
const { include } = await buildAccessScopedInclude(
|
|
219
233
|
{ b: { include: { c: { include: { a: true } } } } },
|
|
220
234
|
config.lists.A.fields,
|
|
221
235
|
{ session: null, context: makeContext() },
|
|
@@ -253,7 +267,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
|
|
|
253
267
|
it('preserves the take and AND-combines it with the access where', async () => {
|
|
254
268
|
const config = scopedConfig()
|
|
255
269
|
|
|
256
|
-
const include = await buildAccessScopedInclude(
|
|
270
|
+
const { include } = await buildAccessScopedInclude(
|
|
257
271
|
{ posts: { take: 10 } },
|
|
258
272
|
config.lists.User.fields,
|
|
259
273
|
{ session: null, context: makeContext() },
|
|
@@ -276,7 +290,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
|
|
|
276
290
|
const config = scopedConfig()
|
|
277
291
|
config.lists.Post.access = { operation: { query: () => false } }
|
|
278
292
|
|
|
279
|
-
const include = await buildAccessScopedInclude(
|
|
293
|
+
const { include } = await buildAccessScopedInclude(
|
|
280
294
|
{ posts: { take: 10 } },
|
|
281
295
|
config.lists.User.fields,
|
|
282
296
|
{ session: null, context: makeContext() },
|
|
@@ -342,7 +356,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
342
356
|
// Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
|
|
343
357
|
const requested = { next: nestedInclude(HOPS_AT_CAP - 1) }
|
|
344
358
|
|
|
345
|
-
const include = await buildAccessScopedInclude(
|
|
359
|
+
const { include } = await buildAccessScopedInclude(
|
|
346
360
|
requested,
|
|
347
361
|
config.lists.L0.fields,
|
|
348
362
|
{ session: null, context: makeContext() },
|
|
@@ -373,7 +387,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
373
387
|
const chainLength = HOPS_AT_CAP + 3
|
|
374
388
|
const { config } = chainConfig(chainLength)
|
|
375
389
|
|
|
376
|
-
const include = await buildAccessScopedInclude(
|
|
390
|
+
const { include } = await buildAccessScopedInclude(
|
|
377
391
|
{},
|
|
378
392
|
config.lists.L0.fields,
|
|
379
393
|
{ session: null, context: makeContext() },
|
|
@@ -383,7 +397,55 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
383
397
|
expect(include).toEqual({})
|
|
384
398
|
})
|
|
385
399
|
|
|
386
|
-
it('a list with no relationships passes
|
|
400
|
+
it('a declared non-relationship key on a list with no relationships passes through unchanged', async () => {
|
|
401
|
+
const config = {
|
|
402
|
+
db: { provider: 'sqlite' },
|
|
403
|
+
lists: {
|
|
404
|
+
Leaf: {
|
|
405
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
406
|
+
access: { operation: { query: () => true } },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
410
|
+
} as any as OpenSaasConfig
|
|
411
|
+
|
|
412
|
+
// A declared field that isn't a relationship — access control does not
|
|
413
|
+
// govern it (e.g. a virtual field named in `include`, stripped later by
|
|
414
|
+
// `stripVirtualFieldsFromInclude`).
|
|
415
|
+
const { include } = await buildAccessScopedInclude(
|
|
416
|
+
{ name: true },
|
|
417
|
+
config.lists.Leaf.fields,
|
|
418
|
+
{ session: null, context: makeContext() },
|
|
419
|
+
config,
|
|
420
|
+
'Leaf',
|
|
421
|
+
)
|
|
422
|
+
expect(include).toEqual({ name: true })
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('rejects a key that is neither declared nor a synthetic back-relation (#1082)', async () => {
|
|
426
|
+
const config = {
|
|
427
|
+
db: { provider: 'sqlite' },
|
|
428
|
+
lists: {
|
|
429
|
+
Leaf: {
|
|
430
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
431
|
+
access: { operation: { query: () => true } },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
435
|
+
} as any as OpenSaasConfig
|
|
436
|
+
|
|
437
|
+
await expect(
|
|
438
|
+
buildAccessScopedInclude(
|
|
439
|
+
{ someUnrelatedKey: true },
|
|
440
|
+
config.lists.Leaf.fields,
|
|
441
|
+
{ session: null, context: makeContext() },
|
|
442
|
+
config,
|
|
443
|
+
'Leaf',
|
|
444
|
+
),
|
|
445
|
+
).rejects.toThrow(UndeclaredIncludeKeyError)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('a bare `_count: true` on a list with no to-many relations yields no `_count` key at all (#1087)', async () => {
|
|
387
449
|
const config = {
|
|
388
450
|
db: { provider: 'sqlite' },
|
|
389
451
|
lists: {
|
|
@@ -395,15 +457,1463 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
395
457
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
396
458
|
} as any as OpenSaasConfig
|
|
397
459
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const include = await buildAccessScopedInclude(
|
|
401
|
-
{ someUnrelatedKey: true },
|
|
460
|
+
const { include } = await buildAccessScopedInclude(
|
|
461
|
+
{ _count: true },
|
|
402
462
|
config.lists.Leaf.fields,
|
|
403
463
|
{ session: null, context: makeContext() },
|
|
404
464
|
config,
|
|
405
465
|
'Leaf',
|
|
406
466
|
)
|
|
407
|
-
expect(include).toEqual({
|
|
467
|
+
expect(include).toEqual({})
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Regression coverage for issue #1087: a caller-supplied `_count` in
|
|
473
|
+
* `include` used to reach Prisma unscoped (#1082's "Out of scope" — the one
|
|
474
|
+
* key `buildAccessScopedInclude`'s own denial rule allowlisted through rather
|
|
475
|
+
* than rejecting or scoping). These tests pin the fix: each named relation's
|
|
476
|
+
* `_count.select` entry is scoped by that relation's own `query` access,
|
|
477
|
+
* exactly like the rest of this module scopes `include`, and a denied
|
|
478
|
+
* relation is recorded in `countDenials` for `filterReadableFields` to inject
|
|
479
|
+
* `0` for post-query (Prisma cannot be asked for a guaranteed `0`).
|
|
480
|
+
*/
|
|
481
|
+
describe('buildAccessScopedInclude — `_count` is scoped like any other relation (issue #1087)', () => {
|
|
482
|
+
function countConfig(postQuery: unknown): OpenSaasConfig {
|
|
483
|
+
return {
|
|
484
|
+
db: { provider: 'sqlite' },
|
|
485
|
+
lists: {
|
|
486
|
+
User: {
|
|
487
|
+
fields: {
|
|
488
|
+
name: { type: 'text' } as FieldConfig,
|
|
489
|
+
posts: rel('Post.author', true),
|
|
490
|
+
secrets: rel('Secret.owner', true),
|
|
491
|
+
},
|
|
492
|
+
access: { operation: { query: () => true } },
|
|
493
|
+
},
|
|
494
|
+
Post: {
|
|
495
|
+
fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
|
|
496
|
+
access: { operation: { query: postQuery } },
|
|
497
|
+
},
|
|
498
|
+
Secret: {
|
|
499
|
+
fields: { body: { type: 'text' } as FieldConfig, owner: rel('User.secrets') },
|
|
500
|
+
access: { operation: { query: () => false } },
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
504
|
+
} as any
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
it('a fully-readable relation counts unchanged', async () => {
|
|
508
|
+
const config = countConfig(() => true)
|
|
509
|
+
|
|
510
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
511
|
+
{ _count: { select: { posts: true } } },
|
|
512
|
+
config.lists.User.fields,
|
|
513
|
+
{ session: null, context: makeContext() },
|
|
514
|
+
config,
|
|
515
|
+
'User',
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
expect(include).toEqual({ _count: { select: { posts: true } } })
|
|
519
|
+
expect(countDenials.keys.size).toBe(0)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('folds the related list row filter into the count select', async () => {
|
|
523
|
+
const config = countConfig(() => ({ published: { equals: true } }))
|
|
524
|
+
|
|
525
|
+
const { include } = await buildAccessScopedInclude(
|
|
526
|
+
{ _count: { select: { posts: true } } },
|
|
527
|
+
config.lists.User.fields,
|
|
528
|
+
{ session: null, context: makeContext() },
|
|
529
|
+
config,
|
|
530
|
+
'User',
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
expect(include).toEqual({
|
|
534
|
+
_count: { select: { posts: { where: { published: { equals: true } } } } },
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('AND-combines a caller-supplied nested `where` with the access filter, never replacing it', async () => {
|
|
539
|
+
const config = countConfig(() => ({ published: { equals: true } }))
|
|
540
|
+
|
|
541
|
+
const { include } = await buildAccessScopedInclude(
|
|
542
|
+
{ _count: { select: { posts: { where: { title: { contains: 'foo' } } } } } },
|
|
543
|
+
config.lists.User.fields,
|
|
544
|
+
{ session: null, context: makeContext() },
|
|
545
|
+
config,
|
|
546
|
+
'User',
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
expect(include).toEqual({
|
|
550
|
+
_count: {
|
|
551
|
+
select: {
|
|
552
|
+
posts: {
|
|
553
|
+
where: {
|
|
554
|
+
AND: [{ published: { equals: true } }, { title: { contains: 'foo' } }],
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('omits a fully-denied relation from the select and records it in `countDenials`', async () => {
|
|
563
|
+
const config = countConfig(() => true)
|
|
564
|
+
|
|
565
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
566
|
+
{ _count: { select: { posts: true, secrets: true } } },
|
|
567
|
+
config.lists.User.fields,
|
|
568
|
+
{ session: null, context: makeContext() },
|
|
569
|
+
config,
|
|
570
|
+
'User',
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
expect(include).toEqual({ _count: { select: { posts: true } } })
|
|
574
|
+
expect(countDenials.keys).toEqual(new Set(['secrets']))
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('a wholly-denied `_count.select` omits the `_count` key entirely, still recording the denial', async () => {
|
|
578
|
+
const config = countConfig(() => true)
|
|
579
|
+
|
|
580
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
581
|
+
{ _count: { select: { secrets: true } } },
|
|
582
|
+
config.lists.User.fields,
|
|
583
|
+
{ session: null, context: makeContext() },
|
|
584
|
+
config,
|
|
585
|
+
'User',
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
expect(include).toEqual({})
|
|
589
|
+
expect(countDenials.keys).toEqual(new Set(['secrets']))
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('a denied relation resolves to 0 without validating its caller `where` — no field-name/read-access oracle', async () => {
|
|
593
|
+
// A caller who cannot read a single `Secret` row supplies a nonsense
|
|
594
|
+
// `where` naming a field that doesn't exist. If denial were checked
|
|
595
|
+
// AFTER `where` validation, this would throw a `ValidationError` naming
|
|
596
|
+
// "Secret"'s fields — letting a caller probe a fully denied list's
|
|
597
|
+
// schema. Denial must win first: the count is 0 regardless of `where`.
|
|
598
|
+
const config = countConfig(() => true)
|
|
599
|
+
|
|
600
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
601
|
+
{ _count: { select: { secrets: { where: { bogusField: { equals: 1 } } } } } },
|
|
602
|
+
config.lists.User.fields,
|
|
603
|
+
{ session: null, context: makeContext() },
|
|
604
|
+
config,
|
|
605
|
+
'User',
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
expect(include).toEqual({})
|
|
609
|
+
expect(countDenials.keys).toEqual(new Set(['secrets']))
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('rejects a `_count.select` key that is not a countable to-many (undeclared)', async () => {
|
|
613
|
+
const config = countConfig(() => true)
|
|
614
|
+
|
|
615
|
+
await expect(
|
|
616
|
+
buildAccessScopedInclude(
|
|
617
|
+
{ _count: { select: { nonsense: true } } },
|
|
618
|
+
config.lists.User.fields,
|
|
619
|
+
{ session: null, context: makeContext() },
|
|
620
|
+
config,
|
|
621
|
+
'User',
|
|
622
|
+
),
|
|
623
|
+
).rejects.toThrow(UndeclaredCountKeyError)
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('rejects a `_count.select` key naming a scalar field', async () => {
|
|
627
|
+
const config = countConfig(() => true)
|
|
628
|
+
|
|
629
|
+
await expect(
|
|
630
|
+
buildAccessScopedInclude(
|
|
631
|
+
{ _count: { select: { name: true } } },
|
|
632
|
+
config.lists.User.fields,
|
|
633
|
+
{ session: null, context: makeContext() },
|
|
634
|
+
config,
|
|
635
|
+
'User',
|
|
636
|
+
),
|
|
637
|
+
).rejects.toThrow(UndeclaredCountKeyError)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('rejects a `_count.select` key naming a to-one relationship', async () => {
|
|
641
|
+
const config = countConfig(() => true)
|
|
642
|
+
|
|
643
|
+
await expect(
|
|
644
|
+
buildAccessScopedInclude(
|
|
645
|
+
{ _count: { select: { author: true } } },
|
|
646
|
+
config.lists.Post.fields,
|
|
647
|
+
{ session: null, context: makeContext() },
|
|
648
|
+
config,
|
|
649
|
+
'Post',
|
|
650
|
+
),
|
|
651
|
+
).rejects.toThrow(UndeclaredCountKeyError)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
// Issue #1111: a relationship hidden via field-level `read` access must not
|
|
655
|
+
// leak its true count through `_count`, even though the related list's rows
|
|
656
|
+
// are otherwise fully readable — the same denial an ordinary `include` of
|
|
657
|
+
// that field already gets from `filterReadableFields`'s `checkFieldAccess`
|
|
658
|
+
// call.
|
|
659
|
+
it('omits a relation whose field-level `read` access denies it, even though the related list is fully readable', async () => {
|
|
660
|
+
const config: OpenSaasConfig = {
|
|
661
|
+
db: { provider: 'sqlite' },
|
|
662
|
+
lists: {
|
|
663
|
+
User: {
|
|
664
|
+
fields: {
|
|
665
|
+
name: { type: 'text' } as FieldConfig,
|
|
666
|
+
posts: {
|
|
667
|
+
type: 'relationship',
|
|
668
|
+
ref: 'Post.author',
|
|
669
|
+
many: true,
|
|
670
|
+
access: { read: () => false },
|
|
671
|
+
} as FieldConfig,
|
|
672
|
+
},
|
|
673
|
+
access: { operation: { query: () => true } },
|
|
674
|
+
},
|
|
675
|
+
Post: {
|
|
676
|
+
fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
|
|
677
|
+
access: { operation: { query: () => true } },
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
681
|
+
} as any
|
|
682
|
+
|
|
683
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
684
|
+
{ _count: { select: { posts: true } } },
|
|
685
|
+
config.lists.User.fields,
|
|
686
|
+
{ session: null, context: makeContext() },
|
|
687
|
+
config,
|
|
688
|
+
'User',
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
expect(include).toEqual({})
|
|
692
|
+
expect(countDenials.keys).toEqual(new Set(['posts']))
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('does not reject a `_count.select` key naming a synthetic back-relation (#1082)', async () => {
|
|
696
|
+
const config = {
|
|
697
|
+
db: { provider: 'sqlite' },
|
|
698
|
+
lists: {
|
|
699
|
+
Category: {
|
|
700
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
701
|
+
access: { operation: { query: () => true } },
|
|
702
|
+
},
|
|
703
|
+
Post: {
|
|
704
|
+
fields: {
|
|
705
|
+
title: { type: 'text' } as FieldConfig,
|
|
706
|
+
// A list-only ref: no `Category` field names it back, so schema
|
|
707
|
+
// generation synthesizes `from_Post_category` on Category.
|
|
708
|
+
category: rel('Category'),
|
|
709
|
+
},
|
|
710
|
+
access: { operation: { query: () => true } },
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
714
|
+
} as any as OpenSaasConfig
|
|
715
|
+
|
|
716
|
+
const { include } = await buildAccessScopedInclude(
|
|
717
|
+
{ _count: { select: { from_Post_category: true } } },
|
|
718
|
+
config.lists.Category.fields,
|
|
719
|
+
{ session: null, context: makeContext() },
|
|
720
|
+
config,
|
|
721
|
+
'Category',
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
expect(include).toEqual({ _count: { select: { from_Post_category: true } } })
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('`_count: true` also expands to a synthetic back-relation, scoped (#1082/#1087)', async () => {
|
|
728
|
+
const config = {
|
|
729
|
+
db: { provider: 'sqlite' },
|
|
730
|
+
lists: {
|
|
731
|
+
Category: {
|
|
732
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
733
|
+
access: { operation: { query: () => true } },
|
|
734
|
+
},
|
|
735
|
+
Post: {
|
|
736
|
+
fields: {
|
|
737
|
+
title: { type: 'text' } as FieldConfig,
|
|
738
|
+
category: rel('Category'),
|
|
739
|
+
},
|
|
740
|
+
// A row filter — proves the synthetic relation's count is scoped
|
|
741
|
+
// exactly like an explicitly-named one, not merely included.
|
|
742
|
+
access: { operation: { query: () => ({ published: { equals: true } }) } },
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
746
|
+
} as any as OpenSaasConfig
|
|
747
|
+
|
|
748
|
+
const { include } = await buildAccessScopedInclude(
|
|
749
|
+
{ _count: true },
|
|
750
|
+
config.lists.Category.fields,
|
|
751
|
+
{ session: null, context: makeContext() },
|
|
752
|
+
config,
|
|
753
|
+
'Category',
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
expect(include).toEqual({
|
|
757
|
+
_count: { select: { from_Post_category: { where: { published: { equals: true } } } } },
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('scopes a relation filter nested inside a caller-supplied count `where` (e.g. `_count.select.posts.where.comments.some`)', async () => {
|
|
762
|
+
const config = {
|
|
763
|
+
db: { provider: 'sqlite' },
|
|
764
|
+
lists: {
|
|
765
|
+
User: {
|
|
766
|
+
fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
|
|
767
|
+
access: { operation: { query: () => true } },
|
|
768
|
+
},
|
|
769
|
+
Post: {
|
|
770
|
+
fields: {
|
|
771
|
+
title: { type: 'text' } as FieldConfig,
|
|
772
|
+
author: rel('User.posts'),
|
|
773
|
+
comments: rel('Comment.post', true),
|
|
774
|
+
},
|
|
775
|
+
access: { operation: { query: () => true } },
|
|
776
|
+
},
|
|
777
|
+
Comment: {
|
|
778
|
+
fields: { body: { type: 'text' } as FieldConfig, post: rel('Post.comments') },
|
|
779
|
+
// A row filter one hop past the counted relation — must still be
|
|
780
|
+
// folded in, or the resulting post count would reveal whether
|
|
781
|
+
// inaccessible comments matching the caller's filter exist.
|
|
782
|
+
access: { operation: { query: () => ({ approved: { equals: true } }) } },
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
786
|
+
} as any as OpenSaasConfig
|
|
787
|
+
|
|
788
|
+
const { include } = await buildAccessScopedInclude(
|
|
789
|
+
{
|
|
790
|
+
_count: {
|
|
791
|
+
select: {
|
|
792
|
+
posts: { where: { comments: { some: { body: { contains: 'hi' } } } } },
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
config.lists.User.fields,
|
|
797
|
+
{ session: null, context: makeContext() },
|
|
798
|
+
config,
|
|
799
|
+
'User',
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
expect(include).toEqual({
|
|
803
|
+
_count: {
|
|
804
|
+
select: {
|
|
805
|
+
posts: {
|
|
806
|
+
where: {
|
|
807
|
+
comments: {
|
|
808
|
+
some: {
|
|
809
|
+
AND: [{ approved: { equals: true } }, { body: { contains: 'hi' } }],
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
})
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
it('resolves a synthetic back-relation named inside a caller-supplied count `where` (#1082/#1092)', async () => {
|
|
820
|
+
const config = {
|
|
821
|
+
db: { provider: 'sqlite' },
|
|
822
|
+
lists: {
|
|
823
|
+
User: {
|
|
824
|
+
fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
|
|
825
|
+
access: { operation: { query: () => true } },
|
|
826
|
+
},
|
|
827
|
+
Post: {
|
|
828
|
+
fields: {
|
|
829
|
+
title: { type: 'text' } as FieldConfig,
|
|
830
|
+
author: rel('User.posts'),
|
|
831
|
+
// A list-only ref: no `Post` field names it back, so schema
|
|
832
|
+
// generation synthesizes `from_Category_posts` on Post.
|
|
833
|
+
category: rel('Category'),
|
|
834
|
+
},
|
|
835
|
+
access: { operation: { query: () => true } },
|
|
836
|
+
},
|
|
837
|
+
Category: {
|
|
838
|
+
fields: {
|
|
839
|
+
name: { type: 'text' } as FieldConfig,
|
|
840
|
+
posts: rel('Post', true),
|
|
841
|
+
},
|
|
842
|
+
// A row filter one hop past the counted relation, reached only
|
|
843
|
+
// through the synthetic back-relation — must still be folded in.
|
|
844
|
+
access: { operation: { query: () => ({ published: { equals: true } }) } },
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
848
|
+
} as any as OpenSaasConfig
|
|
849
|
+
|
|
850
|
+
const { include } = await buildAccessScopedInclude(
|
|
851
|
+
{
|
|
852
|
+
_count: {
|
|
853
|
+
select: {
|
|
854
|
+
posts: { where: { from_Category_posts: { some: { name: { contains: 'news' } } } } },
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
config.lists.User.fields,
|
|
859
|
+
{ session: null, context: makeContext() },
|
|
860
|
+
config,
|
|
861
|
+
'User',
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
expect(include).toEqual({
|
|
865
|
+
_count: {
|
|
866
|
+
select: {
|
|
867
|
+
posts: {
|
|
868
|
+
where: {
|
|
869
|
+
from_Category_posts: {
|
|
870
|
+
some: {
|
|
871
|
+
AND: [{ published: { equals: true } }, { name: { contains: 'news' } }],
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
})
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('`_count: true` expands to every declared to-many relation, scoped', async () => {
|
|
882
|
+
const config = countConfig(() => true)
|
|
883
|
+
|
|
884
|
+
const { include, countDenials } = await buildAccessScopedInclude(
|
|
885
|
+
{ _count: true },
|
|
886
|
+
config.lists.User.fields,
|
|
887
|
+
{ session: null, context: makeContext() },
|
|
888
|
+
config,
|
|
889
|
+
'User',
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
expect(include).toEqual({ _count: { select: { posts: true } } })
|
|
893
|
+
expect(countDenials.keys).toEqual(new Set(['secrets']))
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it('scopes `_count` nested inside an ordinary relation include, at that level', async () => {
|
|
897
|
+
const config = {
|
|
898
|
+
db: { provider: 'sqlite' },
|
|
899
|
+
lists: {
|
|
900
|
+
Org: {
|
|
901
|
+
fields: { name: { type: 'text' } as FieldConfig, users: rel('User.org', true) },
|
|
902
|
+
access: { operation: { query: () => true } },
|
|
903
|
+
},
|
|
904
|
+
User: {
|
|
905
|
+
fields: {
|
|
906
|
+
name: { type: 'text' } as FieldConfig,
|
|
907
|
+
org: rel('Org.users'),
|
|
908
|
+
posts: rel('Post.author', true),
|
|
909
|
+
},
|
|
910
|
+
access: { operation: { query: () => true } },
|
|
911
|
+
},
|
|
912
|
+
Post: {
|
|
913
|
+
fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
|
|
914
|
+
access: { operation: { query: () => ({ published: { equals: true } }) } },
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
918
|
+
} as any as OpenSaasConfig
|
|
919
|
+
|
|
920
|
+
const { include } = await buildAccessScopedInclude(
|
|
921
|
+
{ users: { include: { _count: { select: { posts: true } } } } },
|
|
922
|
+
config.lists.Org.fields,
|
|
923
|
+
{ session: null, context: makeContext() },
|
|
924
|
+
config,
|
|
925
|
+
'Org',
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
expect(include).toEqual({
|
|
929
|
+
users: {
|
|
930
|
+
include: { _count: { select: { posts: { where: { published: { equals: true } } } } } },
|
|
931
|
+
},
|
|
932
|
+
})
|
|
933
|
+
})
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Regression coverage for issue #974: Prisma accepts a nested `where` on an
|
|
938
|
+
* `include` entry only for a to-many relation — the same shape on a to-one
|
|
939
|
+
* relation raises `PrismaClientValidationError`. `buildAccessScopedInclude`
|
|
940
|
+
* must never attach one for a to-one relation, whatever its related list's
|
|
941
|
+
* `query` access resolves to; the scoping information goes into
|
|
942
|
+
* `toOneAccessFilters` instead.
|
|
943
|
+
*/
|
|
944
|
+
describe('buildAccessScopedInclude — to-one relations record a post-query filter, not `where` (issue #974)', () => {
|
|
945
|
+
function ownerConfig(queryAccess: unknown): OpenSaasConfig {
|
|
946
|
+
return {
|
|
947
|
+
db: { provider: 'sqlite' },
|
|
948
|
+
lists: {
|
|
949
|
+
Child: {
|
|
950
|
+
fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
|
|
951
|
+
access: { operation: { query: () => true } },
|
|
952
|
+
},
|
|
953
|
+
Owner: {
|
|
954
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
955
|
+
access: { operation: { query: queryAccess } },
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
959
|
+
} as any
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
it('records the access filter instead of attaching `where` when the related list scopes by a filter', async () => {
|
|
963
|
+
const config = ownerConfig(() => ({ id: { equals: 'u1' } }))
|
|
964
|
+
|
|
965
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
966
|
+
{ owner: true },
|
|
967
|
+
config.lists.Child.fields,
|
|
968
|
+
{ session: null, context: makeContext() },
|
|
969
|
+
config,
|
|
970
|
+
'Child',
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
// The Prisma-bound include never carries a `where` on this to-one key —
|
|
974
|
+
// Prisma would reject it.
|
|
975
|
+
expect(include).toEqual({ owner: true })
|
|
976
|
+
expect(toOneAccessFilters).toEqual({
|
|
977
|
+
filters: {
|
|
978
|
+
owner: { kind: 'scoped', relatedListName: 'Owner', accessWhere: { id: { equals: 'u1' } } },
|
|
979
|
+
},
|
|
980
|
+
nested: {},
|
|
981
|
+
})
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
it('leaves a to-one relation whose related list is fully open untouched — no filter recorded, no extra query later', async () => {
|
|
985
|
+
const config = ownerConfig(() => true)
|
|
986
|
+
|
|
987
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
988
|
+
{ owner: true },
|
|
989
|
+
config.lists.Child.fields,
|
|
990
|
+
{ session: null, context: makeContext() },
|
|
991
|
+
config,
|
|
992
|
+
'Child',
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
expect(include).toEqual({ owner: true })
|
|
996
|
+
expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
it('drops a to-one relation whose related list denies query access outright, and records the denial', async () => {
|
|
1000
|
+
const config = ownerConfig(() => false)
|
|
1001
|
+
|
|
1002
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1003
|
+
{ owner: true },
|
|
1004
|
+
config.lists.Child.fields,
|
|
1005
|
+
{ session: null, context: makeContext() },
|
|
1006
|
+
config,
|
|
1007
|
+
'Child',
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
// Never asked of Prisma at all — same as before this fix.
|
|
1011
|
+
expect(include).toEqual({})
|
|
1012
|
+
expect(toOneAccessFilters).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
it('records a to-one filter nested inside another relation the caller explicitly included', async () => {
|
|
1016
|
+
const config: OpenSaasConfig = {
|
|
1017
|
+
db: { provider: 'sqlite' },
|
|
1018
|
+
lists: {
|
|
1019
|
+
GrandChild: {
|
|
1020
|
+
fields: { name: { type: 'text' } as FieldConfig, child: rel('Child') },
|
|
1021
|
+
access: { operation: { query: () => true } },
|
|
1022
|
+
},
|
|
1023
|
+
Child: {
|
|
1024
|
+
fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
|
|
1025
|
+
access: { operation: { query: () => true } },
|
|
1026
|
+
},
|
|
1027
|
+
Owner: {
|
|
1028
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
1029
|
+
access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1033
|
+
} as any
|
|
1034
|
+
|
|
1035
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1036
|
+
{ child: { include: { owner: true } } },
|
|
1037
|
+
config.lists.GrandChild.fields,
|
|
1038
|
+
{ session: null, context: makeContext() },
|
|
1039
|
+
config,
|
|
1040
|
+
'GrandChild',
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
expect(include).toEqual({ child: { include: { owner: true } } })
|
|
1044
|
+
expect(toOneAccessFilters).toEqual({
|
|
1045
|
+
filters: {},
|
|
1046
|
+
nested: {
|
|
1047
|
+
child: {
|
|
1048
|
+
filters: {
|
|
1049
|
+
owner: {
|
|
1050
|
+
kind: 'scoped',
|
|
1051
|
+
relatedListName: 'Owner',
|
|
1052
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
nested: {},
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
})
|
|
1059
|
+
})
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Regression coverage for issue #1103: a denied to-MANY relation used to be
|
|
1064
|
+
* dropped from `include` with nothing recorded, unlike its to-one sibling
|
|
1065
|
+
* (issue #974, covered above). `filterReadableFields`'s main loop only ever
|
|
1066
|
+
* visits keys present in the raw row, so an unrecorded denial left the key
|
|
1067
|
+
* silently absent from the result — `undefined`, not `[]` — breaking the
|
|
1068
|
+
* fragment API's typed contract (`ResultOf` types a to-many relation as an
|
|
1069
|
+
* array). These tests pin that a to-many denial is now recorded exactly like
|
|
1070
|
+
* a to-one one, sharing the same `toOneAccessFilters` tree and `kind:
|
|
1071
|
+
* 'denied'` entry.
|
|
1072
|
+
*/
|
|
1073
|
+
describe('buildAccessScopedInclude — to-many relations record a denial too, like to-one (issue #1103)', () => {
|
|
1074
|
+
function secretsConfig(): OpenSaasConfig {
|
|
1075
|
+
return {
|
|
1076
|
+
db: { provider: 'sqlite' },
|
|
1077
|
+
lists: {
|
|
1078
|
+
Author: {
|
|
1079
|
+
fields: { name: { type: 'text' } as FieldConfig, secrets: rel('Secret.author', true) },
|
|
1080
|
+
access: { operation: { query: () => true } },
|
|
1081
|
+
},
|
|
1082
|
+
Secret: {
|
|
1083
|
+
fields: { value: { type: 'text' } as FieldConfig },
|
|
1084
|
+
access: { operation: { query: () => false } },
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1088
|
+
} as any
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
it('drops a to-many relation whose related list denies query access outright, and records the denial', async () => {
|
|
1092
|
+
const config = secretsConfig()
|
|
1093
|
+
|
|
1094
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1095
|
+
{ secrets: true },
|
|
1096
|
+
config.lists.Author.fields,
|
|
1097
|
+
{ session: null, context: makeContext() },
|
|
1098
|
+
config,
|
|
1099
|
+
'Author',
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
// Never asked of Prisma at all — same treatment as a denied to-one.
|
|
1103
|
+
expect(include).toEqual({})
|
|
1104
|
+
expect(toOneAccessFilters).toEqual({ filters: { secrets: { kind: 'denied' } }, nested: {} })
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('records the denial even when the caller also asked for a `take`/`where` — a denied relation has no shape to preserve', async () => {
|
|
1108
|
+
const config = secretsConfig()
|
|
1109
|
+
|
|
1110
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1111
|
+
{ secrets: { take: 5, where: { value: { equals: 'x' } } } },
|
|
1112
|
+
config.lists.Author.fields,
|
|
1113
|
+
{ session: null, context: makeContext() },
|
|
1114
|
+
config,
|
|
1115
|
+
'Author',
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
expect(include).toEqual({})
|
|
1119
|
+
expect(toOneAccessFilters).toEqual({ filters: { secrets: { kind: 'denied' } }, nested: {} })
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
// Code-review finding on #1103's own PR: a caller's `Include` type allows a
|
|
1123
|
+
// bare `boolean` for a relation, so `{ secrets: false }` (or a conditional
|
|
1124
|
+
// `{ secrets: cond ? true : undefined }` that resolved to `undefined`)
|
|
1125
|
+
// explicitly opts the relation OUT — no different from the key being
|
|
1126
|
+
// absent altogether. Before this guard, the loop evaluated (and, once
|
|
1127
|
+
// denied, recorded) the key regardless of its value, so a denied relation
|
|
1128
|
+
// the caller explicitly disabled still ended up forced to `[]` downstream
|
|
1129
|
+
// by `field-visibility.ts` — surfacing a value for a key nobody asked for.
|
|
1130
|
+
it('treats an explicit `false` include value as not requested, even when the related list denies query access', async () => {
|
|
1131
|
+
const config = secretsConfig()
|
|
1132
|
+
|
|
1133
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1134
|
+
{ secrets: false },
|
|
1135
|
+
config.lists.Author.fields,
|
|
1136
|
+
{ session: null, context: makeContext() },
|
|
1137
|
+
config,
|
|
1138
|
+
'Author',
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
expect(include).toEqual({})
|
|
1142
|
+
expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
it('treats an explicit `undefined` include value as not requested, even when the related list denies query access', async () => {
|
|
1146
|
+
const config = secretsConfig()
|
|
1147
|
+
|
|
1148
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1149
|
+
{ secrets: undefined },
|
|
1150
|
+
config.lists.Author.fields,
|
|
1151
|
+
{ session: null, context: makeContext() },
|
|
1152
|
+
config,
|
|
1153
|
+
'Author',
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
expect(include).toEqual({})
|
|
1157
|
+
expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('treats an explicit `false` include value as not requested even when the related list is fully open — never flips it to `true`', async () => {
|
|
1161
|
+
const config = secretsConfig()
|
|
1162
|
+
config.lists.Secret.access = { operation: { query: () => true } }
|
|
1163
|
+
|
|
1164
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1165
|
+
{ secrets: false },
|
|
1166
|
+
config.lists.Author.fields,
|
|
1167
|
+
{ session: null, context: makeContext() },
|
|
1168
|
+
config,
|
|
1169
|
+
'Author',
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
expect(include).toEqual({})
|
|
1173
|
+
expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
|
|
1174
|
+
})
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
describe('resolveToOneAccessVisibility (issue #974)', () => {
|
|
1178
|
+
function makeVisibilityContext(findMany: ReturnType<typeof vi.fn>): AccessContext {
|
|
1179
|
+
return {
|
|
1180
|
+
session: null,
|
|
1181
|
+
_isSudo: false,
|
|
1182
|
+
_resolveOutputChain: [],
|
|
1183
|
+
prisma: { owner: { findMany } },
|
|
1184
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
1185
|
+
} as any
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
it('passes a denied filter straight through with no query', async () => {
|
|
1189
|
+
const findMany = vi.fn()
|
|
1190
|
+
const context = makeVisibilityContext(findMany)
|
|
1191
|
+
|
|
1192
|
+
const resolved = await resolveToOneAccessVisibility(
|
|
1193
|
+
[{ id: 'c1' }],
|
|
1194
|
+
{ filters: { owner: { kind: 'denied' } }, nested: {} },
|
|
1195
|
+
{ session: null, context },
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
expect(resolved).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
|
|
1199
|
+
expect(findMany).not.toHaveBeenCalled()
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
it('skips the query when no row carries a value at the scoped key', async () => {
|
|
1203
|
+
const findMany = vi.fn()
|
|
1204
|
+
const context = makeVisibilityContext(findMany)
|
|
1205
|
+
const tree = {
|
|
1206
|
+
filters: {
|
|
1207
|
+
owner: {
|
|
1208
|
+
kind: 'scoped' as const,
|
|
1209
|
+
relatedListName: 'Owner',
|
|
1210
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
nested: {},
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const resolved = await resolveToOneAccessVisibility([{ id: 'c1', owner: null }], tree, {
|
|
1217
|
+
session: null,
|
|
1218
|
+
context,
|
|
1219
|
+
})
|
|
1220
|
+
|
|
1221
|
+
expect(resolved).toEqual({
|
|
1222
|
+
filters: { owner: { kind: 'visible', ids: new Set() } },
|
|
1223
|
+
nested: {},
|
|
1224
|
+
})
|
|
1225
|
+
expect(findMany).not.toHaveBeenCalled()
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
it('batches every row into ONE existence check, through the raw Prisma client, using the exact access filter', async () => {
|
|
1229
|
+
const findMany = vi.fn().mockResolvedValue([{ id: 'u1' }, { id: 'u2' }])
|
|
1230
|
+
const context = makeVisibilityContext(findMany)
|
|
1231
|
+
const tree = {
|
|
1232
|
+
filters: {
|
|
1233
|
+
owner: {
|
|
1234
|
+
kind: 'scoped' as const,
|
|
1235
|
+
relatedListName: 'Owner',
|
|
1236
|
+
accessWhere: { studioId: { equals: 's1' } },
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
nested: {},
|
|
1240
|
+
}
|
|
1241
|
+
const items = [
|
|
1242
|
+
{ id: 'c1', owner: { id: 'u1' } },
|
|
1243
|
+
{ id: 'c2', owner: { id: 'u2' } },
|
|
1244
|
+
{ id: 'c3', owner: { id: 'u3' } }, // excluded by the mocked response below
|
|
1245
|
+
{ id: 'c4', owner: null }, // contributes no id
|
|
1246
|
+
]
|
|
1247
|
+
|
|
1248
|
+
const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
|
|
1249
|
+
|
|
1250
|
+
// One call, not one per row.
|
|
1251
|
+
expect(findMany).toHaveBeenCalledTimes(1)
|
|
1252
|
+
expect(findMany).toHaveBeenCalledWith({
|
|
1253
|
+
where: { AND: [{ studioId: { equals: 's1' } }, { id: { in: ['u1', 'u2', 'u3'] } }] },
|
|
1254
|
+
select: { id: true },
|
|
1255
|
+
})
|
|
1256
|
+
expect(resolved.filters.owner).toEqual({ kind: 'visible', ids: new Set(['u1', 'u2']) })
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
it('recurses into nested trees, flattening rows reached through a to-many hop into one batch', async () => {
|
|
1260
|
+
const ownerFindMany = vi.fn().mockResolvedValue([{ id: 'u1' }])
|
|
1261
|
+
const context: AccessContext = {
|
|
1262
|
+
session: null,
|
|
1263
|
+
_isSudo: false,
|
|
1264
|
+
_resolveOutputChain: [],
|
|
1265
|
+
prisma: { owner: { findMany: ownerFindMany } },
|
|
1266
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
1267
|
+
} as any
|
|
1268
|
+
const tree = {
|
|
1269
|
+
filters: {},
|
|
1270
|
+
nested: {
|
|
1271
|
+
children: {
|
|
1272
|
+
filters: {
|
|
1273
|
+
owner: {
|
|
1274
|
+
kind: 'scoped' as const,
|
|
1275
|
+
relatedListName: 'Owner',
|
|
1276
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
nested: {},
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
}
|
|
1283
|
+
const items = [
|
|
1284
|
+
{
|
|
1285
|
+
id: 'p1',
|
|
1286
|
+
children: [
|
|
1287
|
+
{ id: 'c1', owner: { id: 'u1' } },
|
|
1288
|
+
{ id: 'c2', owner: { id: 'u9' } },
|
|
1289
|
+
],
|
|
1290
|
+
},
|
|
1291
|
+
{ id: 'p2', children: [{ id: 'c3', owner: { id: 'u1' } }] },
|
|
1292
|
+
]
|
|
1293
|
+
|
|
1294
|
+
const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
|
|
1295
|
+
|
|
1296
|
+
// Every child across BOTH parents resolved in one batched call.
|
|
1297
|
+
expect(ownerFindMany).toHaveBeenCalledTimes(1)
|
|
1298
|
+
expect(ownerFindMany).toHaveBeenCalledWith({
|
|
1299
|
+
where: { AND: [{ id: { equals: 'u1' } }, { id: { in: ['u1', 'u9'] } }] },
|
|
1300
|
+
select: { id: true },
|
|
1301
|
+
})
|
|
1302
|
+
expect(resolved.nested.children.filters.owner).toEqual({
|
|
1303
|
+
kind: 'visible',
|
|
1304
|
+
ids: new Set(['u1']),
|
|
1305
|
+
})
|
|
1306
|
+
})
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Regression coverage for issue #1082: a list-only `ref` (`ref: 'Term'`, no
|
|
1311
|
+
* target field) makes schema generation synthesize a back-relation on `Term`
|
|
1312
|
+
* (`from_Bill_term`) that no list config declares. Before this fix,
|
|
1313
|
+
* `buildAccessScopedInclude` treated any key it couldn't resolve to a
|
|
1314
|
+
* DECLARED relationship as "not governed by access control" and passed it
|
|
1315
|
+
* through unchanged — so a caller naming `from_Bill_term` in `include`
|
|
1316
|
+
* bypassed `Bill`'s own `query` access entirely. These tests pin the fix: a
|
|
1317
|
+
* synthetic key is scoped exactly like the declared relationship field it
|
|
1318
|
+
* stands for.
|
|
1319
|
+
*/
|
|
1320
|
+
describe('buildAccessScopedInclude — synthetic back-relation (#1082)', () => {
|
|
1321
|
+
// Term ← Bill.term (list-only ref) ← Bill.lineItems (declared, to-many).
|
|
1322
|
+
// `from_Bill_term` is the back-relation schema generation synthesizes on
|
|
1323
|
+
// Term; nothing in `Term.fields` declares it.
|
|
1324
|
+
function syntheticConfig(billQuery: () => boolean | Record<string, unknown>) {
|
|
1325
|
+
const config = {
|
|
1326
|
+
db: { provider: 'sqlite' },
|
|
1327
|
+
lists: {
|
|
1328
|
+
Term: {
|
|
1329
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
1330
|
+
access: { operation: { query: () => true } },
|
|
1331
|
+
},
|
|
1332
|
+
Bill: {
|
|
1333
|
+
fields: {
|
|
1334
|
+
amount: { type: 'integer' } as FieldConfig,
|
|
1335
|
+
term: rel('Term'), // list-only ref — synthesizes `from_Bill_term` on Term
|
|
1336
|
+
lineItems: rel('LineItem.bill', true),
|
|
1337
|
+
},
|
|
1338
|
+
access: { operation: { query: billQuery } },
|
|
1339
|
+
},
|
|
1340
|
+
LineItem: {
|
|
1341
|
+
fields: { sku: { type: 'text' } as FieldConfig, bill: rel('Bill.lineItems') },
|
|
1342
|
+
access: { operation: { query: () => true } },
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1346
|
+
} as any as OpenSaasConfig
|
|
1347
|
+
return config
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
it('drops the relation entirely when the owning list denies query access', async () => {
|
|
1351
|
+
const config = syntheticConfig(() => false)
|
|
1352
|
+
|
|
1353
|
+
const { include } = await buildAccessScopedInclude(
|
|
1354
|
+
{ from_Bill_term: true },
|
|
1355
|
+
config.lists.Term.fields,
|
|
1356
|
+
{ session: null, context: makeContext() },
|
|
1357
|
+
config,
|
|
1358
|
+
'Term',
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
expect(include).toEqual({})
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
it('AND-folds the owning list access filter with the caller-supplied where', async () => {
|
|
1365
|
+
const config = syntheticConfig(() => ({ paid: { equals: true } }))
|
|
1366
|
+
|
|
1367
|
+
const { include } = await buildAccessScopedInclude(
|
|
1368
|
+
{ from_Bill_term: { where: { amount: { gt: 10 } } } },
|
|
1369
|
+
config.lists.Term.fields,
|
|
1370
|
+
{ session: null, context: makeContext() },
|
|
1371
|
+
config,
|
|
1372
|
+
'Term',
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
expect(include).toEqual({
|
|
1376
|
+
from_Bill_term: {
|
|
1377
|
+
where: { AND: [{ paid: { equals: true } }, { amount: { gt: 10 } }] },
|
|
1378
|
+
},
|
|
1379
|
+
})
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
it('scopes a nested include against the OWNING list fields and consumes depth', async () => {
|
|
1383
|
+
const config = syntheticConfig(() => true)
|
|
1384
|
+
const lineItemQuery = vi.fn(() => true)
|
|
1385
|
+
config.lists.LineItem.access = { operation: { query: lineItemQuery } }
|
|
1386
|
+
|
|
1387
|
+
const { include } = await buildAccessScopedInclude(
|
|
1388
|
+
{ from_Bill_term: { include: { lineItems: true } } },
|
|
1389
|
+
config.lists.Term.fields,
|
|
1390
|
+
{ session: null, context: makeContext() },
|
|
1391
|
+
config,
|
|
1392
|
+
'Term',
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
// The nested `lineItems` key was resolved against Bill's own fields (not
|
|
1396
|
+
// Term's, which has no `lineItems` field at all) — proof the recursion
|
|
1397
|
+
// used the owning list, not the synthetic key's declaring list.
|
|
1398
|
+
expect(lineItemQuery).toHaveBeenCalledTimes(1)
|
|
1399
|
+
expect(include).toEqual({ from_Bill_term: { include: { lineItems: true } } })
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
it('a synthetic hop counts toward the read-include depth cap the same as a declared one', async () => {
|
|
1403
|
+
// A chain like `chainConfig` builds (L0 ↔ L1 ↔ … via declared `next`/`prev`),
|
|
1404
|
+
// except the FIRST hop is synthetic: L1's `prev` is a list-only ref, so L0
|
|
1405
|
+
// never declares `next` at all — it only gets `from_L1_prev` synthesized
|
|
1406
|
+
// onto it. Every hop past that first one is declared, exactly matching the
|
|
1407
|
+
// mixed synthetic-then-declared case the depth cap must bound identically.
|
|
1408
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 2
|
|
1409
|
+
const { config } = chainConfig(chainLength)
|
|
1410
|
+
delete (config.lists.L0.fields as Record<string, unknown>).next
|
|
1411
|
+
;(config.lists.L1.fields as Record<string, FieldConfig>).prev = rel('L0')
|
|
1412
|
+
|
|
1413
|
+
// Outer `from_L1_prev` (hop 1) + nestedInclude(HOPS_AT_CAP) (HOPS_AT_CAP
|
|
1414
|
+
// more, via the declared `next` chain from L1 onward) = HOPS_AT_CAP + 1.
|
|
1415
|
+
const requested = { from_L1_prev: nestedInclude(READ_INCLUDE_MAX_DEPTH) }
|
|
1416
|
+
|
|
1417
|
+
await expect(
|
|
1418
|
+
buildAccessScopedInclude(
|
|
1419
|
+
requested,
|
|
1420
|
+
config.lists.L0.fields,
|
|
1421
|
+
{ session: null, context: makeContext() },
|
|
1422
|
+
config,
|
|
1423
|
+
'L0',
|
|
1424
|
+
),
|
|
1425
|
+
).rejects.toThrow(AccessScopeDepthExceededError)
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
it('still resolves a synthetic hop one level shallower than the cap', async () => {
|
|
1429
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 1
|
|
1430
|
+
const { config } = chainConfig(chainLength)
|
|
1431
|
+
delete (config.lists.L0.fields as Record<string, unknown>).next
|
|
1432
|
+
;(config.lists.L1.fields as Record<string, FieldConfig>).prev = rel('L0')
|
|
1433
|
+
|
|
1434
|
+
const requested = { from_L1_prev: nestedInclude(READ_INCLUDE_MAX_DEPTH - 1) }
|
|
1435
|
+
|
|
1436
|
+
const { include } = await buildAccessScopedInclude(
|
|
1437
|
+
requested,
|
|
1438
|
+
config.lists.L0.fields,
|
|
1439
|
+
{ session: null, context: makeContext() },
|
|
1440
|
+
config,
|
|
1441
|
+
'L0',
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
expect(includeDepth(include)).toBe(READ_INCLUDE_MAX_DEPTH)
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
it('rejects a key that resolves to neither a declared relationship nor a synthetic one', async () => {
|
|
1448
|
+
const config = syntheticConfig(() => true)
|
|
1449
|
+
|
|
1450
|
+
await expect(
|
|
1451
|
+
buildAccessScopedInclude(
|
|
1452
|
+
{ from_Nonexistent_field: true },
|
|
1453
|
+
config.lists.Term.fields,
|
|
1454
|
+
{ session: null, context: makeContext() },
|
|
1455
|
+
config,
|
|
1456
|
+
'Term',
|
|
1457
|
+
),
|
|
1458
|
+
).rejects.toThrow(UndeclaredIncludeKeyError)
|
|
1459
|
+
})
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Regression coverage for issue #1092: a `where`/`orderBy` a caller nests
|
|
1464
|
+
* inside an `include` entry reached Prisma with neither #912's key-existence
|
|
1465
|
+
* check nor #915's field-read check — a probing oracle over exactly the
|
|
1466
|
+
* fields those two tickets close one level up. `secret`/`secretNote` below
|
|
1467
|
+
* deny field-level `read` outright; every other field is a normal declared
|
|
1468
|
+
* field, so a throw in these tests can only come from the new validation.
|
|
1469
|
+
*/
|
|
1470
|
+
describe('buildAccessScopedInclude — nested where/orderBy validation (#1092)', () => {
|
|
1471
|
+
function blogConfig(): OpenSaasConfig {
|
|
1472
|
+
return {
|
|
1473
|
+
db: { provider: 'sqlite' },
|
|
1474
|
+
lists: {
|
|
1475
|
+
Author: {
|
|
1476
|
+
fields: {
|
|
1477
|
+
name: { type: 'text' } as FieldConfig,
|
|
1478
|
+
posts: rel('Post.author', true),
|
|
1479
|
+
},
|
|
1480
|
+
access: { operation: { query: () => true } },
|
|
1481
|
+
},
|
|
1482
|
+
Post: {
|
|
1483
|
+
fields: {
|
|
1484
|
+
title: { type: 'text' } as FieldConfig,
|
|
1485
|
+
secret: { type: 'text', access: { read: () => false } } as unknown as FieldConfig,
|
|
1486
|
+
author: rel('Author.posts'),
|
|
1487
|
+
comments: rel('Comment.post', true),
|
|
1488
|
+
},
|
|
1489
|
+
access: { operation: { query: () => true } },
|
|
1490
|
+
},
|
|
1491
|
+
Comment: {
|
|
1492
|
+
fields: {
|
|
1493
|
+
body: { type: 'text' } as FieldConfig,
|
|
1494
|
+
secretNote: { type: 'text', access: { read: () => false } } as unknown as FieldConfig,
|
|
1495
|
+
post: rel('Post.comments'),
|
|
1496
|
+
},
|
|
1497
|
+
access: { operation: { query: () => true } },
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1500
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1501
|
+
} as any as OpenSaasConfig
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
it('throws when a nested `where` names a field the session cannot read', async () => {
|
|
1505
|
+
const config = blogConfig()
|
|
1506
|
+
|
|
1507
|
+
await expect(
|
|
1508
|
+
buildAccessScopedInclude(
|
|
1509
|
+
{ posts: { where: { secret: { equals: 'x' } } } },
|
|
1510
|
+
config.lists.Author.fields,
|
|
1511
|
+
{ session: null, context: makeContext() },
|
|
1512
|
+
config,
|
|
1513
|
+
'Author',
|
|
1514
|
+
),
|
|
1515
|
+
).rejects.toThrow(ValidationError)
|
|
1516
|
+
await expect(
|
|
1517
|
+
buildAccessScopedInclude(
|
|
1518
|
+
{ posts: { where: { secret: { equals: 'x' } } } },
|
|
1519
|
+
config.lists.Author.fields,
|
|
1520
|
+
{ session: null, context: makeContext() },
|
|
1521
|
+
config,
|
|
1522
|
+
'Author',
|
|
1523
|
+
),
|
|
1524
|
+
).rejects.toThrow(/"secret" is denied by field-level read access/)
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
it('throws when a nested `where` names a key the related list does not declare, naming the related list and key', async () => {
|
|
1528
|
+
const config = blogConfig()
|
|
1529
|
+
|
|
1530
|
+
await expect(
|
|
1531
|
+
buildAccessScopedInclude(
|
|
1532
|
+
{ posts: { where: { bogusField: { equals: 'x' } } } },
|
|
1533
|
+
config.lists.Author.fields,
|
|
1534
|
+
{ session: null, context: makeContext() },
|
|
1535
|
+
config,
|
|
1536
|
+
'Author',
|
|
1537
|
+
),
|
|
1538
|
+
).rejects.toThrow(/Cannot query "Post" — "bogusField" is not a field of this list/)
|
|
1539
|
+
})
|
|
1540
|
+
|
|
1541
|
+
it('applies the same two checks to a nested `orderBy`', async () => {
|
|
1542
|
+
const config = blogConfig()
|
|
1543
|
+
|
|
1544
|
+
await expect(
|
|
1545
|
+
buildAccessScopedInclude(
|
|
1546
|
+
{ posts: { orderBy: { secret: 'asc' } } },
|
|
1547
|
+
config.lists.Author.fields,
|
|
1548
|
+
{ session: null, context: makeContext() },
|
|
1549
|
+
config,
|
|
1550
|
+
'Author',
|
|
1551
|
+
),
|
|
1552
|
+
).rejects.toThrow(ValidationError)
|
|
1553
|
+
|
|
1554
|
+
await expect(
|
|
1555
|
+
buildAccessScopedInclude(
|
|
1556
|
+
{ posts: { orderBy: { bogusField: 'asc' } } },
|
|
1557
|
+
config.lists.Author.fields,
|
|
1558
|
+
{ session: null, context: makeContext() },
|
|
1559
|
+
config,
|
|
1560
|
+
'Author',
|
|
1561
|
+
),
|
|
1562
|
+
).rejects.toThrow(/Cannot query "Post"/)
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
it('resolves against the RELATED list, not the current one — a key valid on the parent but not the related list is rejected', async () => {
|
|
1566
|
+
const config = blogConfig()
|
|
1567
|
+
|
|
1568
|
+
// `name` is declared on Author, not on Post — validating against the
|
|
1569
|
+
// wrong list would let this through.
|
|
1570
|
+
await expect(
|
|
1571
|
+
buildAccessScopedInclude(
|
|
1572
|
+
{ posts: { where: { name: { equals: 'x' } } } },
|
|
1573
|
+
config.lists.Author.fields,
|
|
1574
|
+
{ session: null, context: makeContext() },
|
|
1575
|
+
config,
|
|
1576
|
+
'Author',
|
|
1577
|
+
),
|
|
1578
|
+
).rejects.toThrow(/Cannot query "Post" — "name"/)
|
|
1579
|
+
})
|
|
1580
|
+
|
|
1581
|
+
it('recurses through AND/OR/NOT', async () => {
|
|
1582
|
+
const config = blogConfig()
|
|
1583
|
+
|
|
1584
|
+
await expect(
|
|
1585
|
+
buildAccessScopedInclude(
|
|
1586
|
+
{
|
|
1587
|
+
posts: {
|
|
1588
|
+
where: { AND: [{ title: { contains: 'x' } }, { OR: [{ secret: { equals: 'x' } }] }] },
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
config.lists.Author.fields,
|
|
1592
|
+
{ session: null, context: makeContext() },
|
|
1593
|
+
config,
|
|
1594
|
+
'Author',
|
|
1595
|
+
),
|
|
1596
|
+
).rejects.toThrow(ValidationError)
|
|
1597
|
+
})
|
|
1598
|
+
|
|
1599
|
+
it("the key-existence check recurses through a relation quantifier nested inside the entry's own where", async () => {
|
|
1600
|
+
// #912's existence check (`validateQueryKeys`) is the reused #912/#915
|
|
1601
|
+
// top-level walker, which already recurses through a relation quantifier
|
|
1602
|
+
// regardless of caller position — an undeclared key nested that deep
|
|
1603
|
+
// still rejects, naming the list it actually resolved against (Comment,
|
|
1604
|
+
// not Post).
|
|
1605
|
+
const config = blogConfig()
|
|
1606
|
+
|
|
1607
|
+
await expect(
|
|
1608
|
+
buildAccessScopedInclude(
|
|
1609
|
+
{ posts: { where: { comments: { some: { bogusField: { equals: 'x' } } } } } },
|
|
1610
|
+
config.lists.Author.fields,
|
|
1611
|
+
{ session: null, context: makeContext() },
|
|
1612
|
+
config,
|
|
1613
|
+
'Author',
|
|
1614
|
+
),
|
|
1615
|
+
).rejects.toThrow(/Cannot query "Comment" — "bogusField"/)
|
|
1616
|
+
})
|
|
1617
|
+
|
|
1618
|
+
it("scopes a relation quantifier nested inside the entry's own where against the DEEPER related list — a read-denied field one hop further out still throws (#916)", async () => {
|
|
1619
|
+
// Without this, #1092's fix would only move the oracle one hop further
|
|
1620
|
+
// out instead of closing it: `posts: { where: { comments: { some: {...} } } }`
|
|
1621
|
+
// names Comment, a list the entry's own #912/#915 checks never reach —
|
|
1622
|
+
// only `buildAccessScopedWhere` (#916), reused here, does.
|
|
1623
|
+
const config = blogConfig()
|
|
1624
|
+
|
|
1625
|
+
await expect(
|
|
1626
|
+
buildAccessScopedInclude(
|
|
1627
|
+
{ posts: { where: { comments: { some: { secretNote: { equals: 'x' } } } } } },
|
|
1628
|
+
config.lists.Author.fields,
|
|
1629
|
+
{ session: null, context: makeContext() },
|
|
1630
|
+
config,
|
|
1631
|
+
'Author',
|
|
1632
|
+
),
|
|
1633
|
+
).rejects.toThrow(/Cannot query "Comment" — "secretNote"/)
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
it("folds the deeper related list's own query access into a relation quantifier nested inside the entry's own where", async () => {
|
|
1637
|
+
const config = blogConfig()
|
|
1638
|
+
config.lists.Comment.access = {
|
|
1639
|
+
operation: { query: () => ({ approved: { equals: true } }) },
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const { include } = await buildAccessScopedInclude(
|
|
1643
|
+
{ posts: { where: { comments: { some: { body: { contains: 'hi' } } } } } },
|
|
1644
|
+
config.lists.Author.fields,
|
|
1645
|
+
{ session: null, context: makeContext() },
|
|
1646
|
+
config,
|
|
1647
|
+
'Author',
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
expect(include).toEqual({
|
|
1651
|
+
posts: {
|
|
1652
|
+
where: {
|
|
1653
|
+
comments: {
|
|
1654
|
+
some: { AND: [{ approved: { equals: true } }, { body: { contains: 'hi' } }] },
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
},
|
|
1658
|
+
})
|
|
1659
|
+
})
|
|
1660
|
+
|
|
1661
|
+
it("throws when the deeper related list denies query access outright, matching #916's loud failure", async () => {
|
|
1662
|
+
const config = blogConfig()
|
|
1663
|
+
config.lists.Comment.access = { operation: { query: () => false } }
|
|
1664
|
+
|
|
1665
|
+
await expect(
|
|
1666
|
+
buildAccessScopedInclude(
|
|
1667
|
+
{ posts: { where: { comments: { some: { body: { contains: 'hi' } } } } } },
|
|
1668
|
+
config.lists.Author.fields,
|
|
1669
|
+
{ session: null, context: makeContext() },
|
|
1670
|
+
config,
|
|
1671
|
+
'Author',
|
|
1672
|
+
),
|
|
1673
|
+
).rejects.toThrow(RelationFilterAccessDeniedError)
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
it('recurses through a further nested include, at every level the walk reaches', async () => {
|
|
1677
|
+
const config = blogConfig()
|
|
1678
|
+
|
|
1679
|
+
await expect(
|
|
1680
|
+
buildAccessScopedInclude(
|
|
1681
|
+
{ posts: { include: { comments: { where: { secretNote: { equals: 'x' } } } } } },
|
|
1682
|
+
config.lists.Author.fields,
|
|
1683
|
+
{ session: null, context: makeContext() },
|
|
1684
|
+
config,
|
|
1685
|
+
'Author',
|
|
1686
|
+
),
|
|
1687
|
+
).rejects.toThrow(/Cannot query "Comment" — "secretNote"/)
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
it('accepts a foreign-key scalar a to-one relationship implies, matching the top-level resolver', async () => {
|
|
1691
|
+
const config = blogConfig()
|
|
1692
|
+
|
|
1693
|
+
await expect(
|
|
1694
|
+
buildAccessScopedInclude(
|
|
1695
|
+
{ posts: { where: { authorId: { equals: 'a1' } } } },
|
|
1696
|
+
config.lists.Author.fields,
|
|
1697
|
+
{ session: null, context: makeContext() },
|
|
1698
|
+
config,
|
|
1699
|
+
'Author',
|
|
1700
|
+
),
|
|
1701
|
+
).resolves.not.toThrow()
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1704
|
+
it('leaves a legitimate nested where over readable, declared fields unchanged, still AND-combined with the access filter', async () => {
|
|
1705
|
+
const config = blogConfig()
|
|
1706
|
+
config.lists.Post.access = { operation: { query: () => ({ published: { equals: true } }) } }
|
|
1707
|
+
|
|
1708
|
+
const { include } = await buildAccessScopedInclude(
|
|
1709
|
+
{ posts: { where: { title: { contains: 'hello' } } } },
|
|
1710
|
+
config.lists.Author.fields,
|
|
1711
|
+
{ session: null, context: makeContext() },
|
|
1712
|
+
config,
|
|
1713
|
+
'Author',
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
expect(include).toEqual({
|
|
1717
|
+
posts: {
|
|
1718
|
+
where: { AND: [{ published: { equals: true } }, { title: { contains: 'hello' } }] },
|
|
1719
|
+
},
|
|
1720
|
+
})
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
it('equivalence: the same read-denied predicate throws whether it names the current list, a relation quantifier, or an include-nested where', async () => {
|
|
1724
|
+
// Mirrors the triage comment's three-position table for #1092 — the
|
|
1725
|
+
// include-nested position (the one gap) now matches the other two.
|
|
1726
|
+
const config = blogConfig()
|
|
1727
|
+
|
|
1728
|
+
await expect(
|
|
1729
|
+
buildAccessScopedInclude(
|
|
1730
|
+
{ posts: { where: { secret: { equals: 'x' } } } },
|
|
1731
|
+
config.lists.Author.fields,
|
|
1732
|
+
{ session: null, context: makeContext() },
|
|
1733
|
+
config,
|
|
1734
|
+
'Author',
|
|
1735
|
+
),
|
|
1736
|
+
).rejects.toThrow(ValidationError)
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
describe('synthetic back-relation nested in a where (#1082 interaction)', () => {
|
|
1740
|
+
function syntheticNestedConfig(): OpenSaasConfig {
|
|
1741
|
+
const config = {
|
|
1742
|
+
db: { provider: 'sqlite' },
|
|
1743
|
+
lists: {
|
|
1744
|
+
Company: {
|
|
1745
|
+
fields: {
|
|
1746
|
+
name: { type: 'text' } as FieldConfig,
|
|
1747
|
+
terms: rel('Term', true),
|
|
1748
|
+
},
|
|
1749
|
+
access: { operation: { query: () => true } },
|
|
1750
|
+
},
|
|
1751
|
+
Term: {
|
|
1752
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
1753
|
+
access: { operation: { query: () => true } },
|
|
1754
|
+
},
|
|
1755
|
+
Bill: {
|
|
1756
|
+
fields: {
|
|
1757
|
+
amount: { type: 'integer' } as FieldConfig,
|
|
1758
|
+
term: rel('Term'), // list-only ref — synthesizes `from_Bill_term` on Term
|
|
1759
|
+
},
|
|
1760
|
+
access: { operation: { query: () => true } },
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1764
|
+
} as any as OpenSaasConfig
|
|
1765
|
+
return config
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
it('does not throw for a nested predicate naming a synthetic back-relation', async () => {
|
|
1769
|
+
const config = syntheticNestedConfig()
|
|
1770
|
+
|
|
1771
|
+
await expect(
|
|
1772
|
+
buildAccessScopedInclude(
|
|
1773
|
+
{ terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
|
|
1774
|
+
config.lists.Company.fields,
|
|
1775
|
+
{ session: null, context: makeContext() },
|
|
1776
|
+
config,
|
|
1777
|
+
'Company',
|
|
1778
|
+
),
|
|
1779
|
+
).resolves.not.toThrow()
|
|
1780
|
+
})
|
|
1781
|
+
|
|
1782
|
+
it("recurses into the synthetic's own SOURCE list — an invalid key nested under it still throws, naming that list", async () => {
|
|
1783
|
+
const config = syntheticNestedConfig()
|
|
1784
|
+
|
|
1785
|
+
await expect(
|
|
1786
|
+
buildAccessScopedInclude(
|
|
1787
|
+
{ terms: { where: { from_Bill_term: { some: { bogusField: { equals: 'x' } } } } } },
|
|
1788
|
+
config.lists.Company.fields,
|
|
1789
|
+
{ session: null, context: makeContext() },
|
|
1790
|
+
config,
|
|
1791
|
+
'Company',
|
|
1792
|
+
),
|
|
1793
|
+
).rejects.toThrow(/Cannot query "Bill" — "bogusField"/)
|
|
1794
|
+
})
|
|
1795
|
+
|
|
1796
|
+
it("folds the synthetic's own SOURCE list's query access into the nested quantifier clause", async () => {
|
|
1797
|
+
// Regression coverage for a Codex review finding on #1108: the deeper
|
|
1798
|
+
// #916-style scoping originally only recognised a DECLARED
|
|
1799
|
+
// relationship — a synthetic key's nested quantifier passed through
|
|
1800
|
+
// with neither the source list's `query` access folded in nor its
|
|
1801
|
+
// fields' read access checked, even though the shallow #912/#915
|
|
1802
|
+
// checks (validateQueryKeys/validateQueryFieldReadAccess) already
|
|
1803
|
+
// correctly tolerated and resolved the synthetic key itself.
|
|
1804
|
+
const config = syntheticNestedConfig()
|
|
1805
|
+
config.lists.Bill.access = { operation: { query: () => ({ paid: { equals: true } }) } }
|
|
1806
|
+
|
|
1807
|
+
const { include } = await buildAccessScopedInclude(
|
|
1808
|
+
{ terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
|
|
1809
|
+
config.lists.Company.fields,
|
|
1810
|
+
{ session: null, context: makeContext() },
|
|
1811
|
+
config,
|
|
1812
|
+
'Company',
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
expect(include).toEqual({
|
|
1816
|
+
terms: {
|
|
1817
|
+
where: {
|
|
1818
|
+
from_Bill_term: {
|
|
1819
|
+
some: { AND: [{ paid: { equals: true } }, { amount: { gt: 5 } }] },
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
},
|
|
1823
|
+
})
|
|
1824
|
+
})
|
|
1825
|
+
|
|
1826
|
+
it('throws when a field nested under a synthetic quantifier is denied by field-level read access', async () => {
|
|
1827
|
+
const config = syntheticNestedConfig()
|
|
1828
|
+
;(config.lists.Bill.fields as Record<string, FieldConfig>).secretAmount = {
|
|
1829
|
+
type: 'integer',
|
|
1830
|
+
access: { read: () => false },
|
|
1831
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
|
|
1832
|
+
} as any as FieldConfig
|
|
1833
|
+
|
|
1834
|
+
await expect(
|
|
1835
|
+
buildAccessScopedInclude(
|
|
1836
|
+
{ terms: { where: { from_Bill_term: { some: { secretAmount: { gt: 5 } } } } } },
|
|
1837
|
+
config.lists.Company.fields,
|
|
1838
|
+
{ session: null, context: makeContext() },
|
|
1839
|
+
config,
|
|
1840
|
+
'Company',
|
|
1841
|
+
),
|
|
1842
|
+
).rejects.toThrow(/"secretAmount" is denied by field-level read access/)
|
|
1843
|
+
})
|
|
1844
|
+
|
|
1845
|
+
it("throws when the synthetic's own SOURCE list denies query access outright, matching #916's loud failure", async () => {
|
|
1846
|
+
const config = syntheticNestedConfig()
|
|
1847
|
+
config.lists.Bill.access = { operation: { query: () => false } }
|
|
1848
|
+
|
|
1849
|
+
await expect(
|
|
1850
|
+
buildAccessScopedInclude(
|
|
1851
|
+
{ terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
|
|
1852
|
+
config.lists.Company.fields,
|
|
1853
|
+
{ session: null, context: makeContext() },
|
|
1854
|
+
config,
|
|
1855
|
+
'Company',
|
|
1856
|
+
),
|
|
1857
|
+
).rejects.toThrow(RelationFilterAccessDeniedError)
|
|
1858
|
+
})
|
|
1859
|
+
})
|
|
1860
|
+
|
|
1861
|
+
describe("a to-one relation's `is: null`/`isNot: null` inside a nested quantifier (Codex review finding on #1108)", () => {
|
|
1862
|
+
// Root -> Parent (to-many, unscoped) -> Owner (to-one, query-scoped by a
|
|
1863
|
+
// filter). Parent's own access is `true` (no filter), so the outer
|
|
1864
|
+
// AND-fold is a no-op and cannot mask what this test is isolating: the
|
|
1865
|
+
// fold that happens INSIDE the quantifier's own value.
|
|
1866
|
+
function nullQuantifierConfig(): OpenSaasConfig {
|
|
1867
|
+
return {
|
|
1868
|
+
db: { provider: 'sqlite' },
|
|
1869
|
+
lists: {
|
|
1870
|
+
Root: {
|
|
1871
|
+
fields: { name: { type: 'text' } as FieldConfig, parent: rel('Parent', true) },
|
|
1872
|
+
access: { operation: { query: () => true } },
|
|
1873
|
+
},
|
|
1874
|
+
Parent: {
|
|
1875
|
+
fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
|
|
1876
|
+
access: { operation: { query: () => true } },
|
|
1877
|
+
},
|
|
1878
|
+
Owner: {
|
|
1879
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
1880
|
+
access: { operation: { query: () => ({ secret: { equals: true } }) } },
|
|
1881
|
+
},
|
|
1882
|
+
},
|
|
1883
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
1884
|
+
} as any as OpenSaasConfig
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
it('preserves `is: null` rather than inverting it into an access-filter match', async () => {
|
|
1888
|
+
// Before this fix, folding the access filter into a quantifier's
|
|
1889
|
+
// value unconditionally turned `is: null` ("has no related row") into
|
|
1890
|
+
// `is: <accessWhere>` ("has a related row matching the filter") —
|
|
1891
|
+
// the opposite of the caller's own predicate.
|
|
1892
|
+
const config = nullQuantifierConfig()
|
|
1893
|
+
|
|
1894
|
+
const { include } = await buildAccessScopedInclude(
|
|
1895
|
+
{ parent: { where: { owner: { is: null } } } },
|
|
1896
|
+
config.lists.Root.fields,
|
|
1897
|
+
{ session: null, context: makeContext() },
|
|
1898
|
+
config,
|
|
1899
|
+
'Root',
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1902
|
+
expect(include).toEqual({ parent: { where: { owner: { is: null } } } })
|
|
1903
|
+
})
|
|
1904
|
+
|
|
1905
|
+
it('preserves `isNot: null` the same way', async () => {
|
|
1906
|
+
const config = nullQuantifierConfig()
|
|
1907
|
+
|
|
1908
|
+
const { include } = await buildAccessScopedInclude(
|
|
1909
|
+
{ parent: { where: { owner: { isNot: null } } } },
|
|
1910
|
+
config.lists.Root.fields,
|
|
1911
|
+
{ session: null, context: makeContext() },
|
|
1912
|
+
config,
|
|
1913
|
+
'Root',
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
expect(include).toEqual({ parent: { where: { owner: { isNot: null } } } })
|
|
1917
|
+
})
|
|
408
1918
|
})
|
|
409
1919
|
})
|