@opensaas/stack-core 0.23.0 → 0.25.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 +256 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +378 -0
- 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 +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +426 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/config.test.ts +30 -0
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { getContext } from '../src/context/index.js'
|
|
3
3
|
import { config, list } from '../src/config/index.js'
|
|
4
4
|
import { text, relationship } from '../src/fields/index.js'
|
|
5
|
+
import { checkFieldAccess } from '../src/access/field-access.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Mock Prisma Client for testing
|
|
@@ -266,31 +267,28 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
266
267
|
|
|
267
268
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
268
269
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
270
|
+
// #568: a nested-create field denied by field-level access now THROWS
|
|
271
|
+
// (Keystone fail-loud parity) instead of being silently stripped. Nested
|
|
272
|
+
// writes flow through the same `filterWritableFields` gate, so the denied
|
|
273
|
+
// `role` field rejects the whole write.
|
|
274
|
+
await expect(
|
|
275
|
+
context.db.post.update({
|
|
276
|
+
where: { id: '1' },
|
|
277
|
+
data: {
|
|
278
|
+
title: 'Updated Title',
|
|
279
|
+
author: {
|
|
280
|
+
create: {
|
|
281
|
+
name: 'John',
|
|
282
|
+
email: 'john@example.com',
|
|
283
|
+
role: 'admin', // Denied on create -> throws
|
|
284
|
+
},
|
|
278
285
|
},
|
|
279
286
|
},
|
|
280
|
-
},
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
// Verify the update was called
|
|
284
|
-
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
285
|
-
|
|
286
|
-
// Get the actual data passed to Prisma
|
|
287
|
-
const callArgs = mockPrisma.post.update.mock.calls[0][0]
|
|
288
|
-
const authorCreateData = callArgs.data.author.create
|
|
287
|
+
}),
|
|
288
|
+
).rejects.toThrow(/role/)
|
|
289
289
|
|
|
290
|
-
//
|
|
291
|
-
expect(
|
|
292
|
-
expect(authorCreateData.name).toBe('John')
|
|
293
|
-
expect(authorCreateData.email).toBe('john@example.com')
|
|
290
|
+
// The denied field must abort the write, not let it proceed.
|
|
291
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
294
292
|
})
|
|
295
293
|
|
|
296
294
|
it('should run field validation on nested create', async () => {
|
|
@@ -720,7 +718,7 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
720
718
|
})
|
|
721
719
|
|
|
722
720
|
describe('Access Denial Scenarios', () => {
|
|
723
|
-
it('should deny nested connect when
|
|
721
|
+
it('should deny nested connect when read (query) access scopes out the target row', async () => {
|
|
724
722
|
const testConfig = config({
|
|
725
723
|
db: {
|
|
726
724
|
provider: 'postgresql',
|
|
@@ -733,11 +731,9 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
733
731
|
},
|
|
734
732
|
access: {
|
|
735
733
|
operation: {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
return { id: { equals: session?.userId } }
|
|
740
|
-
},
|
|
734
|
+
// Only allow reading own profile (a scalar filter)
|
|
735
|
+
query: ({ session }) => ({ id: { equals: session?.userId } }),
|
|
736
|
+
update: () => true,
|
|
741
737
|
},
|
|
742
738
|
},
|
|
743
739
|
}),
|
|
@@ -761,11 +757,8 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
761
757
|
title: 'Original Title',
|
|
762
758
|
})
|
|
763
759
|
|
|
764
|
-
//
|
|
765
|
-
mockPrisma.user.
|
|
766
|
-
id: '2',
|
|
767
|
-
name: 'Other User',
|
|
768
|
-
})
|
|
760
|
+
// User 2 is NOT reachable under { id: { equals: '1' } } → findFirst returns null.
|
|
761
|
+
mockPrisma.user.findFirst.mockResolvedValue(null)
|
|
769
762
|
|
|
770
763
|
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
771
764
|
|
|
@@ -774,14 +767,19 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
774
767
|
where: { id: '1' },
|
|
775
768
|
data: {
|
|
776
769
|
author: {
|
|
777
|
-
connect: { id: '2' }, // Connecting to user 2, but session
|
|
770
|
+
connect: { id: '2' }, // Connecting to user 2, but session can only read user 1
|
|
778
771
|
},
|
|
779
772
|
},
|
|
780
773
|
}),
|
|
781
774
|
).rejects.toThrow('Access denied: Cannot connect to this item')
|
|
775
|
+
|
|
776
|
+
// Reachability must be evaluated in the DB, AND-combining connection + filter.
|
|
777
|
+
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
|
778
|
+
where: { AND: [{ id: '2' }, { id: { equals: '1' } }] },
|
|
779
|
+
})
|
|
782
780
|
})
|
|
783
781
|
|
|
784
|
-
it('should allow nested connect when
|
|
782
|
+
it('should allow nested connect when read (query) access is granted (boolean true)', async () => {
|
|
785
783
|
const testConfig = config({
|
|
786
784
|
db: {
|
|
787
785
|
provider: 'postgresql',
|
|
@@ -794,8 +792,8 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
794
792
|
},
|
|
795
793
|
access: {
|
|
796
794
|
operation: {
|
|
797
|
-
query: () => true,
|
|
798
|
-
update: () => true,
|
|
795
|
+
query: () => true, // Allow reading all users
|
|
796
|
+
update: () => true,
|
|
799
797
|
},
|
|
800
798
|
},
|
|
801
799
|
}),
|
|
@@ -843,23 +841,316 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
843
841
|
|
|
844
842
|
expect(result).toBeDefined()
|
|
845
843
|
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
844
|
+
// Boolean-true access must not run a reachability re-check.
|
|
845
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
846
846
|
})
|
|
847
847
|
|
|
848
|
-
it('
|
|
848
|
+
it('connect requires READ access, not target UPDATE (read-not-update repro)', async () => {
|
|
849
|
+
// Caller can READ the target (query=allowAll) but CANNOT update it
|
|
850
|
+
// (update=admin-only). Under the old behaviour this denied the connect;
|
|
851
|
+
// it must now succeed because connect only references the row.
|
|
849
852
|
const testConfig = config({
|
|
850
853
|
db: {
|
|
851
854
|
provider: 'postgresql',
|
|
852
855
|
url: 'postgresql://localhost:5432/test',
|
|
853
856
|
},
|
|
854
857
|
lists: {
|
|
855
|
-
|
|
858
|
+
LessonTerm: list({
|
|
856
859
|
fields: {
|
|
857
860
|
name: text(),
|
|
858
861
|
},
|
|
862
|
+
access: {
|
|
863
|
+
operation: {
|
|
864
|
+
query: () => true, // anyone can read
|
|
865
|
+
update: () => false, // nobody (non-admin) can update
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
}),
|
|
869
|
+
Enrolment: list({
|
|
870
|
+
fields: {
|
|
871
|
+
title: text(),
|
|
872
|
+
term: relationship({ ref: 'LessonTerm' }),
|
|
873
|
+
},
|
|
859
874
|
access: {
|
|
860
875
|
operation: {
|
|
861
876
|
query: () => true,
|
|
862
|
-
update: () =>
|
|
877
|
+
update: () => true,
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
}),
|
|
881
|
+
},
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
mockPrisma.enrolment = {
|
|
885
|
+
findFirst: vi.fn(),
|
|
886
|
+
findMany: vi.fn(),
|
|
887
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'e1', title: 'Term 1 Enrolment' }),
|
|
888
|
+
create: vi.fn(),
|
|
889
|
+
update: vi.fn().mockResolvedValue({ id: 'e1', title: 'Term 1 Enrolment', termId: 't1' }),
|
|
890
|
+
delete: vi.fn(),
|
|
891
|
+
count: vi.fn(),
|
|
892
|
+
}
|
|
893
|
+
mockPrisma.lessonTerm = {
|
|
894
|
+
findFirst: vi.fn(),
|
|
895
|
+
findMany: vi.fn(),
|
|
896
|
+
findUnique: vi.fn().mockResolvedValue({ id: 't1', name: 'Term 1' }),
|
|
897
|
+
create: vi.fn(),
|
|
898
|
+
update: vi.fn(),
|
|
899
|
+
delete: vi.fn(),
|
|
900
|
+
count: vi.fn(),
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'student-1' })
|
|
904
|
+
|
|
905
|
+
const result = await context.db.enrolment.update({
|
|
906
|
+
where: { id: 'e1' },
|
|
907
|
+
data: {
|
|
908
|
+
term: {
|
|
909
|
+
connect: { id: 't1' },
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
expect(result).toBeDefined()
|
|
915
|
+
expect(mockPrisma.enrolment.update).toHaveBeenCalled()
|
|
916
|
+
// query=true → existence check via findUnique, no reachability re-check.
|
|
917
|
+
expect(mockPrisma.lessonTerm.findUnique).toHaveBeenCalledWith({ where: { id: 't1' } })
|
|
918
|
+
expect(mockPrisma.lessonTerm.findFirst).not.toHaveBeenCalled()
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('allows nested connect when a NESTED-RELATION filter is reachable (account-holder self-connect)', async () => {
|
|
922
|
+
// Account's row filter is a nested-relation filter:
|
|
923
|
+
// { user: { id: { equals: session.userId } } }
|
|
924
|
+
// The old in-memory matcher could not evaluate this and always denied.
|
|
925
|
+
const testConfig = config({
|
|
926
|
+
db: {
|
|
927
|
+
provider: 'postgresql',
|
|
928
|
+
url: 'postgresql://localhost:5432/test',
|
|
929
|
+
},
|
|
930
|
+
lists: {
|
|
931
|
+
Account: list({
|
|
932
|
+
fields: {
|
|
933
|
+
name: text(),
|
|
934
|
+
},
|
|
935
|
+
access: {
|
|
936
|
+
operation: {
|
|
937
|
+
query: ({ session }) => ({ user: { id: { equals: session?.userId } } }),
|
|
938
|
+
update: () => true,
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
}),
|
|
942
|
+
Student: list({
|
|
943
|
+
fields: {
|
|
944
|
+
name: text(),
|
|
945
|
+
account: relationship({ ref: 'Account' }),
|
|
946
|
+
},
|
|
947
|
+
access: {
|
|
948
|
+
operation: {
|
|
949
|
+
query: () => true,
|
|
950
|
+
create: () => true,
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
}),
|
|
954
|
+
},
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
mockPrisma.account = {
|
|
958
|
+
findFirst: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
959
|
+
findMany: vi.fn(),
|
|
960
|
+
findUnique: vi.fn(),
|
|
961
|
+
create: vi.fn(),
|
|
962
|
+
update: vi.fn(),
|
|
963
|
+
delete: vi.fn(),
|
|
964
|
+
count: vi.fn(),
|
|
965
|
+
}
|
|
966
|
+
mockPrisma.student = {
|
|
967
|
+
findFirst: vi.fn(),
|
|
968
|
+
findMany: vi.fn(),
|
|
969
|
+
findUnique: vi.fn(),
|
|
970
|
+
create: vi.fn().mockResolvedValue({ id: 's1', name: 'Kid', accountId: 'acc-1' }),
|
|
971
|
+
update: vi.fn(),
|
|
972
|
+
delete: vi.fn(),
|
|
973
|
+
count: vi.fn(),
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
977
|
+
|
|
978
|
+
const result = await context.db.student.create({
|
|
979
|
+
data: {
|
|
980
|
+
name: 'Kid',
|
|
981
|
+
account: { connect: { id: 'acc-1' } },
|
|
982
|
+
},
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
expect(result).toBeDefined()
|
|
986
|
+
expect(mockPrisma.student.create).toHaveBeenCalled()
|
|
987
|
+
// Reachability check evaluated the nested-relation filter in the DB.
|
|
988
|
+
expect(mockPrisma.account.findFirst).toHaveBeenCalledWith({
|
|
989
|
+
where: {
|
|
990
|
+
AND: [{ id: 'acc-1' }, { user: { id: { equals: 'user-1' } } }],
|
|
991
|
+
},
|
|
992
|
+
})
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('denies nested connect when a NESTED-RELATION filter is NOT reachable', async () => {
|
|
996
|
+
const testConfig = config({
|
|
997
|
+
db: {
|
|
998
|
+
provider: 'postgresql',
|
|
999
|
+
url: 'postgresql://localhost:5432/test',
|
|
1000
|
+
},
|
|
1001
|
+
lists: {
|
|
1002
|
+
Account: list({
|
|
1003
|
+
fields: {
|
|
1004
|
+
name: text(),
|
|
1005
|
+
},
|
|
1006
|
+
access: {
|
|
1007
|
+
operation: {
|
|
1008
|
+
query: ({ session }) => ({ user: { id: { equals: session?.userId } } }),
|
|
1009
|
+
update: () => true,
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
}),
|
|
1013
|
+
Student: list({
|
|
1014
|
+
fields: {
|
|
1015
|
+
name: text(),
|
|
1016
|
+
account: relationship({ ref: 'Account' }),
|
|
1017
|
+
},
|
|
1018
|
+
access: {
|
|
1019
|
+
operation: {
|
|
1020
|
+
query: () => true,
|
|
1021
|
+
create: () => true,
|
|
1022
|
+
},
|
|
1023
|
+
},
|
|
1024
|
+
}),
|
|
1025
|
+
},
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
mockPrisma.account = {
|
|
1029
|
+
// Not reachable for this caller → findFirst returns null.
|
|
1030
|
+
findFirst: vi.fn().mockResolvedValue(null),
|
|
1031
|
+
findMany: vi.fn(),
|
|
1032
|
+
findUnique: vi.fn(),
|
|
1033
|
+
create: vi.fn(),
|
|
1034
|
+
update: vi.fn(),
|
|
1035
|
+
delete: vi.fn(),
|
|
1036
|
+
count: vi.fn(),
|
|
1037
|
+
}
|
|
1038
|
+
mockPrisma.student = {
|
|
1039
|
+
findFirst: vi.fn(),
|
|
1040
|
+
findMany: vi.fn(),
|
|
1041
|
+
findUnique: vi.fn(),
|
|
1042
|
+
create: vi.fn(),
|
|
1043
|
+
update: vi.fn(),
|
|
1044
|
+
delete: vi.fn(),
|
|
1045
|
+
count: vi.fn(),
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'attacker' })
|
|
1049
|
+
|
|
1050
|
+
await expect(
|
|
1051
|
+
context.db.student.create({
|
|
1052
|
+
data: {
|
|
1053
|
+
name: 'Kid',
|
|
1054
|
+
account: { connect: { id: 'someone-elses-account' } },
|
|
1055
|
+
},
|
|
1056
|
+
}),
|
|
1057
|
+
).rejects.toThrow('Access denied: Cannot connect to this item')
|
|
1058
|
+
expect(mockPrisma.student.create).not.toHaveBeenCalled()
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
it('evaluates AND/OR/some/none/not boolean-combinator filters via DB reachability', async () => {
|
|
1062
|
+
const complexFilter = {
|
|
1063
|
+
OR: [
|
|
1064
|
+
{ AND: [{ status: { equals: 'active' } }, { NOT: { archived: { equals: true } } }] },
|
|
1065
|
+
{ members: { some: { userId: { equals: 'user-1' } } } },
|
|
1066
|
+
],
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const testConfig = config({
|
|
1070
|
+
db: {
|
|
1071
|
+
provider: 'postgresql',
|
|
1072
|
+
url: 'postgresql://localhost:5432/test',
|
|
1073
|
+
},
|
|
1074
|
+
lists: {
|
|
1075
|
+
Team: list({
|
|
1076
|
+
fields: {
|
|
1077
|
+
name: text(),
|
|
1078
|
+
},
|
|
1079
|
+
access: {
|
|
1080
|
+
operation: {
|
|
1081
|
+
query: () => complexFilter,
|
|
1082
|
+
update: () => true,
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
}),
|
|
1086
|
+
Project: list({
|
|
1087
|
+
fields: {
|
|
1088
|
+
title: text(),
|
|
1089
|
+
team: relationship({ ref: 'Team' }),
|
|
1090
|
+
},
|
|
1091
|
+
access: {
|
|
1092
|
+
operation: {
|
|
1093
|
+
query: () => true,
|
|
1094
|
+
create: () => true,
|
|
1095
|
+
},
|
|
1096
|
+
},
|
|
1097
|
+
}),
|
|
1098
|
+
},
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
mockPrisma.team = {
|
|
1102
|
+
findFirst: vi.fn().mockResolvedValue({ id: 'team-1', name: 'Team A' }),
|
|
1103
|
+
findMany: vi.fn(),
|
|
1104
|
+
findUnique: vi.fn(),
|
|
1105
|
+
create: vi.fn(),
|
|
1106
|
+
update: vi.fn(),
|
|
1107
|
+
delete: vi.fn(),
|
|
1108
|
+
count: vi.fn(),
|
|
1109
|
+
}
|
|
1110
|
+
mockPrisma.project = {
|
|
1111
|
+
findFirst: vi.fn(),
|
|
1112
|
+
findMany: vi.fn(),
|
|
1113
|
+
findUnique: vi.fn(),
|
|
1114
|
+
create: vi.fn().mockResolvedValue({ id: 'p1', title: 'P1', teamId: 'team-1' }),
|
|
1115
|
+
update: vi.fn(),
|
|
1116
|
+
delete: vi.fn(),
|
|
1117
|
+
count: vi.fn(),
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
1121
|
+
|
|
1122
|
+
const result = await context.db.project.create({
|
|
1123
|
+
data: {
|
|
1124
|
+
title: 'P1',
|
|
1125
|
+
team: { connect: { id: 'team-1' } },
|
|
1126
|
+
},
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
expect(result).toBeDefined()
|
|
1130
|
+
// The full boolean-combinator filter is AND-combined with the connection
|
|
1131
|
+
// and handed to the DB — no in-memory walk, no false denial.
|
|
1132
|
+
expect(mockPrisma.team.findFirst).toHaveBeenCalledWith({
|
|
1133
|
+
where: { AND: [{ id: 'team-1' }, complexFilter] },
|
|
1134
|
+
})
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
it('sudo connect bypasses the access check entirely', async () => {
|
|
1138
|
+
const queryAccess = vi.fn(() => false as const)
|
|
1139
|
+
|
|
1140
|
+
const testConfig = config({
|
|
1141
|
+
db: {
|
|
1142
|
+
provider: 'postgresql',
|
|
1143
|
+
url: 'postgresql://localhost:5432/test',
|
|
1144
|
+
},
|
|
1145
|
+
lists: {
|
|
1146
|
+
User: list({
|
|
1147
|
+
fields: {
|
|
1148
|
+
name: text(),
|
|
1149
|
+
},
|
|
1150
|
+
access: {
|
|
1151
|
+
operation: {
|
|
1152
|
+
query: queryAccess, // would deny everything
|
|
1153
|
+
update: () => false,
|
|
863
1154
|
},
|
|
864
1155
|
},
|
|
865
1156
|
}),
|
|
@@ -878,31 +1169,816 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
878
1169
|
},
|
|
879
1170
|
})
|
|
880
1171
|
|
|
881
|
-
mockPrisma.post.findUnique.mockResolvedValue({
|
|
882
|
-
|
|
883
|
-
|
|
1172
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1173
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title', authorId: '2' })
|
|
1174
|
+
|
|
1175
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' }).sudo()
|
|
1176
|
+
|
|
1177
|
+
const result = await context.db.post.update({
|
|
1178
|
+
where: { id: '1' },
|
|
1179
|
+
data: {
|
|
1180
|
+
author: { connect: { id: '2' } },
|
|
1181
|
+
},
|
|
884
1182
|
})
|
|
885
1183
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1184
|
+
expect(result).toBeDefined()
|
|
1185
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1186
|
+
// Sudo skips access evaluation: neither the access fn nor reachability run.
|
|
1187
|
+
expect(queryAccess).not.toHaveBeenCalled()
|
|
1188
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
1189
|
+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled()
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
it("connectOrCreate's connect branch uses read-access + DB reachability and denies unreachable rows", async () => {
|
|
1193
|
+
const testConfig = config({
|
|
1194
|
+
db: {
|
|
1195
|
+
provider: 'postgresql',
|
|
1196
|
+
url: 'postgresql://localhost:5432/test',
|
|
1197
|
+
},
|
|
1198
|
+
lists: {
|
|
1199
|
+
Account: list({
|
|
1200
|
+
fields: {
|
|
1201
|
+
name: text(),
|
|
1202
|
+
},
|
|
1203
|
+
access: {
|
|
1204
|
+
operation: {
|
|
1205
|
+
query: ({ session }) => ({ user: { id: { equals: session?.userId } } }),
|
|
1206
|
+
create: () => true,
|
|
1207
|
+
update: () => true,
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
}),
|
|
1211
|
+
Student: list({
|
|
1212
|
+
fields: {
|
|
1213
|
+
name: text(),
|
|
1214
|
+
account: relationship({ ref: 'Account' }),
|
|
1215
|
+
},
|
|
1216
|
+
access: {
|
|
1217
|
+
operation: {
|
|
1218
|
+
query: () => true,
|
|
1219
|
+
create: () => true,
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
}),
|
|
1223
|
+
},
|
|
889
1224
|
})
|
|
890
1225
|
|
|
891
|
-
|
|
1226
|
+
mockPrisma.account = {
|
|
1227
|
+
// Row exists ...
|
|
1228
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'acc-x', name: 'Other Account' }),
|
|
1229
|
+
// ... but is NOT reachable under the access filter for this caller.
|
|
1230
|
+
findFirst: vi.fn().mockResolvedValue(null),
|
|
1231
|
+
findMany: vi.fn(),
|
|
1232
|
+
create: vi.fn(),
|
|
1233
|
+
update: vi.fn(),
|
|
1234
|
+
delete: vi.fn(),
|
|
1235
|
+
count: vi.fn(),
|
|
1236
|
+
}
|
|
1237
|
+
mockPrisma.student = {
|
|
1238
|
+
findFirst: vi.fn(),
|
|
1239
|
+
findMany: vi.fn(),
|
|
1240
|
+
findUnique: vi.fn(),
|
|
1241
|
+
create: vi.fn(),
|
|
1242
|
+
update: vi.fn(),
|
|
1243
|
+
delete: vi.fn(),
|
|
1244
|
+
count: vi.fn(),
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
892
1248
|
|
|
893
1249
|
await expect(
|
|
894
|
-
context.db.
|
|
895
|
-
where: { id: '1' },
|
|
1250
|
+
context.db.student.create({
|
|
896
1251
|
data: {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1252
|
+
name: 'Kid',
|
|
1253
|
+
account: {
|
|
1254
|
+
connectOrCreate: {
|
|
1255
|
+
where: { id: 'acc-x' },
|
|
1256
|
+
create: { name: 'New Account' },
|
|
901
1257
|
},
|
|
902
1258
|
},
|
|
903
1259
|
},
|
|
904
1260
|
}),
|
|
905
|
-
).rejects.toThrow('Access denied: Cannot
|
|
1261
|
+
).rejects.toThrow('Access denied: Cannot connect to existing item')
|
|
1262
|
+
|
|
1263
|
+
expect(mockPrisma.account.findFirst).toHaveBeenCalledWith({
|
|
1264
|
+
where: { AND: [{ id: 'acc-x' }, { user: { id: { equals: 'user-1' } } }] },
|
|
1265
|
+
})
|
|
1266
|
+
expect(mockPrisma.student.create).not.toHaveBeenCalled()
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it("connectOrCreate's connect branch allows a reachable existing row", async () => {
|
|
1270
|
+
const testConfig = config({
|
|
1271
|
+
db: {
|
|
1272
|
+
provider: 'postgresql',
|
|
1273
|
+
url: 'postgresql://localhost:5432/test',
|
|
1274
|
+
},
|
|
1275
|
+
lists: {
|
|
1276
|
+
Account: list({
|
|
1277
|
+
fields: {
|
|
1278
|
+
name: text(),
|
|
1279
|
+
},
|
|
1280
|
+
access: {
|
|
1281
|
+
operation: {
|
|
1282
|
+
query: ({ session }) => ({ user: { id: { equals: session?.userId } } }),
|
|
1283
|
+
create: () => true,
|
|
1284
|
+
update: () => true,
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
}),
|
|
1288
|
+
Student: list({
|
|
1289
|
+
fields: {
|
|
1290
|
+
name: text(),
|
|
1291
|
+
account: relationship({ ref: 'Account' }),
|
|
1292
|
+
},
|
|
1293
|
+
access: {
|
|
1294
|
+
operation: {
|
|
1295
|
+
query: () => true,
|
|
1296
|
+
create: () => true,
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
}),
|
|
1300
|
+
},
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
mockPrisma.account = {
|
|
1304
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1305
|
+
findFirst: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1306
|
+
findMany: vi.fn(),
|
|
1307
|
+
create: vi.fn(),
|
|
1308
|
+
update: vi.fn(),
|
|
1309
|
+
delete: vi.fn(),
|
|
1310
|
+
count: vi.fn(),
|
|
1311
|
+
}
|
|
1312
|
+
mockPrisma.student = {
|
|
1313
|
+
findFirst: vi.fn(),
|
|
1314
|
+
findMany: vi.fn(),
|
|
1315
|
+
findUnique: vi.fn(),
|
|
1316
|
+
create: vi.fn().mockResolvedValue({ id: 's1', name: 'Kid', accountId: 'acc-1' }),
|
|
1317
|
+
update: vi.fn(),
|
|
1318
|
+
delete: vi.fn(),
|
|
1319
|
+
count: vi.fn(),
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
1323
|
+
|
|
1324
|
+
const result = await context.db.student.create({
|
|
1325
|
+
data: {
|
|
1326
|
+
name: 'Kid',
|
|
1327
|
+
account: {
|
|
1328
|
+
connectOrCreate: {
|
|
1329
|
+
where: { id: 'acc-1' },
|
|
1330
|
+
create: { name: 'New Account' },
|
|
1331
|
+
},
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
expect(result).toBeDefined()
|
|
1337
|
+
expect(mockPrisma.student.create).toHaveBeenCalled()
|
|
1338
|
+
})
|
|
1339
|
+
|
|
1340
|
+
it('should deny nested update when update access is denied on related item', async () => {
|
|
1341
|
+
const testConfig = config({
|
|
1342
|
+
db: {
|
|
1343
|
+
provider: 'postgresql',
|
|
1344
|
+
url: 'postgresql://localhost:5432/test',
|
|
1345
|
+
},
|
|
1346
|
+
lists: {
|
|
1347
|
+
User: list({
|
|
1348
|
+
fields: {
|
|
1349
|
+
name: text(),
|
|
1350
|
+
},
|
|
1351
|
+
access: {
|
|
1352
|
+
operation: {
|
|
1353
|
+
query: () => true,
|
|
1354
|
+
update: () => false, // Deny all updates
|
|
1355
|
+
},
|
|
1356
|
+
},
|
|
1357
|
+
}),
|
|
1358
|
+
Post: list({
|
|
1359
|
+
fields: {
|
|
1360
|
+
title: text(),
|
|
1361
|
+
author: relationship({ ref: 'User.posts' }),
|
|
1362
|
+
},
|
|
1363
|
+
access: {
|
|
1364
|
+
operation: {
|
|
1365
|
+
query: () => true,
|
|
1366
|
+
update: () => true,
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
}),
|
|
1370
|
+
},
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
mockPrisma.post.findUnique.mockResolvedValue({
|
|
1374
|
+
id: '1',
|
|
1375
|
+
title: 'Original Title',
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
mockPrisma.user.findUnique.mockResolvedValue({
|
|
1379
|
+
id: '2',
|
|
1380
|
+
name: 'John Doe',
|
|
1381
|
+
})
|
|
1382
|
+
|
|
1383
|
+
const context = getContext(await testConfig, mockPrisma, null)
|
|
1384
|
+
|
|
1385
|
+
await expect(
|
|
1386
|
+
context.db.post.update({
|
|
1387
|
+
where: { id: '1' },
|
|
1388
|
+
data: {
|
|
1389
|
+
author: {
|
|
1390
|
+
update: {
|
|
1391
|
+
where: { id: '2' },
|
|
1392
|
+
data: { name: 'Jane Doe' },
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1395
|
+
},
|
|
1396
|
+
}),
|
|
1397
|
+
).rejects.toThrow('Access denied: Cannot update related item')
|
|
1398
|
+
})
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
// #588 — nested connect must ALSO be gated by the OWNING relationship field's
|
|
1402
|
+
// field-level access (the `access` on the relationship field of the list being
|
|
1403
|
+
// written, e.g. `Post.author`), in addition to the target read-access + DB
|
|
1404
|
+
// reachability check from #578. A deny on the owning field denies the connect
|
|
1405
|
+
// even when the target row is fully readable/reachable.
|
|
1406
|
+
//
|
|
1407
|
+
// NOTE: the owning relationship field's create/update access is enforced at two
|
|
1408
|
+
// points for a write that names the relationship key: (1) the parent write's
|
|
1409
|
+
// own `filterWritableFields` gate (#568), which fires FIRST and fail-louds with
|
|
1410
|
+
// a "Cannot create/update <field>" ValidationError, and (2) the connect-site
|
|
1411
|
+
// gate added here (#588), which fail-louds with "Access denied: Cannot
|
|
1412
|
+
// connect…". For the standard nested-connect payload #568 short-circuits, so
|
|
1413
|
+
// these deny tests assert the write is denied fail-loud (and never persisted)
|
|
1414
|
+
// rather than pinning the exact layer. The ALLOW + sudo tests below exercise
|
|
1415
|
+
// the connect-site gate's pass-through behaviour directly.
|
|
1416
|
+
const OWNING_FIELD_DENIES = /field-level access denied|Cannot connect/
|
|
1417
|
+
|
|
1418
|
+
describe('Owning-field field-level access gate on nested connect (#588)', () => {
|
|
1419
|
+
it('denies connect (enclosing UPDATE) when the owning field update access is denied, even though target is readable/reachable', async () => {
|
|
1420
|
+
const testConfig = config({
|
|
1421
|
+
db: {
|
|
1422
|
+
provider: 'postgresql',
|
|
1423
|
+
url: 'postgresql://localhost:5432/test',
|
|
1424
|
+
},
|
|
1425
|
+
lists: {
|
|
1426
|
+
User: list({
|
|
1427
|
+
fields: {
|
|
1428
|
+
name: text(),
|
|
1429
|
+
},
|
|
1430
|
+
access: {
|
|
1431
|
+
operation: {
|
|
1432
|
+
query: () => true, // target fully readable
|
|
1433
|
+
update: () => true,
|
|
1434
|
+
},
|
|
1435
|
+
},
|
|
1436
|
+
}),
|
|
1437
|
+
Post: list({
|
|
1438
|
+
fields: {
|
|
1439
|
+
title: text(),
|
|
1440
|
+
// Owning relationship field denies update-time writes.
|
|
1441
|
+
author: relationship({
|
|
1442
|
+
ref: 'User.posts',
|
|
1443
|
+
access: {
|
|
1444
|
+
update: () => false,
|
|
1445
|
+
},
|
|
1446
|
+
}),
|
|
1447
|
+
},
|
|
1448
|
+
access: {
|
|
1449
|
+
operation: {
|
|
1450
|
+
query: () => true,
|
|
1451
|
+
update: () => true,
|
|
1452
|
+
},
|
|
1453
|
+
},
|
|
1454
|
+
}),
|
|
1455
|
+
},
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1459
|
+
// Target row is readable (query=true) and exists.
|
|
1460
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1461
|
+
|
|
1462
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1463
|
+
|
|
1464
|
+
await expect(
|
|
1465
|
+
context.db.post.update({
|
|
1466
|
+
where: { id: '1' },
|
|
1467
|
+
data: {
|
|
1468
|
+
author: { connect: { id: '2' } },
|
|
1469
|
+
},
|
|
1470
|
+
}),
|
|
1471
|
+
).rejects.toThrow(OWNING_FIELD_DENIES)
|
|
1472
|
+
|
|
1473
|
+
expect(mockPrisma.post.update).not.toHaveBeenCalled()
|
|
1474
|
+
// Denied owning field short-circuits before the target row is touched.
|
|
1475
|
+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled()
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
it('denies connect (enclosing CREATE) when the owning field create access is denied, even though target is readable/reachable', async () => {
|
|
1479
|
+
const testConfig = config({
|
|
1480
|
+
db: {
|
|
1481
|
+
provider: 'postgresql',
|
|
1482
|
+
url: 'postgresql://localhost:5432/test',
|
|
1483
|
+
},
|
|
1484
|
+
lists: {
|
|
1485
|
+
Account: list({
|
|
1486
|
+
fields: {
|
|
1487
|
+
name: text(),
|
|
1488
|
+
},
|
|
1489
|
+
access: {
|
|
1490
|
+
operation: {
|
|
1491
|
+
query: () => true, // target fully readable
|
|
1492
|
+
create: () => true,
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
}),
|
|
1496
|
+
Student: list({
|
|
1497
|
+
fields: {
|
|
1498
|
+
name: text(),
|
|
1499
|
+
// Owning relationship field denies create-time writes.
|
|
1500
|
+
account: relationship({
|
|
1501
|
+
ref: 'Account',
|
|
1502
|
+
access: {
|
|
1503
|
+
create: () => false,
|
|
1504
|
+
},
|
|
1505
|
+
}),
|
|
1506
|
+
},
|
|
1507
|
+
access: {
|
|
1508
|
+
operation: {
|
|
1509
|
+
query: () => true,
|
|
1510
|
+
create: () => true,
|
|
1511
|
+
},
|
|
1512
|
+
},
|
|
1513
|
+
}),
|
|
1514
|
+
},
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
mockPrisma.account = {
|
|
1518
|
+
findFirst: vi.fn(),
|
|
1519
|
+
findMany: vi.fn(),
|
|
1520
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1521
|
+
create: vi.fn(),
|
|
1522
|
+
update: vi.fn(),
|
|
1523
|
+
delete: vi.fn(),
|
|
1524
|
+
count: vi.fn(),
|
|
1525
|
+
}
|
|
1526
|
+
mockPrisma.student = {
|
|
1527
|
+
findFirst: vi.fn(),
|
|
1528
|
+
findMany: vi.fn(),
|
|
1529
|
+
findUnique: vi.fn(),
|
|
1530
|
+
create: vi.fn(),
|
|
1531
|
+
update: vi.fn(),
|
|
1532
|
+
delete: vi.fn(),
|
|
1533
|
+
count: vi.fn(),
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
1537
|
+
|
|
1538
|
+
await expect(
|
|
1539
|
+
context.db.student.create({
|
|
1540
|
+
data: {
|
|
1541
|
+
name: 'Kid',
|
|
1542
|
+
account: { connect: { id: 'acc-1' } },
|
|
1543
|
+
},
|
|
1544
|
+
}),
|
|
1545
|
+
).rejects.toThrow(OWNING_FIELD_DENIES)
|
|
1546
|
+
|
|
1547
|
+
expect(mockPrisma.student.create).not.toHaveBeenCalled()
|
|
1548
|
+
expect(mockPrisma.account.findUnique).not.toHaveBeenCalled()
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('allows connect when the owning field access permits and the target row is reachable (enclosing UPDATE)', async () => {
|
|
1552
|
+
const testConfig = config({
|
|
1553
|
+
db: {
|
|
1554
|
+
provider: 'postgresql',
|
|
1555
|
+
url: 'postgresql://localhost:5432/test',
|
|
1556
|
+
},
|
|
1557
|
+
lists: {
|
|
1558
|
+
User: list({
|
|
1559
|
+
fields: {
|
|
1560
|
+
name: text(),
|
|
1561
|
+
},
|
|
1562
|
+
access: {
|
|
1563
|
+
operation: {
|
|
1564
|
+
query: () => true,
|
|
1565
|
+
update: () => true,
|
|
1566
|
+
},
|
|
1567
|
+
},
|
|
1568
|
+
}),
|
|
1569
|
+
Post: list({
|
|
1570
|
+
fields: {
|
|
1571
|
+
title: text(),
|
|
1572
|
+
author: relationship({
|
|
1573
|
+
ref: 'User.posts',
|
|
1574
|
+
access: {
|
|
1575
|
+
update: () => true, // owning field permits
|
|
1576
|
+
},
|
|
1577
|
+
}),
|
|
1578
|
+
},
|
|
1579
|
+
access: {
|
|
1580
|
+
operation: {
|
|
1581
|
+
query: () => true,
|
|
1582
|
+
update: () => true,
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
}),
|
|
1586
|
+
},
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1590
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1591
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title', authorId: '2' })
|
|
1592
|
+
|
|
1593
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1594
|
+
|
|
1595
|
+
const result = await context.db.post.update({
|
|
1596
|
+
where: { id: '1' },
|
|
1597
|
+
data: {
|
|
1598
|
+
author: { connect: { id: '2' } },
|
|
1599
|
+
},
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
expect(result).toBeDefined()
|
|
1603
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1604
|
+
// Owning field allowed → fall through to the #578 target existence check.
|
|
1605
|
+
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '2' } })
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
it('sudo bypasses the owning-field gate (denied owning field, connect still succeeds)', async () => {
|
|
1609
|
+
const owningFieldUpdate = vi.fn(() => false as const)
|
|
1610
|
+
|
|
1611
|
+
const testConfig = config({
|
|
1612
|
+
db: {
|
|
1613
|
+
provider: 'postgresql',
|
|
1614
|
+
url: 'postgresql://localhost:5432/test',
|
|
1615
|
+
},
|
|
1616
|
+
lists: {
|
|
1617
|
+
User: list({
|
|
1618
|
+
fields: {
|
|
1619
|
+
name: text(),
|
|
1620
|
+
},
|
|
1621
|
+
access: {
|
|
1622
|
+
operation: {
|
|
1623
|
+
query: () => false,
|
|
1624
|
+
update: () => false,
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
}),
|
|
1628
|
+
Post: list({
|
|
1629
|
+
fields: {
|
|
1630
|
+
title: text(),
|
|
1631
|
+
author: relationship({
|
|
1632
|
+
ref: 'User.posts',
|
|
1633
|
+
access: {
|
|
1634
|
+
update: owningFieldUpdate, // would deny
|
|
1635
|
+
},
|
|
1636
|
+
}),
|
|
1637
|
+
},
|
|
1638
|
+
access: {
|
|
1639
|
+
operation: {
|
|
1640
|
+
query: () => true,
|
|
1641
|
+
update: () => true,
|
|
1642
|
+
},
|
|
1643
|
+
},
|
|
1644
|
+
}),
|
|
1645
|
+
},
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
mockPrisma.post.findUnique.mockResolvedValue({ id: '1', title: 'Original Title' })
|
|
1649
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Original Title', authorId: '2' })
|
|
1650
|
+
|
|
1651
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' }).sudo()
|
|
1652
|
+
|
|
1653
|
+
const result = await context.db.post.update({
|
|
1654
|
+
where: { id: '1' },
|
|
1655
|
+
data: {
|
|
1656
|
+
author: { connect: { id: '2' } },
|
|
1657
|
+
},
|
|
1658
|
+
})
|
|
1659
|
+
|
|
1660
|
+
expect(result).toBeDefined()
|
|
1661
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1662
|
+
// Sudo skips the whole connect check: neither the owning-field access fn
|
|
1663
|
+
// nor the target reachability/existence read run.
|
|
1664
|
+
expect(owningFieldUpdate).not.toHaveBeenCalled()
|
|
1665
|
+
expect(mockPrisma.user.findFirst).not.toHaveBeenCalled()
|
|
1666
|
+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled()
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
it("connectOrCreate's connect branch is gated by the owning field access (denied → throws, existing row)", async () => {
|
|
1670
|
+
const testConfig = config({
|
|
1671
|
+
db: {
|
|
1672
|
+
provider: 'postgresql',
|
|
1673
|
+
url: 'postgresql://localhost:5432/test',
|
|
1674
|
+
},
|
|
1675
|
+
lists: {
|
|
1676
|
+
Account: list({
|
|
1677
|
+
fields: {
|
|
1678
|
+
name: text(),
|
|
1679
|
+
},
|
|
1680
|
+
access: {
|
|
1681
|
+
operation: {
|
|
1682
|
+
query: () => true, // target readable
|
|
1683
|
+
create: () => true,
|
|
1684
|
+
update: () => true,
|
|
1685
|
+
},
|
|
1686
|
+
},
|
|
1687
|
+
}),
|
|
1688
|
+
Student: list({
|
|
1689
|
+
fields: {
|
|
1690
|
+
name: text(),
|
|
1691
|
+
account: relationship({
|
|
1692
|
+
ref: 'Account',
|
|
1693
|
+
access: {
|
|
1694
|
+
create: () => false, // owning field denies on create
|
|
1695
|
+
},
|
|
1696
|
+
}),
|
|
1697
|
+
},
|
|
1698
|
+
access: {
|
|
1699
|
+
operation: {
|
|
1700
|
+
query: () => true,
|
|
1701
|
+
create: () => true,
|
|
1702
|
+
},
|
|
1703
|
+
},
|
|
1704
|
+
}),
|
|
1705
|
+
},
|
|
1706
|
+
})
|
|
1707
|
+
|
|
1708
|
+
mockPrisma.account = {
|
|
1709
|
+
// Row exists and is reachable, but the owning field gate must still deny.
|
|
1710
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1711
|
+
findFirst: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1712
|
+
findMany: vi.fn(),
|
|
1713
|
+
create: vi.fn(),
|
|
1714
|
+
update: vi.fn(),
|
|
1715
|
+
delete: vi.fn(),
|
|
1716
|
+
count: vi.fn(),
|
|
1717
|
+
}
|
|
1718
|
+
mockPrisma.student = {
|
|
1719
|
+
findFirst: vi.fn(),
|
|
1720
|
+
findMany: vi.fn(),
|
|
1721
|
+
findUnique: vi.fn(),
|
|
1722
|
+
create: vi.fn(),
|
|
1723
|
+
update: vi.fn(),
|
|
1724
|
+
delete: vi.fn(),
|
|
1725
|
+
count: vi.fn(),
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
1729
|
+
|
|
1730
|
+
await expect(
|
|
1731
|
+
context.db.student.create({
|
|
1732
|
+
data: {
|
|
1733
|
+
name: 'Kid',
|
|
1734
|
+
account: {
|
|
1735
|
+
connectOrCreate: {
|
|
1736
|
+
where: { id: 'acc-1' },
|
|
1737
|
+
create: { name: 'New Account' },
|
|
1738
|
+
},
|
|
1739
|
+
},
|
|
1740
|
+
},
|
|
1741
|
+
}),
|
|
1742
|
+
).rejects.toThrow(OWNING_FIELD_DENIES)
|
|
1743
|
+
|
|
1744
|
+
expect(mockPrisma.student.create).not.toHaveBeenCalled()
|
|
1745
|
+
// Denied owning field short-circuits before the target reachability re-check.
|
|
1746
|
+
expect(mockPrisma.account.findFirst).not.toHaveBeenCalled()
|
|
1747
|
+
})
|
|
1748
|
+
|
|
1749
|
+
it("connectOrCreate's connect branch succeeds when the owning field access permits a reachable existing row", async () => {
|
|
1750
|
+
const testConfig = config({
|
|
1751
|
+
db: {
|
|
1752
|
+
provider: 'postgresql',
|
|
1753
|
+
url: 'postgresql://localhost:5432/test',
|
|
1754
|
+
},
|
|
1755
|
+
lists: {
|
|
1756
|
+
Account: list({
|
|
1757
|
+
fields: {
|
|
1758
|
+
name: text(),
|
|
1759
|
+
},
|
|
1760
|
+
access: {
|
|
1761
|
+
operation: {
|
|
1762
|
+
query: () => true,
|
|
1763
|
+
create: () => true,
|
|
1764
|
+
update: () => true,
|
|
1765
|
+
},
|
|
1766
|
+
},
|
|
1767
|
+
}),
|
|
1768
|
+
Student: list({
|
|
1769
|
+
fields: {
|
|
1770
|
+
name: text(),
|
|
1771
|
+
account: relationship({
|
|
1772
|
+
ref: 'Account',
|
|
1773
|
+
access: {
|
|
1774
|
+
create: () => true, // owning field permits
|
|
1775
|
+
},
|
|
1776
|
+
}),
|
|
1777
|
+
},
|
|
1778
|
+
access: {
|
|
1779
|
+
operation: {
|
|
1780
|
+
query: () => true,
|
|
1781
|
+
create: () => true,
|
|
1782
|
+
},
|
|
1783
|
+
},
|
|
1784
|
+
}),
|
|
1785
|
+
},
|
|
1786
|
+
})
|
|
1787
|
+
|
|
1788
|
+
mockPrisma.account = {
|
|
1789
|
+
findUnique: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1790
|
+
findFirst: vi.fn().mockResolvedValue({ id: 'acc-1', name: 'My Account' }),
|
|
1791
|
+
findMany: vi.fn(),
|
|
1792
|
+
create: vi.fn(),
|
|
1793
|
+
update: vi.fn(),
|
|
1794
|
+
delete: vi.fn(),
|
|
1795
|
+
count: vi.fn(),
|
|
1796
|
+
}
|
|
1797
|
+
mockPrisma.student = {
|
|
1798
|
+
findFirst: vi.fn(),
|
|
1799
|
+
findMany: vi.fn(),
|
|
1800
|
+
findUnique: vi.fn(),
|
|
1801
|
+
create: vi.fn().mockResolvedValue({ id: 's1', name: 'Kid', accountId: 'acc-1' }),
|
|
1802
|
+
update: vi.fn(),
|
|
1803
|
+
delete: vi.fn(),
|
|
1804
|
+
count: vi.fn(),
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const context = getContext(await testConfig, mockPrisma, { userId: 'user-1' })
|
|
1808
|
+
|
|
1809
|
+
const result = await context.db.student.create({
|
|
1810
|
+
data: {
|
|
1811
|
+
name: 'Kid',
|
|
1812
|
+
account: {
|
|
1813
|
+
connectOrCreate: {
|
|
1814
|
+
where: { id: 'acc-1' },
|
|
1815
|
+
create: { name: 'New Account' },
|
|
1816
|
+
},
|
|
1817
|
+
},
|
|
1818
|
+
},
|
|
1819
|
+
})
|
|
1820
|
+
|
|
1821
|
+
expect(result).toBeDefined()
|
|
1822
|
+
expect(mockPrisma.student.create).toHaveBeenCalled()
|
|
1823
|
+
})
|
|
1824
|
+
|
|
1825
|
+
// #588 finding — the connect-site owning-field gate must receive the SAME
|
|
1826
|
+
// `item`/`inputData` the canonical Phase-5 `filterWritableFields` call passes,
|
|
1827
|
+
// so a field-access rule that depends on `item`/`inputData` cannot be ALLOWED
|
|
1828
|
+
// by Phase 5 yet DENIED at the connect site (a spurious denial). With those
|
|
1829
|
+
// values threaded, an item-/inputData-dependent rule that resolves to ALLOW at
|
|
1830
|
+
// Phase 5 also resolves to ALLOW at the connect site, so the legitimate connect
|
|
1831
|
+
// succeeds. (#568's Phase-5 gate is the first line of defense and evaluates the
|
|
1832
|
+
// identical rule with the identical values; this test pins that the connect-site
|
|
1833
|
+
// gate does not diverge from it.)
|
|
1834
|
+
it('does not spuriously deny: owning-field access depending on item/inputData allows the connect (enclosing UPDATE)', async () => {
|
|
1835
|
+
const owningUpdate = vi.fn(
|
|
1836
|
+
({ item, inputData }: { item?: { status?: string }; inputData?: { title?: string } }) =>
|
|
1837
|
+
// Depends on BOTH the existing row (`item`) and the write payload
|
|
1838
|
+
// (`inputData`). If the connect-site gate were called without these, it
|
|
1839
|
+
// would throw on `item.status`/`inputData.title` and the legitimate
|
|
1840
|
+
// connect would fail spuriously.
|
|
1841
|
+
item?.status === 'draft' && inputData?.title === 'Updated',
|
|
1842
|
+
)
|
|
1843
|
+
|
|
1844
|
+
const testConfig = config({
|
|
1845
|
+
db: {
|
|
1846
|
+
provider: 'postgresql',
|
|
1847
|
+
url: 'postgresql://localhost:5432/test',
|
|
1848
|
+
},
|
|
1849
|
+
lists: {
|
|
1850
|
+
User: list({
|
|
1851
|
+
fields: {
|
|
1852
|
+
name: text(),
|
|
1853
|
+
},
|
|
1854
|
+
access: {
|
|
1855
|
+
operation: {
|
|
1856
|
+
query: () => true,
|
|
1857
|
+
update: () => true,
|
|
1858
|
+
},
|
|
1859
|
+
},
|
|
1860
|
+
}),
|
|
1861
|
+
Post: list({
|
|
1862
|
+
fields: {
|
|
1863
|
+
title: text(),
|
|
1864
|
+
author: relationship({
|
|
1865
|
+
ref: 'User.posts',
|
|
1866
|
+
access: {
|
|
1867
|
+
// Item-/inputData-dependent: allowed only when editing a draft
|
|
1868
|
+
// and setting title to 'Updated'.
|
|
1869
|
+
update: owningUpdate,
|
|
1870
|
+
},
|
|
1871
|
+
}),
|
|
1872
|
+
},
|
|
1873
|
+
access: {
|
|
1874
|
+
operation: {
|
|
1875
|
+
query: () => true,
|
|
1876
|
+
update: () => true,
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1879
|
+
}),
|
|
1880
|
+
},
|
|
1881
|
+
})
|
|
1882
|
+
|
|
1883
|
+
// Existing row is a draft → the item-dependent rule allows.
|
|
1884
|
+
mockPrisma.post.findUnique.mockResolvedValue({
|
|
1885
|
+
id: '1',
|
|
1886
|
+
title: 'Original Title',
|
|
1887
|
+
status: 'draft',
|
|
1888
|
+
})
|
|
1889
|
+
mockPrisma.user.findUnique.mockResolvedValue({ id: '2', name: 'John Doe' })
|
|
1890
|
+
mockPrisma.post.update.mockResolvedValue({ id: '1', title: 'Updated', authorId: '2' })
|
|
1891
|
+
|
|
1892
|
+
const context = getContext(await testConfig, mockPrisma, { userId: '1' })
|
|
1893
|
+
|
|
1894
|
+
const result = await context.db.post.update({
|
|
1895
|
+
where: { id: '1' },
|
|
1896
|
+
data: {
|
|
1897
|
+
title: 'Updated',
|
|
1898
|
+
author: { connect: { id: '2' } },
|
|
1899
|
+
},
|
|
1900
|
+
})
|
|
1901
|
+
|
|
1902
|
+
// No spurious denial: the connect succeeded.
|
|
1903
|
+
expect(result).toBeDefined()
|
|
1904
|
+
expect(mockPrisma.post.update).toHaveBeenCalled()
|
|
1905
|
+
// Owning field allowed → fall through to the #578 target existence check.
|
|
1906
|
+
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { id: '2' } })
|
|
1907
|
+
|
|
1908
|
+
// The gate was evaluated with the enclosing write's `item` (the draft
|
|
1909
|
+
// originalItem) and `inputData` (the update payload) — the same values
|
|
1910
|
+
// Phase-5 uses — proving the two evaluations cannot diverge.
|
|
1911
|
+
const sawItemAndInput = owningUpdate.mock.calls.some(
|
|
1912
|
+
([arg]) => arg?.item?.status === 'draft' && arg?.inputData?.title === 'Updated',
|
|
1913
|
+
)
|
|
1914
|
+
expect(sawItemAndInput).toBe(true)
|
|
1915
|
+
})
|
|
1916
|
+
})
|
|
1917
|
+
|
|
1918
|
+
// Focused unit test of the connect-site gate's evaluator (#588 finding).
|
|
1919
|
+
//
|
|
1920
|
+
// The connect-site gate calls the SHARED `checkFieldAccess` evaluator with the
|
|
1921
|
+
// enclosing write's `item`/`inputData`. #568 (Phase-5 `filterWritableFields`) is
|
|
1922
|
+
// the first line of defense and calls the same evaluator with the same values,
|
|
1923
|
+
// so an end-to-end sole-connect-site DENY is not separately constructible. This
|
|
1924
|
+
// test therefore pins the evaluator directly: with `item`/`inputData` provided
|
|
1925
|
+
// it honours an item-/inputData-dependent rule (DENY and ALLOW branches), which
|
|
1926
|
+
// is exactly the contract the connect-site gate now relies on.
|
|
1927
|
+
describe('Connect-site field gate evaluator honours item/inputData (#588)', () => {
|
|
1928
|
+
it('DENIES when the item-/inputData-dependent rule resolves false', async () => {
|
|
1929
|
+
const access = {
|
|
1930
|
+
update: ({
|
|
1931
|
+
item,
|
|
1932
|
+
inputData,
|
|
1933
|
+
}: {
|
|
1934
|
+
item?: { status?: string }
|
|
1935
|
+
inputData?: { title?: string }
|
|
1936
|
+
}) => item?.status === 'draft' && inputData?.title === 'Updated',
|
|
1937
|
+
}
|
|
1938
|
+
const ctx = getContext(
|
|
1939
|
+
await config({
|
|
1940
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1941
|
+
lists: { User: list({ fields: { name: text() } }) },
|
|
1942
|
+
}),
|
|
1943
|
+
mockPrisma,
|
|
1944
|
+
{ userId: '1' },
|
|
1945
|
+
)
|
|
1946
|
+
|
|
1947
|
+
const allowed = await checkFieldAccess(access, 'update', {
|
|
1948
|
+
session: ctx.session,
|
|
1949
|
+
item: { status: 'published' }, // not a draft → rule is false
|
|
1950
|
+
inputData: { title: 'Updated' },
|
|
1951
|
+
context: ctx,
|
|
1952
|
+
})
|
|
1953
|
+
expect(allowed).toBe(false)
|
|
1954
|
+
})
|
|
1955
|
+
|
|
1956
|
+
it('ALLOWS when the item-/inputData-dependent rule resolves true', async () => {
|
|
1957
|
+
const access = {
|
|
1958
|
+
update: ({
|
|
1959
|
+
item,
|
|
1960
|
+
inputData,
|
|
1961
|
+
}: {
|
|
1962
|
+
item?: { status?: string }
|
|
1963
|
+
inputData?: { title?: string }
|
|
1964
|
+
}) => item?.status === 'draft' && inputData?.title === 'Updated',
|
|
1965
|
+
}
|
|
1966
|
+
const ctx = getContext(
|
|
1967
|
+
await config({
|
|
1968
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
1969
|
+
lists: { User: list({ fields: { name: text() } }) },
|
|
1970
|
+
}),
|
|
1971
|
+
mockPrisma,
|
|
1972
|
+
{ userId: '1' },
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1975
|
+
const allowed = await checkFieldAccess(access, 'update', {
|
|
1976
|
+
session: ctx.session,
|
|
1977
|
+
item: { status: 'draft' },
|
|
1978
|
+
inputData: { title: 'Updated' },
|
|
1979
|
+
context: ctx,
|
|
1980
|
+
})
|
|
1981
|
+
expect(allowed).toBe(true)
|
|
906
1982
|
})
|
|
907
1983
|
})
|
|
908
1984
|
|