@opensaas/stack-core 0.37.0 → 0.38.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 +68 -0
- package/CLAUDE.md +21 -3
- package/dist/access/access-filter.d.ts +30 -118
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +70 -206
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +67 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +12 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +16 -12
- package/dist/access/errors.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 +91 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +38 -27
- package/dist/context/index.js.map +1 -1
- package/dist/query/index.d.ts +29 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +27 -0
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +84 -267
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +122 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/errors.ts +16 -12
- package/src/access/field-visibility.ts +99 -14
- package/src/access/index.ts +1 -7
- package/src/access/relationship-count.ts +1 -1
- package/src/context/index.ts +52 -33
- package/src/query/index.ts +53 -0
- package/src/query/relationship-options.ts +1 -1
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +27 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
buildIncludeWithAccessControl,
|
|
4
|
-
mergeIncludeWithAccessControl,
|
|
5
|
-
toPrismaInclude,
|
|
6
|
-
} from './access-filter.js'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { buildAccessScopedInclude } from './access-filter.js'
|
|
7
3
|
import { AccessScopeDepthExceededError } from './errors.js'
|
|
8
4
|
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
9
5
|
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
10
6
|
import type { AccessContext } from './types.js'
|
|
11
7
|
|
|
12
8
|
/**
|
|
13
|
-
* Regression coverage for the
|
|
9
|
+
* Regression coverage for `buildAccessScopedInclude`, the caller-directed
|
|
10
|
+
* access-scoping walk introduced by ADR-0026. It replaces the old two-step
|
|
11
|
+
* "auto-walk every relationship of the list, then reconcile against whatever
|
|
12
|
+
* the caller asked for" pipeline (`buildIncludeWithAccessControl` +
|
|
13
|
+
* `mergeIncludeWithAccessControl`): the walk now recurses ONLY into the
|
|
14
|
+
* branches a request (`requestedInclude`) itself names, and never evaluates
|
|
15
|
+
* `query` access on a relation nobody asked for.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* The scenarios below carry forward the guarantees the old two-function
|
|
18
|
+
* pipeline encoded — #566 (caller include augments, never replaces, the
|
|
19
|
+
* access-controlled scope), #752 (a caller `take` survives the merge), #830
|
|
20
|
+
* (fail-closed past the read-include depth cap) — expressed against the new,
|
|
21
|
+
* single-function API. New coverage (the point of ADR-0026 itself): a
|
|
22
|
+
* relation the request doesn't name never has its list's `query` access
|
|
23
|
+
* invoked, and naming a relation fetches its own columns and stops (the "One
|
|
24
|
+
* hop" rule) at every level, not just the root.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
// A relationship field pointing at another list.
|
|
@@ -26,38 +29,11 @@ function rel(ref: string, many = false): FieldConfig {
|
|
|
26
29
|
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
function cyclicConfig(): OpenSaasConfig {
|
|
31
|
-
const allowQuery = () => true
|
|
32
|
-
return {
|
|
33
|
-
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
34
|
-
lists: {
|
|
35
|
-
A: {
|
|
36
|
-
fields: { name: { type: 'text' } as FieldConfig, b: rel('B.a') },
|
|
37
|
-
access: { operation: { query: allowQuery } },
|
|
38
|
-
},
|
|
39
|
-
B: {
|
|
40
|
-
fields: { name: { type: 'text' } as FieldConfig, c: rel('C.b') },
|
|
41
|
-
access: { operation: { query: allowQuery } },
|
|
42
|
-
},
|
|
43
|
-
C: {
|
|
44
|
-
fields: { name: { type: 'text' } as FieldConfig, a: rel('A.c') },
|
|
45
|
-
access: { operation: { query: allowQuery } },
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
49
|
-
} as any
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function makeContext(resolveOutputDepth = 0): AccessContext {
|
|
53
|
-
const chain = Array.from({ length: resolveOutputDepth }, (_, i) => ({
|
|
54
|
-
listKey: 'TestHook',
|
|
55
|
-
fieldKey: `hook${i}`,
|
|
56
|
-
}))
|
|
32
|
+
function makeContext(): AccessContext {
|
|
57
33
|
return {
|
|
58
34
|
session: null,
|
|
59
35
|
_isSudo: false,
|
|
60
|
-
_resolveOutputChain:
|
|
36
|
+
_resolveOutputChain: [],
|
|
61
37
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
62
38
|
} as any
|
|
63
39
|
}
|
|
@@ -81,10 +57,16 @@ function includeDepth(include: unknown): number {
|
|
|
81
57
|
// A straight-line chain of `count` lists, each with a scalar field and a
|
|
82
58
|
// single relationship to the next list: L0 → L1 → … → L(count-1). Every list
|
|
83
59
|
// past the root is query-scoped with a filter unique to it, so a test can
|
|
84
|
-
// assert the
|
|
85
|
-
// (not just that it happens not to throw).
|
|
86
|
-
|
|
60
|
+
// assert the scoped tree actually carries the right `where` at a given hop
|
|
61
|
+
// (not just that it happens not to throw). `access.operation.query` is a
|
|
62
|
+
// `vi.fn()` on every list so tests can additionally assert which lists' access
|
|
63
|
+
// functions were (or were not) invoked.
|
|
64
|
+
function chainConfig(count: number): {
|
|
65
|
+
config: OpenSaasConfig
|
|
66
|
+
queryFns: Record<string, ReturnType<typeof vi.fn>>
|
|
67
|
+
} {
|
|
87
68
|
const lists: Record<string, { fields: Record<string, FieldConfig>; access: unknown }> = {}
|
|
69
|
+
const queryFns: Record<string, ReturnType<typeof vi.fn>> = {}
|
|
88
70
|
for (let i = 0; i < count; i++) {
|
|
89
71
|
const listName = `L${i}`
|
|
90
72
|
const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
|
|
@@ -95,22 +77,24 @@ function chainConfig(count: number): OpenSaasConfig {
|
|
|
95
77
|
fields.prev = rel(`L${i - 1}.next`)
|
|
96
78
|
}
|
|
97
79
|
const filter = { ownerId: { equals: listName } }
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
80
|
+
const queryFn = vi.fn(i === 0 ? () => true : () => filter)
|
|
81
|
+
queryFns[listName] = queryFn
|
|
82
|
+
lists[listName] = { fields, access: { operation: { query: queryFn } } }
|
|
102
83
|
}
|
|
103
84
|
return {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
85
|
+
config: {
|
|
86
|
+
db: { provider: 'sqlite', url: 'file:./dev.db' },
|
|
87
|
+
lists,
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
89
|
+
} as any,
|
|
90
|
+
queryFns,
|
|
91
|
+
}
|
|
108
92
|
}
|
|
109
93
|
|
|
110
|
-
// A
|
|
94
|
+
// A requested `include` value selecting `next` `hops` more times beyond the
|
|
111
95
|
// point at which this value is attached, ending in a bare `true` leaf. E.g.
|
|
112
|
-
// `nestedInclude(0) === true` (stop here); `{ next: nestedInclude(2) }`
|
|
113
|
-
// top level names 3 hops total (the outer `next` plus 2 more).
|
|
96
|
+
// `nestedInclude(0) === true` (stop here); `{ include: { next: nestedInclude(2) } }`
|
|
97
|
+
// at the top level names 3 hops total (the outer `next` plus 2 more).
|
|
114
98
|
function nestedInclude(hops: number): Record<string, unknown> {
|
|
115
99
|
if (hops <= 0) return true as unknown as Record<string, unknown>
|
|
116
100
|
return { include: { next: nestedInclude(hops - 1) } }
|
|
@@ -118,8 +102,8 @@ function nestedInclude(hops: number): Record<string, unknown> {
|
|
|
118
102
|
|
|
119
103
|
// Read the `where` at the end of a chain of nested `next` includes (used to
|
|
120
104
|
// assert row-scoping survived down to a specific hop).
|
|
121
|
-
function whereAtHop(
|
|
122
|
-
let current: unknown =
|
|
105
|
+
function whereAtHop(scoped: Record<string, unknown>, hops: number): unknown {
|
|
106
|
+
let current: unknown = scoped
|
|
123
107
|
for (let i = 0; i < hops; i++) {
|
|
124
108
|
const entry = (current as { next?: unknown })?.next
|
|
125
109
|
if (i === hops - 1) return (entry as { where?: unknown })?.where
|
|
@@ -128,98 +112,127 @@ function whereAtHop(merged: Record<string, unknown>, hops: number): unknown {
|
|
|
128
112
|
return undefined
|
|
129
113
|
}
|
|
130
114
|
|
|
131
|
-
describe('
|
|
132
|
-
it('
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
['A'],
|
|
140
|
-
)
|
|
141
|
-
const include = toPrismaInclude(result)
|
|
142
|
-
|
|
143
|
-
// A → B → C, then C.a closes the cycle back to A → flat (no further nesting).
|
|
144
|
-
expect(include).toEqual({
|
|
145
|
-
b: { include: { c: { include: { a: true } } } },
|
|
146
|
-
})
|
|
147
|
-
// Three distinct lists → depth 3, not the old MAX_DEPTH=5 (which on a cycle
|
|
148
|
-
// could recurse A→B→C→A→B).
|
|
149
|
-
expect(includeDepth(include)).toBe(3)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('flattens a self-referential relationship to a single level', async () => {
|
|
153
|
-
const allowQuery = () => true
|
|
115
|
+
describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
116
|
+
it('does not invoke query access on a relation the request never named', async () => {
|
|
117
|
+
// A → B, A → C (siblings). Requesting only `b` must never touch C's
|
|
118
|
+
// access function — the core guarantee #852 introduces: a request
|
|
119
|
+
// naming one relation no longer walks (and access-checks) every other
|
|
120
|
+
// relationship of the list.
|
|
121
|
+
const queryB = vi.fn(() => true)
|
|
122
|
+
const queryC = vi.fn(() => true)
|
|
154
123
|
const config = {
|
|
155
124
|
db: { provider: 'sqlite' },
|
|
156
125
|
lists: {
|
|
157
|
-
|
|
158
|
-
fields: {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
},
|
|
163
|
-
access: { operation: { query:
|
|
126
|
+
A: {
|
|
127
|
+
fields: { b: rel('B.a'), c: rel('C.a') },
|
|
128
|
+
access: { operation: { query: () => true } },
|
|
129
|
+
},
|
|
130
|
+
B: {
|
|
131
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
132
|
+
access: { operation: { query: queryB } },
|
|
133
|
+
},
|
|
134
|
+
C: {
|
|
135
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
136
|
+
access: { operation: { query: queryC } },
|
|
164
137
|
},
|
|
165
138
|
},
|
|
166
139
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
167
140
|
} as any as OpenSaasConfig
|
|
168
141
|
|
|
169
|
-
const
|
|
170
|
-
|
|
142
|
+
const include = await buildAccessScopedInclude(
|
|
143
|
+
{ b: true },
|
|
144
|
+
config.lists.A.fields,
|
|
145
|
+
{ session: null, context: makeContext() },
|
|
146
|
+
config,
|
|
147
|
+
'A',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
expect(include).toEqual({ b: true })
|
|
151
|
+
expect(queryB).toHaveBeenCalledTimes(1)
|
|
152
|
+
expect(queryC).not.toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("fetches a named relation's own columns and stops — does not auto-expand its subtree (One hop)", async () => {
|
|
156
|
+
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
157
|
+
|
|
158
|
+
const include = await buildAccessScopedInclude(
|
|
159
|
+
{ next: true },
|
|
160
|
+
config.lists.L0.fields,
|
|
171
161
|
{ session: null, context: makeContext() },
|
|
172
162
|
config,
|
|
173
|
-
|
|
174
|
-
['Category'],
|
|
163
|
+
'L0',
|
|
175
164
|
)
|
|
176
|
-
const include = toPrismaInclude(result)
|
|
177
165
|
|
|
178
|
-
//
|
|
179
|
-
|
|
166
|
+
// L1 is fetched and where-scoped; L2/L3 are never reached because the
|
|
167
|
+
// request named `next` bare, with no nested `include` beneath it.
|
|
168
|
+
expect(include).toEqual({ next: { where: { ownerId: { equals: 'L1' } } } })
|
|
180
169
|
expect(includeDepth(include)).toBe(1)
|
|
170
|
+
expect(queryFns.L1).toHaveBeenCalledTimes(1)
|
|
171
|
+
expect(queryFns.L2).not.toHaveBeenCalled()
|
|
172
|
+
expect(queryFns.L3).not.toHaveBeenCalled()
|
|
181
173
|
})
|
|
182
|
-
})
|
|
183
174
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
175
|
+
it('scopes a nested path at every level the request names it', async () => {
|
|
176
|
+
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
177
|
+
|
|
178
|
+
const include = await buildAccessScopedInclude(
|
|
179
|
+
{ next: { include: { next: { include: { next: true } } } } },
|
|
180
|
+
config.lists.L0.fields,
|
|
189
181
|
{ session: null, context: makeContext() },
|
|
190
182
|
config,
|
|
191
|
-
|
|
192
|
-
['A'],
|
|
183
|
+
'L0',
|
|
193
184
|
)
|
|
194
185
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
186
|
+
expect(include).toEqual({
|
|
187
|
+
next: {
|
|
188
|
+
where: { ownerId: { equals: 'L1' } },
|
|
189
|
+
include: {
|
|
190
|
+
next: {
|
|
191
|
+
where: { ownerId: { equals: 'L2' } },
|
|
192
|
+
include: { next: { where: { ownerId: { equals: 'L3' } } } },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
expect(queryFns.L1).toHaveBeenCalledTimes(1)
|
|
198
|
+
expect(queryFns.L2).toHaveBeenCalledTimes(1)
|
|
199
|
+
expect(queryFns.L3).toHaveBeenCalledTimes(1)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('scopes a full cyclic path exactly as requested, one hop stopping the cycle', async () => {
|
|
203
|
+
// A → B → C → A. The caller explicitly names the whole cyclic path; the
|
|
204
|
+
// walk simply follows the finite literal it was given (no cycle guard
|
|
205
|
+
// needed here — that only matters for the declared-dependency fold, see
|
|
206
|
+
// declared-dependencies.ts).
|
|
207
|
+
const allowQuery = () => true
|
|
208
|
+
const config = {
|
|
209
|
+
db: { provider: 'sqlite' },
|
|
210
|
+
lists: {
|
|
211
|
+
A: { fields: { b: rel('B.a') }, access: { operation: { query: allowQuery } } },
|
|
212
|
+
B: { fields: { c: rel('C.b') }, access: { operation: { query: allowQuery } } },
|
|
213
|
+
C: { fields: { a: rel('A.c') }, access: { operation: { query: allowQuery } } },
|
|
214
|
+
},
|
|
215
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
216
|
+
} as any as OpenSaasConfig
|
|
217
|
+
|
|
218
|
+
const include = await buildAccessScopedInclude(
|
|
219
|
+
{ b: { include: { c: { include: { a: true } } } } },
|
|
201
220
|
config.lists.A.fields,
|
|
221
|
+
{ session: null, context: makeContext() },
|
|
202
222
|
config,
|
|
203
223
|
'A',
|
|
204
224
|
)
|
|
205
225
|
|
|
206
|
-
expect(
|
|
207
|
-
b: { include: { c: { include: { a: true } } } },
|
|
208
|
-
})
|
|
209
|
-
// Still bounded to the acyclic path length.
|
|
210
|
-
expect(includeDepth(merged)).toBeLessThanOrEqual(3)
|
|
226
|
+
expect(include).toEqual({ b: { include: { c: { include: { a: true } } } } })
|
|
211
227
|
})
|
|
212
228
|
})
|
|
213
229
|
|
|
214
230
|
/**
|
|
215
|
-
* A caller-supplied `take` on a to-many relation include must survive
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* per-relation access filter — powering the item view's bounded relationship
|
|
219
|
-
* tables without dropping row-level access.
|
|
231
|
+
* A caller-supplied `take` on a to-many relation include must survive
|
|
232
|
+
* scoping (issue #752). It only narrows the fetched rows and can never widen
|
|
233
|
+
* past the access `where`, so it rides on top of the access filter unchanged.
|
|
220
234
|
*/
|
|
221
|
-
describe('
|
|
222
|
-
// A one-list config whose `posts` to-many is query-scoped by an access filter.
|
|
235
|
+
describe('buildAccessScopedInclude — caller take on a to-many relation (issue #752)', () => {
|
|
223
236
|
function scopedConfig(): OpenSaasConfig {
|
|
224
237
|
return {
|
|
225
238
|
db: { provider: 'sqlite' },
|
|
@@ -230,7 +243,6 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
|
|
|
230
243
|
},
|
|
231
244
|
Post: {
|
|
232
245
|
fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
|
|
233
|
-
// Scoped read access → the merge must fold this into the relation include.
|
|
234
246
|
access: { operation: { query: () => ({ published: { equals: true } }) } },
|
|
235
247
|
},
|
|
236
248
|
},
|
|
@@ -240,28 +252,21 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
|
|
|
240
252
|
|
|
241
253
|
it('preserves the take and AND-combines it with the access where', async () => {
|
|
242
254
|
const config = scopedConfig()
|
|
243
|
-
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
244
|
-
config.lists.User.fields,
|
|
245
|
-
{ session: null, context: makeContext() },
|
|
246
|
-
config,
|
|
247
|
-
0,
|
|
248
|
-
['User'],
|
|
249
|
-
)
|
|
250
255
|
|
|
251
|
-
const
|
|
256
|
+
const include = await buildAccessScopedInclude(
|
|
252
257
|
{ posts: { take: 10 } },
|
|
253
|
-
accessControlledInclude,
|
|
254
258
|
config.lists.User.fields,
|
|
259
|
+
{ session: null, context: makeContext() },
|
|
255
260
|
config,
|
|
256
261
|
'User',
|
|
257
262
|
)
|
|
258
263
|
|
|
259
264
|
// The bound rides on top of the access filter — neither is dropped. The
|
|
260
|
-
//
|
|
261
|
-
|
|
265
|
+
// Post→author back-relation is NOT auto-nested (One hop, ADR-0026) since
|
|
266
|
+
// the request didn't name it.
|
|
267
|
+
expect(include).toEqual({
|
|
262
268
|
posts: {
|
|
263
269
|
where: { published: { equals: true } },
|
|
264
|
-
include: { author: true },
|
|
265
270
|
take: 10,
|
|
266
271
|
},
|
|
267
272
|
})
|
|
@@ -269,26 +274,18 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
|
|
|
269
274
|
|
|
270
275
|
it('drops a take for a relation whose query access is denied', async () => {
|
|
271
276
|
const config = scopedConfig()
|
|
272
|
-
// Deny Post reads entirely.
|
|
273
277
|
config.lists.Post.access = { operation: { query: () => false } }
|
|
274
|
-
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
275
|
-
config.lists.User.fields,
|
|
276
|
-
{ session: null, context: makeContext() },
|
|
277
|
-
config,
|
|
278
|
-
0,
|
|
279
|
-
['User'],
|
|
280
|
-
)
|
|
281
278
|
|
|
282
|
-
const
|
|
279
|
+
const include = await buildAccessScopedInclude(
|
|
283
280
|
{ posts: { take: 10 } },
|
|
284
|
-
accessControlledInclude,
|
|
285
281
|
config.lists.User.fields,
|
|
282
|
+
{ session: null, context: makeContext() },
|
|
286
283
|
config,
|
|
287
284
|
'User',
|
|
288
285
|
)
|
|
289
286
|
|
|
290
287
|
// A denied relation is dropped wholesale — the take cannot resurrect it.
|
|
291
|
-
expect(
|
|
288
|
+
expect(include).toEqual({})
|
|
292
289
|
})
|
|
293
290
|
})
|
|
294
291
|
|
|
@@ -296,96 +293,97 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
|
|
|
296
293
|
* Regression coverage for issue #830: the read pipeline used to FAIL OPEN past
|
|
297
294
|
* `READ_INCLUDE_MAX_DEPTH` — a caller-supplied `include` nested deeper than the
|
|
298
295
|
* engine could scope was passed through unscoped rather than denied. These
|
|
299
|
-
* tests pin the exact boundary
|
|
300
|
-
* one level past the cap throws, the same
|
|
301
|
-
* works, and
|
|
296
|
+
* tests pin the exact boundary ADR-0022 introduced and ADR-0026 preserves: a
|
|
297
|
+
* request one level past the cap throws, the same request one level
|
|
298
|
+
* shallower still works, and a request that never reaches the cap is
|
|
299
|
+
* unaffected.
|
|
302
300
|
*/
|
|
303
|
-
describe('
|
|
304
|
-
// A relation `hops` hops from the root sits AT the cap boundary (correctly
|
|
305
|
-
// where-scoped, matching the triage report's "F" list); one hop further is
|
|
306
|
-
// the first one the engine cannot scope ("G"). With
|
|
307
|
-
// READ_INCLUDE_MAX_DEPTH = 5 that boundary is hop 5 / hop 6.
|
|
301
|
+
describe('buildAccessScopedInclude — fail-closed at the read-include depth cap (#830)', () => {
|
|
308
302
|
const HOPS_AT_CAP = READ_INCLUDE_MAX_DEPTH
|
|
309
303
|
|
|
310
|
-
it('throws AccessScopeDepthExceededError when the
|
|
311
|
-
// L0 → L1 → … → L6 (one more list than HOPS_AT_CAP + 1) so a caller
|
|
312
|
-
// include can walk `next` one hop past the boundary.
|
|
304
|
+
it('throws AccessScopeDepthExceededError when the request reaches past the cap', async () => {
|
|
313
305
|
const chainLength = HOPS_AT_CAP + 2
|
|
314
|
-
const config = chainConfig(chainLength)
|
|
315
|
-
|
|
316
|
-
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
317
|
-
config.lists.L0.fields,
|
|
318
|
-
{ session: null, context: makeContext() },
|
|
319
|
-
config,
|
|
320
|
-
0,
|
|
321
|
-
['L0'],
|
|
322
|
-
)
|
|
306
|
+
const { config } = chainConfig(chainLength)
|
|
323
307
|
|
|
324
308
|
// Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP) (HOPS_AT_CAP more) = HOPS_AT_CAP + 1 hops.
|
|
325
|
-
const
|
|
309
|
+
const requested = { next: nestedInclude(HOPS_AT_CAP) }
|
|
326
310
|
|
|
327
|
-
expect(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
accessControlledInclude,
|
|
311
|
+
await expect(
|
|
312
|
+
buildAccessScopedInclude(
|
|
313
|
+
requested,
|
|
331
314
|
config.lists.L0.fields,
|
|
315
|
+
{ session: null, context: makeContext() },
|
|
332
316
|
config,
|
|
333
317
|
'L0',
|
|
334
318
|
),
|
|
335
|
-
).toThrow(AccessScopeDepthExceededError)
|
|
319
|
+
).rejects.toThrow(AccessScopeDepthExceededError)
|
|
336
320
|
})
|
|
337
321
|
|
|
338
|
-
it('
|
|
339
|
-
const chainLength = HOPS_AT_CAP +
|
|
340
|
-
const config = chainConfig(chainLength)
|
|
322
|
+
it('describes a cost refusal, not an inability to scope', async () => {
|
|
323
|
+
const chainLength = HOPS_AT_CAP + 2
|
|
324
|
+
const { config } = chainConfig(chainLength)
|
|
325
|
+
const requested = { next: nestedInclude(HOPS_AT_CAP) }
|
|
341
326
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
327
|
+
await expect(
|
|
328
|
+
buildAccessScopedInclude(
|
|
329
|
+
requested,
|
|
330
|
+
config.lists.L0.fields,
|
|
331
|
+
{ session: null, context: makeContext() },
|
|
332
|
+
config,
|
|
333
|
+
'L0',
|
|
334
|
+
),
|
|
335
|
+
).rejects.toThrow(/cost limit/)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('still row-scopes a request one level shallower than the cap', async () => {
|
|
339
|
+
const chainLength = HOPS_AT_CAP + 1
|
|
340
|
+
const { config } = chainConfig(chainLength)
|
|
349
341
|
|
|
350
342
|
// Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
|
|
351
|
-
const
|
|
343
|
+
const requested = { next: nestedInclude(HOPS_AT_CAP - 1) }
|
|
352
344
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
callerInclude,
|
|
356
|
-
accessControlledInclude,
|
|
345
|
+
const include = await buildAccessScopedInclude(
|
|
346
|
+
requested,
|
|
357
347
|
config.lists.L0.fields,
|
|
348
|
+
{ session: null, context: makeContext() },
|
|
358
349
|
config,
|
|
359
350
|
'L0',
|
|
360
351
|
)
|
|
361
352
|
|
|
362
|
-
expect(includeDepth(
|
|
363
|
-
|
|
364
|
-
// not just present without a throw.
|
|
365
|
-
expect(whereAtHop(merged, HOPS_AT_CAP)).toEqual({ ownerId: { equals: `L${HOPS_AT_CAP}` } })
|
|
353
|
+
expect(includeDepth(include)).toBe(HOPS_AT_CAP)
|
|
354
|
+
expect(whereAtHop(include, HOPS_AT_CAP)).toEqual({ ownerId: { equals: `L${HOPS_AT_CAP}` } })
|
|
366
355
|
})
|
|
367
356
|
|
|
368
|
-
it('does not throw for
|
|
369
|
-
// No caller include at all — the auto-include just stops at the cap
|
|
370
|
-
// silently. This must never throw; only an EXPLICIT caller selection past
|
|
371
|
-
// the cap is a denial.
|
|
357
|
+
it('does not throw for a request that never reaches the cap', async () => {
|
|
372
358
|
const chainLength = HOPS_AT_CAP + 3
|
|
373
|
-
const config = chainConfig(chainLength)
|
|
359
|
+
const { config } = chainConfig(chainLength)
|
|
374
360
|
|
|
375
|
-
|
|
361
|
+
await expect(
|
|
362
|
+
buildAccessScopedInclude(
|
|
363
|
+
{ next: true },
|
|
364
|
+
config.lists.L0.fields,
|
|
365
|
+
{ session: null, context: makeContext() },
|
|
366
|
+
config,
|
|
367
|
+
'L0',
|
|
368
|
+
),
|
|
369
|
+
).resolves.not.toThrow()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('an empty request never throws, even on a deep schema', async () => {
|
|
373
|
+
const chainLength = HOPS_AT_CAP + 3
|
|
374
|
+
const { config } = chainConfig(chainLength)
|
|
375
|
+
|
|
376
|
+
const include = await buildAccessScopedInclude(
|
|
377
|
+
{},
|
|
376
378
|
config.lists.L0.fields,
|
|
377
379
|
{ session: null, context: makeContext() },
|
|
378
380
|
config,
|
|
379
|
-
|
|
380
|
-
['L0'],
|
|
381
|
+
'L0',
|
|
381
382
|
)
|
|
382
|
-
|
|
383
|
-
expect(() => toPrismaInclude(result)).not.toThrow()
|
|
384
|
-
const include = toPrismaInclude(result)
|
|
385
|
-
expect(includeDepth(include)).toBeLessThanOrEqual(HOPS_AT_CAP)
|
|
383
|
+
expect(include).toEqual({})
|
|
386
384
|
})
|
|
387
385
|
|
|
388
|
-
it('a list with no relationships
|
|
386
|
+
it('a list with no relationships passes an unrelated requested key through unchanged', async () => {
|
|
389
387
|
const config = {
|
|
390
388
|
db: { provider: 'sqlite' },
|
|
391
389
|
lists: {
|
|
@@ -397,83 +395,15 @@ describe('mergeIncludeWithAccessControl — fail-closed at the read-include dept
|
|
|
397
395
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
398
396
|
} as any as OpenSaasConfig
|
|
399
397
|
|
|
400
|
-
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
401
|
-
config.lists.Leaf.fields,
|
|
402
|
-
{ session: null, context: makeContext() },
|
|
403
|
-
config,
|
|
404
|
-
0,
|
|
405
|
-
['Leaf'],
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
expect(accessControlledInclude).toEqual({ kind: 'nothing-to-scope' })
|
|
409
|
-
|
|
410
398
|
// An arbitrary (non-declared-relationship) key passed through unchanged —
|
|
411
399
|
// access control does not govern keys it doesn't recognize as relationships.
|
|
412
|
-
const
|
|
400
|
+
const include = await buildAccessScopedInclude(
|
|
413
401
|
{ someUnrelatedKey: true },
|
|
414
|
-
accessControlledInclude,
|
|
415
402
|
config.lists.Leaf.fields,
|
|
403
|
+
{ session: null, context: makeContext() },
|
|
416
404
|
config,
|
|
417
405
|
'Leaf',
|
|
418
406
|
)
|
|
419
|
-
expect(
|
|
420
|
-
})
|
|
421
|
-
})
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Regression coverage for the resolveOutput/virtual-field trigger of #830: a
|
|
425
|
-
* read issued from inside a resolveOutput hook used to lose relation row
|
|
426
|
-
* scoping ENTIRELY (whole-object passthrough) rather than scoping the
|
|
427
|
-
* immediate relation and simply not auto-expanding further. The loop guard
|
|
428
|
-
* that motivated the original passthrough (hooks making DB queries that
|
|
429
|
-
* include relationships back to the same entity) must still hold.
|
|
430
|
-
*/
|
|
431
|
-
describe('buildIncludeWithAccessControl — inside a resolveOutput context', () => {
|
|
432
|
-
it('scopes the immediate relation with its access where but does not auto-expand nested relations', async () => {
|
|
433
|
-
const config = chainConfig(4) // L0 → L1 → L2 → L3
|
|
434
|
-
config.lists.L1.access = { operation: { query: () => ({ tenantId: { equals: 'mine' } }) } }
|
|
435
|
-
|
|
436
|
-
const result = await buildIncludeWithAccessControl(
|
|
437
|
-
config.lists.L0.fields,
|
|
438
|
-
{ session: null, context: makeContext(1) }, // depth > 0 → inside resolveOutput
|
|
439
|
-
config,
|
|
440
|
-
0,
|
|
441
|
-
['L0'],
|
|
442
|
-
)
|
|
443
|
-
|
|
444
|
-
expect(result.kind).toBe('scoped')
|
|
445
|
-
const include = toPrismaInclude(result)
|
|
446
|
-
// L1's own access `where` is applied...
|
|
447
|
-
expect(include).toEqual({ next: { where: { tenantId: { equals: 'mine' } } } })
|
|
448
|
-
// ...but L1's own nested relations (`next` → L2) are NOT auto-included.
|
|
449
|
-
expect(includeDepth(include)).toBe(1)
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
it('terminates on a self-referential relationship instead of looping', async () => {
|
|
453
|
-
const config = {
|
|
454
|
-
db: { provider: 'sqlite' },
|
|
455
|
-
lists: {
|
|
456
|
-
Category: {
|
|
457
|
-
fields: {
|
|
458
|
-
name: { type: 'text' } as FieldConfig,
|
|
459
|
-
parent: rel('Category.children'),
|
|
460
|
-
children: rel('Category.parent', true),
|
|
461
|
-
},
|
|
462
|
-
access: { operation: { query: () => true } },
|
|
463
|
-
},
|
|
464
|
-
},
|
|
465
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
466
|
-
} as any as OpenSaasConfig
|
|
467
|
-
|
|
468
|
-
const result = await buildIncludeWithAccessControl(
|
|
469
|
-
config.lists.Category.fields,
|
|
470
|
-
{ session: null, context: makeContext(1) },
|
|
471
|
-
config,
|
|
472
|
-
0,
|
|
473
|
-
['Category'],
|
|
474
|
-
)
|
|
475
|
-
|
|
476
|
-
const include = toPrismaInclude(result)
|
|
477
|
-
expect(include).toEqual({ parent: true, children: true })
|
|
407
|
+
expect(include).toEqual({ someUnrelatedKey: true })
|
|
478
408
|
})
|
|
479
409
|
})
|