@opensaas/stack-core 0.39.1 → 0.40.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 +156 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +77 -11
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +77 -11
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,6 +3,7 @@ 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
5
|
import { checkFieldAccess } from '../src/access/field-access.js'
|
|
6
|
+
import { InvalidCreateAccessResultError } from '../src/access/errors.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Mock Prisma Client for testing
|
|
@@ -215,6 +216,70 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
215
216
|
).rejects.toThrow('Access denied: Cannot create related item')
|
|
216
217
|
})
|
|
217
218
|
|
|
219
|
+
// #1009: a nested `create` rule returning a filter reads as though it
|
|
220
|
+
// scopes the create, but the nested-create path (like the top-level one)
|
|
221
|
+
// has no existing row to re-check that filter against. Before this fix
|
|
222
|
+
// the check only tested `=== false`, so a filter fell through the nested
|
|
223
|
+
// path exactly like the top-level one and was silently treated as allow.
|
|
224
|
+
it('should throw when nested create access control returns a Prisma filter', async () => {
|
|
225
|
+
const testConfig = config({
|
|
226
|
+
db: {
|
|
227
|
+
provider: 'postgresql',
|
|
228
|
+
url: 'postgresql://localhost:5432/test',
|
|
229
|
+
},
|
|
230
|
+
lists: {
|
|
231
|
+
User: list({
|
|
232
|
+
fields: {
|
|
233
|
+
name: text(),
|
|
234
|
+
email: text(),
|
|
235
|
+
},
|
|
236
|
+
access: {
|
|
237
|
+
operation: {
|
|
238
|
+
query: () => true,
|
|
239
|
+
// Looks like it scopes the nested create by owner. Cannot be
|
|
240
|
+
// honoured here — there is no row yet.
|
|
241
|
+
create: () => ({ ownerId: { equals: 'someone' } }),
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
Post: list({
|
|
246
|
+
fields: {
|
|
247
|
+
title: text(),
|
|
248
|
+
author: relationship({ ref: 'User.posts' }),
|
|
249
|
+
},
|
|
250
|
+
access: {
|
|
251
|
+
operation: {
|
|
252
|
+
query: () => true,
|
|
253
|
+
update: () => true,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
mockPrisma.post.findUnique.mockResolvedValue({
|
|
261
|
+
id: '1',
|
|
262
|
+
title: 'Original Title',
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const context = getContext(await testConfig, mockPrisma, null)
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
context.db.post.update({
|
|
269
|
+
where: { id: '1' },
|
|
270
|
+
data: {
|
|
271
|
+
title: 'Updated Title',
|
|
272
|
+
author: {
|
|
273
|
+
create: {
|
|
274
|
+
name: 'John',
|
|
275
|
+
email: 'john@example.com',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
).rejects.toThrow(InvalidCreateAccessResultError)
|
|
281
|
+
})
|
|
282
|
+
|
|
218
283
|
it('should apply field-level access control to nested create', async () => {
|
|
219
284
|
const testConfig = config({
|
|
220
285
|
db: {
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text, relationship } from '../src/fields/index.js'
|
|
5
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #978: a nested write through a list-only ref's SYNTHETIC reverse relation
|
|
9
|
+
* (`from_<SourceList>_<sourceField>`, generated because Prisma requires an
|
|
10
|
+
* opposite field but the config never declares one) must run the target
|
|
11
|
+
* list's full write pipeline — the same resolveInput/validate/field-rules/
|
|
12
|
+
* beforeOperation/afterOperation a nested write through a DECLARED
|
|
13
|
+
* relationship field gets. Before this fix, the field went unrecognised by
|
|
14
|
+
* `processNestedOperations` and was handed to Prisma completely untransformed
|
|
15
|
+
* — no hooks, no validation, silently committing whatever the caller sent.
|
|
16
|
+
*
|
|
17
|
+
* `Account` declares no relationship field back to `ChargeRequest` — only
|
|
18
|
+
* `ChargeRequest.account: relationship({ ref: 'Account' })` (list-only), so
|
|
19
|
+
* the generator synthesizes `from_ChargeRequest_account` on Account. Reachable
|
|
20
|
+
* only under `sudo()`: the undeclared-key guard refuses it otherwise (pinned
|
|
21
|
+
* below too).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SYNTHETIC_FIELD = 'from_ChargeRequest_account'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A transaction-aware in-memory Prisma mock for Account (one) / ChargeRequest
|
|
28
|
+
* (many), modeling the synthetic back-relation as a real field on the Account
|
|
29
|
+
* model — exactly as Prisma's generated client would, since the field is a
|
|
30
|
+
* genuine schema artefact even though it has no entry in the OpenSaaS config.
|
|
31
|
+
*/
|
|
32
|
+
function createAccountPrisma() {
|
|
33
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
34
|
+
account: new Map(),
|
|
35
|
+
chargeRequest: new Map(),
|
|
36
|
+
}
|
|
37
|
+
const chargeToAccount = new Map<string, string>()
|
|
38
|
+
let idCounter = 0
|
|
39
|
+
const nextId = () => `cr-${++idCounter}`
|
|
40
|
+
|
|
41
|
+
function linkedChargeRequests(accountId: string): Array<Record<string, unknown>> {
|
|
42
|
+
const rows: Array<Record<string, unknown>> = []
|
|
43
|
+
for (const [crId, owner] of chargeToAccount.entries()) {
|
|
44
|
+
if (owner === accountId) {
|
|
45
|
+
const row = tables.chargeRequest.get(crId)
|
|
46
|
+
if (row) rows.push(row)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rows
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applyChargeRequestOps(accountId: string, ops: Record<string, unknown>) {
|
|
53
|
+
if (ops.create) {
|
|
54
|
+
const creates = Array.isArray(ops.create) ? ops.create : [ops.create]
|
|
55
|
+
for (const data of creates as Array<Record<string, unknown>>) {
|
|
56
|
+
const id = (data.id as string) ?? nextId()
|
|
57
|
+
tables.chargeRequest.set(id, { id, ...data })
|
|
58
|
+
chargeToAccount.set(id, accountId)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (ops.update) {
|
|
62
|
+
const updates = Array.isArray(ops.update) ? ops.update : [ops.update]
|
|
63
|
+
for (const u of updates as Array<{ where: { id: string }; data: Record<string, unknown> }>) {
|
|
64
|
+
const existing = tables.chargeRequest.get(u.where.id) ?? { id: u.where.id }
|
|
65
|
+
tables.chargeRequest.set(u.where.id, { ...existing, ...u.data })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (ops.delete) {
|
|
69
|
+
const deletes = Array.isArray(ops.delete) ? ops.delete : [ops.delete]
|
|
70
|
+
for (const d of deletes as Array<{ id: string }>) {
|
|
71
|
+
tables.chargeRequest.delete(d.id)
|
|
72
|
+
chargeToAccount.delete(d.id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildAccountResult(
|
|
78
|
+
accountId: string,
|
|
79
|
+
include?: Record<string, unknown>,
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
const base = tables.account.get(accountId) ?? { id: accountId }
|
|
82
|
+
if (include?.[SYNTHETIC_FIELD]) {
|
|
83
|
+
return { ...base, [SYNTHETIC_FIELD]: linkedChargeRequests(accountId) }
|
|
84
|
+
}
|
|
85
|
+
return { ...base }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const accountModel = {
|
|
89
|
+
findUnique: vi.fn(
|
|
90
|
+
async ({ where, include }: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
91
|
+
if (!tables.account.has(where.id)) return null
|
|
92
|
+
return buildAccountResult(where.id, include)
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
|
96
|
+
if (where?.id) return tables.account.get(where.id) ?? null
|
|
97
|
+
return tables.account.values().next().value ?? null
|
|
98
|
+
}),
|
|
99
|
+
findMany: vi.fn(async () => Array.from(tables.account.values())),
|
|
100
|
+
count: vi.fn(async () => tables.account.size),
|
|
101
|
+
create: vi.fn(
|
|
102
|
+
async ({
|
|
103
|
+
data,
|
|
104
|
+
include,
|
|
105
|
+
}: {
|
|
106
|
+
data: Record<string, unknown>
|
|
107
|
+
include?: Record<string, unknown>
|
|
108
|
+
}) => {
|
|
109
|
+
const id = (data.id as string) ?? `a-${++idCounter}`
|
|
110
|
+
const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
|
|
111
|
+
tables.account.set(id, { id, ...scalars })
|
|
112
|
+
if (nested) applyChargeRequestOps(id, nested as Record<string, unknown>)
|
|
113
|
+
return buildAccountResult(id, include)
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
update: vi.fn(
|
|
117
|
+
async ({
|
|
118
|
+
where,
|
|
119
|
+
data,
|
|
120
|
+
include,
|
|
121
|
+
}: {
|
|
122
|
+
where: { id: string }
|
|
123
|
+
data: Record<string, unknown>
|
|
124
|
+
include?: Record<string, unknown>
|
|
125
|
+
}) => {
|
|
126
|
+
const existing = tables.account.get(where.id) ?? { id: where.id }
|
|
127
|
+
const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
|
|
128
|
+
tables.account.set(where.id, { ...existing, ...scalars })
|
|
129
|
+
if (nested) applyChargeRequestOps(where.id, nested as Record<string, unknown>)
|
|
130
|
+
return buildAccountResult(where.id, include)
|
|
131
|
+
},
|
|
132
|
+
),
|
|
133
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
134
|
+
const existing = tables.account.get(where.id) ?? { id: where.id }
|
|
135
|
+
tables.account.delete(where.id)
|
|
136
|
+
return existing
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeChargeRequestModel() {
|
|
141
|
+
return {
|
|
142
|
+
findUnique: vi.fn(
|
|
143
|
+
async ({ where }: { where: { id: string } }) => tables.chargeRequest.get(where.id) ?? null,
|
|
144
|
+
),
|
|
145
|
+
findFirst: vi.fn(async () => tables.chargeRequest.values().next().value ?? null),
|
|
146
|
+
findMany: vi.fn(async () => Array.from(tables.chargeRequest.values())),
|
|
147
|
+
count: vi.fn(async () => tables.chargeRequest.size),
|
|
148
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
149
|
+
const id = (data.id as string) ?? nextId()
|
|
150
|
+
const row = { id, ...data }
|
|
151
|
+
tables.chargeRequest.set(id, row)
|
|
152
|
+
return row
|
|
153
|
+
}),
|
|
154
|
+
update: vi.fn(
|
|
155
|
+
async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
156
|
+
const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
|
|
157
|
+
const updated = { ...existing, ...data }
|
|
158
|
+
tables.chargeRequest.set(where.id, updated)
|
|
159
|
+
return updated
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
|
163
|
+
const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
|
|
164
|
+
tables.chargeRequest.delete(where.id)
|
|
165
|
+
chargeToAccount.delete(where.id)
|
|
166
|
+
return existing
|
|
167
|
+
}),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const client: Record<string, unknown> = {
|
|
172
|
+
account: accountModel,
|
|
173
|
+
chargeRequest: makeChargeRequestModel(),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
177
|
+
const snapshot = {
|
|
178
|
+
account: new Map(tables.account),
|
|
179
|
+
chargeRequest: new Map(tables.chargeRequest),
|
|
180
|
+
links: new Map(chargeToAccount),
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return await fn(client)
|
|
184
|
+
} catch (err) {
|
|
185
|
+
tables.account = snapshot.account
|
|
186
|
+
tables.chargeRequest = snapshot.chargeRequest
|
|
187
|
+
chargeToAccount.clear()
|
|
188
|
+
for (const [k, v] of snapshot.links) chargeToAccount.set(k, v)
|
|
189
|
+
throw err
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function seedChargeRequest(accountId: string, row: Record<string, unknown>) {
|
|
194
|
+
tables.chargeRequest.set(row.id as string, row)
|
|
195
|
+
chargeToAccount.set(row.id as string, accountId)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { client, tables, seedChargeRequest, linkedChargeRequests }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildConfig(hooks: Parameters<typeof list>[0]['hooks'] = {}) {
|
|
202
|
+
return config({
|
|
203
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
204
|
+
lists: {
|
|
205
|
+
Account: list({
|
|
206
|
+
fields: { name: text() },
|
|
207
|
+
access: {
|
|
208
|
+
operation: {
|
|
209
|
+
query: () => true,
|
|
210
|
+
create: () => true,
|
|
211
|
+
update: () => true,
|
|
212
|
+
delete: () => true,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
// No `requests`/`chargeRequests` field on Account — this ref is
|
|
217
|
+
// list-only, so the generator synthesizes `from_ChargeRequest_account`.
|
|
218
|
+
ChargeRequest: list({
|
|
219
|
+
fields: {
|
|
220
|
+
kind: text(),
|
|
221
|
+
account: relationship({ ref: 'Account' }),
|
|
222
|
+
},
|
|
223
|
+
access: {
|
|
224
|
+
operation: {
|
|
225
|
+
query: () => true,
|
|
226
|
+
create: () => true,
|
|
227
|
+
update: () => true,
|
|
228
|
+
delete: () => true,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
hooks,
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
describe('#978 nested writes through a synthetic reverse relation', () => {
|
|
238
|
+
it('a non-sudo write through the synthetic key is still refused — unchanged', async () => {
|
|
239
|
+
const mock = createAccountPrisma()
|
|
240
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
241
|
+
const context = getContext(await buildConfig(), mock.client, { userId: '1' })
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
context.db.account.update({
|
|
245
|
+
where: { id: 'a1' },
|
|
246
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } } },
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/from_ChargeRequest_account/)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('sudo nested create through the synthetic key runs the target list validate hook and refuses an invalid row', async () => {
|
|
252
|
+
const mock = createAccountPrisma()
|
|
253
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
254
|
+
|
|
255
|
+
const testConfig = buildConfig({
|
|
256
|
+
validate: ({ resolvedData, addValidationError }) => {
|
|
257
|
+
if (resolvedData.kind === 'INCOHERENT') {
|
|
258
|
+
addValidationError('kind must not be INCOHERENT')
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
context.db.account.update({
|
|
266
|
+
where: { id: 'a1' },
|
|
267
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'INCOHERENT' } } },
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toThrow(ValidationError)
|
|
270
|
+
|
|
271
|
+
// Refused before commit — nothing persisted, matching the atomic write
|
|
272
|
+
// pipeline's "throw rolls back the whole transaction" contract.
|
|
273
|
+
expect(mock.tables.chargeRequest.size).toBe(0)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('sudo nested create through the synthetic key runs resolveInput, beforeOperation, and afterOperation', async () => {
|
|
277
|
+
const mock = createAccountPrisma()
|
|
278
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
279
|
+
|
|
280
|
+
const events: string[] = []
|
|
281
|
+
const testConfig = buildConfig({
|
|
282
|
+
resolveInput: ({ resolvedData }) => {
|
|
283
|
+
events.push('resolveInput')
|
|
284
|
+
return { ...resolvedData, kind: (resolvedData.kind as string).toUpperCase() }
|
|
285
|
+
},
|
|
286
|
+
beforeOperation: ({ operation }) => {
|
|
287
|
+
events.push(`beforeOperation:${operation}`)
|
|
288
|
+
},
|
|
289
|
+
afterOperation: ({ operation, item }) => {
|
|
290
|
+
events.push(`afterOperation:${operation}`)
|
|
291
|
+
expect(item).toBeDefined()
|
|
292
|
+
expect((item as Record<string, unknown>).id).toBeDefined()
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
296
|
+
|
|
297
|
+
await context.db.account.update({
|
|
298
|
+
where: { id: 'a1' },
|
|
299
|
+
data: { [SYNTHETIC_FIELD]: { create: { kind: 'deposit' } } },
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(events).toEqual(['resolveInput', 'beforeOperation:create', 'afterOperation:create'])
|
|
303
|
+
const created = Array.from(mock.tables.chargeRequest.values())
|
|
304
|
+
expect(created).toHaveLength(1)
|
|
305
|
+
// resolveInput's transform actually landed in the persisted row.
|
|
306
|
+
expect(created[0].kind).toBe('DEPOSIT')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('created-row recovery works for a synthetic-relation nested create, like the declared path', async () => {
|
|
310
|
+
const mock = createAccountPrisma()
|
|
311
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
312
|
+
mock.seedChargeRequest('a1', { id: 'pre-1', kind: 'PRE_EXISTING' })
|
|
313
|
+
|
|
314
|
+
const createdItems: Array<Record<string, unknown>> = []
|
|
315
|
+
const testConfig = buildConfig({
|
|
316
|
+
afterOperation: ({ operation, item }) => {
|
|
317
|
+
if (operation === 'create') createdItems.push(item as Record<string, unknown>)
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
321
|
+
|
|
322
|
+
await context.db.account.update({
|
|
323
|
+
where: { id: 'a1' },
|
|
324
|
+
data: { [SYNTHETIC_FIELD]: { create: [{ kind: 'DEPOSIT' }, { kind: 'WITHDRAW' }] } },
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// Exactly the two NEW rows fired afterOperation, each with its own
|
|
328
|
+
// distinct persisted id — never the pre-existing row, never a shared id.
|
|
329
|
+
expect(createdItems).toHaveLength(2)
|
|
330
|
+
const ids = createdItems.map((c) => c.id)
|
|
331
|
+
expect(new Set(ids).size).toBe(2)
|
|
332
|
+
expect(ids).not.toContain('pre-1')
|
|
333
|
+
expect(createdItems.map((c) => c.kind).sort()).toEqual(['DEPOSIT', 'WITHDRAW'])
|
|
334
|
+
|
|
335
|
+
expect(
|
|
336
|
+
mock
|
|
337
|
+
.linkedChargeRequests('a1')
|
|
338
|
+
.map((c) => c.kind)
|
|
339
|
+
.sort(),
|
|
340
|
+
).toEqual(['DEPOSIT', 'PRE_EXISTING', 'WITHDRAW'])
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('sudo nested update through the synthetic key fires afterOperation with originalItem and the updated item', async () => {
|
|
344
|
+
const mock = createAccountPrisma()
|
|
345
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
346
|
+
mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
|
|
347
|
+
|
|
348
|
+
const afterOp = vi.fn()
|
|
349
|
+
const testConfig = buildConfig({ afterOperation: afterOp })
|
|
350
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
351
|
+
|
|
352
|
+
await context.db.account.update({
|
|
353
|
+
where: { id: 'a1' },
|
|
354
|
+
data: {
|
|
355
|
+
[SYNTHETIC_FIELD]: { update: { where: { id: 'cr-1' }, data: { kind: 'WITHDRAW' } } },
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
expect(afterOp).toHaveBeenCalledWith(
|
|
360
|
+
expect.objectContaining({
|
|
361
|
+
operation: 'update',
|
|
362
|
+
originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
363
|
+
item: expect.objectContaining({ id: 'cr-1', kind: 'WITHDRAW' }),
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('sudo nested delete through the synthetic key fires before/afterOperation with originalItem', async () => {
|
|
369
|
+
const mock = createAccountPrisma()
|
|
370
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
371
|
+
mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
|
|
372
|
+
|
|
373
|
+
const listBefore = vi.fn()
|
|
374
|
+
const listAfter = vi.fn()
|
|
375
|
+
const testConfig = buildConfig({ beforeOperation: listBefore, afterOperation: listAfter })
|
|
376
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
377
|
+
|
|
378
|
+
await context.db.account.update({
|
|
379
|
+
where: { id: 'a1' },
|
|
380
|
+
data: { [SYNTHETIC_FIELD]: { delete: { id: 'cr-1' } } },
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
expect(listBefore).toHaveBeenCalledWith(
|
|
384
|
+
expect.objectContaining({
|
|
385
|
+
operation: 'delete',
|
|
386
|
+
item: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
387
|
+
}),
|
|
388
|
+
)
|
|
389
|
+
expect(listAfter).toHaveBeenCalledWith(
|
|
390
|
+
expect.objectContaining({
|
|
391
|
+
operation: 'delete',
|
|
392
|
+
originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
expect(mock.tables.chargeRequest.has('cr-1')).toBe(false)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('a throwing nested afterOperation on the synthetic path rolls back the whole write (atomicity)', async () => {
|
|
399
|
+
const mock = createAccountPrisma()
|
|
400
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
401
|
+
|
|
402
|
+
const testConfig = buildConfig({
|
|
403
|
+
afterOperation: ({ operation }) => {
|
|
404
|
+
if (operation === 'create') throw new Error('synthetic afterOperation boom')
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
|
|
408
|
+
|
|
409
|
+
await expect(
|
|
410
|
+
context.db.account.update({
|
|
411
|
+
where: { id: 'a1' },
|
|
412
|
+
data: {
|
|
413
|
+
name: 'Should NOT persist',
|
|
414
|
+
[SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } },
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
).rejects.toThrow('synthetic afterOperation boom')
|
|
418
|
+
|
|
419
|
+
expect(mock.tables.account.get('a1')?.name).toBe('Acme')
|
|
420
|
+
expect(mock.tables.chargeRequest.size).toBe(0)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('a genuinely unknown key (not a synthetic reverse relation) is refused even under sudo', async () => {
|
|
424
|
+
const mock = createAccountPrisma()
|
|
425
|
+
mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
|
|
426
|
+
const context = getContext(await buildConfig(), mock.client, { userId: '1' }).sudo()
|
|
427
|
+
|
|
428
|
+
await expect(
|
|
429
|
+
context.db.account.update({
|
|
430
|
+
where: { id: 'a1' },
|
|
431
|
+
data: { totallyBogusKey: { create: { kind: 'DEPOSIT' } } },
|
|
432
|
+
}),
|
|
433
|
+
).rejects.toThrow(/totallyBogusKey/)
|
|
434
|
+
|
|
435
|
+
expect(mock.tables.account.get('a1')?.name).toBe('Acme')
|
|
436
|
+
})
|
|
437
|
+
})
|
|
@@ -143,4 +143,32 @@ describe('Password Field Type Safety', () => {
|
|
|
143
143
|
expect(await passwordField.compare('test')).toBe(false)
|
|
144
144
|
}
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
it('should redact the password field when a read row is JSON.stringify-ed', async () => {
|
|
148
|
+
const readableConfig = config({
|
|
149
|
+
db: { provider: 'sqlite' },
|
|
150
|
+
lists: {
|
|
151
|
+
User: list({
|
|
152
|
+
fields: {
|
|
153
|
+
email: text({ validation: { isRequired: true } }),
|
|
154
|
+
password: password({ validation: { isRequired: true } }),
|
|
155
|
+
},
|
|
156
|
+
access: { operation: { query: () => true } },
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
const readablePrismaClient = {
|
|
161
|
+
user: {
|
|
162
|
+
...mockPrismaClient.user,
|
|
163
|
+
findFirst: mockPrismaClient.user.findUnique,
|
|
164
|
+
},
|
|
165
|
+
} as unknown as PrismaClient
|
|
166
|
+
const context = getContext(await readableConfig, readablePrismaClient, null)
|
|
167
|
+
|
|
168
|
+
const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
169
|
+
|
|
170
|
+
const serialized = JSON.stringify(user)
|
|
171
|
+
expect(serialized).not.toContain('$2a$10$hashedpassword')
|
|
172
|
+
expect(JSON.parse(serialized).password).toEqual({ isSet: true })
|
|
173
|
+
})
|
|
146
174
|
})
|
package/tests/password.test.ts
CHANGED
|
@@ -161,12 +161,26 @@ describe('Password Utilities', () => {
|
|
|
161
161
|
expect(`Password: ${wrapped}`).toBe(`Password: ${hash}`)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
-
it('should
|
|
164
|
+
it('should redact the hash when serialized to JSON', async () => {
|
|
165
165
|
const hash = await hashPassword('test')
|
|
166
166
|
const wrapped = new HashedPassword(hash)
|
|
167
167
|
|
|
168
|
+
expect(JSON.stringify(wrapped)).toBe(JSON.stringify({ isSet: true }))
|
|
169
|
+
|
|
168
170
|
const json = JSON.stringify({ password: wrapped })
|
|
169
|
-
expect(json).toBe(JSON.stringify({ password:
|
|
171
|
+
expect(json).toBe(JSON.stringify({ password: { isSet: true } }))
|
|
172
|
+
expect(json).not.toContain(hash)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should keep returning the raw hash from string-coercion surfaces', async () => {
|
|
176
|
+
const hash = await hashPassword('test')
|
|
177
|
+
const wrapped = new HashedPassword(hash)
|
|
178
|
+
|
|
179
|
+
expect(wrapped.toString()).toBe(hash)
|
|
180
|
+
expect(wrapped.valueOf()).toBe(hash)
|
|
181
|
+
expect(String(wrapped)).toBe(hash)
|
|
182
|
+
expect(`${wrapped}`).toBe(hash)
|
|
183
|
+
expect(wrapped == hash).toBe(true)
|
|
170
184
|
})
|
|
171
185
|
|
|
172
186
|
it('should work with valueOf', async () => {
|