@opensaas/stack-core 0.24.0 → 0.26.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 +263 -0
- package/CLAUDE.md +50 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- 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/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +36 -0
- package/dist/context/apply-defaults.d.ts.map +1 -0
- package/dist/context/apply-defaults.js +70 -0
- package/dist/context/apply-defaults.js.map +1 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +10 -0
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +79 -18
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +194 -39
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +558 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +340 -68
- package/src/context/nested-operations.ts +976 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/index.ts +5 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/context.test.ts +481 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/sudo.test.ts +7 -3
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { applyCreateDefaults } from '../src/context/apply-defaults.js'
|
|
3
|
+
import { text, integer, checkbox, timestamp, virtual, relationship } from '../src/fields/index.js'
|
|
4
|
+
import type { FieldConfig } from '../src/config/types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Direct unit tests for `applyCreateDefaults` (#615). These exercise every guard
|
|
8
|
+
* rail of the real helper — virtual / system / relationship skips, the
|
|
9
|
+
* no-`defaultValue` skip, explicit-value and explicit-`null` preservation, the
|
|
10
|
+
* timestamp `{ kind: 'now' }` sentinel skip, and concrete `Date` injection — so
|
|
11
|
+
* the resolve-then-validate helper is fully covered without mocking its logic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function fields(config: Record<string, FieldConfig>): Record<string, FieldConfig> {
|
|
15
|
+
return config
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('applyCreateDefaults', () => {
|
|
19
|
+
it('injects a declared defaultValue into an omitted field', () => {
|
|
20
|
+
const resolved = applyCreateDefaults(
|
|
21
|
+
{},
|
|
22
|
+
fields({
|
|
23
|
+
label: text({ defaultValue: 'PLACEHOLDER' }),
|
|
24
|
+
count: integer({ defaultValue: 7 }),
|
|
25
|
+
active: checkbox({ defaultValue: true }),
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
expect(resolved).toEqual({ label: 'PLACEHOLDER', count: 7, active: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('leaves a field with NO defaultValue untouched (key stays absent)', () => {
|
|
33
|
+
const resolved = applyCreateDefaults({}, fields({ label: text() }))
|
|
34
|
+
|
|
35
|
+
expect('label' in resolved).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('preserves an explicitly-provided value over the default', () => {
|
|
39
|
+
const resolved = applyCreateDefaults(
|
|
40
|
+
{ count: 99 },
|
|
41
|
+
fields({ count: integer({ defaultValue: 7 }) }),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
expect(resolved.count).toBe(99)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('preserves an explicit null and does not overwrite it with the default', () => {
|
|
48
|
+
const resolved = applyCreateDefaults(
|
|
49
|
+
{ note: null },
|
|
50
|
+
fields({ note: text({ defaultValue: 'DEFAULT_NOTE' }) }),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(resolved.note).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('skips virtual fields (no scalar default to inject)', () => {
|
|
57
|
+
const resolved = applyCreateDefaults(
|
|
58
|
+
{},
|
|
59
|
+
fields({
|
|
60
|
+
// A virtual field cannot carry a stored default; it must be skipped.
|
|
61
|
+
computed: virtual({
|
|
62
|
+
type: 'string',
|
|
63
|
+
hooks: { resolveOutput: () => 'x' },
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
}) as any,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect('computed' in resolved).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('skips system fields (id/createdAt/updatedAt)', () => {
|
|
73
|
+
const resolved = applyCreateDefaults(
|
|
74
|
+
{},
|
|
75
|
+
fields({
|
|
76
|
+
id: text({ defaultValue: 'SHOULD_NOT_INJECT' }),
|
|
77
|
+
createdAt: timestamp({ defaultValue: new Date('2020-01-01T00:00:00Z') }),
|
|
78
|
+
updatedAt: timestamp({ defaultValue: new Date('2020-01-01T00:00:00Z') }),
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(resolved).toEqual({})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('skips relationship fields (they carry connect/create payloads, not literals)', () => {
|
|
86
|
+
const resolved = applyCreateDefaults(
|
|
87
|
+
{},
|
|
88
|
+
fields({
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
owner: relationship({ ref: 'User' }) as any,
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect('owner' in resolved).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("does NOT inject the timestamp { kind: 'now' } sentinel (left for the DB @default(now()))", () => {
|
|
98
|
+
const resolved = applyCreateDefaults(
|
|
99
|
+
{},
|
|
100
|
+
fields({ when: timestamp({ defaultValue: { kind: 'now' } }) }),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect('when' in resolved).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('DOES inject a concrete Date default (a real literal, unlike the now sentinel)', () => {
|
|
107
|
+
const date = new Date('2021-06-15T12:00:00Z')
|
|
108
|
+
const resolved = applyCreateDefaults({}, fields({ when: timestamp({ defaultValue: date }) }))
|
|
109
|
+
|
|
110
|
+
expect(resolved.when).toBe(date)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns the same resolvedData object it was given (mutation contract)', () => {
|
|
114
|
+
const input: Record<string, unknown> = {}
|
|
115
|
+
const result = applyCreateDefaults(input, fields({ label: text({ defaultValue: 'x' }) }))
|
|
116
|
+
|
|
117
|
+
expect(result).toBe(input)
|
|
118
|
+
})
|
|
119
|
+
})
|
package/tests/context.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { defineFragment } from '../src/query/index.js'
|
|
4
|
+
import { virtual } from '../src/fields/index.js'
|
|
3
5
|
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
4
6
|
|
|
5
7
|
describe('getContext', () => {
|
|
@@ -212,6 +214,131 @@ describe('getContext', () => {
|
|
|
212
214
|
expect(result).toEqual(mockUser)
|
|
213
215
|
})
|
|
214
216
|
|
|
217
|
+
describe('findUnique unique-where enforcement (#567)', () => {
|
|
218
|
+
it('accepts a valid unique where (id) and keeps access + include intact', async () => {
|
|
219
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
220
|
+
mockPrisma.user.findFirst.mockResolvedValue(mockUser)
|
|
221
|
+
|
|
222
|
+
const context = await getContext(config, mockPrisma, null)
|
|
223
|
+
const result = await context.db.user.findUnique({
|
|
224
|
+
where: { id: '1' },
|
|
225
|
+
include: { posts: true },
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Access control still runs and the underlying delegate is invoked with
|
|
229
|
+
// the merged where + include (proving access + include path is intact).
|
|
230
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
where: expect.objectContaining({ id: '1' }),
|
|
233
|
+
include: { posts: true },
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
expect(result).toEqual(mockUser)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('accepts a configured-unique field (email) as the unique where', async () => {
|
|
240
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
241
|
+
mockPrisma.user.findFirst.mockResolvedValue(mockUser)
|
|
242
|
+
|
|
243
|
+
const context = await getContext(config, mockPrisma, null)
|
|
244
|
+
const result = await context.db.user.findUnique({
|
|
245
|
+
where: { email: 'john@example.com' },
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalled()
|
|
249
|
+
expect(result).toEqual(mockUser)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('narrows the result through a query fragment with a unique where', async () => {
|
|
253
|
+
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
254
|
+
mockPrisma.user.findFirst.mockResolvedValue(mockUser)
|
|
255
|
+
|
|
256
|
+
const fragment = defineFragment<{ id: string; name: string; email: string }>()({
|
|
257
|
+
id: true,
|
|
258
|
+
name: true,
|
|
259
|
+
} as const)
|
|
260
|
+
|
|
261
|
+
const context = await getContext(config, mockPrisma, null)
|
|
262
|
+
const result = await context.db.user.findUnique({ where: { id: '1' }, query: fragment })
|
|
263
|
+
|
|
264
|
+
// Fragment narrows the result to only the requested fields (email omitted)
|
|
265
|
+
expect(result).toEqual({ id: '1', name: 'John' })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('THROWS on a non-unique where (caller-shape error, not a silent null)', async () => {
|
|
269
|
+
mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John' })
|
|
270
|
+
|
|
271
|
+
const context = await getContext(config, mockPrisma, null)
|
|
272
|
+
|
|
273
|
+
// `name` is not a unique key — this is misuse and must throw, not return null.
|
|
274
|
+
await expect(context.db.user.findUnique({ where: { name: 'John' } })).rejects.toThrow(
|
|
275
|
+
/requires a unique `where`/,
|
|
276
|
+
)
|
|
277
|
+
// The error guides the caller toward findFirst (the non-unique escape hatch).
|
|
278
|
+
await expect(context.db.user.findUnique({ where: { name: 'John' } })).rejects.toThrow(
|
|
279
|
+
/findFirst/,
|
|
280
|
+
)
|
|
281
|
+
// Guard runs before any DB access.
|
|
282
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('THROWS when a unique key is mixed with extra non-unique keys', async () => {
|
|
286
|
+
const context = await getContext(config, mockPrisma, null)
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
context.db.user.findUnique({ where: { id: '1', name: 'John' } }),
|
|
290
|
+
).rejects.toThrow(/requires a unique `where`/)
|
|
291
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('THROWS on an empty where', async () => {
|
|
295
|
+
const context = await getContext(config, mockPrisma, null)
|
|
296
|
+
|
|
297
|
+
await expect(context.db.user.findUnique({ where: {} })).rejects.toThrow(
|
|
298
|
+
/requires a unique `where`/,
|
|
299
|
+
)
|
|
300
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns null on access denial (silent-failure contract preserved)', async () => {
|
|
304
|
+
const deniedConfig: OpenSaasConfig = {
|
|
305
|
+
...config,
|
|
306
|
+
lists: {
|
|
307
|
+
...config.lists,
|
|
308
|
+
User: {
|
|
309
|
+
...config.lists.User,
|
|
310
|
+
access: {
|
|
311
|
+
operation: {
|
|
312
|
+
query: () => false,
|
|
313
|
+
create: () => true,
|
|
314
|
+
update: () => true,
|
|
315
|
+
delete: () => true,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John' })
|
|
322
|
+
|
|
323
|
+
const context = await getContext(deniedConfig, mockPrisma, null)
|
|
324
|
+
const result = await context.db.user.findUnique({ where: { id: '1' } })
|
|
325
|
+
|
|
326
|
+
// Access denied -> null (not a throw), and the DB is never queried.
|
|
327
|
+
expect(result).toBeNull()
|
|
328
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('returns null when no record matches a valid unique where', async () => {
|
|
332
|
+
mockPrisma.user.findFirst.mockResolvedValue(null)
|
|
333
|
+
|
|
334
|
+
const context = await getContext(config, mockPrisma, null)
|
|
335
|
+
const result = await context.db.user.findUnique({ where: { id: 'missing' } })
|
|
336
|
+
|
|
337
|
+
expect(result).toBeNull()
|
|
338
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalled()
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
215
342
|
it('should delegate findMany to prisma with access control', async () => {
|
|
216
343
|
const mockUsers = [
|
|
217
344
|
{ id: '1', name: 'John' },
|
|
@@ -226,6 +353,360 @@ describe('getContext', () => {
|
|
|
226
353
|
expect(result).toEqual(mockUsers)
|
|
227
354
|
})
|
|
228
355
|
|
|
356
|
+
describe('findFirst', () => {
|
|
357
|
+
it('should return the first matching row', async () => {
|
|
358
|
+
const mockUsers = [
|
|
359
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
360
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
361
|
+
]
|
|
362
|
+
mockPrisma.user.findMany.mockResolvedValue(mockUsers)
|
|
363
|
+
|
|
364
|
+
const context = await getContext(config, mockPrisma, null)
|
|
365
|
+
const result = await context.db.user.findFirst()
|
|
366
|
+
|
|
367
|
+
// Delegates to the access-controlled findMany with take: 1
|
|
368
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }))
|
|
369
|
+
expect(result).toEqual(mockUsers[0])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('should return null (not undefined, not throw) when nothing matches', async () => {
|
|
373
|
+
mockPrisma.user.findMany.mockResolvedValue([])
|
|
374
|
+
|
|
375
|
+
const context = await getContext(config, mockPrisma, null)
|
|
376
|
+
const result = await context.db.user.findFirst({ where: { name: 'Nobody' } })
|
|
377
|
+
|
|
378
|
+
expect(result).toBeNull()
|
|
379
|
+
expect(result).not.toBeUndefined()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('should respect where and orderBy', async () => {
|
|
383
|
+
const mockUser = { id: '2', name: 'Jane', email: 'jane@example.com' }
|
|
384
|
+
mockPrisma.user.findMany.mockResolvedValue([mockUser])
|
|
385
|
+
|
|
386
|
+
const context = await getContext(config, mockPrisma, null)
|
|
387
|
+
const result = await context.db.user.findFirst({
|
|
388
|
+
where: { name: 'Jane' },
|
|
389
|
+
orderBy: { name: 'asc' },
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
|
|
393
|
+
expect.objectContaining({
|
|
394
|
+
where: expect.objectContaining({ name: 'Jane' }),
|
|
395
|
+
orderBy: { name: 'asc' },
|
|
396
|
+
take: 1,
|
|
397
|
+
}),
|
|
398
|
+
)
|
|
399
|
+
expect(result).toEqual(mockUser)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should honour query-access denial the same as findMany (denied -> null)', async () => {
|
|
403
|
+
const deniedConfig: OpenSaasConfig = {
|
|
404
|
+
...config,
|
|
405
|
+
lists: {
|
|
406
|
+
...config.lists,
|
|
407
|
+
User: {
|
|
408
|
+
...config.lists.User,
|
|
409
|
+
access: {
|
|
410
|
+
operation: {
|
|
411
|
+
query: () => false,
|
|
412
|
+
create: () => true,
|
|
413
|
+
update: () => true,
|
|
414
|
+
delete: () => true,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
mockPrisma.user.findMany.mockResolvedValue([
|
|
421
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
422
|
+
])
|
|
423
|
+
|
|
424
|
+
const context = await getContext(deniedConfig, mockPrisma, null)
|
|
425
|
+
const result = await context.db.user.findFirst()
|
|
426
|
+
|
|
427
|
+
// Denied query short-circuits before hitting prisma — exactly like findMany
|
|
428
|
+
expect(result).toBeNull()
|
|
429
|
+
expect(mockPrisma.user.findMany).not.toHaveBeenCalled()
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('should respect a query fragment, narrowing the returned single result', async () => {
|
|
433
|
+
const mockUsers = [
|
|
434
|
+
{ id: '1', name: 'John', email: 'john@example.com' },
|
|
435
|
+
{ id: '2', name: 'Jane', email: 'jane@example.com' },
|
|
436
|
+
]
|
|
437
|
+
mockPrisma.user.findMany.mockResolvedValue(mockUsers)
|
|
438
|
+
|
|
439
|
+
const fragment = defineFragment<{ id: string; name: string; email: string }>()({
|
|
440
|
+
id: true,
|
|
441
|
+
name: true,
|
|
442
|
+
} as const)
|
|
443
|
+
|
|
444
|
+
const context = await getContext(config, mockPrisma, null)
|
|
445
|
+
const result = await context.db.user.findFirst({ query: fragment })
|
|
446
|
+
|
|
447
|
+
// Fragment narrows the result to only the requested fields (email omitted)
|
|
448
|
+
expect(result).toEqual({ id: '1', name: 'John' })
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe('explicit include merges with access control (#566)', () => {
|
|
453
|
+
// Author has many Posts; Post.query access scopes to published posts only.
|
|
454
|
+
// A caller-supplied `include` must NOT bypass that per-relation filter.
|
|
455
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
456
|
+
let relPrisma: any
|
|
457
|
+
let relConfig: OpenSaasConfig
|
|
458
|
+
|
|
459
|
+
beforeEach(() => {
|
|
460
|
+
relPrisma = {
|
|
461
|
+
author: {
|
|
462
|
+
findFirst: vi.fn(),
|
|
463
|
+
findUnique: vi.fn(),
|
|
464
|
+
findMany: vi.fn(),
|
|
465
|
+
},
|
|
466
|
+
post: {
|
|
467
|
+
findFirst: vi.fn(),
|
|
468
|
+
findUnique: vi.fn(),
|
|
469
|
+
findMany: vi.fn(),
|
|
470
|
+
},
|
|
471
|
+
comment: {
|
|
472
|
+
findFirst: vi.fn(),
|
|
473
|
+
findMany: vi.fn(),
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
relConfig = {
|
|
478
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
479
|
+
lists: {
|
|
480
|
+
Author: {
|
|
481
|
+
fields: {
|
|
482
|
+
name: { type: 'text' },
|
|
483
|
+
posts: { type: 'relationship', ref: 'Post.author', many: true },
|
|
484
|
+
},
|
|
485
|
+
access: { operation: { query: () => true } },
|
|
486
|
+
},
|
|
487
|
+
Post: {
|
|
488
|
+
fields: {
|
|
489
|
+
title: { type: 'text' },
|
|
490
|
+
author: { type: 'relationship', ref: 'Author.posts' },
|
|
491
|
+
comments: { type: 'relationship', ref: 'Comment.post', many: true },
|
|
492
|
+
},
|
|
493
|
+
access: {
|
|
494
|
+
// Row filter: only published posts are visible.
|
|
495
|
+
operation: { query: () => ({ status: { equals: 'published' } }) },
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
Comment: {
|
|
499
|
+
fields: {
|
|
500
|
+
body: { type: 'text' },
|
|
501
|
+
post: { type: 'relationship', ref: 'Post.comments' },
|
|
502
|
+
},
|
|
503
|
+
access: {
|
|
504
|
+
operation: { query: () => ({ approved: { equals: true } }) },
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
Secret: {
|
|
508
|
+
fields: {
|
|
509
|
+
value: { type: 'text' },
|
|
510
|
+
},
|
|
511
|
+
// Fully denied list.
|
|
512
|
+
access: { operation: { query: () => false } },
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
// Add a denied relation onto Author for the "drop denied relation" test.
|
|
517
|
+
relConfig.lists.Author.fields.secrets = {
|
|
518
|
+
type: 'relationship',
|
|
519
|
+
ref: 'Secret',
|
|
520
|
+
many: true,
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('findMany: caller include {posts:true} applies the relation access where (not bare true)', async () => {
|
|
525
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
526
|
+
|
|
527
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
528
|
+
await context.db.author.findMany({ include: { posts: true } })
|
|
529
|
+
|
|
530
|
+
// The relation is fetched WITH the Post query-access where (NOT bare true),
|
|
531
|
+
// proving the row-level bypass is closed.
|
|
532
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
533
|
+
expect(call.include.posts).not.toBe(true)
|
|
534
|
+
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('findUnique: caller include {posts:true} applies the relation access where', async () => {
|
|
538
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] })
|
|
539
|
+
|
|
540
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
541
|
+
await context.db.author.findUnique({ where: { id: 'a1' }, include: { posts: true } })
|
|
542
|
+
|
|
543
|
+
const call = relPrisma.author.findFirst.mock.calls[0][0]
|
|
544
|
+
expect(call.where).toEqual(expect.objectContaining({ id: 'a1' }))
|
|
545
|
+
expect(call.include.posts).not.toBe(true)
|
|
546
|
+
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('drops a relation whose query access is false when named in the caller include', async () => {
|
|
550
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
551
|
+
|
|
552
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
553
|
+
await context.db.author.findMany({ include: { secrets: true, posts: true } })
|
|
554
|
+
|
|
555
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
556
|
+
// Denied `secrets` relation is dropped; allowed `posts` keeps its filter.
|
|
557
|
+
expect(call.include.secrets).toBeUndefined()
|
|
558
|
+
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('AND-combines a caller nested where with the relation access where', async () => {
|
|
562
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
563
|
+
|
|
564
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
565
|
+
await context.db.author.findMany({
|
|
566
|
+
include: { posts: { where: { title: { contains: 'hello' } } } },
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
570
|
+
// Both the access where and the caller where are applied via AND.
|
|
571
|
+
expect(call.include.posts.where).toEqual({
|
|
572
|
+
AND: [{ status: { equals: 'published' } }, { title: { contains: 'hello' } }],
|
|
573
|
+
})
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('access-filters nested (2-level) caller includes at every level', async () => {
|
|
577
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
578
|
+
|
|
579
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
580
|
+
await context.db.author.findMany({
|
|
581
|
+
include: { posts: { include: { comments: true } } },
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
585
|
+
// Level 1 (posts) and level 2 (comments) both carry their access where.
|
|
586
|
+
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
587
|
+
expect(call.include.posts.include.comments.where).toEqual({ approved: { equals: true } })
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('sudo with explicit include returns the include unfiltered (behaviour preserved)', async () => {
|
|
591
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
592
|
+
|
|
593
|
+
const context = await getContext(relConfig, relPrisma, null).sudo()
|
|
594
|
+
await context.db.author.findMany({ include: { posts: true, secrets: true } })
|
|
595
|
+
|
|
596
|
+
// Under sudo the caller include is used as-is: no filter, nothing dropped.
|
|
597
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith(
|
|
598
|
+
expect.objectContaining({ include: { posts: true, secrets: true } }),
|
|
599
|
+
)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('query fragment path is unaffected by the merge (fragment include used, unfiltered)', async () => {
|
|
603
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
604
|
+
|
|
605
|
+
const postsFragment = defineFragment<{ id: string; title: string }>()({
|
|
606
|
+
title: true,
|
|
607
|
+
} as const)
|
|
608
|
+
const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
|
|
609
|
+
id: true,
|
|
610
|
+
name: true,
|
|
611
|
+
posts: postsFragment,
|
|
612
|
+
} as const)
|
|
613
|
+
|
|
614
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
615
|
+
await context.db.author.findMany({ query: fragment })
|
|
616
|
+
|
|
617
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
618
|
+
// Fragment-built include is used as-is; the merge helper is NOT applied to
|
|
619
|
+
// the fragment path, so the relation carries no access `where` here. (The
|
|
620
|
+
// fragment posts-selection contains only scalars, so it builds to `true`.)
|
|
621
|
+
expect(call.include).toEqual({ posts: true })
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
// Regression: when buildIncludeWithAccessControl returns `undefined` (a
|
|
625
|
+
// NON-denial outcome), the caller include must PASS THROUGH unchanged
|
|
626
|
+
// rather than every declared relation being silently dropped. The original
|
|
627
|
+
// #566 merge treated `undefined` as "all relations denied" (fail-closed
|
|
628
|
+
// data loss). `undefined` is returned in three non-denial cases:
|
|
629
|
+
// 1. inside a resolveOutput hook / virtual-field context,
|
|
630
|
+
// 2. at MAX_DEPTH,
|
|
631
|
+
// 3. when a list has no relationships.
|
|
632
|
+
describe('passes caller include through when no access include is computed', () => {
|
|
633
|
+
// Build an Author config with a virtual field whose resolveOutput issues a
|
|
634
|
+
// read WITH an explicit include. While that hook runs,
|
|
635
|
+
// _resolveOutputCounter.depth > 0, so buildIncludeWithAccessControl
|
|
636
|
+
// returns undefined for the inner read — exercising the
|
|
637
|
+
// `accessControlledInclude === undefined` passthrough path.
|
|
638
|
+
function configWithResolveOutputProbe(
|
|
639
|
+
callerInclude: Record<string, unknown>,
|
|
640
|
+
capture: (include: unknown) => void,
|
|
641
|
+
): OpenSaasConfig {
|
|
642
|
+
return {
|
|
643
|
+
...relConfig,
|
|
644
|
+
lists: {
|
|
645
|
+
...relConfig.lists,
|
|
646
|
+
Author: {
|
|
647
|
+
...relConfig.lists.Author,
|
|
648
|
+
fields: {
|
|
649
|
+
...relConfig.lists.Author.fields,
|
|
650
|
+
commentSummary: virtual({
|
|
651
|
+
type: 'string',
|
|
652
|
+
hooks: {
|
|
653
|
+
resolveOutput: async ({ context }) => {
|
|
654
|
+
await context.db.comment.findMany({ include: callerInclude })
|
|
655
|
+
capture(relPrisma.comment.findMany.mock.calls[0][0].include)
|
|
656
|
+
return 'summary'
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
}),
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
it('findUnique inside a resolveOutput hook keeps the caller include (not dropped)', async () => {
|
|
667
|
+
let innerIncludeSeen: unknown
|
|
668
|
+
const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
|
|
669
|
+
innerIncludeSeen = include
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
|
|
673
|
+
relPrisma.comment.findMany.mockResolvedValue([{ id: 'c1', body: 'hi', post: null }])
|
|
674
|
+
|
|
675
|
+
const context = await getContext(hookConfig, relPrisma, null)
|
|
676
|
+
await context.db.author.findUnique({ where: { id: 'a1' } })
|
|
677
|
+
|
|
678
|
+
// The caller include `{ post: true }` survives: the declared `post`
|
|
679
|
+
// relation is NOT dropped despite the access include being undefined here.
|
|
680
|
+
expect(innerIncludeSeen).toEqual({ post: true })
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('findMany inside a resolveOutput hook passes a NESTED caller include through whole', async () => {
|
|
684
|
+
let innerIncludeSeen: unknown
|
|
685
|
+
const hookConfig = configWithResolveOutputProbe(
|
|
686
|
+
{ post: { include: { author: true } } },
|
|
687
|
+
(include) => {
|
|
688
|
+
innerIncludeSeen = include
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
693
|
+
relPrisma.comment.findMany.mockResolvedValue([{ id: 'c1', body: 'hi', post: null }])
|
|
694
|
+
|
|
695
|
+
const context = await getContext(hookConfig, relPrisma, null)
|
|
696
|
+
await context.db.author.findMany()
|
|
697
|
+
|
|
698
|
+
// The whole nested caller include passes through untouched (no access
|
|
699
|
+
// include exists to merge against in resolveOutput context).
|
|
700
|
+
expect(innerIncludeSeen).toEqual({ post: { include: { author: true } } })
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
// The MAX_DEPTH and no-relationships cases also make
|
|
704
|
+
// buildIncludeWithAccessControl return undefined; they flow through the
|
|
705
|
+
// identical `accessControlledInclude === undefined` branch verified above,
|
|
706
|
+
// so the resolveOutput probe covers all three non-denial cases.
|
|
707
|
+
})
|
|
708
|
+
})
|
|
709
|
+
|
|
229
710
|
it('should delegate create to prisma with access control and hooks', async () => {
|
|
230
711
|
const mockUser = { id: '1', name: 'John', email: 'john@example.com' }
|
|
231
712
|
mockPrisma.user.create.mockResolvedValue(mockUser)
|