@opensaas/stack-core 0.1.0 → 0.1.2

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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/CLAUDE.md +264 -0
  4. package/LICENSE +21 -0
  5. package/dist/access/engine.d.ts +1 -1
  6. package/dist/access/engine.d.ts.map +1 -1
  7. package/dist/access/engine.js +22 -5
  8. package/dist/access/engine.js.map +1 -1
  9. package/dist/access/index.d.ts +1 -1
  10. package/dist/access/index.d.ts.map +1 -1
  11. package/dist/access/index.js.map +1 -1
  12. package/dist/access/types.d.ts +33 -0
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +2 -1
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js.map +1 -1
  17. package/dist/config/types.d.ts +236 -1
  18. package/dist/config/types.d.ts.map +1 -1
  19. package/dist/context/index.d.ts +4 -2
  20. package/dist/context/index.d.ts.map +1 -1
  21. package/dist/context/index.js +32 -54
  22. package/dist/context/index.js.map +1 -1
  23. package/dist/context/nested-operations.d.ts.map +1 -1
  24. package/dist/context/nested-operations.js +45 -2
  25. package/dist/context/nested-operations.js.map +1 -1
  26. package/dist/fields/index.d.ts +45 -1
  27. package/dist/fields/index.d.ts.map +1 -1
  28. package/dist/fields/index.js +81 -0
  29. package/dist/fields/index.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/access/engine.ts +34 -2
  35. package/src/access/index.ts +1 -0
  36. package/src/access/types.ts +47 -0
  37. package/src/config/index.ts +9 -0
  38. package/src/config/types.ts +246 -0
  39. package/src/context/index.ts +46 -63
  40. package/src/context/nested-operations.ts +70 -2
  41. package/src/fields/index.ts +85 -0
  42. package/src/index.ts +4 -0
  43. package/tests/nested-access-and-hooks.test.ts +903 -0
  44. package/tsconfig.tsbuildinfo +1 -1
  45. package/vitest.config.ts +1 -1
@@ -0,0 +1,903 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import { config, list } from '../src/config/index.js'
4
+ import { text, relationship } from '../src/fields/index.js'
5
+
6
+ /**
7
+ * Mock Prisma Client for testing
8
+ */
9
+ function createMockPrisma() {
10
+ const db = {
11
+ post: {
12
+ findFirst: vi.fn(),
13
+ findMany: vi.fn(),
14
+ findUnique: vi.fn(),
15
+ create: vi.fn(),
16
+ update: vi.fn(),
17
+ delete: vi.fn(),
18
+ count: vi.fn(),
19
+ },
20
+ user: {
21
+ findFirst: vi.fn(),
22
+ findMany: vi.fn(),
23
+ findUnique: vi.fn(),
24
+ create: vi.fn(),
25
+ update: vi.fn(),
26
+ delete: vi.fn(),
27
+ count: vi.fn(),
28
+ },
29
+ comment: {
30
+ findFirst: vi.fn(),
31
+ findMany: vi.fn(),
32
+ findUnique: vi.fn(),
33
+ create: vi.fn(),
34
+ update: vi.fn(),
35
+ delete: vi.fn(),
36
+ count: vi.fn(),
37
+ },
38
+ }
39
+
40
+ return db
41
+ }
42
+
43
+ describe('Nested Operations - Access Control and Hooks', () => {
44
+ let mockPrisma: ReturnType<typeof createMockPrisma>
45
+
46
+ beforeEach(() => {
47
+ mockPrisma = createMockPrisma()
48
+ vi.clearAllMocks()
49
+ })
50
+
51
+ describe('Nested Create Operations', () => {
52
+ it('should run hooks and access control for nested create', async () => {
53
+ const userResolveInputHook = vi.fn(async ({ inputValue }) => inputValue?.toUpperCase())
54
+ const userListResolveInputHook = vi.fn(async ({ resolvedData }) => resolvedData)
55
+ const userValidateInputHook = vi.fn(async () => {})
56
+ const postResolveInputHook = vi.fn(async ({ resolvedData }) => resolvedData)
57
+
58
+ const testConfig = config({
59
+ db: {
60
+ provider: 'postgresql',
61
+ url: 'postgresql://localhost:5432/test',
62
+ },
63
+ lists: {
64
+ User: list({
65
+ fields: {
66
+ name: text({
67
+ hooks: {
68
+ resolveInput: userResolveInputHook,
69
+ },
70
+ }),
71
+ email: text(),
72
+ },
73
+ access: {
74
+ operation: {
75
+ query: () => true,
76
+ create: () => true,
77
+ },
78
+ },
79
+ hooks: {
80
+ resolveInput: userListResolveInputHook,
81
+ validateInput: userValidateInputHook,
82
+ },
83
+ }),
84
+ Post: list({
85
+ fields: {
86
+ title: text(),
87
+ author: relationship({ ref: 'User.posts' }),
88
+ },
89
+ access: {
90
+ operation: {
91
+ query: () => true,
92
+ update: () => true,
93
+ },
94
+ },
95
+ hooks: {
96
+ resolveInput: postResolveInputHook,
97
+ },
98
+ }),
99
+ },
100
+ })
101
+
102
+ // Mock the database to return existing post
103
+ mockPrisma.post.findUnique.mockResolvedValue({
104
+ id: '1',
105
+ title: 'Original Title',
106
+ })
107
+
108
+ // Mock the update to return the updated post
109
+ mockPrisma.post.update.mockResolvedValue({
110
+ id: '1',
111
+ title: 'Updated Title',
112
+ authorId: '2',
113
+ })
114
+
115
+ const context = getContext(testConfig, mockPrisma, { userId: '1' })
116
+
117
+ await context.db.post.update({
118
+ where: { id: '1' },
119
+ data: {
120
+ title: 'Updated Title',
121
+ author: {
122
+ create: {
123
+ name: 'john',
124
+ email: 'john@example.com',
125
+ },
126
+ },
127
+ },
128
+ })
129
+
130
+ // Verify User hooks were called
131
+ expect(userListResolveInputHook).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ operation: 'create',
134
+ resolvedData: expect.objectContaining({
135
+ name: 'john',
136
+ email: 'john@example.com',
137
+ }),
138
+ }),
139
+ )
140
+
141
+ expect(userResolveInputHook).toHaveBeenCalledWith(
142
+ expect.objectContaining({
143
+ inputValue: 'john',
144
+ operation: 'create',
145
+ fieldName: 'name',
146
+ }),
147
+ )
148
+
149
+ expect(userValidateInputHook).toHaveBeenCalled()
150
+
151
+ // Verify Post hooks were called
152
+ expect(postResolveInputHook).toHaveBeenCalled()
153
+ })
154
+
155
+ it('should enforce create access control on nested create', async () => {
156
+ const testConfig = config({
157
+ db: {
158
+ provider: 'postgresql',
159
+ url: 'postgresql://localhost:5432/test',
160
+ },
161
+ lists: {
162
+ User: list({
163
+ fields: {
164
+ name: text(),
165
+ email: text(),
166
+ },
167
+ access: {
168
+ operation: {
169
+ query: () => true,
170
+ create: () => false, // Deny create
171
+ },
172
+ },
173
+ }),
174
+ Post: list({
175
+ fields: {
176
+ title: text(),
177
+ author: relationship({ ref: 'User.posts' }),
178
+ },
179
+ access: {
180
+ operation: {
181
+ query: () => true,
182
+ update: () => true,
183
+ },
184
+ },
185
+ }),
186
+ },
187
+ })
188
+
189
+ mockPrisma.post.findUnique.mockResolvedValue({
190
+ id: '1',
191
+ title: 'Original Title',
192
+ })
193
+
194
+ const context = getContext(testConfig, mockPrisma, null)
195
+
196
+ await expect(
197
+ context.db.post.update({
198
+ where: { id: '1' },
199
+ data: {
200
+ title: 'Updated Title',
201
+ author: {
202
+ create: {
203
+ name: 'John',
204
+ email: 'john@example.com',
205
+ },
206
+ },
207
+ },
208
+ }),
209
+ ).rejects.toThrow('Access denied: Cannot create related item')
210
+ })
211
+
212
+ it('should apply field-level access control to nested create', async () => {
213
+ const testConfig = config({
214
+ db: {
215
+ provider: 'postgresql',
216
+ url: 'postgresql://localhost:5432/test',
217
+ },
218
+ lists: {
219
+ User: list({
220
+ fields: {
221
+ name: text(),
222
+ email: text(),
223
+ role: text({
224
+ access: {
225
+ create: () => false, // Cannot set role on create
226
+ },
227
+ }),
228
+ },
229
+ access: {
230
+ operation: {
231
+ query: () => true,
232
+ create: () => true,
233
+ },
234
+ },
235
+ }),
236
+ Post: list({
237
+ fields: {
238
+ title: text(),
239
+ author: relationship({ ref: 'User.posts' }),
240
+ },
241
+ access: {
242
+ operation: {
243
+ query: () => true,
244
+ update: () => true,
245
+ },
246
+ },
247
+ }),
248
+ },
249
+ })
250
+
251
+ mockPrisma.post.findUnique.mockResolvedValue({
252
+ id: '1',
253
+ title: 'Original Title',
254
+ })
255
+
256
+ mockPrisma.post.update.mockResolvedValue({
257
+ id: '1',
258
+ title: 'Updated Title',
259
+ authorId: '2',
260
+ })
261
+
262
+ const context = getContext(testConfig, mockPrisma, null)
263
+
264
+ await context.db.post.update({
265
+ where: { id: '1' },
266
+ data: {
267
+ title: 'Updated Title',
268
+ author: {
269
+ create: {
270
+ name: 'John',
271
+ email: 'john@example.com',
272
+ role: 'admin', // Should be filtered out
273
+ },
274
+ },
275
+ },
276
+ })
277
+
278
+ // Verify the update was called
279
+ expect(mockPrisma.post.update).toHaveBeenCalled()
280
+
281
+ // Get the actual data passed to Prisma
282
+ const callArgs = mockPrisma.post.update.mock.calls[0][0]
283
+ const authorCreateData = callArgs.data.author.create
284
+
285
+ // Role should be filtered out
286
+ expect(authorCreateData.role).toBeUndefined()
287
+ expect(authorCreateData.name).toBe('John')
288
+ expect(authorCreateData.email).toBe('john@example.com')
289
+ })
290
+
291
+ it('should run field validation on nested create', async () => {
292
+ const testConfig = config({
293
+ db: {
294
+ provider: 'postgresql',
295
+ url: 'postgresql://localhost:5432/test',
296
+ },
297
+ lists: {
298
+ User: list({
299
+ fields: {
300
+ name: text({ validation: { isRequired: true } }),
301
+ email: text({ validation: { isRequired: true } }),
302
+ },
303
+ access: {
304
+ operation: {
305
+ query: () => true,
306
+ create: () => true,
307
+ },
308
+ },
309
+ }),
310
+ Post: list({
311
+ fields: {
312
+ title: text(),
313
+ author: relationship({ ref: 'User.posts' }),
314
+ },
315
+ access: {
316
+ operation: {
317
+ query: () => true,
318
+ update: () => true,
319
+ },
320
+ },
321
+ }),
322
+ },
323
+ })
324
+
325
+ mockPrisma.post.findUnique.mockResolvedValue({
326
+ id: '1',
327
+ title: 'Original Title',
328
+ })
329
+
330
+ const context = getContext(testConfig, mockPrisma, null)
331
+
332
+ await expect(
333
+ context.db.post.update({
334
+ where: { id: '1' },
335
+ data: {
336
+ title: 'Updated Title',
337
+ author: {
338
+ create: {
339
+ name: '', // Empty required field
340
+ email: 'john@example.com',
341
+ },
342
+ },
343
+ },
344
+ }),
345
+ ).rejects.toThrow('Name is required')
346
+ })
347
+ })
348
+
349
+ describe('Nested Read Operations with Includes', () => {
350
+ it('should apply query access control to relationships', async () => {
351
+ const testConfig = config({
352
+ db: {
353
+ provider: 'postgresql',
354
+ url: 'postgresql://localhost:5432/test',
355
+ },
356
+ lists: {
357
+ User: list({
358
+ fields: {
359
+ name: text(),
360
+ email: text(),
361
+ posts: relationship({ ref: 'Post.author', many: true }),
362
+ },
363
+ access: {
364
+ operation: {
365
+ query: () => true,
366
+ },
367
+ },
368
+ }),
369
+ Post: list({
370
+ fields: {
371
+ title: text(),
372
+ status: text(),
373
+ author: relationship({ ref: 'User.posts' }),
374
+ },
375
+ access: {
376
+ operation: {
377
+ // Only show published posts
378
+ query: () => ({ status: { equals: 'published' } }),
379
+ },
380
+ },
381
+ }),
382
+ },
383
+ })
384
+
385
+ mockPrisma.user.findFirst.mockResolvedValue({
386
+ id: '1',
387
+ name: 'John Doe',
388
+ email: 'john@example.com',
389
+ posts: [
390
+ { id: '1', title: 'Published Post', status: 'published' },
391
+ { id: '2', title: 'Draft Post', status: 'draft' },
392
+ ],
393
+ })
394
+
395
+ const context = getContext(testConfig, mockPrisma, null)
396
+
397
+ await context.db.user.findUnique({
398
+ where: { id: '1' },
399
+ })
400
+
401
+ // Verify findFirst was called with access filter
402
+ expect(mockPrisma.user.findFirst).toHaveBeenCalledWith(
403
+ expect.objectContaining({
404
+ include: expect.objectContaining({
405
+ posts: expect.objectContaining({
406
+ where: { status: { equals: 'published' } },
407
+ }),
408
+ }),
409
+ }),
410
+ )
411
+ })
412
+
413
+ it('should apply field-level read access to nested relationships', async () => {
414
+ const userResolveOutputHook = vi.fn(({ value }) => `***${value}***`)
415
+
416
+ const testConfig = config({
417
+ db: {
418
+ provider: 'postgresql',
419
+ url: 'postgresql://localhost:5432/test',
420
+ },
421
+ lists: {
422
+ User: list({
423
+ fields: {
424
+ name: text(),
425
+ email: text({
426
+ access: {
427
+ read: () => false, // Hide email
428
+ },
429
+ }),
430
+ secretField: text({
431
+ hooks: {
432
+ resolveOutput: userResolveOutputHook,
433
+ },
434
+ }),
435
+ },
436
+ access: {
437
+ operation: {
438
+ query: () => true,
439
+ },
440
+ },
441
+ }),
442
+ Post: list({
443
+ fields: {
444
+ title: text(),
445
+ author: relationship({ ref: 'User.posts' }),
446
+ },
447
+ access: {
448
+ operation: {
449
+ query: () => true,
450
+ },
451
+ },
452
+ }),
453
+ },
454
+ })
455
+
456
+ mockPrisma.post.findFirst.mockResolvedValue({
457
+ id: '1',
458
+ title: 'Test Post',
459
+ author: {
460
+ id: '1',
461
+ name: 'John Doe',
462
+ email: 'john@example.com',
463
+ secretField: 'secret',
464
+ },
465
+ })
466
+
467
+ const context = getContext(testConfig, mockPrisma, null)
468
+
469
+ const result = await context.db.post.findUnique({
470
+ where: { id: '1' },
471
+ })
472
+
473
+ // Email should be filtered out
474
+ expect(result?.author?.email).toBeUndefined()
475
+ expect(result?.author?.name).toBe('John Doe')
476
+
477
+ // resolveOutput hook should have been applied
478
+ expect(result?.author?.secretField).toBe('***secret***')
479
+ expect(userResolveOutputHook).toHaveBeenCalled()
480
+ })
481
+
482
+ it('should deny access to relationships when query access is false', async () => {
483
+ const testConfig = config({
484
+ db: {
485
+ provider: 'postgresql',
486
+ url: 'postgresql://localhost:5432/test',
487
+ },
488
+ lists: {
489
+ User: list({
490
+ fields: {
491
+ name: text(),
492
+ email: text(),
493
+ },
494
+ access: {
495
+ operation: {
496
+ query: () => false, // Deny access to users
497
+ },
498
+ },
499
+ }),
500
+ Post: list({
501
+ fields: {
502
+ title: text(),
503
+ author: relationship({ ref: 'User.posts' }),
504
+ },
505
+ access: {
506
+ operation: {
507
+ query: () => true,
508
+ },
509
+ },
510
+ }),
511
+ },
512
+ })
513
+
514
+ mockPrisma.post.findFirst.mockResolvedValue({
515
+ id: '1',
516
+ title: 'Test Post',
517
+ // Author should not be included due to access control
518
+ })
519
+
520
+ const context = getContext(testConfig, mockPrisma, null)
521
+
522
+ await context.db.post.findUnique({
523
+ where: { id: '1' },
524
+ })
525
+
526
+ // Verify include does NOT include author (access denied)
527
+ expect(mockPrisma.post.findFirst).toHaveBeenCalledWith(
528
+ expect.objectContaining({
529
+ include: expect.not.objectContaining({
530
+ author: expect.anything(),
531
+ }),
532
+ }),
533
+ )
534
+ })
535
+ })
536
+
537
+ describe('Update Return Values with Field Access', () => {
538
+ it('should filter return fields based on field-level read access', async () => {
539
+ const testConfig = config({
540
+ db: {
541
+ provider: 'postgresql',
542
+ url: 'postgresql://localhost:5432/test',
543
+ },
544
+ lists: {
545
+ Post: list({
546
+ fields: {
547
+ title: text(),
548
+ content: text(),
549
+ internalNotes: text({
550
+ access: {
551
+ read: () => false, // Hide from read
552
+ },
553
+ }),
554
+ },
555
+ access: {
556
+ operation: {
557
+ query: () => true,
558
+ update: () => true,
559
+ },
560
+ },
561
+ }),
562
+ },
563
+ })
564
+
565
+ mockPrisma.post.findUnique.mockResolvedValue({
566
+ id: '1',
567
+ title: 'Original Title',
568
+ content: 'Original Content',
569
+ internalNotes: 'Old notes',
570
+ })
571
+
572
+ mockPrisma.post.update.mockResolvedValue({
573
+ id: '1',
574
+ title: 'Updated Title',
575
+ content: 'Updated Content',
576
+ internalNotes: 'New secret notes',
577
+ })
578
+
579
+ const context = getContext(testConfig, mockPrisma, null)
580
+
581
+ const result = await context.db.post.update({
582
+ where: { id: '1' },
583
+ data: {
584
+ title: 'Updated Title',
585
+ content: 'Updated Content',
586
+ internalNotes: 'New secret notes',
587
+ },
588
+ })
589
+
590
+ // internalNotes should be filtered out from response
591
+ expect(result?.title).toBe('Updated Title')
592
+ expect(result?.content).toBe('Updated Content')
593
+ expect(result?.internalNotes).toBeUndefined()
594
+ })
595
+
596
+ it('should apply resolveOutput hooks to update return value', async () => {
597
+ const titleResolveOutputHook = vi.fn(({ value }) => value.toUpperCase())
598
+
599
+ const testConfig = config({
600
+ db: {
601
+ provider: 'postgresql',
602
+ url: 'postgresql://localhost:5432/test',
603
+ },
604
+ lists: {
605
+ Post: list({
606
+ fields: {
607
+ title: text({
608
+ hooks: {
609
+ resolveOutput: titleResolveOutputHook,
610
+ },
611
+ }),
612
+ },
613
+ access: {
614
+ operation: {
615
+ query: () => true,
616
+ update: () => true,
617
+ },
618
+ },
619
+ }),
620
+ },
621
+ })
622
+
623
+ mockPrisma.post.findUnique.mockResolvedValue({
624
+ id: '1',
625
+ title: 'Original Title',
626
+ })
627
+
628
+ mockPrisma.post.update.mockResolvedValue({
629
+ id: '1',
630
+ title: 'updated title',
631
+ })
632
+
633
+ const context = getContext(testConfig, mockPrisma, null)
634
+
635
+ const result = await context.db.post.update({
636
+ where: { id: '1' },
637
+ data: {
638
+ title: 'updated title',
639
+ },
640
+ })
641
+
642
+ // resolveOutput hook should transform the return value
643
+ expect(result?.title).toBe('UPDATED TITLE')
644
+ expect(titleResolveOutputHook).toHaveBeenCalled()
645
+ })
646
+
647
+ it('should filter nested relationships in update return value', async () => {
648
+ const testConfig = config({
649
+ db: {
650
+ provider: 'postgresql',
651
+ url: 'postgresql://localhost:5432/test',
652
+ },
653
+ lists: {
654
+ User: list({
655
+ fields: {
656
+ name: text(),
657
+ email: text({
658
+ access: {
659
+ read: () => false, // Hide email
660
+ },
661
+ }),
662
+ },
663
+ access: {
664
+ operation: {
665
+ query: () => true,
666
+ create: () => true,
667
+ },
668
+ },
669
+ }),
670
+ Post: list({
671
+ fields: {
672
+ title: text(),
673
+ author: relationship({ ref: 'User.posts' }),
674
+ },
675
+ access: {
676
+ operation: {
677
+ query: () => true,
678
+ update: () => true,
679
+ },
680
+ },
681
+ }),
682
+ },
683
+ })
684
+
685
+ mockPrisma.post.findUnique.mockResolvedValue({
686
+ id: '1',
687
+ title: 'Original Title',
688
+ })
689
+
690
+ mockPrisma.post.update.mockResolvedValue({
691
+ id: '1',
692
+ title: 'Updated Title',
693
+ authorId: '2',
694
+ })
695
+
696
+ const context = getContext(testConfig, mockPrisma, null)
697
+
698
+ const result = await context.db.post.update({
699
+ where: { id: '1' },
700
+ data: {
701
+ title: 'Updated Title',
702
+ author: {
703
+ create: {
704
+ name: 'John',
705
+ email: 'john@example.com',
706
+ },
707
+ },
708
+ },
709
+ })
710
+
711
+ // Verify result exists
712
+ expect(result).toBeDefined()
713
+ expect(result?.title).toBe('Updated Title')
714
+ })
715
+ })
716
+
717
+ describe('Access Denial Scenarios', () => {
718
+ it('should deny nested connect when update access is denied on related item', async () => {
719
+ const testConfig = config({
720
+ db: {
721
+ provider: 'postgresql',
722
+ url: 'postgresql://localhost:5432/test',
723
+ },
724
+ lists: {
725
+ User: list({
726
+ fields: {
727
+ name: text(),
728
+ },
729
+ access: {
730
+ operation: {
731
+ query: () => true,
732
+ update: ({ session }) => {
733
+ // Only allow updating own profile
734
+ return { id: { equals: session?.userId } }
735
+ },
736
+ },
737
+ },
738
+ }),
739
+ Post: list({
740
+ fields: {
741
+ title: text(),
742
+ author: relationship({ ref: 'User.posts' }),
743
+ },
744
+ access: {
745
+ operation: {
746
+ query: () => true,
747
+ update: () => true,
748
+ },
749
+ },
750
+ }),
751
+ },
752
+ })
753
+
754
+ mockPrisma.post.findUnique.mockResolvedValue({
755
+ id: '1',
756
+ title: 'Original Title',
757
+ })
758
+
759
+ // Mock finding the user to connect (different from session user)
760
+ mockPrisma.user.findUnique.mockResolvedValue({
761
+ id: '2',
762
+ name: 'Other User',
763
+ })
764
+
765
+ const context = getContext(testConfig, mockPrisma, { userId: '1' })
766
+
767
+ await expect(
768
+ context.db.post.update({
769
+ where: { id: '1' },
770
+ data: {
771
+ author: {
772
+ connect: { id: '2' }, // Connecting to user 2, but session is user 1
773
+ },
774
+ },
775
+ }),
776
+ ).rejects.toThrow('Access denied: Cannot connect to this item')
777
+ })
778
+
779
+ it('should allow nested connect when update access is granted', async () => {
780
+ const testConfig = config({
781
+ db: {
782
+ provider: 'postgresql',
783
+ url: 'postgresql://localhost:5432/test',
784
+ },
785
+ lists: {
786
+ User: list({
787
+ fields: {
788
+ name: text(),
789
+ },
790
+ access: {
791
+ operation: {
792
+ query: () => true,
793
+ update: () => true, // Allow all updates
794
+ },
795
+ },
796
+ }),
797
+ Post: list({
798
+ fields: {
799
+ title: text(),
800
+ author: relationship({ ref: 'User.posts' }),
801
+ },
802
+ access: {
803
+ operation: {
804
+ query: () => true,
805
+ update: () => true,
806
+ },
807
+ },
808
+ }),
809
+ },
810
+ })
811
+
812
+ mockPrisma.post.findUnique.mockResolvedValue({
813
+ id: '1',
814
+ title: 'Original Title',
815
+ })
816
+
817
+ mockPrisma.user.findUnique.mockResolvedValue({
818
+ id: '2',
819
+ name: 'John Doe',
820
+ })
821
+
822
+ mockPrisma.post.update.mockResolvedValue({
823
+ id: '1',
824
+ title: 'Original Title',
825
+ authorId: '2',
826
+ })
827
+
828
+ const context = getContext(testConfig, mockPrisma, { userId: '1' })
829
+
830
+ const result = await context.db.post.update({
831
+ where: { id: '1' },
832
+ data: {
833
+ author: {
834
+ connect: { id: '2' },
835
+ },
836
+ },
837
+ })
838
+
839
+ expect(result).toBeDefined()
840
+ expect(mockPrisma.post.update).toHaveBeenCalled()
841
+ })
842
+
843
+ it('should deny nested update when update access is denied on related item', async () => {
844
+ const testConfig = config({
845
+ db: {
846
+ provider: 'postgresql',
847
+ url: 'postgresql://localhost:5432/test',
848
+ },
849
+ lists: {
850
+ User: list({
851
+ fields: {
852
+ name: text(),
853
+ },
854
+ access: {
855
+ operation: {
856
+ query: () => true,
857
+ update: () => false, // Deny all updates
858
+ },
859
+ },
860
+ }),
861
+ Post: list({
862
+ fields: {
863
+ title: text(),
864
+ author: relationship({ ref: 'User.posts' }),
865
+ },
866
+ access: {
867
+ operation: {
868
+ query: () => true,
869
+ update: () => true,
870
+ },
871
+ },
872
+ }),
873
+ },
874
+ })
875
+
876
+ mockPrisma.post.findUnique.mockResolvedValue({
877
+ id: '1',
878
+ title: 'Original Title',
879
+ })
880
+
881
+ mockPrisma.user.findUnique.mockResolvedValue({
882
+ id: '2',
883
+ name: 'John Doe',
884
+ })
885
+
886
+ const context = getContext(testConfig, mockPrisma, null)
887
+
888
+ await expect(
889
+ context.db.post.update({
890
+ where: { id: '1' },
891
+ data: {
892
+ author: {
893
+ update: {
894
+ where: { id: '2' },
895
+ data: { name: 'Jane Doe' },
896
+ },
897
+ },
898
+ },
899
+ }),
900
+ ).rejects.toThrow('Access denied: Cannot update related item')
901
+ })
902
+ })
903
+ })