@opensaas/stack-core 0.42.2 → 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.
@@ -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 the pass-through kinds (disconnect, delete,
10
- * deleteMany, set, updateMany) is dispatched via the registry. The tests assert
11
- * the exact payload handed to Prisma so a regression in dispatch/ordering is
12
- * caught.
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('pass-through kinds', () => {
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('passes deleteMany, set and updateMany through unchanged', async () => {
122
- // NOTE (#569 / ADR-0010): nested `delete` is no longer a pass-through kind —
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
- deleteMany: { label: { contains: 'x' } },
143
- set: [{ id: 'b' }],
144
- updateMany: { where: { id: 'c' }, data: { label: 'renamed' } },
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 untouched
274
+ // disconnect is passed through once its target is verified reachable
189
275
  expect(passedTags.disconnect).toEqual({ id: 'old-tag' })
190
276
  })
191
277
  })