@opensaas/stack-core 0.24.0 → 0.25.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 +223 -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/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- 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 +552 -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/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/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -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/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- 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/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
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)
|
|
@@ -778,17 +778,31 @@ describe('Field Types', () => {
|
|
|
778
778
|
const field = json({ validation: { isRequired: true } })
|
|
779
779
|
const schema = field.getZodSchema('metadata', 'create')
|
|
780
780
|
|
|
781
|
+
// Any present non-null JSON value is accepted, including falsy values
|
|
781
782
|
expect(schema.safeParse({ key: 'value' }).success).toBe(true)
|
|
782
|
-
|
|
783
|
-
expect(schema.safeParse(
|
|
783
|
+
expect(schema.safeParse([1, 2, 3]).success).toBe(true)
|
|
784
|
+
expect(schema.safeParse(0).success).toBe(true)
|
|
785
|
+
expect(schema.safeParse('').success).toBe(true)
|
|
786
|
+
expect(schema.safeParse(false).success).toBe(true)
|
|
787
|
+
// A required json field must reject undefined on create (issue #597)
|
|
788
|
+
expect(schema.safeParse(undefined).success).toBe(false)
|
|
789
|
+
// A required json field means non-null: reject a present null (issue #604)
|
|
790
|
+
expect(schema.safeParse(null).success).toBe(false)
|
|
784
791
|
})
|
|
785
792
|
|
|
786
|
-
test('allows undefined for required field in update mode', () => {
|
|
793
|
+
test('allows undefined but rejects null for required field in update mode', () => {
|
|
787
794
|
const field = json({ validation: { isRequired: true } })
|
|
788
795
|
const schema = field.getZodSchema('metadata', 'update')
|
|
789
796
|
|
|
790
797
|
expect(schema.safeParse({ key: 'value' }).success).toBe(true)
|
|
798
|
+
// Omitted/undefined still passes on update (issue #570)
|
|
791
799
|
expect(schema.safeParse(undefined).success).toBe(true)
|
|
800
|
+
// Present non-null falsy values pass
|
|
801
|
+
expect(schema.safeParse(0).success).toBe(true)
|
|
802
|
+
expect(schema.safeParse('').success).toBe(true)
|
|
803
|
+
expect(schema.safeParse(false).success).toBe(true)
|
|
804
|
+
// A present null is rejected — required json means non-null (issue #604)
|
|
805
|
+
expect(schema.safeParse(null).success).toBe(false)
|
|
792
806
|
})
|
|
793
807
|
})
|
|
794
808
|
|