@opensaas/stack-core 0.39.2 → 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 +150 -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 +76 -10
- 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 +76 -10
- 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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { uniqueConstraintOf } from '../src/lib/prisma-errors.js'
|
|
3
|
+
|
|
4
|
+
// Fixtures below are the verified output captured during triage of issue #979
|
|
5
|
+
// (PGlite behind a Postgres wire-protocol socket, @prisma/adapter-pg@7.8.0,
|
|
6
|
+
// @prisma/client@7.8.0) — not a hand-invented shape.
|
|
7
|
+
const compositeDriverAdapterError = {
|
|
8
|
+
code: 'P2002',
|
|
9
|
+
meta: {
|
|
10
|
+
modelName: 'Thing',
|
|
11
|
+
driverAdapterError: {
|
|
12
|
+
name: 'DriverAdapterError',
|
|
13
|
+
cause: {
|
|
14
|
+
originalCode: '23505',
|
|
15
|
+
originalMessage: 'duplicate key value violates unique constraint "thing_tenant_slug_key"',
|
|
16
|
+
kind: 'UniqueConstraintViolation',
|
|
17
|
+
constraint: { fields: ['"tenantId"', 'slug'] },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const singleColumnDriverAdapterError = {
|
|
24
|
+
code: 'P2002',
|
|
25
|
+
meta: {
|
|
26
|
+
modelName: 'Thing',
|
|
27
|
+
driverAdapterError: {
|
|
28
|
+
name: 'DriverAdapterError',
|
|
29
|
+
cause: {
|
|
30
|
+
originalCode: '23505',
|
|
31
|
+
originalMessage: 'duplicate key value violates unique constraint "thing_extRef_key"',
|
|
32
|
+
kind: 'UniqueConstraintViolation',
|
|
33
|
+
constraint: { fields: ['"extRef"'] },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// This shape is read directly out of @prisma/adapter-better-sqlite3's own
|
|
40
|
+
// error-mapping source (its SQLITE_CONSTRAINT_UNIQUE branch), not hand-invented:
|
|
41
|
+
// `constraint.fields` comes unquoted (SQLite has no Postgres-style identifier
|
|
42
|
+
// quoting), and `originalMessage` ("UNIQUE constraint failed: Post.slug") has no
|
|
43
|
+
// quoted constraint name for the regex to recover.
|
|
44
|
+
const sqliteDriverAdapterError = {
|
|
45
|
+
code: 'P2002',
|
|
46
|
+
meta: {
|
|
47
|
+
modelName: 'Post',
|
|
48
|
+
driverAdapterError: {
|
|
49
|
+
name: 'DriverAdapterError',
|
|
50
|
+
cause: {
|
|
51
|
+
originalCode: 'SQLITE_CONSTRAINT_UNIQUE',
|
|
52
|
+
originalMessage: 'UNIQUE constraint failed: Post.slug',
|
|
53
|
+
kind: 'UniqueConstraintViolation',
|
|
54
|
+
constraint: { fields: ['slug'] },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('uniqueConstraintOf', () => {
|
|
61
|
+
it('resolves fields and constraint name from a composite driver-adapter P2002', () => {
|
|
62
|
+
expect(uniqueConstraintOf(compositeDriverAdapterError)).toEqual({
|
|
63
|
+
fields: ['tenantId', 'slug'],
|
|
64
|
+
constraintName: 'thing_tenant_slug_key',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves fields (no constraint name) from a SQLite driver-adapter P2002', () => {
|
|
69
|
+
// SQLite's error text carries no quoted constraint name, so this must
|
|
70
|
+
// degrade to fields-only rather than leaving an empty/garbage name.
|
|
71
|
+
expect(uniqueConstraintOf(sqliteDriverAdapterError)).toEqual({ fields: ['slug'] })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resolves a single-column driver-adapter P2002', () => {
|
|
75
|
+
expect(uniqueConstraintOf(singleColumnDriverAdapterError)).toEqual({
|
|
76
|
+
fields: ['extRef'],
|
|
77
|
+
constraintName: 'thing_extRef_key',
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('strips quotes only from identifiers Postgres quoted (camelCase), leaving plain names as-is', () => {
|
|
82
|
+
const info = uniqueConstraintOf(compositeDriverAdapterError)
|
|
83
|
+
// `tenantId` is camelCase and arrives quoted; `slug` is lowercase and does not.
|
|
84
|
+
expect(info?.fields).toEqual(['tenantId', 'slug'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('prefers an already-populated meta.target and does not consult driverAdapterError', () => {
|
|
88
|
+
const error = {
|
|
89
|
+
code: 'P2002',
|
|
90
|
+
meta: {
|
|
91
|
+
target: ['email'],
|
|
92
|
+
// Present but must be ignored — meta.target already wins.
|
|
93
|
+
driverAdapterError: {
|
|
94
|
+
cause: {
|
|
95
|
+
originalMessage: 'duplicate key value violates unique constraint "other_key"',
|
|
96
|
+
constraint: { fields: ['other'] },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(uniqueConstraintOf(error)).toEqual({ fields: ['email'] })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns undefined for a P2002 with no recoverable constraint information', () => {
|
|
106
|
+
expect(uniqueConstraintOf({ code: 'P2002', meta: {} })).toBeUndefined()
|
|
107
|
+
expect(uniqueConstraintOf({ code: 'P2002' })).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns undefined for a non-P2002 error', () => {
|
|
111
|
+
expect(uniqueConstraintOf({ code: 'P2025', meta: { target: ['email'] } })).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns undefined for non-error values without throwing', () => {
|
|
115
|
+
expect(uniqueConstraintOf(null)).toBeUndefined()
|
|
116
|
+
expect(uniqueConstraintOf(undefined)).toBeUndefined()
|
|
117
|
+
expect(uniqueConstraintOf('boom')).toBeUndefined()
|
|
118
|
+
expect(uniqueConstraintOf({})).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('never throws when the adapter error shape is malformed at any level', () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
uniqueConstraintOf({ code: 'P2002', meta: { driverAdapterError: null } }),
|
|
124
|
+
).not.toThrow()
|
|
125
|
+
expect(() =>
|
|
126
|
+
uniqueConstraintOf({ code: 'P2002', meta: { driverAdapterError: { cause: null } } }),
|
|
127
|
+
).not.toThrow()
|
|
128
|
+
expect(() =>
|
|
129
|
+
uniqueConstraintOf({
|
|
130
|
+
code: 'P2002',
|
|
131
|
+
meta: { driverAdapterError: { cause: { constraint: { fields: [1, null, 'ok'] } } } },
|
|
132
|
+
}),
|
|
133
|
+
).not.toThrow()
|
|
134
|
+
expect(
|
|
135
|
+
uniqueConstraintOf({
|
|
136
|
+
code: 'P2002',
|
|
137
|
+
meta: { driverAdapterError: { cause: { constraint: { fields: [1, null, 'ok'] } } } },
|
|
138
|
+
}),
|
|
139
|
+
).toEqual({ fields: ['ok'] })
|
|
140
|
+
})
|
|
141
|
+
})
|
package/tests/singleton.test.ts
CHANGED
|
@@ -278,12 +278,20 @@ describe('Singleton Lists', () => {
|
|
|
278
278
|
featuredAuthorId: 'a1',
|
|
279
279
|
featuredAuthor: { id: 'a1', name: 'Ann' },
|
|
280
280
|
})
|
|
281
|
+
// `featuredAuthor` is to-one, so Prisma cannot scope it with a nested
|
|
282
|
+
// `where` (#974) — it's fetched unscoped, then this batched existence
|
|
283
|
+
// check decides whether it survives.
|
|
284
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1' }])
|
|
281
285
|
|
|
282
286
|
const context = getContext(relConfig, relPrisma, null)
|
|
283
287
|
const result = await context.db.homePage.get({ include: { featuredAuthor: true } })
|
|
284
288
|
|
|
285
289
|
const call = relPrisma.homePage.findFirst.mock.calls[0][0]
|
|
286
|
-
expect(call.include.featuredAuthor).
|
|
290
|
+
expect(call.include.featuredAuthor).toBe(true)
|
|
291
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith({
|
|
292
|
+
where: { AND: [{ published: { equals: true } }, { id: { in: ['a1'] } }] },
|
|
293
|
+
select: { id: true },
|
|
294
|
+
})
|
|
287
295
|
expect(result?.featuredAuthor).toEqual({ id: 'a1', name: 'Ann' })
|
|
288
296
|
})
|
|
289
297
|
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import type { OpenSaasConfig } from '../src/config/types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* End-to-end regression coverage for issue #974: `include`-ing a to-one
|
|
7
|
+
* relation whose related list's `query` access resolves to a filter used to
|
|
8
|
+
* throw `PrismaClientValidationError` — the access-scoped include builder
|
|
9
|
+
* attached that filter as a nested `where`, which Prisma only accepts on a
|
|
10
|
+
* to-many include. These tests exercise the fix through `context.db`, the
|
|
11
|
+
* same surface the issue's reproduction used, with a membership-derived
|
|
12
|
+
* filter (an `OR` reaching through a relation) matching the reporter's real
|
|
13
|
+
* case — proving the filter is handed to Prisma unmodified rather than
|
|
14
|
+
* hand-evaluated.
|
|
15
|
+
*/
|
|
16
|
+
describe('include on a to-one relationship with a filtered related list (#974)', () => {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
let mockPrisma: any
|
|
19
|
+
let config: OpenSaasConfig
|
|
20
|
+
const membershipFilter = {
|
|
21
|
+
OR: [
|
|
22
|
+
{ id: { equals: 'self-id' } },
|
|
23
|
+
{ memberships: { some: { studio: { admins: { some: { id: { equals: 'self-id' } } } } } } },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockPrisma = {
|
|
29
|
+
child: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
30
|
+
user: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
config = {
|
|
34
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
35
|
+
lists: {
|
|
36
|
+
Child: {
|
|
37
|
+
fields: {
|
|
38
|
+
name: { type: 'text' },
|
|
39
|
+
owner: { type: 'relationship', ref: 'User' },
|
|
40
|
+
},
|
|
41
|
+
access: { operation: { query: () => true } },
|
|
42
|
+
},
|
|
43
|
+
User: {
|
|
44
|
+
fields: { name: { type: 'text' } },
|
|
45
|
+
access: { operation: { query: () => membershipFilter } },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('findMany succeeds instead of throwing, admits a visible owner, and nulls an excluded one', async () => {
|
|
52
|
+
mockPrisma.child.findMany.mockResolvedValue([
|
|
53
|
+
{ id: 'c1', name: 'A', owner: { id: 'u1', name: 'Visible' } },
|
|
54
|
+
{ id: 'c2', name: 'B', owner: { id: 'u2', name: 'Hidden' } },
|
|
55
|
+
{ id: 'c3', name: 'C', owner: null },
|
|
56
|
+
])
|
|
57
|
+
// Only u1 satisfies the access filter.
|
|
58
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
|
|
59
|
+
|
|
60
|
+
const context = getContext(config, mockPrisma, { userId: 'self-id' })
|
|
61
|
+
const result = await context.db.child.findMany({ include: { owner: true } })
|
|
62
|
+
|
|
63
|
+
// The Prisma call itself carries no `where` on `owner` — this is exactly
|
|
64
|
+
// the shape Prisma rejected before the fix.
|
|
65
|
+
expect(mockPrisma.child.findMany).toHaveBeenCalledWith(
|
|
66
|
+
expect.objectContaining({ include: { owner: true } }),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// One batched existence check, evaluating the EXACT filter `checkAccess`
|
|
70
|
+
// produced — never a hand-rolled interpretation of it.
|
|
71
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledTimes(1)
|
|
72
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
|
|
73
|
+
where: { AND: [membershipFilter, { id: { in: ['u1', 'u2'] } }] },
|
|
74
|
+
select: { id: true },
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(result).toHaveLength(3)
|
|
78
|
+
expect(result[0].owner).toEqual({ id: 'u1', name: 'Visible' })
|
|
79
|
+
expect(result[1].owner).toBeNull()
|
|
80
|
+
expect(result[2].owner).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('findUnique and findFirst apply the same nulling for a single row', async () => {
|
|
84
|
+
const deniedRow = { id: 'c2', name: 'B', owner: { id: 'u2', name: 'Hidden' } }
|
|
85
|
+
// findUnique reads through the model's own `findFirst`; findFirst is
|
|
86
|
+
// sugar over `findMany` (see `createFindFirst` in context/index.ts) — both
|
|
87
|
+
// need mocking here.
|
|
88
|
+
mockPrisma.child.findFirst.mockResolvedValue(deniedRow)
|
|
89
|
+
mockPrisma.child.findMany.mockResolvedValue([deniedRow])
|
|
90
|
+
mockPrisma.user.findMany.mockResolvedValue([])
|
|
91
|
+
|
|
92
|
+
const context = getContext(config, mockPrisma, { userId: 'self-id' })
|
|
93
|
+
const viaFindUnique = await context.db.child.findUnique({
|
|
94
|
+
where: { id: 'c2' },
|
|
95
|
+
include: { owner: true },
|
|
96
|
+
})
|
|
97
|
+
const viaFindFirst = await context.db.child.findFirst({ include: { owner: true } })
|
|
98
|
+
|
|
99
|
+
expect(viaFindUnique?.owner).toBeNull()
|
|
100
|
+
expect(viaFindFirst?.owner).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('nulls every parent row when the related list denies query access outright, without issuing an existence check', async () => {
|
|
104
|
+
config.lists.User.access = { operation: { query: () => false } }
|
|
105
|
+
mockPrisma.child.findMany.mockResolvedValue([
|
|
106
|
+
{ id: 'c1', name: 'A' }, // never fetched at all — access-filter drops the key from `include`
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
const context = getContext(config, mockPrisma, { userId: 'self-id' })
|
|
110
|
+
const result = await context.db.child.findMany({ include: { owner: true } })
|
|
111
|
+
|
|
112
|
+
expect(mockPrisma.child.findMany).toHaveBeenCalledWith(expect.objectContaining({ include: {} }))
|
|
113
|
+
expect(mockPrisma.user.findMany).not.toHaveBeenCalled()
|
|
114
|
+
expect(result[0].owner).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('leaves the relation unchanged and issues no extra query when the related list is fully open', async () => {
|
|
118
|
+
config.lists.User.access = { operation: { query: () => true } }
|
|
119
|
+
mockPrisma.child.findMany.mockResolvedValue([
|
|
120
|
+
{ id: 'c1', name: 'A', owner: { id: 'u1', name: 'Ann' } },
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const context = getContext(config, mockPrisma, { userId: 'self-id' })
|
|
124
|
+
const result = await context.db.child.findMany({ include: { owner: true } })
|
|
125
|
+
|
|
126
|
+
expect(mockPrisma.child.findMany).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({ include: { owner: true } }),
|
|
128
|
+
)
|
|
129
|
+
expect(mockPrisma.user.findMany).not.toHaveBeenCalled()
|
|
130
|
+
expect(result[0].owner).toEqual({ id: 'u1', name: 'Ann' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('scopes a filtered to-one relation nested inside a to-many include, at every row', async () => {
|
|
134
|
+
config.lists.Child.fields.notes = { type: 'relationship', ref: 'Note.child', many: true }
|
|
135
|
+
config.lists.Note = {
|
|
136
|
+
fields: {
|
|
137
|
+
body: { type: 'text' },
|
|
138
|
+
child: { type: 'relationship', ref: 'Child.notes' },
|
|
139
|
+
author: { type: 'relationship', ref: 'User' },
|
|
140
|
+
},
|
|
141
|
+
access: { operation: { query: () => true } },
|
|
142
|
+
}
|
|
143
|
+
mockPrisma.child.findMany.mockResolvedValue([
|
|
144
|
+
{
|
|
145
|
+
id: 'c1',
|
|
146
|
+
name: 'A',
|
|
147
|
+
notes: [
|
|
148
|
+
{ id: 'n1', body: 'ok', author: { id: 'u1', name: 'Visible' } },
|
|
149
|
+
{ id: 'n2', body: 'no', author: { id: 'u2', name: 'Hidden' } },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
])
|
|
153
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1' }])
|
|
154
|
+
|
|
155
|
+
const context = getContext(config, mockPrisma, { userId: 'self-id' })
|
|
156
|
+
const result = await context.db.child.findMany({
|
|
157
|
+
include: { notes: { include: { author: true } } },
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// The to-many `notes` relation still carries no `where` here (Note's
|
|
161
|
+
// query access is fully open); the nested to-one `author` inside it is
|
|
162
|
+
// resolved via ONE batched existence check across both notes.
|
|
163
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledTimes(1)
|
|
164
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
|
|
165
|
+
where: { AND: [membershipFilter, { id: { in: ['u1', 'u2'] } }] },
|
|
166
|
+
select: { id: true },
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(result[0].notes[0].author).toEqual({ id: 'u1', name: 'Visible' })
|
|
170
|
+
expect(result[0].notes[1].author).toBeNull()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text } from '../src/fields/index.js'
|
|
5
|
+
import type { PrismaClient } from '@prisma/client'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #980: `context.withSession(session)` — the sibling to `sudo()` on the
|
|
9
|
+
* other axis. It substitutes the session a derived context carries while
|
|
10
|
+
* reusing the receiver's already-resolved config and client (including a
|
|
11
|
+
* transaction client) and running access control/hooks exactly as normal.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe('withSession', () => {
|
|
15
|
+
const mockPrisma = {
|
|
16
|
+
post: {
|
|
17
|
+
findFirst: vi.fn(),
|
|
18
|
+
findMany: vi.fn(),
|
|
19
|
+
findUnique: vi.fn(),
|
|
20
|
+
create: vi.fn(),
|
|
21
|
+
update: vi.fn(),
|
|
22
|
+
delete: vi.fn(),
|
|
23
|
+
count: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as PrismaClient
|
|
26
|
+
|
|
27
|
+
const seenSessions: (Record<string, unknown> | null | undefined)[] = []
|
|
28
|
+
|
|
29
|
+
const testConfig = config({
|
|
30
|
+
db: { provider: 'sqlite' },
|
|
31
|
+
lists: {
|
|
32
|
+
Post: list({
|
|
33
|
+
fields: {
|
|
34
|
+
title: text({ validation: { isRequired: true } }),
|
|
35
|
+
},
|
|
36
|
+
access: {
|
|
37
|
+
operation: {
|
|
38
|
+
query: async () => true,
|
|
39
|
+
create: async ({ session }) => session?.role === 'admin',
|
|
40
|
+
update: async ({ session }) => session?.role === 'admin',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
hooks: {
|
|
44
|
+
validate: async ({ context }) => {
|
|
45
|
+
seenSessions.push(context.session as Record<string, unknown> | null | undefined)
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks()
|
|
54
|
+
seenSessions.length = 0
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns a context whose session is the substituted one, leaving the original unmutated', () => {
|
|
58
|
+
const original = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
59
|
+
const derived = original.withSession({ userId: '2', role: 'admin' })
|
|
60
|
+
|
|
61
|
+
expect(derived.session).toEqual({ userId: '2', role: 'admin' })
|
|
62
|
+
expect(original.session).toEqual({ userId: '1', role: 'user' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('a list validate hook sees the substituted session, not the original', async () => {
|
|
66
|
+
const original = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
67
|
+
const derived = original.withSession({ userId: '2', role: 'admin' })
|
|
68
|
+
|
|
69
|
+
mockPrisma.post.create.mockResolvedValue({ id: '1', title: 'Hi' })
|
|
70
|
+
|
|
71
|
+
await derived.db.post.create({ data: { title: 'Hi' } })
|
|
72
|
+
|
|
73
|
+
expect(seenSessions).toEqual([{ userId: '2', role: 'admin' }])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('access rules evaluate against the substituted session — a derived context can do neither more nor less than one built directly with it', async () => {
|
|
77
|
+
const adminSession = { userId: '2', role: 'admin' }
|
|
78
|
+
const derived = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' }).withSession(
|
|
79
|
+
adminSession,
|
|
80
|
+
)
|
|
81
|
+
const direct = getContext(testConfig, mockPrisma, adminSession)
|
|
82
|
+
|
|
83
|
+
mockPrisma.post.create.mockResolvedValue({ id: '1', title: 'Hi' })
|
|
84
|
+
|
|
85
|
+
const derivedResult = await derived.db.post.create({ data: { title: 'Hi' } })
|
|
86
|
+
const directResult = await direct.db.post.create({ data: { title: 'Hi' } })
|
|
87
|
+
|
|
88
|
+
expect(derivedResult).toMatchObject({ title: 'Hi' })
|
|
89
|
+
expect(directResult).toMatchObject({ title: 'Hi' })
|
|
90
|
+
|
|
91
|
+
// Neither more: a non-admin substituted session is denied exactly like a
|
|
92
|
+
// context built with that session directly.
|
|
93
|
+
const userSession = { userId: '3', role: 'user' }
|
|
94
|
+
const derivedDenied = getContext(testConfig, mockPrisma, adminSession).withSession(userSession)
|
|
95
|
+
const directDenied = getContext(testConfig, mockPrisma, userSession)
|
|
96
|
+
|
|
97
|
+
expect(await derivedDenied.db.post.create({ data: { title: 'Nope' } })).toBeNull()
|
|
98
|
+
expect(await directDenied.db.post.create({ data: { title: 'Nope' } })).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('withSession(null) yields an anonymous context and access rules treat it as such', async () => {
|
|
102
|
+
const derived = getContext(testConfig, mockPrisma, { userId: '1', role: 'admin' }).withSession(
|
|
103
|
+
null,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(derived.session).toBeNull()
|
|
107
|
+
|
|
108
|
+
const result = await derived.db.post.create({ data: { title: 'Nope' } })
|
|
109
|
+
expect(result).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('context.withSession(s).sudo() and context.sudo().withSession(s) are equivalent', () => {
|
|
113
|
+
const base = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
114
|
+
const admin = { userId: '2', role: 'admin' }
|
|
115
|
+
|
|
116
|
+
const a = base.withSession(admin).sudo()
|
|
117
|
+
const b = base.sudo().withSession(admin)
|
|
118
|
+
|
|
119
|
+
expect(a._isSudo).toBe(true)
|
|
120
|
+
expect(b._isSudo).toBe(true)
|
|
121
|
+
expect(a.session).toEqual(admin)
|
|
122
|
+
expect(b.session).toEqual(admin)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('preserves sudo state: withSession on a sudo context stays sudo, on a non-sudo context stays non-sudo', () => {
|
|
126
|
+
const base = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
127
|
+
const admin = { userId: '2', role: 'admin' }
|
|
128
|
+
|
|
129
|
+
expect(base.sudo().withSession(admin)._isSudo).toBe(true)
|
|
130
|
+
expect(base.withSession(admin)._isSudo).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('reuses the receiver client — no second connection is opened', () => {
|
|
134
|
+
const original = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
135
|
+
const derived = original.withSession({ userId: '2', role: 'admin' })
|
|
136
|
+
|
|
137
|
+
expect(derived.prisma).toBe(original.prisma)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('reuses the receiver storage', () => {
|
|
141
|
+
const original = getContext(testConfig, mockPrisma, { userId: '1', role: 'user' })
|
|
142
|
+
const derived = original.withSession({ userId: '2', role: 'admin' })
|
|
143
|
+
|
|
144
|
+
expect(derived.storage).toBe(original.storage)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('hooks fire on the derived context exactly as on any other', async () => {
|
|
148
|
+
const original = getContext(testConfig, mockPrisma, { userId: '1', role: 'admin' })
|
|
149
|
+
const derived = original.withSession({ userId: '2', role: 'admin' })
|
|
150
|
+
|
|
151
|
+
mockPrisma.post.create.mockResolvedValue({ id: '1', title: 'Hi' })
|
|
152
|
+
|
|
153
|
+
await derived.db.post.create({ data: { title: 'Hi' } })
|
|
154
|
+
|
|
155
|
+
expect(seenSessions).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('withSession inside context.transaction (#614/#980)', () => {
|
|
160
|
+
function createTxPrisma() {
|
|
161
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = { post: new Map() }
|
|
162
|
+
let idCounter = 0
|
|
163
|
+
const nextId = () => `id-${++idCounter}`
|
|
164
|
+
|
|
165
|
+
function makeModel(table: string) {
|
|
166
|
+
return {
|
|
167
|
+
findUnique: vi.fn(
|
|
168
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
169
|
+
),
|
|
170
|
+
findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
|
|
171
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
172
|
+
count: vi.fn(async () => tables[table].size),
|
|
173
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
174
|
+
const id = (data.id as string) ?? nextId()
|
|
175
|
+
const record = { ...data, id }
|
|
176
|
+
tables[table].set(id, record)
|
|
177
|
+
return record
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const client: Record<string, unknown> = { post: makeModel('post') }
|
|
183
|
+
|
|
184
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
185
|
+
const snapshot: Record<string, Map<string, Record<string, unknown>>> = {}
|
|
186
|
+
for (const [name, map] of Object.entries(tables)) snapshot[name] = new Map(map)
|
|
187
|
+
const { $transaction: _omit, ...models } = client
|
|
188
|
+
void _omit
|
|
189
|
+
try {
|
|
190
|
+
return await fn(models)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
for (const [name, map] of Object.entries(snapshot)) tables[name] = map
|
|
193
|
+
throw err
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { client, tables }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const txConfig = config({
|
|
201
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
202
|
+
lists: {
|
|
203
|
+
Post: list({
|
|
204
|
+
fields: { title: text() },
|
|
205
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('tx.withSession(s) writes participate in the same transaction and roll back with it', async () => {
|
|
211
|
+
const mock = createTxPrisma()
|
|
212
|
+
const context = getContext(txConfig, mock.client, { userId: '1' })
|
|
213
|
+
|
|
214
|
+
// Commits: the substituted-session write is inside the callback's transaction.
|
|
215
|
+
const created = await context.transaction((tx) =>
|
|
216
|
+
tx.withSession({ userId: '2' }).db.post.create({ data: { title: 'committed' } }),
|
|
217
|
+
)
|
|
218
|
+
expect(created).toMatchObject({ title: 'committed' })
|
|
219
|
+
expect(mock.tables.post.size).toBe(1)
|
|
220
|
+
|
|
221
|
+
// Rolls back: a throw after the substituted-session write undoes it too,
|
|
222
|
+
// proving the write joined the same transaction rather than a separate one.
|
|
223
|
+
await expect(
|
|
224
|
+
context.transaction(async (tx) => {
|
|
225
|
+
await tx.withSession({ userId: '3' }).db.post.create({ data: { title: 'rollback-me' } })
|
|
226
|
+
throw new Error('boom')
|
|
227
|
+
}),
|
|
228
|
+
).rejects.toThrow('boom')
|
|
229
|
+
expect(mock.tables.post.size).toBe(1)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('tx.withSession(s) carries the substituted session', async () => {
|
|
233
|
+
const mock = createTxPrisma()
|
|
234
|
+
const context = getContext(txConfig, mock.client, { userId: '1' })
|
|
235
|
+
|
|
236
|
+
const seen = await context.transaction(async (tx) => {
|
|
237
|
+
const withOther = tx.withSession({ userId: 'other' })
|
|
238
|
+
return withOther.session
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(seen).toEqual({ userId: 'other' })
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
deleteWriteStrategy,
|
|
7
7
|
} from '../src/context/write-pipeline.js'
|
|
8
8
|
import { ValidationError } from '../src/hooks/index.js'
|
|
9
|
+
import { InvalidCreateAccessResultError } from '../src/access/errors.js'
|
|
9
10
|
import { text } from '../src/fields/index.js'
|
|
10
11
|
import type { OpenSaasConfig, ListConfig } from '../src/config/types.js'
|
|
11
12
|
import type { AccessContext, PrismaClientLike } from '../src/access/types.js'
|
|
@@ -289,6 +290,34 @@ describe('Write Pipeline — short-circuit to null (silent failure)', () => {
|
|
|
289
290
|
expect(events).not.toContain('list:resolveInput')
|
|
290
291
|
})
|
|
291
292
|
|
|
293
|
+
// #1009: a `create` rule returning a filter reads as though it scopes the
|
|
294
|
+
// create, but create has no existing row to re-check that filter against
|
|
295
|
+
// (unlike update/delete, which do). Before this fix the only test was
|
|
296
|
+
// `=== false`, so a filter fell through and was silently treated as allow.
|
|
297
|
+
it('create: access control returning a filter throws InvalidCreateAccessResultError, before DB and before beforeOperation', async () => {
|
|
298
|
+
const { prisma, post } = makeFakePrisma()
|
|
299
|
+
const listConfig = makeListConfig({
|
|
300
|
+
operationAccess: { create: () => ({ authorId: 'someone' }) },
|
|
301
|
+
})
|
|
302
|
+
const context = makeContext()
|
|
303
|
+
|
|
304
|
+
await expect(
|
|
305
|
+
runWritePipeline({
|
|
306
|
+
listName: 'Post',
|
|
307
|
+
listConfig,
|
|
308
|
+
prisma,
|
|
309
|
+
context,
|
|
310
|
+
config: makeConfig(listConfig),
|
|
311
|
+
inputData: { title: 'hi' },
|
|
312
|
+
strategy: createWriteStrategy('Post', listConfig, context),
|
|
313
|
+
}),
|
|
314
|
+
).rejects.toThrow(InvalidCreateAccessResultError)
|
|
315
|
+
|
|
316
|
+
expect(post.create).not.toHaveBeenCalled()
|
|
317
|
+
expect(events).not.toContain('list:beforeOperation')
|
|
318
|
+
expect(events).not.toContain('list:resolveInput')
|
|
319
|
+
})
|
|
320
|
+
|
|
292
321
|
it('update: missing target short-circuits to null before access, hooks, and DB', async () => {
|
|
293
322
|
const { prisma, post } = makeFakePrisma({ existing: null })
|
|
294
323
|
const listConfig = makeListConfig()
|