@opensaas/stack-core 0.42.3 → 0.43.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 +25 -0
- package/dist/context/nested-operations.d.ts +27 -0
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +140 -24
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/context/nested-operations.ts +168 -23
- package/src/index.ts +8 -0
- package/tests/context.test.ts +5 -0
- package/tests/nested-operation-registry.test.ts +110 -24
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -35,6 +35,52 @@ import { applyCreateDefaults } from './apply-defaults.js'
|
|
|
35
35
|
* persisted. See ADR-0010 for the full mechanism.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when a non-sudo write's payload carries a nested `set`, `updateMany`
|
|
40
|
+
* or `deleteMany` under a relationship key (#1384). These three kinds were
|
|
41
|
+
* historically a pass-through straight to Prisma: the target list's
|
|
42
|
+
* `operation.update`/`operation.delete` access was never consulted, no hooks
|
|
43
|
+
* ran, and an unscoped `updateMany`/`deleteMany` `where` reached rows well
|
|
44
|
+
* outside the parent's own subtree. ADR-0050 removes nested relation input
|
|
45
|
+
* from the secured write surface entirely on the `prisma-8` line, so building
|
|
46
|
+
* per-kind access machinery for these three kinds on `main` would be work
|
|
47
|
+
* that major deletes wholesale — this refuses them instead, an interim
|
|
48
|
+
* fail-closed fix mirroring ADR-0050's own refusal shape.
|
|
49
|
+
*
|
|
50
|
+
* The refusal fires before any part of the write executes, so a payload
|
|
51
|
+
* mixing a refused kind with a permitted one persists nothing. `sudo()`
|
|
52
|
+
* still accepts all three kinds unchanged — the same escape hatch every
|
|
53
|
+
* other access-control refusal in this module leans on.
|
|
54
|
+
*
|
|
55
|
+
* Replacement: author the writes against the target list directly, via
|
|
56
|
+
* `context.db.<targetList>`, wrapped in `context.transaction` when they must
|
|
57
|
+
* land atomically with this write.
|
|
58
|
+
*/
|
|
59
|
+
export class NestedRelationInputError extends Error {
|
|
60
|
+
public listKey: string
|
|
61
|
+
public fieldKey: string
|
|
62
|
+
public kinds: readonly string[]
|
|
63
|
+
|
|
64
|
+
constructor(listKey: string, fieldKey: string, kinds: readonly string[]) {
|
|
65
|
+
const kindList = kinds.map((kind) => `"${kind}"`).join(', ')
|
|
66
|
+
super(
|
|
67
|
+
`Cannot write "${listKey}.${fieldKey}" — this payload carries a nested ${kindList} ` +
|
|
68
|
+
`operation, which non-sudo contexts no longer accept (#1384): the target list's access ` +
|
|
69
|
+
`rules were never consulted for these kinds. Author the writes against the target list ` +
|
|
70
|
+
`directly (\`context.db.<targetList>\`), wrapped in \`context.transaction\` when they must ` +
|
|
71
|
+
`land atomically with this write, or use \`context.sudo()\` if this write is trusted to ` +
|
|
72
|
+
`bypass the target list's access entirely.`,
|
|
73
|
+
)
|
|
74
|
+
this.name = 'NestedRelationInputError'
|
|
75
|
+
this.listKey = listKey
|
|
76
|
+
this.fieldKey = fieldKey
|
|
77
|
+
this.kinds = kinds
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Nested-op kinds refused outright for non-sudo contexts. See {@link NestedRelationInputError}. */
|
|
82
|
+
const REFUSED_KINDS = ['set', 'updateMany', 'deleteMany'] as const
|
|
83
|
+
|
|
38
84
|
/** A deferred nested `afterOperation` task, run once the parent has persisted. */
|
|
39
85
|
export interface AfterTask {
|
|
40
86
|
/** Field name on the parent linking to the related list (for include lookup). */
|
|
@@ -371,20 +417,30 @@ async function processNestedCreate(
|
|
|
371
417
|
}
|
|
372
418
|
|
|
373
419
|
/**
|
|
374
|
-
* Verify that a single connection
|
|
420
|
+
* Verify that a single connection (or, for `disconnect`, disconnection)
|
|
421
|
+
* target is reachable for the caller.
|
|
375
422
|
*
|
|
376
|
-
*
|
|
377
|
-
* Keystone —
|
|
378
|
-
* A filter-result query access is evaluated in the
|
|
379
|
-
* `findFirst({ where: { AND: [connection, accessFilter] } })`
|
|
380
|
-
* memory, so it correctly handles arbitrary nested-relation
|
|
381
|
-
* boolean combinators; a non-existent id is folded into the
|
|
423
|
+
* Both directions reference an existing row rather than modifying it, so —
|
|
424
|
+
* mirroring Keystone — they require **read/query** access on the target
|
|
425
|
+
* (#578/#1384), not update. A filter-result query access is evaluated in the
|
|
426
|
+
* DATABASE via `findFirst({ where: { AND: [connection, accessFilter] } })`
|
|
427
|
+
* rather than in memory, so it correctly handles arbitrary nested-relation
|
|
428
|
+
* predicates and boolean combinators; a non-existent id is folded into the
|
|
429
|
+
* same check.
|
|
382
430
|
*
|
|
383
|
-
*
|
|
384
|
-
* `Post.author`'s `create`/`update` access)
|
|
385
|
-
*
|
|
386
|
-
* on the
|
|
387
|
-
* is readable/reachable.
|
|
431
|
+
* For `connect` (the default, `action: 'connect'`), the OWNING relationship
|
|
432
|
+
* field's field-level access (e.g. `Post.author`'s `create`/`update` access)
|
|
433
|
+
* must ADDITIONALLY permit the connect (#588) — the other half Keystone
|
|
434
|
+
* requires: read access on the target AND write access on the owning field. A
|
|
435
|
+
* deny here denies the connect even when the target row is readable/reachable.
|
|
436
|
+
*
|
|
437
|
+
* For `disconnect` (`action: 'disconnect'`), the caller passes
|
|
438
|
+
* `owningFieldAccess: undefined`, which makes this second gate a no-op
|
|
439
|
+
* (`checkFieldAccess` defaults an absent rule to allow) — deliberately: the
|
|
440
|
+
* owning field's write access is already enforced by Phase 5's
|
|
441
|
+
* `filterWritableFields` before nested-op processing ever sees this payload,
|
|
442
|
+
* so re-running it here would only duplicate that gate, not add one. See
|
|
443
|
+
* {@link processNestedDisconnect}.
|
|
388
444
|
*
|
|
389
445
|
* Sudo bypasses the entire check (handled by the caller).
|
|
390
446
|
*/
|
|
@@ -399,10 +455,15 @@ async function verifyConnectReachable(
|
|
|
399
455
|
enclosingOperation: 'create' | 'update',
|
|
400
456
|
enclosingItem: Record<string, unknown> | undefined,
|
|
401
457
|
enclosingInputData: Record<string, unknown> | undefined,
|
|
458
|
+
action: 'connect' | 'disconnect' = 'connect',
|
|
402
459
|
): Promise<void> {
|
|
403
460
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
404
461
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
405
462
|
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
463
|
+
const deniedMessage =
|
|
464
|
+
action === 'connect'
|
|
465
|
+
? 'Access denied: Cannot connect to this item'
|
|
466
|
+
: 'Access denied: Cannot disconnect from this item'
|
|
406
467
|
|
|
407
468
|
// #588 owning-field gate (see docblock above). `item`/`inputData` are the
|
|
408
469
|
// ENCLOSING write's `originalItem`/`inputData` — the same values the canonical
|
|
@@ -415,7 +476,7 @@ async function verifyConnectReachable(
|
|
|
415
476
|
context,
|
|
416
477
|
})
|
|
417
478
|
if (!owningFieldAllowed) {
|
|
418
|
-
throw new Error(
|
|
479
|
+
throw new Error(deniedMessage)
|
|
419
480
|
}
|
|
420
481
|
|
|
421
482
|
const queryAccess = relatedListConfig.access?.operation?.query
|
|
@@ -425,14 +486,14 @@ async function verifyConnectReachable(
|
|
|
425
486
|
})
|
|
426
487
|
|
|
427
488
|
if (accessResult === false) {
|
|
428
|
-
throw new Error(
|
|
489
|
+
throw new Error(deniedMessage)
|
|
429
490
|
}
|
|
430
491
|
|
|
431
492
|
// Full access still verifies the row exists, to keep "Item not found" behaviour.
|
|
432
493
|
if (accessResult === true) {
|
|
433
494
|
const item = await model.findUnique({ where: connection })
|
|
434
495
|
if (!item) {
|
|
435
|
-
throw new Error(`Cannot
|
|
496
|
+
throw new Error(`Cannot ${action}: Item not found`)
|
|
436
497
|
}
|
|
437
498
|
return
|
|
438
499
|
}
|
|
@@ -444,7 +505,7 @@ async function verifyConnectReachable(
|
|
|
444
505
|
})
|
|
445
506
|
|
|
446
507
|
if (!reachable) {
|
|
447
|
-
throw new Error(
|
|
508
|
+
throw new Error(deniedMessage)
|
|
448
509
|
}
|
|
449
510
|
}
|
|
450
511
|
|
|
@@ -486,6 +547,56 @@ async function processNestedConnect(
|
|
|
486
547
|
return connections
|
|
487
548
|
}
|
|
488
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Process a nested `disconnect` (#1384).
|
|
552
|
+
*
|
|
553
|
+
* `{ disconnect: true }` — the to-one boolean form — nulls the foreign key on
|
|
554
|
+
* the row already being updated; that row's own update access has already
|
|
555
|
+
* been checked (by the enclosing write), so it is left permitted, unchanged,
|
|
556
|
+
* with no target-list check. This is the form the admin UI's `removeRelated`
|
|
557
|
+
* action emits for a to-one back-reference (ADR-0018).
|
|
558
|
+
*
|
|
559
|
+
* `{ disconnect: { <criteria> } }` — or an array of criteria, for a to-many —
|
|
560
|
+
* names a target row on the OTHER list. This requires that target's
|
|
561
|
+
* `operation.query` access, Keystone's semantic, reusing `verifyConnectReachable`
|
|
562
|
+
* (see its docblock for why the owning-field gate is skipped here) rather than
|
|
563
|
+
* a second reachability check.
|
|
564
|
+
*/
|
|
565
|
+
async function processNestedDisconnect(
|
|
566
|
+
value: unknown,
|
|
567
|
+
relatedListName: string,
|
|
568
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
569
|
+
relatedListConfig: ListConfig<any>,
|
|
570
|
+
context: StackContext,
|
|
571
|
+
prisma: unknown,
|
|
572
|
+
enclosingOperation: 'create' | 'update',
|
|
573
|
+
): Promise<unknown> {
|
|
574
|
+
if (value === true || context._isSudo) {
|
|
575
|
+
return value
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const criteriaArray = Array.isArray(value)
|
|
579
|
+
? (value as Array<Record<string, unknown>>)
|
|
580
|
+
: [value as Record<string, unknown>]
|
|
581
|
+
|
|
582
|
+
for (const criteria of criteriaArray) {
|
|
583
|
+
await verifyConnectReachable(
|
|
584
|
+
criteria,
|
|
585
|
+
relatedListName,
|
|
586
|
+
relatedListConfig,
|
|
587
|
+
context,
|
|
588
|
+
prisma,
|
|
589
|
+
undefined,
|
|
590
|
+
enclosingOperation,
|
|
591
|
+
undefined,
|
|
592
|
+
undefined,
|
|
593
|
+
'disconnect',
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return value
|
|
598
|
+
}
|
|
599
|
+
|
|
489
600
|
/**
|
|
490
601
|
* Re-check a nested update/delete access result against the target row,
|
|
491
602
|
* mirroring the Write Pipeline's `resolveExistingTarget` (#1081): `false`
|
|
@@ -1000,10 +1111,19 @@ interface NestedOpHandler {
|
|
|
1000
1111
|
*
|
|
1001
1112
|
* Kinds that run the full hook pipeline (`create`, `update`, `delete`, and the
|
|
1002
1113
|
* create branch of `connectOrCreate`) run `beforeOperation` inline and register
|
|
1003
|
-
* deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch
|
|
1004
|
-
* enforce access only
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1114
|
+
* deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch,
|
|
1115
|
+
* and `disconnect`'s target-row form, enforce access only — no hooks (#1384;
|
|
1116
|
+
* this settles the `disconnect` half of #569's original scope note, which is
|
|
1117
|
+
* now closed).
|
|
1118
|
+
*
|
|
1119
|
+
* `set`, `updateMany` and `deleteMany` are refused outright for non-sudo
|
|
1120
|
+
* contexts before dispatch even reaches this registry (see `REFUSED_KINDS` /
|
|
1121
|
+
* {@link NestedRelationInputError} near the top of this file) rather than
|
|
1122
|
+
* given their own access machinery — ADR-0050 removes nested relation input
|
|
1123
|
+
* from the secured write surface entirely on the `prisma-8` line, so that
|
|
1124
|
+
* machinery would be built into a module the next major deletes wholesale.
|
|
1125
|
+
* Their entries below therefore only ever run under `sudo()`, where they
|
|
1126
|
+
* remain an unchecked, hook-free pass-through to Prisma, unchanged.
|
|
1007
1127
|
*/
|
|
1008
1128
|
const nestedOpRegistry: Record<string, NestedOpHandler> = {
|
|
1009
1129
|
create: {
|
|
@@ -1125,9 +1245,22 @@ const nestedOpRegistry: Record<string, NestedOpHandler> = {
|
|
|
1125
1245
|
afterTasks,
|
|
1126
1246
|
),
|
|
1127
1247
|
},
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
disconnect: {
|
|
1248
|
+
// Gated pass-through: no hooks, but the target-row form requires the target
|
|
1249
|
+
// list's operation.query access (#1384). See processNestedDisconnect.
|
|
1250
|
+
disconnect: {
|
|
1251
|
+
needsInclude: false,
|
|
1252
|
+
execute: ({ value, relatedListName, relatedListConfig, context, prisma, enclosingOperation }) =>
|
|
1253
|
+
processNestedDisconnect(
|
|
1254
|
+
value,
|
|
1255
|
+
relatedListName,
|
|
1256
|
+
relatedListConfig,
|
|
1257
|
+
context,
|
|
1258
|
+
prisma,
|
|
1259
|
+
enclosingOperation,
|
|
1260
|
+
),
|
|
1261
|
+
},
|
|
1262
|
+
// Refused for non-sudo before dispatch reaches here (see REFUSED_KINDS) —
|
|
1263
|
+
// these entries only ever run under sudo, as an unchecked pass-through.
|
|
1131
1264
|
deleteMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
|
|
1132
1265
|
set: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
|
|
1133
1266
|
updateMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
|
|
@@ -1161,6 +1294,18 @@ async function processFieldNestedOps(
|
|
|
1161
1294
|
parentListName: string,
|
|
1162
1295
|
parentOriginalItem: Record<string, unknown> | undefined,
|
|
1163
1296
|
): Promise<Record<string, unknown>> {
|
|
1297
|
+
// Refuse a non-sudo payload carrying a refused kind BEFORE any part of the
|
|
1298
|
+
// write executes — including before another kind on this same field
|
|
1299
|
+
// dispatches (see NestedRelationInputError's docblock). Collects every
|
|
1300
|
+
// refused kind present so a payload mixing e.g. `set` and `updateMany`
|
|
1301
|
+
// reports both in one error, not just the first found.
|
|
1302
|
+
if (!args.context._isSudo) {
|
|
1303
|
+
const refusedKinds = REFUSED_KINDS.filter((kind) => valueRecord[kind] !== undefined)
|
|
1304
|
+
if (refusedKinds.length > 0) {
|
|
1305
|
+
throw new NestedRelationInputError(parentListName, fieldName, refusedKinds)
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1164
1309
|
const nestedOp: Record<string, unknown> = {}
|
|
1165
1310
|
|
|
1166
1311
|
// Created-row recovery is only needed when this field has a creating kind
|
package/src/index.ts
CHANGED
|
@@ -107,6 +107,14 @@ export { InvalidCreateAccessResultError } from './access/index.js'
|
|
|
107
107
|
// validation failure.
|
|
108
108
|
export { RelationFilterAccessDeniedError } from './access/index.js'
|
|
109
109
|
|
|
110
|
+
// Thrown by a non-sudo write whose payload carries a nested `set`,
|
|
111
|
+
// `updateMany` or `deleteMany` under a relationship key (see #1384). These
|
|
112
|
+
// three kinds were a pass-through straight to Prisma with no target-list
|
|
113
|
+
// access check and no hooks; `sudo()` still accepts them unchanged. Author
|
|
114
|
+
// the writes against the target list directly instead (`context.db.<list>`,
|
|
115
|
+
// wrapped in `context.transaction` when they must land atomically).
|
|
116
|
+
export { NestedRelationInputError } from './context/nested-operations.js'
|
|
117
|
+
|
|
110
118
|
// Field self-containment validation — checks each field implements the
|
|
111
119
|
// generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
|
|
112
120
|
// getPrismaRelation for relationships) so a misimplemented field fails early
|
package/tests/context.test.ts
CHANGED
|
@@ -260,6 +260,11 @@ describe('getContext', () => {
|
|
|
260
260
|
update: vi.fn().mockResolvedValue({ id: 'l1', title: 'L' }),
|
|
261
261
|
delete: vi.fn(),
|
|
262
262
|
},
|
|
263
|
+
// The disconnect target-row form now verifies the target is reachable
|
|
264
|
+
// under the caller's own query access (#1384) before disconnecting it.
|
|
265
|
+
teacher: {
|
|
266
|
+
findUnique: vi.fn().mockResolvedValue({ id: 't1', name: 'T' }),
|
|
267
|
+
},
|
|
263
268
|
}
|
|
264
269
|
const m2mConfig: OpenSaasConfig = {
|
|
265
270
|
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
@@ -2,14 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { getContext } from '../src/context/index.js'
|
|
3
3
|
import { config, list } from '../src/config/index.js'
|
|
4
4
|
import { text, relationship } from '../src/fields/index.js'
|
|
5
|
+
import { NestedRelationInputError } from '../src/context/nested-operations.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* These tests pin the behaviour of the nested-operation handler registry that
|
|
8
9
|
* sits behind `processNestedOperations`. Each nested-op kind (create, connect,
|
|
9
|
-
* connectOrCreate, update) plus
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* connectOrCreate, update, delete) plus `disconnect` (gated, #1384) is
|
|
11
|
+
* dispatched via the registry. `set`/`updateMany`/`deleteMany` are refused
|
|
12
|
+
* for non-sudo contexts (#1384) rather than dispatched at all. The tests
|
|
13
|
+
* assert the exact payload handed to Prisma so a regression in
|
|
14
|
+
* dispatch/ordering is caught.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
function createMockPrisma() {
|
|
@@ -105,8 +107,8 @@ describe('Nested Operation Handler Registry', () => {
|
|
|
105
107
|
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original' })
|
|
106
108
|
})
|
|
107
109
|
|
|
108
|
-
describe('
|
|
109
|
-
it('passes disconnect through unchanged', async () => {
|
|
110
|
+
describe('disconnect (gated, #1384)', () => {
|
|
111
|
+
it('passes { disconnect: true } through unchanged with no target check', async () => {
|
|
110
112
|
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
111
113
|
|
|
112
114
|
await context.db.post.update({
|
|
@@ -116,33 +118,47 @@ describe('Nested Operation Handler Registry', () => {
|
|
|
116
118
|
|
|
117
119
|
const passedData = mockPrisma.post.update.mock.calls[0][0].data
|
|
118
120
|
expect(passedData.author).toEqual({ disconnect: true })
|
|
121
|
+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled()
|
|
119
122
|
})
|
|
120
123
|
|
|
121
|
-
it('
|
|
122
|
-
|
|
123
|
-
// it now runs the full delete hook pipeline (access + before/afterOperation),
|
|
124
|
-
// so it is tested separately below. `deleteMany`/`set`/`updateMany` remain
|
|
125
|
-
// pass-through (out of scope for #569) and the payload is handed to Prisma
|
|
126
|
-
// unchanged.
|
|
124
|
+
it('verifies target query access before passing a criteria-form disconnect through', async () => {
|
|
125
|
+
mockPrisma.tag.findUnique.mockResolvedValue({ id: 'old-tag', label: 'x' })
|
|
127
126
|
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
128
127
|
|
|
129
128
|
await context.db.post.update({
|
|
130
129
|
where: { id: '1' },
|
|
131
|
-
data: {
|
|
132
|
-
tags: {
|
|
133
|
-
deleteMany: { label: { contains: 'x' } },
|
|
134
|
-
set: [{ id: 'b' }],
|
|
135
|
-
updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
|
|
136
|
-
},
|
|
137
|
-
},
|
|
130
|
+
data: { tags: { disconnect: { id: 'old-tag' } } },
|
|
138
131
|
})
|
|
139
132
|
|
|
133
|
+
expect(mockPrisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: 'old-tag' } })
|
|
140
134
|
const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
|
|
141
|
-
expect(passedTags).toEqual({
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
135
|
+
expect(passedTags).toEqual({ disconnect: { id: 'old-tag' } })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('denies a criteria-form disconnect naming a target the session cannot reach', async () => {
|
|
139
|
+
mockPrisma.tag.findUnique.mockResolvedValue(null)
|
|
140
|
+
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
context.db.post.update({
|
|
144
|
+
where: { id: '1' },
|
|
145
|
+
data: { tags: { disconnect: { id: 'missing-tag' } } },
|
|
146
|
+
}),
|
|
147
|
+
).rejects.toThrow(/Cannot disconnect: Item not found/)
|
|
148
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('skips the target check under sudo, matching the historical pass-through', async () => {
|
|
152
|
+
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' }).sudo()
|
|
153
|
+
|
|
154
|
+
await context.db.post.update({
|
|
155
|
+
where: { id: '1' },
|
|
156
|
+
data: { tags: { disconnect: { id: 'old-tag' } } },
|
|
145
157
|
})
|
|
158
|
+
|
|
159
|
+
expect(mockPrisma.tag.findUnique).not.toHaveBeenCalled()
|
|
160
|
+
const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
|
|
161
|
+
expect(passedTags).toEqual({ disconnect: { id: 'old-tag' } })
|
|
146
162
|
})
|
|
147
163
|
|
|
148
164
|
it('runs the delete hook pipeline for nested delete then hands the payload to Prisma', async () => {
|
|
@@ -168,8 +184,78 @@ describe('Nested Operation Handler Registry', () => {
|
|
|
168
184
|
})
|
|
169
185
|
})
|
|
170
186
|
|
|
187
|
+
describe('refused kinds (set/updateMany/deleteMany, #1384)', () => {
|
|
188
|
+
it('refuses a non-sudo deleteMany/set/updateMany payload, naming the list, field and kinds', async () => {
|
|
189
|
+
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
190
|
+
|
|
191
|
+
let caught: unknown
|
|
192
|
+
try {
|
|
193
|
+
await context.db.post.update({
|
|
194
|
+
where: { id: '1' },
|
|
195
|
+
data: {
|
|
196
|
+
tags: {
|
|
197
|
+
deleteMany: { label: { contains: 'x' } },
|
|
198
|
+
set: [{ id: 'b' }],
|
|
199
|
+
updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
} catch (err) {
|
|
204
|
+
caught = err
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
expect(caught).toBeInstanceOf(NestedRelationInputError)
|
|
208
|
+
const error = caught as NestedRelationInputError
|
|
209
|
+
expect(error.listKey).toBe('Post')
|
|
210
|
+
expect(error.fieldKey).toBe('tags')
|
|
211
|
+
expect(error.kinds).toEqual(['set', 'updateMany', 'deleteMany'])
|
|
212
|
+
// Nothing persisted — the refusal fires before the parent write executes.
|
|
213
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('refuses even when the same payload also carries a permitted kind', async () => {
|
|
217
|
+
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
context.db.post.update({
|
|
221
|
+
where: { id: '1' },
|
|
222
|
+
data: {
|
|
223
|
+
tags: {
|
|
224
|
+
create: { label: 'new-tag' },
|
|
225
|
+
deleteMany: { label: { contains: 'x' } },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
).rejects.toThrow(NestedRelationInputError)
|
|
230
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('still passes deleteMany, set and updateMany through unchanged under sudo', async () => {
|
|
234
|
+
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' }).sudo()
|
|
235
|
+
|
|
236
|
+
await context.db.post.update({
|
|
237
|
+
where: { id: '1' },
|
|
238
|
+
data: {
|
|
239
|
+
tags: {
|
|
240
|
+
deleteMany: { label: { contains: 'x' } },
|
|
241
|
+
set: [{ id: 'b' }],
|
|
242
|
+
updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
|
|
248
|
+
expect(passedTags).toEqual({
|
|
249
|
+
deleteMany: { label: { contains: 'x' } },
|
|
250
|
+
set: [{ id: 'b' }],
|
|
251
|
+
updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
171
256
|
describe('multiple kinds on a single field', () => {
|
|
172
257
|
it('dispatches create and disconnect together, preserving both', async () => {
|
|
258
|
+
mockPrisma.tag.findUnique.mockResolvedValue({ id: 'old-tag', label: 'x' })
|
|
173
259
|
const context = getContext(await buildConfig(), mockPrisma, { userId: '1' })
|
|
174
260
|
|
|
175
261
|
await context.db.post.update({
|
|
@@ -185,7 +271,7 @@ describe('Nested Operation Handler Registry', () => {
|
|
|
185
271
|
const passedTags = mockPrisma.post.update.mock.calls[0][0].data.tags
|
|
186
272
|
// create is processed through hooks/access (object preserved)
|
|
187
273
|
expect(passedTags.create).toEqual({ label: 'new-tag' })
|
|
188
|
-
// disconnect is passed through
|
|
274
|
+
// disconnect is passed through once its target is verified reachable
|
|
189
275
|
expect(passedTags.disconnect).toEqual({ id: 'old-tag' })
|
|
190
276
|
})
|
|
191
277
|
})
|