@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,358 @@
1
+ /**
2
+ * Field-Level Security at the GraphQL read boundary, for `MJ: Record Changes`.
3
+ *
4
+ * `MapFieldNamesToCodeNames` is where denied fields are stripped and encrypted ones masked on
5
+ * every single-record return path. It is also where the audit trail would otherwise walk straight
6
+ * out: Record Changes has field security switched OFF, so the denied-set logic sitting beside this
7
+ * is a no-op for it, and its `ChangesJSON` carries another entity's old and new values verbatim.
8
+ *
9
+ * These tests cover the resolver half specifically — the provider half (RunView, both cache paths)
10
+ * and the projection semantics themselves live in MJCore's
11
+ * `fieldSecurity.recordChanges.test.ts`.
12
+ */
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+
15
+ // ─── Hoisted mocks (same preamble as resolverBase.fls.test.ts) ─────────────
16
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
17
+ SQLServerDataProvider: class {},
18
+ }));
19
+
20
+ vi.mock('@memberjunction/generic-database-provider', () => ({
21
+ UserCache: { get Users() { return []; } },
22
+ }));
23
+
24
+ vi.mock('cloudevents', () => ({
25
+ CloudEvent: class {},
26
+ httpTransport: () => () => undefined,
27
+ emitterFor: () => () => undefined,
28
+ }));
29
+
30
+ vi.mock('type-graphql', () => ({
31
+ Resolver: () => () => undefined,
32
+ Mutation: () => () => undefined,
33
+ Query: () => () => undefined,
34
+ Subscription: () => () => undefined,
35
+ Ctx: () => () => undefined,
36
+ Arg: () => () => undefined,
37
+ PubSub: () => () => undefined,
38
+ Root: () => () => undefined,
39
+ ObjectType: () => () => undefined,
40
+ InputType: () => () => undefined,
41
+ Field: () => () => undefined,
42
+ FieldResolver: () => () => undefined,
43
+ Int: () => undefined,
44
+ Float: () => undefined,
45
+ registerEnumType: () => undefined,
46
+ }));
47
+
48
+ vi.mock('graphql', () => ({
49
+ GraphQLError: class extends Error {
50
+ constructor(msg: string) { super(msg); }
51
+ },
52
+ }));
53
+
54
+ vi.mock('mssql', () => ({}));
55
+
56
+ vi.mock('@memberjunction/api-keys', () => ({
57
+ GetAPIKeyEngine: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('@memberjunction/encryption', () => ({
61
+ EncryptionEngine: { Instance: {} },
62
+ }));
63
+
64
+ // Unlike the sibling FLS suite, these tests actually RUN the mapper, so the mock has to answer
65
+ // MapFieldName. Identity is correct here: none of the fixture's field names need bridging.
66
+ vi.mock('@memberjunction/graphql-dataprovider', () => ({
67
+ FieldMapper: class {
68
+ public MapFieldName(name: string): string { return name; }
69
+ static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() };
70
+ },
71
+ }));
72
+
73
+ vi.mock('../generic/PubSubManager.js', () => ({
74
+ PubSubManager: class { static Instance = { publish: vi.fn() }; },
75
+ }));
76
+
77
+ vi.mock('../generic/PushStatusResolver.js', () => ({
78
+ PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
79
+ PushStatusNotification: class {},
80
+ PushStatusResolver: class {},
81
+ }));
82
+
83
+ vi.mock('../generic/CacheInvalidationResolver.js', () => ({
84
+ CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
85
+ }));
86
+
87
+ vi.mock('../generic/RunViewResolver.js', () => ({
88
+ RunViewByIDInput: class {},
89
+ RunViewByNameInput: class {},
90
+ RunDynamicViewInput: class {},
91
+ }));
92
+
93
+ vi.mock('../generic/DeleteOptionsInput.js', () => ({
94
+ DeleteOptionsInput: class {},
95
+ }));
96
+
97
+ vi.mock('../types.js', () => ({
98
+ RunViewGenericParams: class {},
99
+ }));
100
+
101
+ vi.mock('@memberjunction/core', async () => {
102
+ const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
103
+ return {
104
+ ...actual,
105
+ LogError: vi.fn(),
106
+ LogStatus: vi.fn(),
107
+ };
108
+ });
109
+
110
+ vi.mock('@memberjunction/core-entities', () => ({}));
111
+
112
+ // ─── Import after mocks ──────────────────────────────────────────────────
113
+ import { ResolverBase } from '../generic/ResolverBase';
114
+ import { EntityInfo, IMetadataProvider, RecordChangesEntityName, UserInfo, UserRoleInfo } from '@memberjunction/core';
115
+
116
+ // ─── Fixtures ─────────────────────────────────────────────────────────────
117
+
118
+ const HR_ROLE_ID = 'A0000000-0000-0000-0000-000000000001';
119
+ const INTERN_ROLE_ID = 'A0000000-0000-0000-0000-000000000003';
120
+ const EMPLOYEES_ENTITY_ID = 'E0000000-0000-0000-0000-0000000000E1';
121
+ const RECORD_CHANGES_ENTITY_ID = 'E0000000-0000-0000-0000-0000000000E3';
122
+
123
+ function openTo(fieldId: string, roles: string[] = [HR_ROLE_ID, INTERN_ROLE_ID]): Record<string, unknown>[] {
124
+ return roles.map((roleId, i) => ({
125
+ ID: `${fieldId}-open-${i}`,
126
+ EntityFieldID: fieldId,
127
+ RoleID: roleId,
128
+ ReadAccess: 'Allow',
129
+ UpdateAccess: 'Allow',
130
+ CreateAccess: 'Allow',
131
+ }));
132
+ }
133
+
134
+ /** The entity the audit rows are ABOUT — `Salary` readable only by HR. */
135
+ function employeesEntity(): EntityInfo {
136
+ return new EntityInfo({
137
+ ID: EMPLOYEES_ENTITY_ID,
138
+ Name: 'Employees',
139
+ SchemaName: 'dbo',
140
+ BaseTable: 'Employee',
141
+ BaseView: 'vwEmployees',
142
+ IncludeInAPI: true,
143
+ EnableFieldLevelSecurity: true,
144
+ Permissions: [
145
+ { EntityID: EMPLOYEES_ENTITY_ID, RoleID: HR_ROLE_ID, CanRead: true },
146
+ { EntityID: EMPLOYEES_ENTITY_ID, RoleID: INTERN_ROLE_ID, CanRead: true },
147
+ ],
148
+ Fields: [
149
+ { ID: 'e-f-id', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 1, Name: 'ID', Entity: 'Employees', Type: 'uniqueidentifier', IsPrimaryKey: true },
150
+ { ID: 'e-f-name', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 2, Name: 'Name', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('e-f-name') },
151
+ { ID: 'e-f-salary', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 3, Name: 'Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('e-f-salary', [HR_ROLE_ID]) },
152
+ ],
153
+ });
154
+ }
155
+
156
+ /** The audit entity — field security OFF, which is the default and the point of the exercise. */
157
+ function recordChangesEntity(): EntityInfo {
158
+ return new EntityInfo({
159
+ ID: RECORD_CHANGES_ENTITY_ID,
160
+ Name: RecordChangesEntityName,
161
+ SchemaName: '__mj',
162
+ BaseTable: 'RecordChange',
163
+ BaseView: 'vwRecordChanges',
164
+ IncludeInAPI: true,
165
+ EnableFieldLevelSecurity: false,
166
+ Permissions: [],
167
+ Fields: [
168
+ { ID: 'rc-f-id', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 1, Name: 'ID', Entity: RecordChangesEntityName, Type: 'uniqueidentifier', IsPrimaryKey: true },
169
+ { ID: 'rc-f-entityid', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 2, Name: 'EntityID', Entity: RecordChangesEntityName, Type: 'uniqueidentifier' },
170
+ { ID: 'rc-f-changesjson', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 3, Name: 'ChangesJSON', Entity: RecordChangesEntityName, Type: 'nvarchar' },
171
+ { ID: 'rc-f-desc', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 4, Name: 'ChangesDescription', Entity: RecordChangesEntityName, Type: 'nvarchar' },
172
+ { ID: 'rc-f-full', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 5, Name: 'FullRecordJSON', Entity: RecordChangesEntityName, Type: 'nvarchar' },
173
+ ],
174
+ });
175
+ }
176
+
177
+ function buildUser(roleIds: string[], id = 'user-1'): UserInfo {
178
+ const u = new UserInfo();
179
+ u.ID = id;
180
+ u.Name = 'Test User';
181
+ u.Email = `${id}@test.com`;
182
+ u.IsActive = true;
183
+ (u as unknown as Record<string, unknown>)['_UserRoles'] =
184
+ roleIds.map(rid => new UserRoleInfo({ UserID: id, RoleID: rid, Role: `Role-${rid}` }));
185
+ return u;
186
+ }
187
+
188
+ /** Answers only what the mapper and the projector ask of a provider. */
189
+ function testProvider(entities: EntityInfo[]): IMetadataProvider {
190
+ return {
191
+ get Entities() { return entities; },
192
+ EntityByName: (name: string) => entities.find(e => e.Name.trim().toLowerCase() === name?.trim().toLowerCase()),
193
+ EntityByID: (id: string) => entities.find(e => e.ID?.trim().toLowerCase() === id?.trim().toLowerCase()),
194
+ } as unknown as IMetadataProvider;
195
+ }
196
+
197
+ const SALARY_CHANGES_JSON = JSON.stringify({
198
+ Name: { field: 'Name', oldValue: 'Ada', newValue: 'Ada L' },
199
+ Salary: { field: 'Salary', oldValue: 100000, newValue: 120000 },
200
+ });
201
+
202
+ function changeRow(): Record<string, unknown> {
203
+ return {
204
+ ID: 'rc-1',
205
+ EntityID: EMPLOYEES_ENTITY_ID,
206
+ ChangesJSON: SALARY_CHANGES_JSON,
207
+ ChangesDescription: 'Salary changed from 100000 to 120000',
208
+ FullRecordJSON: JSON.stringify({ ID: 'emp-1', Name: 'Ada L', Salary: 120000 }),
209
+ };
210
+ }
211
+
212
+ /** Exposes the protected mapping and write-guard boundaries. */
213
+ class TestResolver extends ResolverBase {
214
+ public MapOne(dataObject: Record<string, unknown>, user: UserInfo, provider: IMetadataProvider): Promise<Record<string, unknown>> {
215
+ return this.MapFieldNamesToCodeNames(RecordChangesEntityName, dataObject, user, provider);
216
+ }
217
+ public MapMany(rows: Record<string, unknown>[], user: UserInfo, provider: IMetadataProvider): Promise<Record<string, unknown>[]> {
218
+ return this.ArrayMapFieldNamesToCodeNames(RecordChangesEntityName, rows, user, provider);
219
+ }
220
+ public StripPayload(
221
+ entityInfo: EntityInfo, user: UserInfo,
222
+ input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
223
+ clientNewValues: Record<string, unknown>, provider: IMetadataProvider
224
+ ): boolean {
225
+ return this.StripRecordChangePayloadFromClientInput(entityInfo, user, input, clientNewValues, provider);
226
+ }
227
+ }
228
+
229
+ /** The shape UpdateRecord builds before applying the guards. */
230
+ function clientPayload(fields: Record<string, unknown>, oldValues?: Array<{ Key: string; Value: unknown }>) {
231
+ const input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown> = { ...fields };
232
+ if (oldValues) {
233
+ input.OldValues___ = oldValues;
234
+ }
235
+ return { input, clientNewValues: { ...fields } };
236
+ }
237
+
238
+ // ─── Tests ────────────────────────────────────────────────────────────────
239
+
240
+ describe('MapFieldNamesToCodeNames — Record Changes payload security', () => {
241
+ let resolver: TestResolver;
242
+ let provider: IMetadataProvider;
243
+
244
+ beforeEach(() => {
245
+ resolver = new TestResolver();
246
+ provider = testProvider([employeesEntity(), recordChangesEntity()]);
247
+ });
248
+
249
+ it('narrows ChangesJSON and withholds ChangesDescription for a restricted caller', async () => {
250
+ const mapped = await resolver.MapOne(changeRow(), buildUser([INTERN_ROLE_ID]), provider);
251
+
252
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).not.toHaveProperty('Salary');
253
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).toHaveProperty('Name');
254
+ expect('ChangesDescription' in mapped).toBe(false);
255
+ expect(JSON.parse(mapped['FullRecordJSON'] as string)).not.toHaveProperty('Salary');
256
+ });
257
+
258
+ it('returns the full payload for a caller with no denials on the target entity', async () => {
259
+ const mapped = await resolver.MapOne(changeRow(), buildUser([HR_ROLE_ID]), provider);
260
+
261
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).toHaveProperty('Salary');
262
+ expect(mapped['ChangesDescription']).toBe('Salary changed from 100000 to 120000');
263
+ });
264
+
265
+ it('does not modify the caller-supplied row — resolvers pass the cache its own objects', async () => {
266
+ const row = changeRow();
267
+ const snapshot = { ...row };
268
+ await resolver.MapOne(row, buildUser([INTERN_ROLE_ID]), provider);
269
+ expect(row).toEqual(snapshot);
270
+ });
271
+
272
+ it('applies the same treatment through the array form', async () => {
273
+ const mapped = await resolver.MapMany([changeRow(), changeRow()], buildUser([INTERN_ROLE_ID]), provider);
274
+
275
+ for (const row of mapped) {
276
+ expect(JSON.parse(row['ChangesJSON'] as string)).not.toHaveProperty('Salary');
277
+ expect('ChangesDescription' in row).toBe(false);
278
+ }
279
+ });
280
+
281
+ it('is a no-op with no context user', async () => {
282
+ const mapped = await resolver.MapOne(changeRow(), undefined as unknown as UserInfo, provider);
283
+ expect(mapped['ChangesDescription']).toBe('Salary changed from 100000 to 120000');
284
+ });
285
+ });
286
+
287
+ /**
288
+ * The write half. Without it the read projection destroys the thing it protects: a restricted
289
+ * caller is handed a NARROWED ChangesJSON, that value hydrates a client entity as an ordinary
290
+ * loaded value, and GenerateSaveSQL writes every SP-parameter field — so saving the record for any
291
+ * reason (editing Comments, say) would overwrite the stored audit payload with the narrowed one.
292
+ */
293
+ describe('StripRecordChangePayloadFromClientInput (write guard)', () => {
294
+ let resolver: TestResolver;
295
+ let provider: IMetadataProvider;
296
+ let rcEntity: EntityInfo;
297
+
298
+ beforeEach(() => {
299
+ resolver = new TestResolver();
300
+ rcEntity = recordChangesEntity();
301
+ provider = testProvider([employeesEntity(), rcEntity]);
302
+ });
303
+
304
+ it('refuses every payload column a restricted caller sends back', () => {
305
+ const { input, clientNewValues } = clientPayload({
306
+ ID: 'rc-1',
307
+ Comments: 'looks fine',
308
+ ChangesJSON: JSON.stringify({ Name: { field: 'Name', oldValue: 'Ada', newValue: 'Ada L' } }), // narrowed
309
+ FullRecordJSON: JSON.stringify({ ID: 'emp-1', Name: 'Ada L' }), // narrowed
310
+ });
311
+
312
+ const forcedTruthLoad = resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
313
+
314
+ expect(forcedTruthLoad).toBe(true);
315
+ expect(clientNewValues).toEqual({ ID: 'rc-1', Comments: 'looks fine' });
316
+ expect(input).not.toHaveProperty('ChangesJSON');
317
+ expect(input).not.toHaveProperty('FullRecordJSON');
318
+ });
319
+
320
+ it('leaves the edit the user actually made alone', () => {
321
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', Comments: 'reviewed' });
322
+ resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
323
+ expect(clientNewValues['Comments']).toBe('reviewed');
324
+ });
325
+
326
+ it('drops payload entries from OldValues so a narrowed value cannot read as a conflict', () => {
327
+ const { input, clientNewValues } = clientPayload(
328
+ { ID: 'rc-1', Comments: 'reviewed' },
329
+ [{ Key: 'ID', Value: 'rc-1' }, { Key: 'ChangesJSON', Value: '{"Name":{}}' }, { Key: 'Comments', Value: '' }]
330
+ );
331
+ resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
332
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: 'rc-1' }, { Key: 'Comments', Value: '' }]);
333
+ });
334
+
335
+ it('forces the truth-load even when the client sent no payload key at all', () => {
336
+ // 'MJ: Record Changes' has TrackRecordChanges off, so a client sending OldValues would
337
+ // otherwise hydrate from those values and never read what the database holds.
338
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', Comments: 'x' }, [{ Key: 'ID', Value: 'rc-1' }]);
339
+ expect(resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider)).toBe(true);
340
+ });
341
+
342
+ it('does NOT interfere with a caller who carries no denials anywhere', () => {
343
+ const payload = JSON.stringify({ Salary: { field: 'Salary', oldValue: 1, newValue: 2 } });
344
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', ChangesJSON: payload });
345
+
346
+ const forcedTruthLoad = resolver.StripPayload(rcEntity, buildUser([HR_ROLE_ID]), input, clientNewValues, provider);
347
+
348
+ expect(forcedTruthLoad).toBe(false);
349
+ expect(clientNewValues['ChangesJSON']).toBe(payload);
350
+ });
351
+
352
+ it('does not apply to any other entity', () => {
353
+ const employees = employeesEntity();
354
+ const { input, clientNewValues } = clientPayload({ ID: 'e-1', ChangesJSON: '{}' });
355
+ expect(resolver.StripPayload(employees, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider)).toBe(false);
356
+ expect(clientNewValues['ChangesJSON']).toBe('{}');
357
+ });
358
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What a write-refusal GraphQLError carries BESIDES its message.
3
+ *
4
+ * PR #3973 made the refusal's prose (`CompleteMessage`) reach the client. This is the structural
5
+ * half: a `Validate()` / `ValidateAsync()` refusal names the offending FIELD in each
6
+ * `ValidationErrorInfo.Source`, and the client's form can only paint that field red if the wire says
7
+ * which field. `RefusalExtensions` adds `extensions.validationErrors` for exactly that; the message
8
+ * is untouched. Written from the expected behaviour: the client (GraphQLDataProvider.Save) reads
9
+ * `response.errors[0].extensions.validationErrors` and hands it to `DeserializeValidationErrors`.
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { describe, expect, it } from 'vitest';
14
+ import { BaseEntityResult } from '@memberjunction/core';
15
+ import { DeserializeValidationErrors, ValidationErrorInfo, ValidationErrorType } from '@memberjunction/global';
16
+ import { RefusalExtensions } from '../generic/refusalExtensions';
17
+
18
+ function refused(...errors: ValidationErrorInfo[]): BaseEntityResult {
19
+ const result = new BaseEntityResult();
20
+ result.Success = false;
21
+ result.Errors = errors;
22
+ return result;
23
+ }
24
+
25
+ describe('RefusalExtensions', () => {
26
+ it('always carries the code and entity name the client already depends on', () => {
27
+ expect(RefusalExtensions('SAVE_ENTITY_ERROR', 'MJ: Tag Scopes', undefined)).toEqual({
28
+ code: 'SAVE_ENTITY_ERROR',
29
+ entityName: 'MJ: Tag Scopes',
30
+ });
31
+ });
32
+
33
+ it('omits validationErrors (not an empty array) when the refusal had no structured errors', () => {
34
+ // A SQL / provider failure sets Message and no Errors. A client must be able to tell
35
+ // "validation said no" from "the database said no" by the KEY being absent.
36
+ const dbFailure = new BaseEntityResult();
37
+ dbFailure.Success = false;
38
+ dbFailure.Message = 'Timeout expired while saving.';
39
+ const ext = RefusalExtensions('CREATE_ENTITY_ERROR', 'Widgets', dbFailure);
40
+ expect(ext).toEqual({ code: 'CREATE_ENTITY_ERROR', entityName: 'Widgets' });
41
+ expect('validationErrors' in ext).toBe(false);
42
+ expect('messageIncludesValidationErrors' in ext).toBe(false);
43
+ });
44
+
45
+ it('carries every field-named reason as a plain, JSON-safe object', () => {
46
+ const ext = RefusalExtensions(
47
+ 'SAVE_ENTITY_ERROR',
48
+ 'MJ: Tag Scopes',
49
+ refused(
50
+ new ValidationErrorInfo('TagID', 'Cannot add TagScope row for tag "Global" because it is marked IsGlobal=1.', 'abc'),
51
+ new ValidationErrorInfo('', 'Record-level note', null, ValidationErrorType.Warning),
52
+ ),
53
+ );
54
+ expect(ext.validationErrors).toEqual([
55
+ { Source: 'TagID', Message: 'Cannot add TagScope row for tag "Global" because it is marked IsGlobal=1.', Value: 'abc', Type: 'Failure' },
56
+ { Source: '', Message: 'Record-level note', Value: null, Type: 'Warning' },
57
+ ]);
58
+ // The server states that the message it throws (CompleteMessage) already renders these — the
59
+ // client must never have to infer that from the text.
60
+ expect(ext.messageIncludesValidationErrors).toBe(true);
61
+ // What GraphQL actually ships is JSON: the payload must survive it unchanged.
62
+ expect(JSON.parse(JSON.stringify(ext))).toEqual(ext);
63
+ });
64
+
65
+ it('is what the client rehydrates: DeserializeValidationErrors(extensions.validationErrors) round-trips', () => {
66
+ const original = [new ValidationErrorInfo('Amount', 'Amount must be positive', -5)];
67
+ const ext = RefusalExtensions('SAVE_ENTITY_ERROR', 'Orders', refused(...original));
68
+ const wire = JSON.parse(JSON.stringify(ext)) as { validationErrors?: unknown };
69
+ expect(DeserializeValidationErrors(wire.validationErrors)).toEqual(original);
70
+ });
71
+
72
+ it('does not alter the message the throw already carries — CompleteMessage stays the prose', () => {
73
+ const result = refused(new ValidationErrorInfo('TagID', 'No such Tag exists.', 'abc'));
74
+ // The resolver throws `CompleteMessage ?? 'Unknown error'` and attaches RefusalExtensions;
75
+ // both come from the same result, and the prose must remain a superset of the structured text.
76
+ expect(result.CompleteMessage).toContain('No such Tag exists.');
77
+ expect(RefusalExtensions('SAVE_ENTITY_ERROR', 'MJ: Tag Scopes', result).validationErrors?.[0].Message).toBe('No such Tag exists.');
78
+ });
79
+ });
80
+
81
+ describe('ResolverBase attaches RefusalExtensions on every LatestResult-backed refusal throw', () => {
82
+ const lines = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8').split('\n');
83
+
84
+ it('each `LatestResult?.CompleteMessage ??` throw passes RefusalExtensions within the next two lines', () => {
85
+ const throws = lines
86
+ .map((line, i) => ({ line, n: i + 1 }))
87
+ .filter(({ line }) => /throw new GraphQLError\(entityObject\.LatestResult\?\.CompleteMessage \?\?/.test(line));
88
+ expect(throws.length, 'create, update and delete refusals').toBe(3);
89
+ for (const { n } of throws) {
90
+ const window = lines.slice(n, n + 2).join('\n');
91
+ expect(window, `throw at line ${n} must attach RefusalExtensions`).toMatch(/extensions:\s*RefusalExtensions\(/);
92
+ }
93
+ });
94
+
95
+ it('keeps the three error codes distinct and passes LatestResult, not a literal extensions object', () => {
96
+ const source = lines.join('\n');
97
+ for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
98
+ expect(source).toMatch(new RegExp(`RefusalExtensions\\('${code}', entityName, entityObject\\.LatestResult\\)`));
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What the API says when a save or a delete is REFUSED.
3
+ *
4
+ * The three write resolvers all end the same way — `throw new GraphQLError(<a string off
5
+ * LatestResult> ?? 'Unknown error')` — and they did not agree on which string. `CreateRecord` read
6
+ * `CompleteMessage`; `UpdateRecord` and `DeleteRecord` read `Message`. A validation refusal puts its
7
+ * reasons in `Errors` and leaves `Message` null, so the same rule on the same entity explained itself
8
+ * on a create and produced the literal text "Unknown error" on an update.
9
+ *
10
+ * Nothing could catch it: both properties exist, both are strings, and the `??` fallback renders the
11
+ * failure as a plausible-looking error rather than a crash. It is visible only by refusing an update
12
+ * and reading what comes back.
13
+ *
14
+ * The first two cases below guard the direction of RISK in the change — that swapping the property
15
+ * cannot lose a plain `Message`, and cannot blank out the `'Unknown error'` fallback. The last case
16
+ * is the only one that fails if a resolver is switched back, and it has to read the source to do it:
17
+ * any test that restates what a resolver reads stays green through the regression, which is exactly
18
+ * how this survived (a create-path test proved nothing about update).
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { describe, expect, it } from 'vitest';
23
+ import { BaseEntityResult } from '@memberjunction/core';
24
+
25
+ /** The shape a validation refusal leaves: `Message` untouched, reasons in `Errors`. */
26
+ function refusedByValidation(...messages: string[]): BaseEntityResult {
27
+ const result = new BaseEntityResult();
28
+ result.Success = false;
29
+ result.Errors = messages.map((Message) => ({
30
+ Source: 'SomeField',
31
+ Message,
32
+ Value: null,
33
+ Type: 'Failure',
34
+ })) as unknown as BaseEntityResult['Errors'];
35
+ return result;
36
+ }
37
+
38
+ /** Exactly what the update/delete resolvers hand to `GraphQLError`, fallback included. */
39
+ const asReported = (r: BaseEntityResult) => r.CompleteMessage ?? 'Unknown error';
40
+
41
+ describe('switching the update/delete throws to CompleteMessage', () => {
42
+ it('does NOT lose a plain Message — it is a superset, not a replacement', () => {
43
+ // The risk in the change: a non-validation failure (a provider/SQL error) sets `Message` and
44
+ // no `Errors`. The new property must still report it.
45
+ const result = new BaseEntityResult();
46
+ result.Success = false;
47
+ result.Message = 'Timeout expired while saving.';
48
+
49
+ expect(asReported(result)).toBe('Timeout expired while saving.');
50
+ });
51
+
52
+ it('still falls back to "Unknown error" when there is genuinely nothing to say', () => {
53
+ // The other direction: if CompleteMessage returned '' rather than undefined for an empty
54
+ // result, `??` would not fire and the user would get a blank error.
55
+ expect(asReported(new BaseEntityResult())).toBe('Unknown error');
56
+ });
57
+
58
+ it('carries the validation prose that "Unknown error" used to replace', () => {
59
+ const prose = 'Record what this contract says instead of the standard clause.';
60
+ const result = refusedByValidation(prose);
61
+
62
+ expect(result.Message, 'a refusal leaves Message unset — this is why reading it failed').toBeFalsy();
63
+ expect(asReported(result)).toBe(prose);
64
+ });
65
+ });
66
+
67
+ describe('ResolverBase selects CompleteMessage on every write-refusal throw', () => {
68
+ const source = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8');
69
+
70
+ it('no refusal throw reads the bare LatestResult.Message', () => {
71
+ // `\b` so the longer `CompleteMessage` does not match.
72
+ const offenders = source
73
+ .split('\n')
74
+ .map((line, i) => ({ line: line.trim(), n: i + 1 }))
75
+ .filter(({ line }) => /LatestResult\??\.Message\b/.test(line));
76
+
77
+ expect(
78
+ offenders,
79
+ `these lines still read the bare Message:\n${offenders.map((o) => ` ${o.n}: ${o.line}`).join('\n')}`,
80
+ ).toEqual([]);
81
+ });
82
+
83
+ it('keeps the three error codes distinct — the fix must not have collapsed the paths', () => {
84
+ for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
85
+ expect(source, code).toContain(code);
86
+ }
87
+ });
88
+ });