@opensaas/stack-core 0.30.0 → 0.31.1
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 +412 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +14 -1
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +50 -0
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/field-access.js +6 -6
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +46 -12
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.d.ts +60 -0
- package/dist/access/relationship-count.d.ts.map +1 -0
- package/dist/access/relationship-count.js +233 -0
- package/dist/access/relationship-count.js.map +1 -0
- package/dist/access/relationship-count.test.d.ts +2 -0
- package/dist/access/relationship-count.test.d.ts.map +1 -0
- package/dist/access/relationship-count.test.js +185 -0
- package/dist/access/relationship-count.test.js.map +1 -0
- package/dist/access/relationship-label-filter.d.ts +45 -0
- package/dist/access/relationship-label-filter.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.js +93 -0
- package/dist/access/relationship-label-filter.js.map +1 -0
- package/dist/access/relationship-label-filter.test.d.ts +2 -0
- package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
- package/dist/access/relationship-label-filter.test.js +126 -0
- package/dist/access/relationship-label-filter.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/nav-count.d.ts +34 -0
- package/dist/config/nav-count.d.ts.map +1 -0
- package/dist/config/nav-count.js +71 -0
- package/dist/config/nav-count.js.map +1 -0
- package/dist/config/types.d.ts +305 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +11 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +28 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +205 -0
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +7 -1
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/extend.d.ts +1 -0
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +166 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +32 -0
- package/dist/filter/collect.d.ts.map +1 -0
- package/dist/filter/collect.js +60 -0
- package/dist/filter/collect.js.map +1 -0
- package/dist/filter/filter.test.d.ts +2 -0
- package/dist/filter/filter.test.d.ts.map +1 -0
- package/dist/filter/filter.test.js +348 -0
- package/dist/filter/filter.test.js.map +1 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +16 -0
- package/dist/filter/index.js.map +1 -0
- package/dist/filter/map.d.ts +18 -0
- package/dist/filter/map.d.ts.map +1 -0
- package/dist/filter/map.js +60 -0
- package/dist/filter/map.js.map +1 -0
- package/dist/filter/parse.d.ts +33 -0
- package/dist/filter/parse.d.ts.map +1 -0
- package/dist/filter/parse.js +103 -0
- package/dist/filter/parse.js.map +1 -0
- package/dist/filter/serialize.d.ts +25 -0
- package/dist/filter/serialize.d.ts.map +1 -0
- package/dist/filter/serialize.js +68 -0
- package/dist/filter/serialize.js.map +1 -0
- package/dist/filter/serialize.test.d.ts +2 -0
- package/dist/filter/serialize.test.d.ts.map +1 -0
- package/dist/filter/serialize.test.js +83 -0
- package/dist/filter/serialize.test.js.map +1 -0
- package/dist/filter/types.d.ts +122 -0
- package/dist/filter/types.d.ts.map +1 -0
- package/dist/filter/types.js +24 -0
- package/dist/filter/types.js.map +1 -0
- package/dist/hooks/index.d.ts +30 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +79 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +79 -0
- package/src/access/access-filter.ts +15 -4
- package/src/access/field-access.ts +6 -6
- package/src/access/multi-column-read-write.test.ts +68 -19
- package/src/access/relationship-count.test.ts +269 -0
- package/src/access/relationship-count.ts +311 -0
- package/src/access/relationship-label-filter.test.ts +177 -0
- package/src/access/relationship-label-filter.ts +140 -0
- package/src/config/index.ts +6 -0
- package/src/config/nav-count.ts +85 -0
- package/src/config/types.ts +319 -1
- package/src/context/hook-pipeline.ts +19 -0
- package/src/context/index.ts +285 -3
- package/src/context/nested-operations.ts +22 -0
- package/src/extend.ts +9 -0
- package/src/fields/index.ts +168 -0
- package/src/filter/collect.ts +72 -0
- package/src/filter/filter.test.ts +400 -0
- package/src/filter/index.ts +25 -0
- package/src/filter/map.ts +68 -0
- package/src/filter/parse.ts +106 -0
- package/src/filter/serialize.test.ts +115 -0
- package/src/filter/serialize.ts +75 -0
- package/src/filter/types.ts +120 -0
- package/src/hooks/index.ts +87 -57
- package/src/index.ts +54 -0
- package/src/internal.ts +5 -0
- package/tests/context.test.ts +690 -0
- package/tests/multi-column-validation-ordering.test.ts +305 -0
- package/tests/nav-count.test.ts +189 -0
- package/tsconfig.tsbuildinfo +1 -1
package/tests/context.test.ts
CHANGED
|
@@ -202,6 +202,696 @@ describe('getContext', () => {
|
|
|
202
202
|
})
|
|
203
203
|
})
|
|
204
204
|
|
|
205
|
+
describe('removeRelated (relationship-table row removal)', () => {
|
|
206
|
+
it('unlinks a to-one back-reference via an update on the related list', async () => {
|
|
207
|
+
const existing = { id: 'p1', title: 'T', content: 'c', authorId: 'u1' }
|
|
208
|
+
mockPrisma.post.findUnique.mockResolvedValue(existing)
|
|
209
|
+
mockPrisma.post.update.mockResolvedValue({ id: 'p1', title: 'T', authorId: null })
|
|
210
|
+
|
|
211
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
212
|
+
const result = await context.serverAction({
|
|
213
|
+
listKey: 'Post',
|
|
214
|
+
action: 'removeRelated',
|
|
215
|
+
mode: 'disconnect',
|
|
216
|
+
id: 'p1',
|
|
217
|
+
field: 'author',
|
|
218
|
+
parentId: 'u1',
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// Disconnect is an UPDATE on the related list nulling the back-reference
|
|
222
|
+
// (a to-one back-ref disconnects with `true`), never a delete. The
|
|
223
|
+
// distinct `{ removed }` shape avoids a redirect-on-success wrapper.
|
|
224
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
225
|
+
const updateArg = mockPrisma.post.update.mock.calls[0][0]
|
|
226
|
+
expect(updateArg.where).toEqual({ id: 'p1' })
|
|
227
|
+
expect(updateArg.data.author).toEqual({ disconnect: true })
|
|
228
|
+
expect(mockPrisma.post.delete).not.toHaveBeenCalled()
|
|
229
|
+
expect(result).toEqual({ removed: true })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('deletes the related row when mode is delete', async () => {
|
|
233
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: 'p1', title: 'T', authorId: 'u1' })
|
|
234
|
+
mockPrisma.post.delete.mockResolvedValue({ id: 'p1', title: 'T' })
|
|
235
|
+
|
|
236
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
237
|
+
const result = await context.serverAction({
|
|
238
|
+
listKey: 'Post',
|
|
239
|
+
action: 'removeRelated',
|
|
240
|
+
mode: 'delete',
|
|
241
|
+
id: 'p1',
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(mockPrisma.post.delete).toHaveBeenCalled()
|
|
245
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
246
|
+
expect(result).toEqual({ removed: true })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('disconnects a to-many back-reference by parent id (many-to-many)', async () => {
|
|
250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
251
|
+
const m2mPrisma: any = {
|
|
252
|
+
lesson: {
|
|
253
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
|
|
254
|
+
update: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
|
|
255
|
+
delete: vi.fn(),
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
const m2mConfig: OpenSaasConfig = {
|
|
259
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
260
|
+
lists: {
|
|
261
|
+
Lesson: {
|
|
262
|
+
fields: {
|
|
263
|
+
title: { type: 'text' },
|
|
264
|
+
teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
|
|
265
|
+
},
|
|
266
|
+
access: {
|
|
267
|
+
operation: {
|
|
268
|
+
query: () => true,
|
|
269
|
+
create: () => true,
|
|
270
|
+
update: () => true,
|
|
271
|
+
delete: () => true,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
Teacher: {
|
|
276
|
+
fields: {
|
|
277
|
+
name: { type: 'text' },
|
|
278
|
+
lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
|
|
279
|
+
},
|
|
280
|
+
access: {
|
|
281
|
+
operation: {
|
|
282
|
+
query: () => true,
|
|
283
|
+
create: () => true,
|
|
284
|
+
update: () => true,
|
|
285
|
+
delete: () => true,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
|
|
293
|
+
await context.serverAction({
|
|
294
|
+
listKey: 'Lesson',
|
|
295
|
+
action: 'removeRelated',
|
|
296
|
+
mode: 'disconnect',
|
|
297
|
+
id: 'l1',
|
|
298
|
+
field: 'teachers',
|
|
299
|
+
parentId: 't1',
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const updateArg = m2mPrisma.lesson.update.mock.calls[0][0]
|
|
303
|
+
// A to-many back-reference disconnects the specific parent by id.
|
|
304
|
+
expect(updateArg.data.teachers).toEqual({ disconnect: { id: 't1' } })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('returns a generic error (Silent failure) when the update is access-denied', async () => {
|
|
308
|
+
const deniedConfig: OpenSaasConfig = {
|
|
309
|
+
...config,
|
|
310
|
+
lists: {
|
|
311
|
+
...config.lists,
|
|
312
|
+
Post: {
|
|
313
|
+
...config.lists.Post,
|
|
314
|
+
access: {
|
|
315
|
+
operation: {
|
|
316
|
+
query: () => true,
|
|
317
|
+
create: () => true,
|
|
318
|
+
update: () => false, // no update access → disconnect denied
|
|
319
|
+
delete: () => true,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
|
|
327
|
+
const result = await context.serverAction({
|
|
328
|
+
listKey: 'Post',
|
|
329
|
+
action: 'removeRelated',
|
|
330
|
+
mode: 'disconnect',
|
|
331
|
+
id: 'p1',
|
|
332
|
+
field: 'author',
|
|
333
|
+
parentId: 'u1',
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Denied: reported as not removed with a generic reason (no leak).
|
|
337
|
+
expect(result).toEqual({ removed: false, error: 'Access denied or operation failed' })
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('bulkAction (custom list bulk actions, #736)', () => {
|
|
342
|
+
// A config whose Post list declares a custom bulk action that runs each
|
|
343
|
+
// selected id through the SECURED context (`context.db.post.update`), so
|
|
344
|
+
// access control + hooks apply per id.
|
|
345
|
+
function configWithPublishAction(
|
|
346
|
+
overrides?: Partial<import('../src/config/types.js').BulkAction>,
|
|
347
|
+
): OpenSaasConfig {
|
|
348
|
+
return {
|
|
349
|
+
...config,
|
|
350
|
+
lists: {
|
|
351
|
+
...config.lists,
|
|
352
|
+
Post: {
|
|
353
|
+
...config.lists.Post,
|
|
354
|
+
fields: {
|
|
355
|
+
...config.lists.Post.fields,
|
|
356
|
+
status: { type: 'text' },
|
|
357
|
+
},
|
|
358
|
+
ui: {
|
|
359
|
+
listView: {
|
|
360
|
+
bulkActions: [
|
|
361
|
+
{
|
|
362
|
+
key: 'publish',
|
|
363
|
+
label: 'Publish',
|
|
364
|
+
handler: async ({ ids, context }) => {
|
|
365
|
+
let n = 0
|
|
366
|
+
for (const id of ids) {
|
|
367
|
+
const r = await context.db.post.update({
|
|
368
|
+
where: { id },
|
|
369
|
+
data: { status: 'published' },
|
|
370
|
+
})
|
|
371
|
+
if (r) n++
|
|
372
|
+
}
|
|
373
|
+
return { message: `Published ${n} of ${ids.length}` }
|
|
374
|
+
},
|
|
375
|
+
...overrides,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
it('runs the handler over the selected ids through the secured context', async () => {
|
|
386
|
+
mockPrisma.post.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
|
|
387
|
+
Promise.resolve({ id: where.id, title: 'T', content: 'c' }),
|
|
388
|
+
)
|
|
389
|
+
mockPrisma.post.update.mockImplementation(({ where }: { where: { id: string } }) =>
|
|
390
|
+
Promise.resolve({ id: where.id, status: 'published' }),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const context = await getContext(configWithPublishAction(), mockPrisma, { userId: 'u1' })
|
|
394
|
+
const result = await context.serverAction({
|
|
395
|
+
listKey: 'Post',
|
|
396
|
+
action: 'bulkAction',
|
|
397
|
+
key: 'publish',
|
|
398
|
+
ids: ['p1', 'p2'],
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// Each id went through the secured update delegate, not a raw call.
|
|
402
|
+
expect(mockPrisma.post.update).toHaveBeenCalledTimes(2)
|
|
403
|
+
expect(result).toEqual({ bulkAction: true, message: 'Published 2 of 2' })
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('absorbs a per-id Silent failure into the handler count (no leak)', async () => {
|
|
407
|
+
// Update access scopes writes to id p1 only → p2 is denied and returns
|
|
408
|
+
// null through the secured delegate. The handler simply does not count
|
|
409
|
+
// it; the outcome never reveals which id was denied.
|
|
410
|
+
const scopedConfig = configWithPublishAction()
|
|
411
|
+
scopedConfig.lists.Post.access = {
|
|
412
|
+
operation: {
|
|
413
|
+
query: () => true,
|
|
414
|
+
create: () => true,
|
|
415
|
+
update: () => ({ id: { equals: 'p1' } }),
|
|
416
|
+
delete: () => true,
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
// The access filter merges into the existence pre-check: only p1 is
|
|
420
|
+
// visible, so p2's update is denied (Silent failure → null) before it
|
|
421
|
+
// ever reaches the update delegate.
|
|
422
|
+
mockPrisma.post.findUnique.mockImplementation(({ where }: { where: { id: string } }) =>
|
|
423
|
+
where.id === 'p1'
|
|
424
|
+
? Promise.resolve({ id: 'p1', title: 'T', content: 'c' })
|
|
425
|
+
: Promise.resolve(null),
|
|
426
|
+
)
|
|
427
|
+
// The filter-match re-check (mergeFilters) only ever runs for the row
|
|
428
|
+
// that passed the existence pre-check (p1), so returning it is enough.
|
|
429
|
+
mockPrisma.post.findFirst.mockResolvedValue({ id: 'p1', title: 'T', content: 'c' })
|
|
430
|
+
mockPrisma.post.update.mockResolvedValue({ id: 'p1', status: 'published' })
|
|
431
|
+
|
|
432
|
+
const context = await getContext(scopedConfig, mockPrisma, { userId: 'u1' })
|
|
433
|
+
const result = await context.serverAction({
|
|
434
|
+
listKey: 'Post',
|
|
435
|
+
action: 'bulkAction',
|
|
436
|
+
key: 'publish',
|
|
437
|
+
ids: ['p1', 'p2'],
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
expect(result).toEqual({ bulkAction: true, message: 'Published 1 of 2' })
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('returns an error for an unknown action key', async () => {
|
|
444
|
+
const context = await getContext(configWithPublishAction(), mockPrisma, { userId: 'u1' })
|
|
445
|
+
const result = await context.serverAction({
|
|
446
|
+
listKey: 'Post',
|
|
447
|
+
action: 'bulkAction',
|
|
448
|
+
key: 'nope',
|
|
449
|
+
ids: ['p1'],
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
expect(result).toMatchObject({ bulkAction: false })
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('re-checks hasAccess server-side so a hidden action cannot be invoked', async () => {
|
|
456
|
+
const context = await getContext(
|
|
457
|
+
configWithPublishAction({ hasAccess: () => false }),
|
|
458
|
+
mockPrisma,
|
|
459
|
+
{ userId: 'u1' },
|
|
460
|
+
)
|
|
461
|
+
const result = await context.serverAction({
|
|
462
|
+
listKey: 'Post',
|
|
463
|
+
action: 'bulkAction',
|
|
464
|
+
key: 'publish',
|
|
465
|
+
ids: ['p1', 'p2'],
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
expect(result).toEqual({ bulkAction: false, error: 'Access denied' })
|
|
469
|
+
// The handler never ran.
|
|
470
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('returns a generic message (not the internal text) and logs when the handler throws a plain Error', async () => {
|
|
474
|
+
// An unexpected, non-Prisma, non-known-error bug inside the handler must
|
|
475
|
+
// not surface its internal message to the client (#761).
|
|
476
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
477
|
+
try {
|
|
478
|
+
const context = await getContext(
|
|
479
|
+
configWithPublishAction({
|
|
480
|
+
handler: async () => {
|
|
481
|
+
throw new Error('secret internal detail: connection string leaked')
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
mockPrisma,
|
|
485
|
+
{ userId: 'u1' },
|
|
486
|
+
)
|
|
487
|
+
const result = await context.serverAction({
|
|
488
|
+
listKey: 'Post',
|
|
489
|
+
action: 'bulkAction',
|
|
490
|
+
key: 'publish',
|
|
491
|
+
ids: ['p1', 'p2'],
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// Client sees the generic fallback — never the internal message.
|
|
495
|
+
expect(result).toEqual({ bulkAction: false, error: 'Action failed' })
|
|
496
|
+
if ('error' in result) {
|
|
497
|
+
expect(result.error).not.toContain('secret internal detail')
|
|
498
|
+
}
|
|
499
|
+
// The real error is logged server-side for the operator.
|
|
500
|
+
expect(consoleError).toHaveBeenCalled()
|
|
501
|
+
const loggedArgs = consoleError.mock.calls[0]
|
|
502
|
+
expect(loggedArgs.some((arg) => arg instanceof Error)).toBe(true)
|
|
503
|
+
} finally {
|
|
504
|
+
consoleError.mockRestore()
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
describe('updateRelated (relationship-table inline cell edit)', () => {
|
|
510
|
+
it('updates a single field on the related row through the secured context', async () => {
|
|
511
|
+
mockPrisma.post.findUnique.mockResolvedValue({
|
|
512
|
+
id: 'p1',
|
|
513
|
+
title: 'Old',
|
|
514
|
+
content: 'c',
|
|
515
|
+
authorId: 'u1',
|
|
516
|
+
})
|
|
517
|
+
mockPrisma.post.update.mockResolvedValue({
|
|
518
|
+
id: 'p1',
|
|
519
|
+
title: 'New',
|
|
520
|
+
content: 'c',
|
|
521
|
+
authorId: 'u1',
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
525
|
+
const result = await context.serverAction({
|
|
526
|
+
listKey: 'Post',
|
|
527
|
+
action: 'updateRelated',
|
|
528
|
+
id: 'p1',
|
|
529
|
+
field: 'title',
|
|
530
|
+
value: 'New',
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// The edit is an UPDATE on the RELATED list, one field only. The distinct
|
|
534
|
+
// `{ updated }` shape avoids a redirect-on-success wrapper.
|
|
535
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
536
|
+
const updateArg = mockPrisma.post.update.mock.calls[0][0]
|
|
537
|
+
expect(updateArg.where).toEqual({ id: 'p1' })
|
|
538
|
+
expect(updateArg.data.title).toBe('New')
|
|
539
|
+
expect(mockPrisma.post.delete).not.toHaveBeenCalled()
|
|
540
|
+
expect(result).toEqual({ updated: true })
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('returns a generic error (Silent failure) when the update is access-denied', async () => {
|
|
544
|
+
const deniedConfig: OpenSaasConfig = {
|
|
545
|
+
...config,
|
|
546
|
+
lists: {
|
|
547
|
+
...config.lists,
|
|
548
|
+
Post: {
|
|
549
|
+
...config.lists.Post,
|
|
550
|
+
access: {
|
|
551
|
+
operation: {
|
|
552
|
+
query: () => true,
|
|
553
|
+
create: () => true,
|
|
554
|
+
update: () => false, // no update access → inline edit denied
|
|
555
|
+
delete: () => true,
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
|
|
563
|
+
const result = await context.serverAction({
|
|
564
|
+
listKey: 'Post',
|
|
565
|
+
action: 'updateRelated',
|
|
566
|
+
id: 'p1',
|
|
567
|
+
field: 'title',
|
|
568
|
+
value: 'New',
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// Denied: reported as not updated with a generic reason (no denied-vs-absent leak).
|
|
572
|
+
expect(result).toEqual({ updated: false, error: 'Access denied or operation failed' })
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('surfaces a validation error so the cell can revert with a reason', async () => {
|
|
576
|
+
const validationConfig: OpenSaasConfig = {
|
|
577
|
+
...config,
|
|
578
|
+
lists: {
|
|
579
|
+
...config.lists,
|
|
580
|
+
Post: {
|
|
581
|
+
...config.lists.Post,
|
|
582
|
+
hooks: {
|
|
583
|
+
validateInput: async (args) => {
|
|
584
|
+
if (args.operation === 'delete') return
|
|
585
|
+
const { resolvedData, addValidationError } = args
|
|
586
|
+
if (resolvedData.title === 'spam') {
|
|
587
|
+
addValidationError('Title cannot contain the word "spam"')
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
}
|
|
594
|
+
mockPrisma.post.findUnique.mockResolvedValue({
|
|
595
|
+
id: 'p1',
|
|
596
|
+
title: 'Old',
|
|
597
|
+
content: 'c',
|
|
598
|
+
authorId: 'u1',
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
const context = await getContext(validationConfig, mockPrisma, { userId: 'u1' })
|
|
602
|
+
const result = await context.serverAction({
|
|
603
|
+
listKey: 'Post',
|
|
604
|
+
action: 'updateRelated',
|
|
605
|
+
id: 'p1',
|
|
606
|
+
field: 'title',
|
|
607
|
+
value: 'spam',
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// A validation error becomes { updated: false } with the reason; the write
|
|
611
|
+
// never reaches the database.
|
|
612
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
613
|
+
expect(result).toMatchObject({ updated: false })
|
|
614
|
+
const failure = result as { updated: boolean; error?: string }
|
|
615
|
+
expect(failure.error).toContain('spam')
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
describe('createRelated (relationship-table pre-linked create)', () => {
|
|
620
|
+
it('creates a row on the related list with the to-one back-reference preset to the parent', async () => {
|
|
621
|
+
const created = { id: 'p1', title: 'New', content: 'c', authorId: 'u1' }
|
|
622
|
+
mockPrisma.post.create.mockResolvedValue(created)
|
|
623
|
+
// The connect target must be reachable (the related list's read access).
|
|
624
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
625
|
+
|
|
626
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
627
|
+
const result = await context.serverAction({
|
|
628
|
+
listKey: 'Post',
|
|
629
|
+
action: 'createRelated',
|
|
630
|
+
data: { title: 'New', content: 'c' },
|
|
631
|
+
field: 'author',
|
|
632
|
+
parentId: 'u1',
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
// The back-reference is set on the SERVER (a to-one back-ref connects a
|
|
636
|
+
// single parent), so the new row links to exactly the parent being edited.
|
|
637
|
+
expect(mockPrisma.post.create).toHaveBeenCalled()
|
|
638
|
+
const createArg = mockPrisma.post.create.mock.calls[0][0]
|
|
639
|
+
expect(createArg.data.title).toBe('New')
|
|
640
|
+
expect(createArg.data.author).toEqual({ connect: { id: 'u1' } })
|
|
641
|
+
// Distinct `{ created }` shape (never a single-op `success`).
|
|
642
|
+
expect(result).toEqual({ created: true, id: 'p1' })
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('connects a to-many back-reference by parent id (many-to-many)', async () => {
|
|
646
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
647
|
+
const m2mPrisma: any = {
|
|
648
|
+
lesson: {
|
|
649
|
+
findUnique: vi.fn(),
|
|
650
|
+
update: vi.fn(),
|
|
651
|
+
delete: vi.fn(),
|
|
652
|
+
create: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
|
|
653
|
+
},
|
|
654
|
+
teacher: {
|
|
655
|
+
findUnique: vi.fn().mockResolvedValue({ id: 't1' }),
|
|
656
|
+
update: vi.fn(),
|
|
657
|
+
delete: vi.fn(),
|
|
658
|
+
create: vi.fn(),
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
const m2mConfig: OpenSaasConfig = {
|
|
662
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
663
|
+
lists: {
|
|
664
|
+
Lesson: {
|
|
665
|
+
fields: {
|
|
666
|
+
title: { type: 'text' },
|
|
667
|
+
teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
|
|
668
|
+
},
|
|
669
|
+
access: {
|
|
670
|
+
operation: {
|
|
671
|
+
query: () => true,
|
|
672
|
+
create: () => true,
|
|
673
|
+
update: () => true,
|
|
674
|
+
delete: () => true,
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
Teacher: {
|
|
679
|
+
fields: {
|
|
680
|
+
name: { type: 'text' },
|
|
681
|
+
lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
|
|
682
|
+
},
|
|
683
|
+
access: {
|
|
684
|
+
operation: {
|
|
685
|
+
query: () => true,
|
|
686
|
+
create: () => true,
|
|
687
|
+
update: () => true,
|
|
688
|
+
delete: () => true,
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
|
|
696
|
+
await context.serverAction({
|
|
697
|
+
listKey: 'Lesson',
|
|
698
|
+
action: 'createRelated',
|
|
699
|
+
data: { title: 'L' },
|
|
700
|
+
field: 'teachers',
|
|
701
|
+
parentId: 't1',
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const createArg = m2mPrisma.lesson.create.mock.calls[0][0]
|
|
705
|
+
// A to-many back-reference connects the parent by id.
|
|
706
|
+
expect(createArg.data.teachers).toEqual({ connect: [{ id: 't1' }] })
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
// Security-critical invariant (#758): the server sets the back-reference by
|
|
710
|
+
// spreading client `data` and THEN overwriting `data[field]` from the
|
|
711
|
+
// trusted `parentId`, so a HOSTILE client-supplied `data[field]` can never
|
|
712
|
+
// re-target the link to a different parent. These lock that overwrite for
|
|
713
|
+
// both the to-one and the to-many back-reference shapes.
|
|
714
|
+
it('overwrites a hostile client-supplied to-one back-reference with the trusted parentId', async () => {
|
|
715
|
+
const created = { id: 'p1', title: 'New', authorId: 'u1' }
|
|
716
|
+
mockPrisma.post.create.mockResolvedValue(created)
|
|
717
|
+
// The connect target must be reachable (the related list's read access).
|
|
718
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
719
|
+
|
|
720
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
721
|
+
await context.serverAction({
|
|
722
|
+
listKey: 'Post',
|
|
723
|
+
action: 'createRelated',
|
|
724
|
+
// Hostile: the client tries to re-target the link to a different parent
|
|
725
|
+
// by supplying the back-reference field itself.
|
|
726
|
+
data: { title: 'New', author: 'evil-id' },
|
|
727
|
+
field: 'author',
|
|
728
|
+
parentId: 'u1',
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// The server OVERWRITES the spread-in hostile value with a connect to the
|
|
732
|
+
// trusted parentId — the evil id never reaches the secured create.
|
|
733
|
+
const createArg = mockPrisma.post.create.mock.calls[0][0]
|
|
734
|
+
expect(createArg.data.author).toEqual({ connect: { id: 'u1' } })
|
|
735
|
+
expect(createArg.data.author).not.toBe('evil-id')
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('overwrites a hostile client-supplied to-many back-reference with the trusted parentId', async () => {
|
|
739
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
740
|
+
const m2mPrisma: any = {
|
|
741
|
+
lesson: {
|
|
742
|
+
findUnique: vi.fn(),
|
|
743
|
+
update: vi.fn(),
|
|
744
|
+
delete: vi.fn(),
|
|
745
|
+
create: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
|
|
746
|
+
},
|
|
747
|
+
teacher: {
|
|
748
|
+
findUnique: vi.fn().mockResolvedValue({ id: 't1' }),
|
|
749
|
+
update: vi.fn(),
|
|
750
|
+
delete: vi.fn(),
|
|
751
|
+
create: vi.fn(),
|
|
752
|
+
},
|
|
753
|
+
}
|
|
754
|
+
const m2mConfig: OpenSaasConfig = {
|
|
755
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
756
|
+
lists: {
|
|
757
|
+
Lesson: {
|
|
758
|
+
fields: {
|
|
759
|
+
title: { type: 'text' },
|
|
760
|
+
teachers: { type: 'relationship', ref: 'Teacher.lessons', many: true },
|
|
761
|
+
},
|
|
762
|
+
access: {
|
|
763
|
+
operation: {
|
|
764
|
+
query: () => true,
|
|
765
|
+
create: () => true,
|
|
766
|
+
update: () => true,
|
|
767
|
+
delete: () => true,
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
Teacher: {
|
|
772
|
+
fields: {
|
|
773
|
+
name: { type: 'text' },
|
|
774
|
+
lessons: { type: 'relationship', ref: 'Lesson.teachers', many: true },
|
|
775
|
+
},
|
|
776
|
+
access: {
|
|
777
|
+
operation: {
|
|
778
|
+
query: () => true,
|
|
779
|
+
create: () => true,
|
|
780
|
+
update: () => true,
|
|
781
|
+
delete: () => true,
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const context = await getContext(m2mConfig, m2mPrisma, { userId: 'u1' })
|
|
789
|
+
await context.serverAction({
|
|
790
|
+
listKey: 'Lesson',
|
|
791
|
+
action: 'createRelated',
|
|
792
|
+
// Hostile: the client tries to connect a different teacher.
|
|
793
|
+
data: { title: 'L', teachers: { connect: [{ id: 'evil-id' }] } },
|
|
794
|
+
field: 'teachers',
|
|
795
|
+
parentId: 't1',
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
// The to-many back-reference is overwritten to connect exactly the trusted
|
|
799
|
+
// parent by id — the hostile connect is discarded.
|
|
800
|
+
const createArg = m2mPrisma.lesson.create.mock.calls[0][0]
|
|
801
|
+
expect(createArg.data.teachers).toEqual({ connect: [{ id: 't1' }] })
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
// Defensive guard (#758): malformed direct calls (unreachable from the
|
|
805
|
+
// drawer, which always sends a valid relationship back-reference plus both
|
|
806
|
+
// field and parentId) are rejected rather than degrading to an unguarded
|
|
807
|
+
// create.
|
|
808
|
+
it('rejects a malformed call supplying only one of field/parentId (defensive guard)', async () => {
|
|
809
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
810
|
+
const result = await context.serverAction({
|
|
811
|
+
listKey: 'Post',
|
|
812
|
+
action: 'createRelated',
|
|
813
|
+
data: { title: 'New', author: 'evil-id' },
|
|
814
|
+
field: 'author',
|
|
815
|
+
// parentId omitted — a lone back-reference field is malformed.
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
expect(result).toEqual({
|
|
819
|
+
created: false,
|
|
820
|
+
error: 'createRelated requires both field and parentId, or neither',
|
|
821
|
+
})
|
|
822
|
+
// The unguarded create is never attempted.
|
|
823
|
+
expect(mockPrisma.post.create).not.toHaveBeenCalled()
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('rejects a back-reference that does not name a relationship field (defensive guard)', async () => {
|
|
827
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
828
|
+
const result = await context.serverAction({
|
|
829
|
+
listKey: 'Post',
|
|
830
|
+
action: 'createRelated',
|
|
831
|
+
data: { title: 'New' },
|
|
832
|
+
field: 'title', // a scalar field, not a relationship
|
|
833
|
+
parentId: 'u1',
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
expect(result).toMatchObject({ created: false })
|
|
837
|
+
expect(mockPrisma.post.create).not.toHaveBeenCalled()
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('returns a generic error (Silent failure) when the create is access-denied', async () => {
|
|
841
|
+
const deniedConfig: OpenSaasConfig = {
|
|
842
|
+
...config,
|
|
843
|
+
lists: {
|
|
844
|
+
...config.lists,
|
|
845
|
+
Post: {
|
|
846
|
+
...config.lists.Post,
|
|
847
|
+
access: {
|
|
848
|
+
operation: {
|
|
849
|
+
query: () => true,
|
|
850
|
+
create: () => false, // no create access → create denied
|
|
851
|
+
update: () => true,
|
|
852
|
+
delete: () => true,
|
|
853
|
+
},
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const context = await getContext(deniedConfig, mockPrisma, { userId: 'u1' })
|
|
860
|
+
const result = await context.serverAction({
|
|
861
|
+
listKey: 'Post',
|
|
862
|
+
action: 'createRelated',
|
|
863
|
+
data: { title: 'New' },
|
|
864
|
+
field: 'author',
|
|
865
|
+
parentId: 'u1',
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
// Denied: reported as not created with a generic reason (no leak). The
|
|
869
|
+
// create is never attempted at the Prisma layer.
|
|
870
|
+
expect(result).toEqual({ created: false, error: 'Access denied or operation failed' })
|
|
871
|
+
expect(mockPrisma.post.create).not.toHaveBeenCalled()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('surfaces field errors from the create (e.g. a unique constraint) in the drawer shape', async () => {
|
|
875
|
+
mockPrisma.post.create.mockRejectedValue({ code: 'P2002', meta: { target: ['title'] } })
|
|
876
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
|
877
|
+
|
|
878
|
+
const context = await getContext(config, mockPrisma, { userId: 'u1' })
|
|
879
|
+
const result = await context.serverAction({
|
|
880
|
+
listKey: 'Post',
|
|
881
|
+
action: 'createRelated',
|
|
882
|
+
data: { title: 'Dup' },
|
|
883
|
+
field: 'author',
|
|
884
|
+
parentId: 'u1',
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
// Parsed to a distinct { created: false } with per-field errors the drawer
|
|
888
|
+
// renders — never a single-op `success` envelope.
|
|
889
|
+
expect(result).toMatchObject({ created: false })
|
|
890
|
+
const created = result as { created: boolean; fieldErrors?: Record<string, string> }
|
|
891
|
+
expect(created.fieldErrors?.title).toBeDefined()
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
205
895
|
describe('relationshipOptions', () => {
|
|
206
896
|
it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => {
|
|
207
897
|
mockPrisma.user.findMany.mockResolvedValue([
|