@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -0
- package/dist/access/types.d.ts +3 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +2 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +7 -0
- package/dist/context/index.js.map +1 -1
- 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/access/types.ts +16 -0
- package/src/context/index.ts +26 -10
- 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
|
@@ -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
|
})
|