@opensaas/stack-core 0.20.1 → 0.22.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 +334 -0
- package/CLAUDE.md +29 -11
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +178 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.d.ts +2 -0
- package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
- package/dist/access/multi-column-read-write.test.js +149 -0
- package/dist/access/multi-column-read-write.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/types.d.ts +334 -5
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/format-prisma-default.d.ts +35 -0
- package/dist/fields/format-prisma-default.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.js +52 -0
- package/dist/fields/format-prisma-default.js.map +1 -0
- package/dist/fields/format-prisma-default.test.d.ts +2 -0
- package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.test.js +54 -0
- package/dist/fields/format-prisma-default.test.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +267 -18
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/select.test.js +85 -0
- package/dist/fields/select.test.js.map +1 -1
- package/dist/fields/text-keystone-compat.test.d.ts +2 -0
- package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
- package/dist/fields/text-keystone-compat.test.js +93 -0
- package/dist/fields/text-keystone-compat.test.js.map +1 -0
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +246 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -9
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +33 -0
- package/dist/index.test.js.map +1 -0
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/mcp/handler.js +0 -1
- package/dist/mcp/handler.js.map +1 -1
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +269 -0
- package/src/access/index.ts +7 -4
- package/src/access/multi-column-read-write.test.ts +255 -0
- package/src/config/index.ts +3 -0
- package/src/config/types.ts +342 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +19 -0
- package/src/fields/format-prisma-default.test.ts +64 -0
- package/src/fields/format-prisma-default.ts +67 -0
- package/src/fields/index.ts +375 -20
- package/src/fields/select.test.ts +99 -0
- package/src/fields/text-keystone-compat.test.ts +126 -0
- package/src/hooks/index.ts +270 -0
- package/src/index.test.ts +50 -0
- package/src/index.ts +35 -82
- package/src/internal.ts +49 -0
- package/src/mcp/handler.ts +0 -2
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
runWritePipeline,
|
|
4
|
+
createWriteStrategy,
|
|
5
|
+
updateWriteStrategy,
|
|
6
|
+
deleteWriteStrategy,
|
|
7
|
+
} from '../src/context/write-pipeline.js'
|
|
8
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
9
|
+
import { text } from '../src/fields/index.js'
|
|
10
|
+
import type { OpenSaasConfig, ListConfig } from '../src/config/types.js'
|
|
11
|
+
import type { AccessContext, PrismaClientLike } from '../src/access/types.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unit tests for the Write Pipeline — the single module that owns the canonical
|
|
15
|
+
* secured write sequence. These drive the pipeline directly through its
|
|
16
|
+
* interface (fake Prisma model + spy hooks + a list config), which is the whole
|
|
17
|
+
* point of the deepening: the phase order becomes the test surface.
|
|
18
|
+
*
|
|
19
|
+
* Asserted once, across create/update/delete:
|
|
20
|
+
* - phases run in the documented order;
|
|
21
|
+
* - access denial / missing target / filter non-match short-circuit to `null`
|
|
22
|
+
* BEFORE the DB call and BEFORE beforeOperation;
|
|
23
|
+
* - validation failure throws ValidationError and never hits the DB;
|
|
24
|
+
* - sudo bypasses access + writable filtering;
|
|
25
|
+
* - afterOperation sees the persisted row and the correct originalItem.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Shared ordered log of phase events for order assertions.
|
|
29
|
+
let events: string[]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a fake Prisma model whose methods log their calls. The pipeline
|
|
33
|
+
* resolves the model dynamically via getDbKey('Post') -> 'post'.
|
|
34
|
+
*/
|
|
35
|
+
function makeFakePrisma(overrides?: {
|
|
36
|
+
existing?: Record<string, unknown> | null
|
|
37
|
+
filterMatch?: Record<string, unknown> | null
|
|
38
|
+
created?: Record<string, unknown>
|
|
39
|
+
updated?: Record<string, unknown>
|
|
40
|
+
deleted?: Record<string, unknown>
|
|
41
|
+
count?: number
|
|
42
|
+
}) {
|
|
43
|
+
const created = overrides?.created ?? { id: '1', title: 'created' }
|
|
44
|
+
const updated = overrides?.updated ?? { id: '1', title: 'updated' }
|
|
45
|
+
const deleted = overrides?.deleted ?? { id: '1', title: 'deleted' }
|
|
46
|
+
|
|
47
|
+
const post = {
|
|
48
|
+
findUnique: vi.fn(async () => {
|
|
49
|
+
events.push('db:findUnique')
|
|
50
|
+
return overrides?.existing ?? null
|
|
51
|
+
}),
|
|
52
|
+
findFirst: vi.fn(async () => {
|
|
53
|
+
events.push('db:findFirst')
|
|
54
|
+
return overrides?.filterMatch ?? null
|
|
55
|
+
}),
|
|
56
|
+
count: vi.fn(async () => {
|
|
57
|
+
events.push('db:count')
|
|
58
|
+
return overrides?.count ?? 0
|
|
59
|
+
}),
|
|
60
|
+
create: vi.fn(async () => {
|
|
61
|
+
events.push('db:create')
|
|
62
|
+
return created
|
|
63
|
+
}),
|
|
64
|
+
update: vi.fn(async () => {
|
|
65
|
+
events.push('db:update')
|
|
66
|
+
return updated
|
|
67
|
+
}),
|
|
68
|
+
delete: vi.fn(async () => {
|
|
69
|
+
events.push('db:delete')
|
|
70
|
+
return deleted
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { prisma: { post } as unknown as PrismaClientLike, post }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a minimal AccessContext for the pipeline.
|
|
79
|
+
*/
|
|
80
|
+
function makeContext(opts?: { isSudo?: boolean }): AccessContext {
|
|
81
|
+
return {
|
|
82
|
+
session: { userId: 'u1' },
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
prisma: {} as any,
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
db: {} as any,
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
+
storage: {} as any,
|
|
89
|
+
plugins: {},
|
|
90
|
+
_isSudo: opts?.isSudo ?? false,
|
|
91
|
+
_resolveOutputCounter: { depth: 0 },
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A list config with a spy on every hook, recording its phase into `events`.
|
|
97
|
+
* `title` is a required field so we can exercise built-in field rules.
|
|
98
|
+
*/
|
|
99
|
+
function makeListConfig(opts?: {
|
|
100
|
+
operationAccess?: {
|
|
101
|
+
create?: () => boolean | Record<string, unknown>
|
|
102
|
+
update?: () => boolean | Record<string, unknown>
|
|
103
|
+
delete?: () => boolean | Record<string, unknown>
|
|
104
|
+
}
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
|
+
}): ListConfig<any> {
|
|
107
|
+
return {
|
|
108
|
+
fields: {
|
|
109
|
+
// Use the real text() builder so built-in field rules (isRequired) and
|
|
110
|
+
// getZodSchema actually exist, while spying on each hook phase.
|
|
111
|
+
title: text({
|
|
112
|
+
validation: { isRequired: true },
|
|
113
|
+
hooks: {
|
|
114
|
+
resolveInput: async ({ resolvedData, fieldKey }) => {
|
|
115
|
+
events.push('field:resolveInput')
|
|
116
|
+
return resolvedData[fieldKey]
|
|
117
|
+
},
|
|
118
|
+
validate: async () => {
|
|
119
|
+
events.push('field:validate')
|
|
120
|
+
},
|
|
121
|
+
beforeOperation: async () => {
|
|
122
|
+
events.push('field:beforeOperation')
|
|
123
|
+
},
|
|
124
|
+
afterOperation: async () => {
|
|
125
|
+
events.push('field:afterOperation')
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
access: {
|
|
131
|
+
operation: {
|
|
132
|
+
query: () => true,
|
|
133
|
+
create: opts?.operationAccess?.create ?? (() => true),
|
|
134
|
+
update: opts?.operationAccess?.update ?? (() => true),
|
|
135
|
+
delete: opts?.operationAccess?.delete ?? (() => true),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
hooks: {
|
|
139
|
+
resolveInput: async ({ resolvedData }) => {
|
|
140
|
+
events.push('list:resolveInput')
|
|
141
|
+
return resolvedData
|
|
142
|
+
},
|
|
143
|
+
validate: async () => {
|
|
144
|
+
events.push('list:validate')
|
|
145
|
+
},
|
|
146
|
+
beforeOperation: async () => {
|
|
147
|
+
events.push('list:beforeOperation')
|
|
148
|
+
},
|
|
149
|
+
afterOperation: async () => {
|
|
150
|
+
events.push('list:afterOperation')
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
154
|
+
} as ListConfig<any>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeConfig(listConfig: ListConfig<unknown>): OpenSaasConfig {
|
|
158
|
+
return {
|
|
159
|
+
db: { provider: 'sqlite' },
|
|
160
|
+
lists: { Post: listConfig },
|
|
161
|
+
} as unknown as OpenSaasConfig
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
events = []
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('Write Pipeline — phase order', () => {
|
|
169
|
+
it('runs create phases in the documented order (resolveInput → validate → beforeOp → DB → afterOp → Field Visibility)', async () => {
|
|
170
|
+
const { prisma, post } = makeFakePrisma({ created: { id: '1', title: 'hi' } })
|
|
171
|
+
const listConfig = makeListConfig()
|
|
172
|
+
const context = makeContext()
|
|
173
|
+
|
|
174
|
+
const result = await runWritePipeline({
|
|
175
|
+
listName: 'Post',
|
|
176
|
+
listConfig,
|
|
177
|
+
prisma,
|
|
178
|
+
context,
|
|
179
|
+
config: makeConfig(listConfig),
|
|
180
|
+
inputData: { title: 'hi' },
|
|
181
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
expect(result).toEqual({ id: '1', title: 'hi' })
|
|
185
|
+
// The DB call must come AFTER all input/validate/before hooks and BEFORE after hooks.
|
|
186
|
+
expect(events).toEqual([
|
|
187
|
+
'list:resolveInput',
|
|
188
|
+
'field:resolveInput',
|
|
189
|
+
'list:validate',
|
|
190
|
+
'field:validate',
|
|
191
|
+
'field:beforeOperation',
|
|
192
|
+
'list:beforeOperation',
|
|
193
|
+
'db:create',
|
|
194
|
+
'list:afterOperation',
|
|
195
|
+
'field:afterOperation',
|
|
196
|
+
])
|
|
197
|
+
// resolveInput strictly precedes validate, which precedes the DB write.
|
|
198
|
+
expect(events.indexOf('list:resolveInput')).toBeLessThan(events.indexOf('list:validate'))
|
|
199
|
+
expect(events.indexOf('list:validate')).toBeLessThan(events.indexOf('db:create'))
|
|
200
|
+
expect(events.indexOf('db:create')).toBeLessThan(events.indexOf('list:afterOperation'))
|
|
201
|
+
expect(post.create).toHaveBeenCalledTimes(1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('runs update phases in the documented order, fetching the target first', async () => {
|
|
205
|
+
const existing = { id: '1', title: 'old' }
|
|
206
|
+
const { prisma, post } = makeFakePrisma({ existing, updated: { id: '1', title: 'new' } })
|
|
207
|
+
const listConfig = makeListConfig()
|
|
208
|
+
const context = makeContext()
|
|
209
|
+
|
|
210
|
+
const result = await runWritePipeline({
|
|
211
|
+
listName: 'Post',
|
|
212
|
+
listConfig,
|
|
213
|
+
prisma,
|
|
214
|
+
context,
|
|
215
|
+
config: makeConfig(listConfig),
|
|
216
|
+
inputData: { title: 'new' },
|
|
217
|
+
strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(result).toEqual({ id: '1', title: 'new' })
|
|
221
|
+
expect(events).toEqual([
|
|
222
|
+
'db:findUnique',
|
|
223
|
+
'list:resolveInput',
|
|
224
|
+
'field:resolveInput',
|
|
225
|
+
'list:validate',
|
|
226
|
+
'field:validate',
|
|
227
|
+
'field:beforeOperation',
|
|
228
|
+
'list:beforeOperation',
|
|
229
|
+
'db:update',
|
|
230
|
+
'list:afterOperation',
|
|
231
|
+
'field:afterOperation',
|
|
232
|
+
])
|
|
233
|
+
expect(post.update).toHaveBeenCalledTimes(1)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('runs delete phases in the documented order, SKIPPING the input-shaping phases', async () => {
|
|
237
|
+
const existing = { id: '1', title: 'doomed' }
|
|
238
|
+
const { prisma, post } = makeFakePrisma({ existing, deleted: existing })
|
|
239
|
+
const listConfig = makeListConfig()
|
|
240
|
+
const context = makeContext()
|
|
241
|
+
|
|
242
|
+
const result = await runWritePipeline({
|
|
243
|
+
listName: 'Post',
|
|
244
|
+
listConfig,
|
|
245
|
+
prisma,
|
|
246
|
+
context,
|
|
247
|
+
config: makeConfig(listConfig),
|
|
248
|
+
inputData: undefined,
|
|
249
|
+
strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
expect(result).toEqual(existing)
|
|
253
|
+
// Delete runs only validate/field-validate — NO resolveInput.
|
|
254
|
+
expect(events).toEqual([
|
|
255
|
+
'db:findUnique',
|
|
256
|
+
'list:validate',
|
|
257
|
+
'field:validate',
|
|
258
|
+
'field:beforeOperation',
|
|
259
|
+
'list:beforeOperation',
|
|
260
|
+
'db:delete',
|
|
261
|
+
'list:afterOperation',
|
|
262
|
+
'field:afterOperation',
|
|
263
|
+
])
|
|
264
|
+
expect(events).not.toContain('list:resolveInput')
|
|
265
|
+
expect(events).not.toContain('field:resolveInput')
|
|
266
|
+
expect(post.delete).toHaveBeenCalledTimes(1)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('Write Pipeline — short-circuit to null (silent failure)', () => {
|
|
271
|
+
it('create: access denied short-circuits to null before DB and before beforeOperation', async () => {
|
|
272
|
+
const { prisma, post } = makeFakePrisma()
|
|
273
|
+
const listConfig = makeListConfig({ operationAccess: { create: () => false } })
|
|
274
|
+
const context = makeContext()
|
|
275
|
+
|
|
276
|
+
const result = await runWritePipeline({
|
|
277
|
+
listName: 'Post',
|
|
278
|
+
listConfig,
|
|
279
|
+
prisma,
|
|
280
|
+
context,
|
|
281
|
+
config: makeConfig(listConfig),
|
|
282
|
+
inputData: { title: 'hi' },
|
|
283
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(result).toBeNull()
|
|
287
|
+
expect(post.create).not.toHaveBeenCalled()
|
|
288
|
+
expect(events).not.toContain('list:beforeOperation')
|
|
289
|
+
expect(events).not.toContain('list:resolveInput')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('update: missing target short-circuits to null before access, hooks, and DB', async () => {
|
|
293
|
+
const { prisma, post } = makeFakePrisma({ existing: null })
|
|
294
|
+
const listConfig = makeListConfig()
|
|
295
|
+
const context = makeContext()
|
|
296
|
+
|
|
297
|
+
const result = await runWritePipeline({
|
|
298
|
+
listName: 'Post',
|
|
299
|
+
listConfig,
|
|
300
|
+
prisma,
|
|
301
|
+
context,
|
|
302
|
+
config: makeConfig(listConfig),
|
|
303
|
+
inputData: { title: 'new' },
|
|
304
|
+
strategy: updateWriteStrategy(listConfig, context, { id: 'missing' }),
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
expect(result).toBeNull()
|
|
308
|
+
expect(post.update).not.toHaveBeenCalled()
|
|
309
|
+
expect(events).toEqual(['db:findUnique'])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('update: filter non-match short-circuits to null before DB and beforeOperation', async () => {
|
|
313
|
+
const existing = { id: '1', title: 'old' }
|
|
314
|
+
// filterMatch null => the access filter does not match the target row.
|
|
315
|
+
const { prisma, post } = makeFakePrisma({ existing, filterMatch: null })
|
|
316
|
+
const listConfig = makeListConfig({
|
|
317
|
+
operationAccess: { update: () => ({ authorId: 'someone-else' }) },
|
|
318
|
+
})
|
|
319
|
+
const context = makeContext()
|
|
320
|
+
|
|
321
|
+
const result = await runWritePipeline({
|
|
322
|
+
listName: 'Post',
|
|
323
|
+
listConfig,
|
|
324
|
+
prisma,
|
|
325
|
+
context,
|
|
326
|
+
config: makeConfig(listConfig),
|
|
327
|
+
inputData: { title: 'new' },
|
|
328
|
+
strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
expect(result).toBeNull()
|
|
332
|
+
expect(post.update).not.toHaveBeenCalled()
|
|
333
|
+
// Resolution ran findUnique then findFirst (the filter re-check), then bailed.
|
|
334
|
+
expect(events).toEqual(['db:findUnique', 'db:findFirst'])
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('update: a filter that matches the target proceeds through the full pipeline', async () => {
|
|
338
|
+
const existing = { id: '1', title: 'old' }
|
|
339
|
+
const { prisma, post } = makeFakePrisma({
|
|
340
|
+
existing,
|
|
341
|
+
filterMatch: existing,
|
|
342
|
+
updated: { id: '1', title: 'new' },
|
|
343
|
+
})
|
|
344
|
+
const listConfig = makeListConfig({
|
|
345
|
+
operationAccess: { update: () => ({ authorId: 'u1' }) },
|
|
346
|
+
})
|
|
347
|
+
const context = makeContext()
|
|
348
|
+
|
|
349
|
+
const result = await runWritePipeline({
|
|
350
|
+
listName: 'Post',
|
|
351
|
+
listConfig,
|
|
352
|
+
prisma,
|
|
353
|
+
context,
|
|
354
|
+
config: makeConfig(listConfig),
|
|
355
|
+
inputData: { title: 'new' },
|
|
356
|
+
strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(result).toEqual({ id: '1', title: 'new' })
|
|
360
|
+
expect(post.findFirst).toHaveBeenCalledTimes(1)
|
|
361
|
+
expect(post.update).toHaveBeenCalledTimes(1)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('delete: access denied short-circuits to null before DB', async () => {
|
|
365
|
+
const existing = { id: '1', title: 'x' }
|
|
366
|
+
const { prisma, post } = makeFakePrisma({ existing })
|
|
367
|
+
const listConfig = makeListConfig({ operationAccess: { delete: () => false } })
|
|
368
|
+
const context = makeContext()
|
|
369
|
+
|
|
370
|
+
const result = await runWritePipeline({
|
|
371
|
+
listName: 'Post',
|
|
372
|
+
listConfig,
|
|
373
|
+
prisma,
|
|
374
|
+
context,
|
|
375
|
+
config: makeConfig(listConfig),
|
|
376
|
+
inputData: undefined,
|
|
377
|
+
strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
expect(result).toBeNull()
|
|
381
|
+
expect(post.delete).not.toHaveBeenCalled()
|
|
382
|
+
expect(events).not.toContain('list:beforeOperation')
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('Write Pipeline — validation throws (NOT silent)', () => {
|
|
387
|
+
it('create: a missing required field throws ValidationError and never reaches the DB', async () => {
|
|
388
|
+
const { prisma, post } = makeFakePrisma()
|
|
389
|
+
const listConfig = makeListConfig()
|
|
390
|
+
const context = makeContext()
|
|
391
|
+
|
|
392
|
+
await expect(
|
|
393
|
+
runWritePipeline({
|
|
394
|
+
listName: 'Post',
|
|
395
|
+
listConfig,
|
|
396
|
+
prisma,
|
|
397
|
+
context,
|
|
398
|
+
config: makeConfig(listConfig),
|
|
399
|
+
inputData: {}, // title is required but absent
|
|
400
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
401
|
+
}),
|
|
402
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
403
|
+
|
|
404
|
+
expect(post.create).not.toHaveBeenCalled()
|
|
405
|
+
// Built-in field rules run AFTER the validate hooks and BEFORE beforeOperation.
|
|
406
|
+
expect(events).toContain('list:validate')
|
|
407
|
+
expect(events).not.toContain('list:beforeOperation')
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
describe('Write Pipeline — sudo mode', () => {
|
|
412
|
+
it('create: sudo skips operation-level access checks', async () => {
|
|
413
|
+
const accessSpy = vi.fn(() => false)
|
|
414
|
+
const { prisma, post } = makeFakePrisma({ created: { id: '1', title: 'hi' } })
|
|
415
|
+
const listConfig = makeListConfig({ operationAccess: { create: accessSpy } })
|
|
416
|
+
const context = makeContext({ isSudo: true })
|
|
417
|
+
|
|
418
|
+
const result = await runWritePipeline({
|
|
419
|
+
listName: 'Post',
|
|
420
|
+
listConfig,
|
|
421
|
+
prisma,
|
|
422
|
+
context,
|
|
423
|
+
config: makeConfig(listConfig),
|
|
424
|
+
inputData: { title: 'hi' },
|
|
425
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// Access denied would normally null this out; sudo bypasses the check.
|
|
429
|
+
expect(result).toEqual({ id: '1', title: 'hi' })
|
|
430
|
+
expect(accessSpy).not.toHaveBeenCalled()
|
|
431
|
+
expect(post.create).toHaveBeenCalledTimes(1)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('update: sudo skips access and skips the filter re-check', async () => {
|
|
435
|
+
const accessSpy = vi.fn(() => ({ authorId: 'someone-else' }))
|
|
436
|
+
const existing = { id: '1', title: 'old' }
|
|
437
|
+
const { prisma, post } = makeFakePrisma({
|
|
438
|
+
existing,
|
|
439
|
+
filterMatch: null,
|
|
440
|
+
updated: { id: '1', title: 'new' },
|
|
441
|
+
})
|
|
442
|
+
const listConfig = makeListConfig({ operationAccess: { update: accessSpy } })
|
|
443
|
+
const context = makeContext({ isSudo: true })
|
|
444
|
+
|
|
445
|
+
const result = await runWritePipeline({
|
|
446
|
+
listName: 'Post',
|
|
447
|
+
listConfig,
|
|
448
|
+
prisma,
|
|
449
|
+
context,
|
|
450
|
+
config: makeConfig(listConfig),
|
|
451
|
+
inputData: { title: 'new' },
|
|
452
|
+
strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
expect(result).toEqual({ id: '1', title: 'new' })
|
|
456
|
+
expect(accessSpy).not.toHaveBeenCalled()
|
|
457
|
+
expect(post.findFirst).not.toHaveBeenCalled() // no filter re-check under sudo
|
|
458
|
+
expect(post.update).toHaveBeenCalledTimes(1)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('create: sudo skips writable-field filtering (denied field still written)', async () => {
|
|
462
|
+
// A field whose create access is false would normally be stripped by
|
|
463
|
+
// filterWritableFields; under sudo it must survive into the DB payload.
|
|
464
|
+
const captured: Record<string, unknown>[] = []
|
|
465
|
+
const post = {
|
|
466
|
+
findUnique: vi.fn(),
|
|
467
|
+
findFirst: vi.fn(),
|
|
468
|
+
count: vi.fn(async () => 0),
|
|
469
|
+
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
|
|
470
|
+
captured.push(args.data)
|
|
471
|
+
return { id: '1', ...args.data }
|
|
472
|
+
}),
|
|
473
|
+
update: vi.fn(),
|
|
474
|
+
delete: vi.fn(),
|
|
475
|
+
}
|
|
476
|
+
const prisma = { post } as unknown as PrismaClientLike
|
|
477
|
+
|
|
478
|
+
const listConfig = {
|
|
479
|
+
fields: {
|
|
480
|
+
title: { type: 'text' },
|
|
481
|
+
locked: { type: 'text', access: { create: async () => false } },
|
|
482
|
+
},
|
|
483
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
484
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
485
|
+
} as unknown as ListConfig<any>
|
|
486
|
+
const context = makeContext({ isSudo: true })
|
|
487
|
+
|
|
488
|
+
const result = await runWritePipeline({
|
|
489
|
+
listName: 'Post',
|
|
490
|
+
listConfig,
|
|
491
|
+
prisma,
|
|
492
|
+
context,
|
|
493
|
+
config: makeConfig(listConfig),
|
|
494
|
+
inputData: { title: 'hi', locked: 'secret' },
|
|
495
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
expect(captured[0]).toEqual({ title: 'hi', locked: 'secret' })
|
|
499
|
+
expect(result).toMatchObject({ locked: 'secret' })
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
describe('Write Pipeline — afterOperation originalItem', () => {
|
|
504
|
+
it('create: afterOperation receives the persisted row and undefined originalItem', async () => {
|
|
505
|
+
const persisted = { id: '1', title: 'hi' }
|
|
506
|
+
const { prisma } = makeFakePrisma({ created: persisted })
|
|
507
|
+
const afterOp = vi.fn()
|
|
508
|
+
const listConfig = {
|
|
509
|
+
fields: { title: { type: 'text' } },
|
|
510
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
511
|
+
hooks: { afterOperation: afterOp },
|
|
512
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
513
|
+
} as unknown as ListConfig<any>
|
|
514
|
+
const context = makeContext()
|
|
515
|
+
|
|
516
|
+
await runWritePipeline({
|
|
517
|
+
listName: 'Post',
|
|
518
|
+
listConfig,
|
|
519
|
+
prisma,
|
|
520
|
+
context,
|
|
521
|
+
config: makeConfig(listConfig),
|
|
522
|
+
inputData: { title: 'hi' },
|
|
523
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
expect(afterOp).toHaveBeenCalledTimes(1)
|
|
527
|
+
const arg = afterOp.mock.calls[0][0]
|
|
528
|
+
expect(arg.operation).toBe('create')
|
|
529
|
+
expect(arg.item).toEqual(persisted)
|
|
530
|
+
expect('originalItem' in arg).toBe(false)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('update: afterOperation receives the persisted row and the original row as originalItem', async () => {
|
|
534
|
+
const existing = { id: '1', title: 'old' }
|
|
535
|
+
const updated = { id: '1', title: 'new' }
|
|
536
|
+
const { prisma } = makeFakePrisma({ existing, updated })
|
|
537
|
+
const afterOp = vi.fn()
|
|
538
|
+
const listConfig = {
|
|
539
|
+
fields: { title: { type: 'text' } },
|
|
540
|
+
access: { operation: { query: () => true, update: () => true } },
|
|
541
|
+
hooks: { afterOperation: afterOp },
|
|
542
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
543
|
+
} as unknown as ListConfig<any>
|
|
544
|
+
const context = makeContext()
|
|
545
|
+
|
|
546
|
+
await runWritePipeline({
|
|
547
|
+
listName: 'Post',
|
|
548
|
+
listConfig,
|
|
549
|
+
prisma,
|
|
550
|
+
context,
|
|
551
|
+
config: makeConfig(listConfig),
|
|
552
|
+
inputData: { title: 'new' },
|
|
553
|
+
strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
const arg = afterOp.mock.calls[0][0]
|
|
557
|
+
expect(arg.operation).toBe('update')
|
|
558
|
+
expect(arg.item).toEqual(updated)
|
|
559
|
+
expect(arg.originalItem).toEqual(existing)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('delete: afterOperation receives the original row as originalItem', async () => {
|
|
563
|
+
const existing = { id: '1', title: 'doomed' }
|
|
564
|
+
const { prisma } = makeFakePrisma({ existing, deleted: existing })
|
|
565
|
+
const afterOp = vi.fn()
|
|
566
|
+
const listConfig = {
|
|
567
|
+
fields: { title: { type: 'text' } },
|
|
568
|
+
access: { operation: { query: () => true, delete: () => true } },
|
|
569
|
+
hooks: { afterOperation: afterOp },
|
|
570
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
571
|
+
} as unknown as ListConfig<any>
|
|
572
|
+
const context = makeContext()
|
|
573
|
+
|
|
574
|
+
await runWritePipeline({
|
|
575
|
+
listName: 'Post',
|
|
576
|
+
listConfig,
|
|
577
|
+
prisma,
|
|
578
|
+
context,
|
|
579
|
+
config: makeConfig(listConfig),
|
|
580
|
+
inputData: undefined,
|
|
581
|
+
strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
const arg = afterOp.mock.calls[0][0]
|
|
585
|
+
expect(arg.operation).toBe('delete')
|
|
586
|
+
expect(arg.originalItem).toEqual(existing)
|
|
587
|
+
})
|
|
588
|
+
})
|