@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.7

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 (250) hide show
  1. package/README.md +30 -11
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.js +2 -2
  4. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  5. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  6. package/dist/auth/exampleNewUserSubClass.js +9 -4
  7. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  9. package/dist/auth/magicLink/MagicLinkService.js +2 -8
  10. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  11. package/dist/auth/newUsers.d.ts.map +1 -1
  12. package/dist/auth/newUsers.js +4 -12
  13. package/dist/auth/newUsers.js.map +1 -1
  14. package/dist/auth/principals.d.ts +92 -0
  15. package/dist/auth/principals.d.ts.map +1 -0
  16. package/dist/auth/principals.js +193 -0
  17. package/dist/auth/principals.js.map +1 -0
  18. package/dist/config.d.ts +106 -12
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +41 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
  23. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  24. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
  25. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  26. package/dist/generated/graphql-schemas/__mj.d.ts +7607 -6853
  27. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  28. package/dist/generated/graphql-schemas/__mj.js +25141 -21645
  29. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  30. package/dist/generic/ResolverBase.d.ts +152 -2
  31. package/dist/generic/ResolverBase.d.ts.map +1 -1
  32. package/dist/generic/ResolverBase.js +389 -10
  33. package/dist/generic/ResolverBase.js.map +1 -1
  34. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  35. package/dist/generic/RunViewResolver.js +26 -2
  36. package/dist/generic/RunViewResolver.js.map +1 -1
  37. package/dist/generic/refusalExtensions.d.ts +52 -0
  38. package/dist/generic/refusalExtensions.d.ts.map +1 -0
  39. package/dist/generic/refusalExtensions.js +16 -0
  40. package/dist/generic/refusalExtensions.js.map +1 -0
  41. package/dist/index.d.ts +11 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +131 -93
  44. package/dist/index.js.map +1 -1
  45. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  46. package/dist/realtimeWidget/WidgetSessionService.js +2 -12
  47. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  48. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  49. package/dist/realtimeWidget/visitorIdentity.js +20 -5
  50. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  51. package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
  52. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  53. package/dist/resolvers/EntityRecordNameResolver.js +64 -7
  54. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  55. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/FileCategoryResolver.js +1 -2
  57. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  58. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  59. package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
  60. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  61. package/dist/resolvers/QuerySystemUserResolver.js +1 -1
  62. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  63. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
  64. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  65. package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
  66. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  67. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
  68. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  69. package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
  70. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  71. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  72. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  73. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  74. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  75. package/dist/resolvers/UserViewResolver.js +8 -4
  76. package/dist/resolvers/UserViewResolver.js.map +1 -1
  77. package/dist/rest/EntityCRUDHandler.d.ts +2 -1
  78. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  79. package/dist/rest/EntityCRUDHandler.js +3 -18
  80. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  81. package/dist/rest/RESTEndpointHandler.d.ts +2 -1
  82. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  83. package/dist/rest/RESTEndpointHandler.js +3 -16
  84. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  85. package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
  86. package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
  87. package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
  88. package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
  89. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  90. package/dist/serverExtensionReservedRoots.js +2 -3
  91. package/dist/serverExtensionReservedRoots.js.map +1 -1
  92. package/package.json +92 -91
  93. package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
  94. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
  95. package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
  96. package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
  97. package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
  98. package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
  99. package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
  100. package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
  101. package/src/__tests__/WidgetSessionService.test.ts +4 -1
  102. package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
  103. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  104. package/src/__tests__/newUsers.test.ts +36 -14
  105. package/src/__tests__/principals.callSites.test.ts +112 -0
  106. package/src/__tests__/principals.logging.test.ts +160 -0
  107. package/src/__tests__/principals.test.ts +305 -0
  108. package/src/__tests__/resolverBase.fls.test.ts +483 -0
  109. package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
  110. package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
  111. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  112. package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
  113. package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
  114. package/src/agentSessions/SessionJanitor.ts +2 -2
  115. package/src/auth/exampleNewUserSubClass.ts +9 -6
  116. package/src/auth/magicLink/MagicLinkService.ts +2 -8
  117. package/src/auth/newUsers.ts +7 -15
  118. package/src/auth/principals.ts +257 -0
  119. package/src/config.ts +44 -4
  120. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
  121. package/src/generated/graphql-schemas/__mj.ts +18966 -16452
  122. package/src/generic/ResolverBase.ts +467 -9
  123. package/src/generic/RunViewResolver.ts +28 -2
  124. package/src/generic/refusalExtensions.ts +59 -0
  125. package/src/index.ts +154 -102
  126. package/src/realtimeWidget/WidgetSessionService.ts +2 -14
  127. package/src/realtimeWidget/visitorIdentity.ts +21 -6
  128. package/src/resolvers/EntityRecordNameResolver.ts +74 -8
  129. package/src/resolvers/FileCategoryResolver.ts +2 -3
  130. package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
  131. package/src/resolvers/QuerySystemUserResolver.ts +1 -1
  132. package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
  133. package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
  134. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  135. package/src/resolvers/UserViewResolver.ts +8 -4
  136. package/src/rest/EntityCRUDHandler.ts +3 -19
  137. package/src/rest/RESTEndpointHandler.ts +3 -17
  138. package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
  139. package/src/serverExtensionReservedRoots.ts +2 -3
  140. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
  141. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
  142. package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
  143. package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
  144. package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
  145. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
  146. package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
  147. package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
  148. package/dist/resolvers/TelephonyResolver.d.ts +0 -26
  149. package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
  150. package/dist/resolvers/TelephonyResolver.js +0 -86
  151. package/dist/resolvers/TelephonyResolver.js.map +0 -1
  152. package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
  153. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
  154. package/dist/resolvers/VonageTelephonyResolver.js +0 -86
  155. package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
  156. package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
  157. package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
  158. package/dist/telephony/RingCentralTelephonyService.js +0 -262
  159. package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
  160. package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
  161. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
  162. package/dist/telephony/TeamsMeetingsRouter.js +0 -96
  163. package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
  164. package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
  165. package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
  166. package/dist/telephony/TeamsMeetingsService.js +0 -196
  167. package/dist/telephony/TeamsMeetingsService.js.map +0 -1
  168. package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
  169. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
  170. package/dist/telephony/TwilioTelephonyRouter.js +0 -143
  171. package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
  172. package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
  173. package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
  174. package/dist/telephony/TwilioTelephonyService.js +0 -193
  175. package/dist/telephony/TwilioTelephonyService.js.map +0 -1
  176. package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
  177. package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
  178. package/dist/telephony/VonageTelephonyRouter.js +0 -201
  179. package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
  180. package/dist/telephony/VonageTelephonyService.d.ts +0 -90
  181. package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
  182. package/dist/telephony/VonageTelephonyService.js +0 -191
  183. package/dist/telephony/VonageTelephonyService.js.map +0 -1
  184. package/dist/telephony/calendar-scheduler.d.ts +0 -67
  185. package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
  186. package/dist/telephony/calendar-scheduler.js +0 -133
  187. package/dist/telephony/calendar-scheduler.js.map +0 -1
  188. package/dist/telephony/index.d.ts +0 -29
  189. package/dist/telephony/index.d.ts.map +0 -1
  190. package/dist/telephony/index.js +0 -38
  191. package/dist/telephony/index.js.map +0 -1
  192. package/dist/telephony/media-upgrade-router.d.ts +0 -47
  193. package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
  194. package/dist/telephony/media-upgrade-router.js +0 -72
  195. package/dist/telephony/media-upgrade-router.js.map +0 -1
  196. package/dist/telephony/ringcentral-runtime.d.ts +0 -14
  197. package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
  198. package/dist/telephony/ringcentral-runtime.js +0 -18
  199. package/dist/telephony/ringcentral-runtime.js.map +0 -1
  200. package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
  201. package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
  202. package/dist/telephony/teams-meetings-runtime.js +0 -20
  203. package/dist/telephony/teams-meetings-runtime.js.map +0 -1
  204. package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
  205. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
  206. package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
  207. package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
  208. package/dist/telephony/telephony-runtime.d.ts +0 -14
  209. package/dist/telephony/telephony-runtime.d.ts.map +0 -1
  210. package/dist/telephony/telephony-runtime.js +0 -18
  211. package/dist/telephony/telephony-runtime.js.map +0 -1
  212. package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
  213. package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
  214. package/dist/telephony/twilioMediaRegistry.js +0 -106
  215. package/dist/telephony/twilioMediaRegistry.js.map +0 -1
  216. package/dist/telephony/vonage-runtime.d.ts +0 -14
  217. package/dist/telephony/vonage-runtime.d.ts.map +0 -1
  218. package/dist/telephony/vonage-runtime.js +0 -18
  219. package/dist/telephony/vonage-runtime.js.map +0 -1
  220. package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
  221. package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
  222. package/dist/telephony/vonageMediaRegistry.js +0 -158
  223. package/dist/telephony/vonageMediaRegistry.js.map +0 -1
  224. package/src/__tests__/media-upgrade-router.test.ts +0 -43
  225. package/src/__tests__/teams-meetings-service.test.ts +0 -65
  226. package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
  227. package/src/__tests__/twilio-telephony-service.test.ts +0 -23
  228. package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
  229. package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
  230. package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
  231. package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
  232. package/src/resolvers/TelephonyResolver.ts +0 -63
  233. package/src/resolvers/VonageTelephonyResolver.ts +0 -63
  234. package/src/telephony/RingCentralTelephonyService.ts +0 -342
  235. package/src/telephony/TeamsMeetingsRouter.ts +0 -124
  236. package/src/telephony/TeamsMeetingsService.ts +0 -268
  237. package/src/telephony/TwilioTelephonyRouter.ts +0 -184
  238. package/src/telephony/TwilioTelephonyService.ts +0 -261
  239. package/src/telephony/VonageTelephonyRouter.ts +0 -239
  240. package/src/telephony/VonageTelephonyService.ts +0 -259
  241. package/src/telephony/calendar-scheduler.ts +0 -176
  242. package/src/telephony/index.ts +0 -43
  243. package/src/telephony/media-upgrade-router.ts +0 -79
  244. package/src/telephony/ringcentral-runtime.ts +0 -22
  245. package/src/telephony/teams-meetings-runtime.ts +0 -24
  246. package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
  247. package/src/telephony/telephony-runtime.ts +0 -22
  248. package/src/telephony/twilioMediaRegistry.ts +0 -137
  249. package/src/telephony/vonage-runtime.ts +0 -22
  250. package/src/telephony/vonageMediaRegistry.ts +0 -200
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Tests for ResolverBase.StripDeniedReadFieldsFromClientInput — the field-level-security
3
+ * guard on the update path.
4
+ *
5
+ * The hazard it closes: a read-denied field is stripped from every payload the client
6
+ * receives, so the client's Save fabricates a value for it (default / 0 / '' / null) and sends
7
+ * it back. Without the guard, UpdateRecord's SetMany applies the fabrication — silent data
8
+ * loss when the user holds update access, and a spurious save failure on UNRELATED edits when
9
+ * they don't. The guard strips denied-read keys from the client's new values and OldValues
10
+ * before anything applies them, and its `true` return forces the caller onto the
11
+ * load-truth-from-DB branch so denied fields hold real values a stripped SetMany never
12
+ * touches.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
15
+
16
+ // ─── Hoisted mocks ────────────────────────────────────────────────────────
17
+ const { mockUserCacheUsers } = vi.hoisted(() => ({
18
+ mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
19
+ }));
20
+
21
+ // Stub external deps before imports
22
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
23
+ SQLServerDataProvider: class {},
24
+ }));
25
+
26
+ // UserCache moved to the shared provider package, so the mock has to follow it — mocking it on
27
+ // sqlserver-dataprovider silently no-ops and the real (empty) cache answers instead.
28
+ vi.mock('@memberjunction/generic-database-provider', () => ({
29
+ UserCache: {
30
+ get Users() { return mockUserCacheUsers; },
31
+ },
32
+ }));
33
+
34
+ vi.mock('cloudevents', () => ({
35
+ CloudEvent: class {},
36
+ httpTransport: () => () => undefined,
37
+ emitterFor: () => () => undefined,
38
+ }));
39
+
40
+ vi.mock('type-graphql', () => ({
41
+ Resolver: () => () => undefined,
42
+ Mutation: () => () => undefined,
43
+ Query: () => () => undefined,
44
+ Subscription: () => () => undefined,
45
+ Ctx: () => () => undefined,
46
+ Arg: () => () => undefined,
47
+ PubSub: () => () => undefined,
48
+ Root: () => () => undefined,
49
+ ObjectType: () => () => undefined,
50
+ InputType: () => () => undefined,
51
+ Field: () => () => undefined,
52
+ FieldResolver: () => () => undefined,
53
+ Int: () => undefined,
54
+ Float: () => undefined,
55
+ registerEnumType: () => undefined,
56
+ }));
57
+
58
+ vi.mock('graphql', () => ({
59
+ GraphQLError: class extends Error {
60
+ constructor(msg: string) { super(msg); }
61
+ },
62
+ }));
63
+
64
+ vi.mock('mssql', () => ({}));
65
+
66
+ vi.mock('@memberjunction/api-keys', () => ({
67
+ GetAPIKeyEngine: vi.fn(),
68
+ }));
69
+
70
+ vi.mock('@memberjunction/encryption', () => ({
71
+ EncryptionEngine: { Instance: {} },
72
+ }));
73
+
74
+ vi.mock('@memberjunction/graphql-dataprovider', () => ({
75
+ FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
76
+ }));
77
+
78
+ vi.mock('../generic/PubSubManager.js', () => ({
79
+ PubSubManager: class { static Instance = { publish: vi.fn() }; },
80
+ }));
81
+
82
+ vi.mock('../generic/PushStatusResolver.js', () => ({
83
+ PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
84
+ PushStatusNotification: class {},
85
+ PushStatusResolver: class {},
86
+ }));
87
+
88
+ vi.mock('../generic/CacheInvalidationResolver.js', () => ({
89
+ CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
90
+ }));
91
+
92
+ vi.mock('../generic/RunViewResolver.js', () => ({
93
+ RunViewByIDInput: class {},
94
+ RunViewByNameInput: class {},
95
+ RunDynamicViewInput: class {},
96
+ }));
97
+
98
+ vi.mock('../generic/DeleteOptionsInput.js', () => ({
99
+ DeleteOptionsInput: class {},
100
+ }));
101
+
102
+ vi.mock('../types.js', () => ({
103
+ RunViewGenericParams: class {},
104
+ }));
105
+
106
+ vi.mock('@memberjunction/core', async () => {
107
+ const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
108
+ return {
109
+ ...actual,
110
+ LogError: vi.fn(),
111
+ LogStatus: vi.fn(),
112
+ };
113
+ });
114
+
115
+ vi.mock('@memberjunction/core-entities', () => ({}));
116
+
117
+ // ─── Import after mocks ──────────────────────────────────────────────────
118
+ import { ResolverBase } from '../generic/ResolverBase';
119
+ import { EntityInfo, UserInfo, UserRoleInfo } from '@memberjunction/core';
120
+
121
+ // ─── Metadata builders (mirrors MJCore's fieldSecurity.enforcement tests) ──
122
+
123
+ const HR_ROLE_ID = 'A0000000-0000-0000-0000-000000000001';
124
+ const INTERN_ROLE_ID = 'A0000000-0000-0000-0000-000000000003';
125
+ const ENTITY_ID = 'entity-employees';
126
+
127
+ /**
128
+ * An `Employees` entity: `Salary` and `Base Salary` are readable/updatable only by HR.
129
+ * `Base Salary` has a space, so its CodeName (`Base_Salary`) differs from its Name —
130
+ * exercising the Name→CodeName bridge (input keys are CodeNames).
131
+ */
132
+ /**
133
+ * Full access to `fieldId` for every listed role. Snapshot initialization writes rows like these
134
+ * for every (field, role) that should have one — which is why the UNRESTRICTED fields below need
135
+ * them explicitly. On an FLS-enabled entity a field with no rows is denied, not open.
136
+ */
137
+ function openTo(fieldId: string, roles: string[] = [HR_ROLE_ID, INTERN_ROLE_ID]): Record<string, unknown>[] {
138
+ return roles.map((roleId, i) => ({
139
+ ID: `${fieldId}-open-${i}`,
140
+ EntityFieldID: fieldId,
141
+ RoleID: roleId,
142
+ ReadAccess: 'Allow',
143
+ UpdateAccess: 'Allow',
144
+ CreateAccess: 'Allow',
145
+ }));
146
+ }
147
+
148
+ function employeeEntityInit(withFls: boolean): Record<string, unknown> {
149
+ return {
150
+ ID: ENTITY_ID,
151
+ Name: 'Employees',
152
+ SchemaName: 'dbo',
153
+ BaseTable: 'Employee',
154
+ BaseView: 'vwEmployees',
155
+ IncludeInAPI: true,
156
+ AllowCreateAPI: true,
157
+ AllowUpdateAPI: true,
158
+ AllowDeleteAPI: true,
159
+ // The flag is the enforcement gate — `withFls: false` switches field security off
160
+ // entirely rather than merely removing rows.
161
+ EnableFieldLevelSecurity: withFls,
162
+ Permissions: [
163
+ { EntityID: ENTITY_ID, RoleID: HR_ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true },
164
+ { EntityID: ENTITY_ID, RoleID: INTERN_ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true },
165
+ ],
166
+ Fields: [
167
+ { ID: 'f-id', EntityID: ENTITY_ID, Sequence: 1, Name: 'ID', Entity: 'Employees', Type: 'uniqueidentifier', IsPrimaryKey: true },
168
+ { ID: 'f-name', EntityID: ENTITY_ID, Sequence: 2, Name: 'Name', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('f-name') },
169
+ { ID: 'f-salary', EntityID: ENTITY_ID, Sequence: 3, Name: 'Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('f-salary', [HR_ROLE_ID]) },
170
+ { ID: 'f-base-salary', EntityID: ENTITY_ID, Sequence: 4, Name: 'Base Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('f-base-salary', [HR_ROLE_ID]) },
171
+ { ID: 'f-notes', EntityID: ENTITY_ID, Sequence: 5, Name: 'Notes', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('f-notes') },
172
+ ],
173
+ };
174
+ }
175
+
176
+ function buildUser(roleIds: string[], id = 'user-1'): UserInfo {
177
+ const u = new UserInfo();
178
+ u.ID = id;
179
+ u.Name = 'Test User';
180
+ u.Email = `${id}@test.com`;
181
+ u.IsActive = true;
182
+ (u as unknown as Record<string, unknown>)['_UserRoles'] =
183
+ roleIds.map(rid => new UserRoleInfo({ UserID: id, RoleID: rid, Role: `Role-${rid}` }));
184
+ return u;
185
+ }
186
+
187
+ type ClientInput = { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>;
188
+
189
+ /** Subclass that exposes the protected guards for testing. */
190
+ class TestResolver extends ResolverBase {
191
+ public TestStrip(entityInfo: EntityInfo, userInfo: UserInfo, input: ClientInput, clientNewValues: Record<string, unknown>): boolean {
192
+ return this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
193
+ }
194
+
195
+ public TestMustLoadTruth(entityInfo: EntityInfo, input: ClientInput, hasDeniedReadFields: boolean, hasNarrowedAuditPayload: boolean): boolean {
196
+ return this.MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload);
197
+ }
198
+ }
199
+
200
+ /** Builds the (input, clientNewValues) pair the way UpdateRecord does. */
201
+ function makeClientPayload(fields: Record<string, unknown>, oldValues?: Array<{ Key: string; Value: unknown }>): { input: ClientInput; clientNewValues: Record<string, unknown> } {
202
+ const input: ClientInput = { ...fields };
203
+ if (oldValues) {
204
+ input.OldValues___ = oldValues;
205
+ }
206
+ const clientNewValues: Record<string, unknown> = { ...fields };
207
+ return { input, clientNewValues };
208
+ }
209
+
210
+ // ─── Tests ────────────────────────────────────────────────────────────────
211
+
212
+ describe('ResolverBase.StripDeniedReadFieldsFromClientInput', () => {
213
+ let resolver: TestResolver;
214
+ let flsEntity: EntityInfo;
215
+ let noFlsEntity: EntityInfo;
216
+
217
+ beforeEach(() => {
218
+ resolver = new TestResolver();
219
+ flsEntity = new EntityInfo(employeeEntityInit(true));
220
+ noFlsEntity = new EntityInfo(employeeEntityInit(false));
221
+ });
222
+
223
+ it('strips a denied-read field from clientNewValues AND input, and returns true', () => {
224
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Notes: 'edited', Salary: 0 });
225
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
226
+
227
+ expect(result).toBe(true);
228
+ expect(clientNewValues).not.toHaveProperty('Salary');
229
+ expect(input).not.toHaveProperty('Salary');
230
+ expect(clientNewValues).toEqual({ ID: '1', Notes: 'edited' });
231
+ });
232
+
233
+ it('removes denied-field entries from OldValues___ (fabricated old values must not drive conflict detection)', () => {
234
+ const { input, clientNewValues } = makeClientPayload(
235
+ { ID: '1', Notes: 'edited', Salary: 0 },
236
+ [{ Key: 'ID', Value: '1' }, { Key: 'Notes', Value: 'orig' }, { Key: 'Salary', Value: '0' }]
237
+ );
238
+ resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
239
+
240
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: '1' }, { Key: 'Notes', Value: 'orig' }]);
241
+ });
242
+
243
+ it('bridges Name → CodeName: a denied field with a space is stripped by its CodeName key', () => {
244
+ // 'Base Salary' arrives from the wire as its CodeName 'Base_Salary'
245
+ const { input, clientNewValues } = makeClientPayload(
246
+ { ID: '1', Notes: 'edited', Base_Salary: 100 },
247
+ [{ Key: 'Base_Salary', Value: '100' }]
248
+ );
249
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
250
+
251
+ expect(result).toBe(true);
252
+ expect(clientNewValues).not.toHaveProperty('Base_Salary');
253
+ expect(input.OldValues___).toEqual([]);
254
+ });
255
+
256
+ it('leaves permitted fields and metadata blobs untouched', () => {
257
+ const restore = { marker: true };
258
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Name: 'Ada', Notes: 'n', Salary: 5 });
259
+ (input as Record<string, unknown>)['RestoreContext___'] = restore;
260
+ resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
261
+
262
+ expect(clientNewValues).toEqual({ ID: '1', Name: 'Ada', Notes: 'n' });
263
+ expect(input['RestoreContext___']).toBe(restore);
264
+ expect(input['Name']).toBe('Ada');
265
+ });
266
+
267
+ it('returns true even when the client sent nothing for the denied field — the truth-load must still be forced', () => {
268
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Notes: 'edited' });
269
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
270
+
271
+ expect(result).toBe(true);
272
+ expect(clientNewValues).toEqual({ ID: '1', Notes: 'edited' });
273
+ });
274
+
275
+ it('is a no-op returning false when the entity has no field security', () => {
276
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 12345 });
277
+ const result = resolver.TestStrip(noFlsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
278
+
279
+ expect(result).toBe(false);
280
+ expect(clientNewValues['Salary']).toBe(12345);
281
+ expect(input['Salary']).toBe(12345);
282
+ });
283
+
284
+ it('is a no-op returning false for a user who HOLDS the granting role', () => {
285
+ const { input, clientNewValues } = makeClientPayload(
286
+ { ID: '1', Salary: 99000 },
287
+ [{ Key: 'Salary', Value: '85000' }]
288
+ );
289
+ const result = resolver.TestStrip(flsEntity, buildUser([HR_ROLE_ID]), input, clientNewValues);
290
+
291
+ expect(result).toBe(false);
292
+ expect(clientNewValues['Salary']).toBe(99000);
293
+ expect(input.OldValues___).toHaveLength(1);
294
+ });
295
+
296
+ it('is a no-op returning false without a user', () => {
297
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 1 });
298
+ const result = resolver.TestStrip(flsEntity, undefined as unknown as UserInfo, input, clientNewValues);
299
+
300
+ expect(result).toBe(false);
301
+ expect(clientNewValues['Salary']).toBe(1);
302
+ });
303
+
304
+ it('matches input keys case-insensitively (wire casing must not defeat the strip)', () => {
305
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', SALARY: 0 });
306
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
307
+
308
+ expect(result).toBe(true);
309
+ expect(clientNewValues).not.toHaveProperty('SALARY');
310
+ });
311
+ });
312
+
313
+ // ─── A write-only field is a FORBIDDEN configuration ──────────────────────
314
+ //
315
+ // Read is required for Update, enforced by a row-level CHECK constraint AND by the clamp in
316
+ // EntityFieldInfo.GetUserFieldPermissions that catches the across-roles case the constraint
317
+ // cannot see. These tests write the forbidden rows DIRECTLY into metadata, bypassing the
318
+ // constraint, and assert the runtime still refuses to produce a write-only outcome.
319
+
320
+ describe('write-only fields are unreachable, so every denied-read field is stripped', () => {
321
+ const resolver = new TestResolver();
322
+
323
+ /**
324
+ * Salary with the forbidden shape written straight into metadata: the intern is denied READ
325
+ * but granted UPDATE. A CHECK constraint would refuse this row; reaching it here means it
326
+ * was written outside the entity path.
327
+ */
328
+ function forbiddenWriteOnlyEntity(): EntityInfo {
329
+ const init = employeeEntityInit(true);
330
+ const fields = init['Fields'] as Array<Record<string, unknown>>;
331
+ const salary = fields.find(f => f['Name'] === 'Salary')!;
332
+ salary['EntityFieldPermissions'] = [
333
+ ...openTo('f-salary', [HR_ROLE_ID]),
334
+ { ID: 'p2', EntityFieldID: 'f-salary', RoleID: INTERN_ROLE_ID, ReadAccess: 'No Access', UpdateAccess: 'Allow', CreateAccess: 'No Access' },
335
+ ];
336
+ return new EntityInfo(init);
337
+ }
338
+
339
+ it('the aggregation clamps the forbidden row — update does not survive a denied read', () => {
340
+ const entity = forbiddenWriteOnlyEntity();
341
+ const intern = buildUser([INTERN_ROLE_ID]);
342
+
343
+ expect(entity.GetDeniedReadFields(intern).has('salary')).toBe(true);
344
+ expect(entity.GetDeniedUpdateFields(intern).has('salary')).toBe(true);
345
+ });
346
+
347
+ it('strips the value anyway — a client cannot have a legitimate value for a field it never saw', () => {
348
+ const entity = forbiddenWriteOnlyEntity();
349
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 123456 });
350
+ const result = resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
351
+
352
+ expect(result).toBe(true); // truth-load still forced — the denied-read set is non-empty
353
+ expect(clientNewValues).not.toHaveProperty('Salary');
354
+ expect(input).not.toHaveProperty('Salary');
355
+ });
356
+
357
+ it('removes the OldValues entry too (the client cannot know a true old value)', () => {
358
+ const entity = forbiddenWriteOnlyEntity();
359
+ const { input, clientNewValues } = makeClientPayload(
360
+ { ID: '1', Salary: 123456 },
361
+ [{ Key: 'ID', Value: '1' }, { Key: 'Salary', Value: '99' }]
362
+ );
363
+ resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
364
+
365
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: '1' }]);
366
+ expect(clientNewValues).not.toHaveProperty('Salary');
367
+ });
368
+
369
+ it('strips EVERY denied-read field, with no update-permission split', () => {
370
+ // Base Salary is denied read AND update; Salary carries the forbidden write-only row.
371
+ // Both are stripped, because denied-read ∩ denied-update is now just denied-read.
372
+ const entity = forbiddenWriteOnlyEntity();
373
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 123456, Base_Salary: 7 });
374
+ resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
375
+
376
+ expect(clientNewValues).not.toHaveProperty('Salary');
377
+ expect(clientNewValues).not.toHaveProperty('Base_Salary');
378
+ });
379
+ });
380
+
381
+ /**
382
+ * Tests for ResolverBase.MustLoadTruthFromDatabase — the hydration-source decision.
383
+ *
384
+ * `UpdateRecord` may hydrate the entity from the client's `OldValues___` instead of loading the
385
+ * row, as an optimization for the case where nothing needs true prior state. This predicate decides
386
+ * when that shortcut is unsafe.
387
+ *
388
+ * The security case, and the reason `EnableFieldLevelSecurity` is in the condition at all: the
389
+ * canonical FLS configuration is Read Allow + Update Deny, which leaves `hasDeniedReadFields`
390
+ * FALSE. Before this predicate consulted the entity flag, such a caller took the shortcut and was
391
+ * hydrated from its own `OldValues___`. A value supplied there arrives via `LoadFromData`, which
392
+ * the EntityField setter records as the field's initial value — so it is not dirty,
393
+ * `CheckFieldLevelUpdatePermissions` (which rejects only `field.Dirty && denied`) never sees it,
394
+ * and `GenerateSaveSQL` sends it anyway because it filters on `NotLoaded`, never on `Dirty`.
395
+ * Pinning a value in `OldValues___` was a write to a field the caller may not write.
396
+ */
397
+ describe('ResolverBase.MustLoadTruthFromDatabase', () => {
398
+ let resolver: TestResolver;
399
+
400
+ beforeEach(() => {
401
+ resolver = new TestResolver();
402
+ });
403
+
404
+ /**
405
+ * `Employees` where the Intern role may READ every field — including Salary — but may not
406
+ * UPDATE Salary. "You can see the salaries, you just cannot change them."
407
+ *
408
+ * Every field must be readable, not just Salary. `StripDeniedReadFieldsFromClientInput`
409
+ * returns `true` whenever the caller's denied-READ set is non-empty AT ALL, regardless of what
410
+ * the payload contained, so a single unrelated read denial anywhere on the entity would force
411
+ * the truth-load by itself and mask the gap. A caller with no read denials is the one whose
412
+ * hydration source was decided purely by the terms this test is about.
413
+ */
414
+ function readAllowUpdateDenyEntity(withFls: boolean): EntityInfo {
415
+ const init = employeeEntityInit(withFls);
416
+ init['TrackRecordChanges'] = false; // the shortcut is only reachable with tracking off
417
+ const fields = init['Fields'] as Array<Record<string, unknown>>;
418
+ for (const f of fields) {
419
+ if (f['ID'] === 'f-id') continue; // primary key — never restrictable
420
+ f['EntityFieldPermissions'] = openTo(f['ID'] as string);
421
+ }
422
+ const salary = fields.find((f) => f['ID'] === 'f-salary')!;
423
+ salary['EntityFieldPermissions'] = [
424
+ ...openTo('f-salary', [HR_ROLE_ID]),
425
+ { ID: 'f-salary-intern', EntityFieldID: 'f-salary', RoleID: INTERN_ROLE_ID, ReadAccess: 'Allow', UpdateAccess: 'Deny', CreateAccess: 'Deny' },
426
+ ];
427
+ return new EntityInfo(init);
428
+ }
429
+
430
+ const withOldValues = (): ClientInput => ({
431
+ ID: '1',
432
+ Notes: 'edited',
433
+ OldValues___: [
434
+ { Key: 'ID', Value: '1' },
435
+ { Key: 'Notes', Value: 'original' },
436
+ { Key: 'Salary', Value: '999999' }, // the pinned value — never in the mutation input
437
+ ],
438
+ });
439
+
440
+ it('the fixture really is Read Allow + Update Deny, with an EMPTY denied-READ set', () => {
441
+ const entity = readAllowUpdateDenyEntity(true);
442
+ const intern = buildUser([INTERN_ROLE_ID]);
443
+
444
+ // This is what makes the bypass reachable: nothing to strip, so hasDeniedReadFields is false.
445
+ expect(entity.GetDeniedReadFields(intern).size).toBe(0);
446
+ expect(entity.GetDeniedUpdateFields(intern).has('salary')).toBe(true);
447
+ });
448
+
449
+ it('REGRESSION: forces the truth-load on an FLS entity even when no field is denied READ', () => {
450
+ const entity = readAllowUpdateDenyEntity(true);
451
+ const intern = buildUser([INTERN_ROLE_ID]);
452
+ const input = withOldValues();
453
+
454
+ // Exactly what UpdateRecord computes: the strip finds nothing to remove, so it returns false.
455
+ const hasDeniedReadFields = resolver.TestStrip(entity, intern, input, { ID: '1', Notes: 'edited' });
456
+ expect(hasDeniedReadFields).toBe(false);
457
+ expect(input.OldValues___).toContainEqual({ Key: 'Salary', Value: '999999' }); // survives the strip
458
+
459
+ // Before the fix every other term was false here and the client's OldValues won.
460
+ expect(resolver.TestMustLoadTruth(entity, input, hasDeniedReadFields, false)).toBe(true);
461
+ });
462
+
463
+ it('keeps the OldValues shortcut when field-level security is OFF (no cost to the ~99%)', () => {
464
+ const entity = readAllowUpdateDenyEntity(false);
465
+ expect(resolver.TestMustLoadTruth(entity, withOldValues(), false, false)).toBe(false);
466
+ });
467
+
468
+ it('still forces the truth-load for each pre-existing reason, independently', () => {
469
+ const flsOff = readAllowUpdateDenyEntity(false);
470
+
471
+ // No OldValues to hydrate from.
472
+ expect(resolver.TestMustLoadTruth(flsOff, { ID: '1' }, false, false)).toBe(true);
473
+ // A denied READ field was stripped.
474
+ expect(resolver.TestMustLoadTruth(flsOff, withOldValues(), true, false)).toBe(true);
475
+ // The audit payload was narrowed.
476
+ expect(resolver.TestMustLoadTruth(flsOff, withOldValues(), false, true)).toBe(true);
477
+
478
+ // The entity tracks record changes.
479
+ const tracked = employeeEntityInit(false);
480
+ tracked['TrackRecordChanges'] = true;
481
+ expect(resolver.TestMustLoadTruth(new EntityInfo(tracked), withOldValues(), false, false)).toBe(true);
482
+ });
483
+ });