@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +264 -0
- package/LICENSE +21 -0
- package/dist/access/engine.d.ts +1 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +22 -5
- package/dist/access/engine.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.map +1 -1
- package/dist/access/types.d.ts +33 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +236 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +4 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +32 -54
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +45 -2
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/fields/index.d.ts +45 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +81 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/access/engine.ts +34 -2
- package/src/access/index.ts +1 -0
- package/src/access/types.ts +47 -0
- package/src/config/index.ts +9 -0
- package/src/config/types.ts +246 -0
- package/src/context/index.ts +46 -63
- package/src/context/nested-operations.ts +70 -2
- package/src/fields/index.ts +85 -0
- package/src/index.ts +4 -0
- package/tests/nested-access-and-hooks.test.ts +903 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
+
})
|