@opensaas/stack-core 0.39.2 → 0.41.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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -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 +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -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 +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- 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 +31 -34
- 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/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- 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 +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- 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 +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -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 +389 -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: {
|
|
@@ -1406,6 +1471,330 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
1406
1471
|
}),
|
|
1407
1472
|
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1408
1473
|
})
|
|
1474
|
+
|
|
1475
|
+
// #1081: a nested update/delete access rule returning a Prisma filter (the
|
|
1476
|
+
// common `gate ? filter : false` composition) must be re-checked against
|
|
1477
|
+
// the target row in the DATABASE, mirroring the top-level write pipeline's
|
|
1478
|
+
// `resolveExistingTarget` and the nested-connect reachability check (#578)
|
|
1479
|
+
// — not treated as an unconditional allow just because it isn't `false`.
|
|
1480
|
+
describe('Nested update/delete honour a filter-result access control (#1081)', () => {
|
|
1481
|
+
it('denies nested update (to-one) when the update access filter does not match the target row', async () => {
|
|
1482
|
+
const userResolveInput = vi.fn(async ({ resolvedData }) => resolvedData)
|
|
1483
|
+
|
|
1484
|
+
const testConfig = config({
|
|
1485
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1486
|
+
lists: {
|
|
1487
|
+
User: list({
|
|
1488
|
+
fields: { name: text() },
|
|
1489
|
+
access: {
|
|
1490
|
+
operation: {
|
|
1491
|
+
query: () => true,
|
|
1492
|
+
update: ({ session }) => ({ id: { equals: session?.userId } }),
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
hooks: { resolveInput: userResolveInput },
|
|
1496
|
+
}),
|
|
1497
|
+
Post: list({
|
|
1498
|
+
fields: {
|
|
1499
|
+
title: text(),
|
|
1500
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1501
|
+
},
|
|
1502
|
+
access: {
|
|
1503
|
+
operation: { query: () => true, update: () => true },
|
|
1504
|
+
},
|
|
1505
|
+
}),
|
|
1506
|
+
},
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1510
|
+
// The target row exists ...
|
|
1511
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1512
|
+
// ... but is NOT reachable under { id: { equals: '1' } } for this caller.
|
|
1513
|
+
mockPrisma.user.findFirst.mockResolvedValue(null)
|
|
1514
|
+
|
|
1515
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1516
|
+
|
|
1517
|
+
await expect(
|
|
1518
|
+
context.db.post.update({
|
|
1519
|
+
where: { id: '1' },
|
|
1520
|
+
data: {
|
|
1521
|
+
author: {
|
|
1522
|
+
update: { where: { id: '2' }, data: { name: 'Jane Doe' } },
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
}),
|
|
1526
|
+
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1527
|
+
|
|
1528
|
+
// Reachability re-checked in the DB, AND-combining the target `where`
|
|
1529
|
+
// with the returned filter — not an in-memory test.
|
|
1530
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
|
1531
|
+
where: { AND: [{ id: { equals: '1' } }, { id: '2' }] },
|
|
1532
|
+
})
|
|
1533
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1534
|
+
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
1535
|
+
// Denied before any of the target list's hooks fire.
|
|
1536
|
+
expect(userResolveInput).not.toHaveBeenCalled()
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
it('allows nested update (to-one) when the update access filter matches the target row', async () => {
|
|
1540
|
+
const testConfig = config({
|
|
1541
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1542
|
+
lists: {
|
|
1543
|
+
User: list({
|
|
1544
|
+
fields: { name: text() },
|
|
1545
|
+
access: {
|
|
1546
|
+
operation: {
|
|
1547
|
+
query: () => true,
|
|
1548
|
+
update: ({ session }) => ({ id: { equals: session?.userId } }),
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
}),
|
|
1552
|
+
Post: list({
|
|
1553
|
+
fields: {
|
|
1554
|
+
title: text(),
|
|
1555
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1556
|
+
},
|
|
1557
|
+
access: {
|
|
1558
|
+
operation: { query: () => true, update: () => true },
|
|
1559
|
+
},
|
|
1560
|
+
}),
|
|
1561
|
+
},
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1565
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '1', name: 'John Doe' })
|
|
1566
|
+
// Reachable: the target IS the caller's own row.
|
|
1567
|
+
mockPrisma.user.findFirst.mockResolvedValue({ id: '1', name: 'John Doe' })
|
|
1568
|
+
mockPrisma.post.update.mockResolvedValue({
|
|
1569
|
+
id: '1',
|
|
1570
|
+
title: 'Original Title',
|
|
1571
|
+
authorId: '1',
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1575
|
+
|
|
1576
|
+
const result = await context.db.post.update({
|
|
1577
|
+
where: { id: '1' },
|
|
1578
|
+
data: {
|
|
1579
|
+
author: {
|
|
1580
|
+
update: { where: { id: '1' }, data: { name: 'Jane Doe' } },
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
expect(result).toBeDefined()
|
|
1586
|
+
// The re-check matched, so the nested write reaches Prisma's own
|
|
1587
|
+
// nested-update syntax on the PARENT's `update` call (Prisma persists
|
|
1588
|
+
// it, not a separate `user.update` call).
|
|
1589
|
+
expect(mockPrisma.post.update).toHaveBeenCalledWith(
|
|
1590
|
+
expect.objectContaining({
|
|
1591
|
+
data: expect.objectContaining({
|
|
1592
|
+
author: { update: { where: { id: '1' }, data: { name: 'Jane Doe' } } },
|
|
1593
|
+
}),
|
|
1594
|
+
}),
|
|
1595
|
+
)
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
it('denies nested update (to-many array) when the update access filter does not match the target row', async () => {
|
|
1599
|
+
const testConfig = config({
|
|
1600
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1601
|
+
lists: {
|
|
1602
|
+
Comment: list({
|
|
1603
|
+
fields: { body: text() },
|
|
1604
|
+
access: {
|
|
1605
|
+
operation: {
|
|
1606
|
+
query: () => true,
|
|
1607
|
+
update: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
}),
|
|
1611
|
+
Post: list({
|
|
1612
|
+
fields: {
|
|
1613
|
+
title: text(),
|
|
1614
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1615
|
+
},
|
|
1616
|
+
access: {
|
|
1617
|
+
operation: { query: () => true, update: () => true },
|
|
1618
|
+
},
|
|
1619
|
+
}),
|
|
1620
|
+
},
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1624
|
+
mockPrisma.comment.findUnique.mockResolvedValue({
|
|
1625
|
+
id: 'c1',
|
|
1626
|
+
body: 'hi',
|
|
1627
|
+
authorId: 'someone-else',
|
|
1628
|
+
})
|
|
1629
|
+
mockPrisma.comment.findFirst.mockResolvedValue(null)
|
|
1630
|
+
|
|
1631
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1632
|
+
|
|
1633
|
+
await expect(
|
|
1634
|
+
context.db.post.update({
|
|
1635
|
+
where: { id: '1' },
|
|
1636
|
+
data: {
|
|
1637
|
+
comments: {
|
|
1638
|
+
update: [{ where: { id: 'c1' }, data: { body: 'edited' } }],
|
|
1639
|
+
},
|
|
1640
|
+
},
|
|
1641
|
+
}),
|
|
1642
|
+
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1643
|
+
|
|
1644
|
+
expect(mockPrisma.comment.findFirst).toHaveBeenCalledWith({
|
|
1645
|
+
where: { AND: [{ authorId: { equals: '1' } }, { id: 'c1' }] },
|
|
1646
|
+
})
|
|
1647
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it('denies nested delete when the delete access filter does not match the target row', async () => {
|
|
1651
|
+
const commentBeforeOperation = vi.fn(async () => {})
|
|
1652
|
+
|
|
1653
|
+
const testConfig = config({
|
|
1654
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1655
|
+
lists: {
|
|
1656
|
+
Comment: list({
|
|
1657
|
+
fields: { body: text() },
|
|
1658
|
+
access: {
|
|
1659
|
+
operation: {
|
|
1660
|
+
query: () => true,
|
|
1661
|
+
delete: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1662
|
+
},
|
|
1663
|
+
},
|
|
1664
|
+
hooks: { beforeOperation: commentBeforeOperation },
|
|
1665
|
+
}),
|
|
1666
|
+
Post: list({
|
|
1667
|
+
fields: {
|
|
1668
|
+
title: text(),
|
|
1669
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1670
|
+
},
|
|
1671
|
+
access: {
|
|
1672
|
+
operation: { query: () => true, update: () => true },
|
|
1673
|
+
},
|
|
1674
|
+
}),
|
|
1675
|
+
},
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1679
|
+
mockPrisma.comment.findUnique.mockResolvedValue({
|
|
1680
|
+
id: 'c1',
|
|
1681
|
+
body: 'hi',
|
|
1682
|
+
authorId: 'someone-else',
|
|
1683
|
+
})
|
|
1684
|
+
mockPrisma.comment.findFirst.mockResolvedValue(null)
|
|
1685
|
+
|
|
1686
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1687
|
+
|
|
1688
|
+
await expect(
|
|
1689
|
+
context.db.post.update({
|
|
1690
|
+
where: { id: '1' },
|
|
1691
|
+
data: {
|
|
1692
|
+
comments: { delete: { id: 'c1' } },
|
|
1693
|
+
},
|
|
1694
|
+
}),
|
|
1695
|
+
).rejects.toThrow('Access denied: Cannot delete related item')
|
|
1696
|
+
|
|
1697
|
+
expect(mockPrisma.comment.findFirst).toHaveBeenCalledWith({
|
|
1698
|
+
where: { AND: [{ authorId: { equals: '1' } }, { id: 'c1' }] },
|
|
1699
|
+
})
|
|
1700
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1701
|
+
expect(commentBeforeOperation).not.toHaveBeenCalled()
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1704
|
+
it('allows nested delete when the delete access filter matches the target row', async () => {
|
|
1705
|
+
const testConfig = config({
|
|
1706
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1707
|
+
lists: {
|
|
1708
|
+
Comment: list({
|
|
1709
|
+
fields: { body: text() },
|
|
1710
|
+
access: {
|
|
1711
|
+
operation: {
|
|
1712
|
+
query: () => true,
|
|
1713
|
+
delete: ({ session }) => ({ authorId: { equals: session?.userId } }),
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
}),
|
|
1717
|
+
Post: list({
|
|
1718
|
+
fields: {
|
|
1719
|
+
title: text(),
|
|
1720
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1721
|
+
},
|
|
1722
|
+
access: {
|
|
1723
|
+
operation: { query: () => true, update: () => true },
|
|
1724
|
+
},
|
|
1725
|
+
}),
|
|
1726
|
+
},
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1730
|
+
mockPrisma.comment.findUnique.mockResolvedValue({ id: 'c1', body: 'hi', authorId: '1' })
|
|
1731
|
+
mockPrisma.comment.findFirst.mockResolvedValue({ id: 'c1', body: 'hi', authorId: '1' })
|
|
1732
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1733
|
+
|
|
1734
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1735
|
+
|
|
1736
|
+
const result = await context.db.post.update({
|
|
1737
|
+
where: { id: '1' },
|
|
1738
|
+
data: {
|
|
1739
|
+
comments: { delete: { id: 'c1' } },
|
|
1740
|
+
},
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
expect(result).toBeDefined()
|
|
1744
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
it('sudo bypasses the reachability re-check for both nested update and nested delete', async () => {
|
|
1748
|
+
const updateAccess = vi.fn(() => ({ id: { equals: 'nobody' } }))
|
|
1749
|
+
const deleteAccess = vi.fn(() => ({ id: { equals: 'nobody' } }))
|
|
1750
|
+
|
|
1751
|
+
const testConfig = config({
|
|
1752
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1753
|
+
lists: {
|
|
1754
|
+
User: list({
|
|
1755
|
+
fields: { name: text() },
|
|
1756
|
+
access: { operation: { query: () => true, update: updateAccess } },
|
|
1757
|
+
}),
|
|
1758
|
+
Comment: list({
|
|
1759
|
+
fields: { body: text() },
|
|
1760
|
+
access: { operation: { query: () => true, delete: deleteAccess } },
|
|
1761
|
+
}),
|
|
1762
|
+
Post: list({
|
|
1763
|
+
fields: {
|
|
1764
|
+
title: text(),
|
|
1765
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1766
|
+
comments: relationship({ ref: 'Comment', many: true }),
|
|
1767
|
+
},
|
|
1768
|
+
access: {
|
|
1769
|
+
operation: { query: () => true, update: () => true },
|
|
1770
|
+
},
|
|
1771
|
+
}),
|
|
1772
|
+
},
|
|
1773
|
+
})
|
|
1774
|
+
|
|
1775
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1776
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1777
|
+
mockPrisma.comment.findUnique.mockResolvedValue({ id: 'c1', body: 'hi' })
|
|
1778
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1779
|
+
|
|
1780
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' }).sudo()
|
|
1781
|
+
|
|
1782
|
+
const result = await context.db.post.update({
|
|
1783
|
+
where: { id: '1' },
|
|
1784
|
+
data: {
|
|
1785
|
+
author: { update: { where: { id: '2' }, data: { name: 'Jane Doe' } } },
|
|
1786
|
+
comments: { delete: { id: 'c1' } },
|
|
1787
|
+
},
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
expect(result).toBeDefined()
|
|
1791
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1792
|
+
expect(updateAccess).not.toHaveBeenCalled()
|
|
1793
|
+
expect(deleteAccess).not.toHaveBeenCalled()
|
|
1794
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
1795
|
+
expect(mockPrisma.comment.findFirst).not.toHaveBeenCalled()
|
|
1796
|
+
})
|
|
1797
|
+
})
|
|
1409
1798
|
})
|
|
1410
1799
|
|
|
1411
1800
|
// #588 — nested connect must ALSO be gated by the OWNING relationship field's
|