@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.
Files changed (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +256 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/types.d.ts +378 -0
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +19 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +153 -26
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +59 -3
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +552 -129
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts +91 -0
  32. package/dist/context/transaction-boundary.d.ts.map +1 -0
  33. package/dist/context/transaction-boundary.js +329 -0
  34. package/dist/context/transaction-boundary.js.map +1 -0
  35. package/dist/context/write-pipeline.d.ts +15 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js +173 -10
  38. package/dist/context/write-pipeline.js.map +1 -1
  39. package/dist/fields/calendar-day.test.d.ts +2 -0
  40. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  41. package/dist/fields/calendar-day.test.js +120 -0
  42. package/dist/fields/calendar-day.test.js.map +1 -0
  43. package/dist/fields/index.d.ts +18 -2
  44. package/dist/fields/index.d.ts.map +1 -1
  45. package/dist/fields/index.js +93 -17
  46. package/dist/fields/index.js.map +1 -1
  47. package/dist/hooks/index.d.ts +116 -0
  48. package/dist/hooks/index.d.ts.map +1 -1
  49. package/dist/hooks/index.js +154 -0
  50. package/dist/hooks/index.js.map +1 -1
  51. package/dist/validation/schema.test.js +222 -1
  52. package/dist/validation/schema.test.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/access/access-filter.ts +156 -0
  55. package/src/access/field-access.test.ts +255 -0
  56. package/src/access/field-access.ts +91 -5
  57. package/src/access/index.ts +1 -1
  58. package/src/access/types.ts +45 -0
  59. package/src/config/index.ts +2 -0
  60. package/src/config/types.ts +426 -0
  61. package/src/context/index.ts +207 -37
  62. package/src/context/nested-operations.ts +969 -143
  63. package/src/context/transaction-boundary.ts +440 -0
  64. package/src/context/write-pipeline.ts +234 -13
  65. package/src/fields/calendar-day.test.ts +140 -0
  66. package/src/fields/index.ts +96 -16
  67. package/src/hooks/index.ts +265 -0
  68. package/src/validation/schema.test.ts +266 -1
  69. package/tests/access.test.ts +24 -16
  70. package/tests/config.test.ts +30 -0
  71. package/tests/context.test.ts +481 -0
  72. package/tests/field-types.test.ts +17 -3
  73. package/tests/nested-access-and-hooks.test.ts +1130 -54
  74. package/tests/nested-operation-registry.test.ts +28 -3
  75. package/tests/nested-write-hooks.test.ts +864 -0
  76. package/tests/transaction-boundary-hooks.test.ts +465 -0
  77. 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
- await context.db.post.update({
270
- where: { id: '1' },
271
- data: {
272
- title: 'Updated Title',
273
- author: {
274
- create: {
275
- name: 'John',
276
- email: 'john@example.com',
277
- role: 'admin', // Should be filtered out
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
- // Role should be filtered out
291
- expect(authorCreateData.role).toBeUndefined()
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 update access is denied on related item', async () => {
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
- query: () => true,
737
- update: ({ session }) => {
738
- // Only allow updating own profile
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
- // Mock finding the user to connect (different from session user)
765
- mockPrisma.user.findUnique.mockResolvedValue({
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 is user 1
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 update access is granted', async () => {
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, // Allow all updates
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('should deny nested update when update access is denied on related item', async () => {
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
- User: list({
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: () => false, // Deny all updates
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
- id: '1',
883
- title: 'Original Title',
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
- mockPrisma.user.findUnique.mockResolvedValue({
887
- id: '2',
888
- name: 'John Doe',
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
- const context = getContext(await testConfig, mockPrisma, null)
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.post.update({
895
- where: { id: '1' },
1250
+ context.db.student.create({
896
1251
  data: {
897
- author: {
898
- update: {
899
- where: { id: '2' },
900
- data: { name: 'Jane Doe' },
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 update related item')
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