@memberjunction/server 6.1.0-edge.1 → 6.1.0-edge.3

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 (261) hide show
  1. package/LICENSE +180 -4
  2. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  3. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  4. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  5. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  6. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  7. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderEngine.js +245 -0
  9. package/dist/auth/AuthProviderEngine.js.map +1 -0
  10. package/dist/auth/exampleNewUserSubClass.js +1 -1
  11. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  12. package/dist/auth/index.d.ts +11 -0
  13. package/dist/auth/index.d.ts.map +1 -1
  14. package/dist/auth/index.js +65 -18
  15. package/dist/auth/index.js.map +1 -1
  16. package/dist/auth/initializeProviders.d.ts +64 -1
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +117 -5
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.js +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  22. package/dist/auth/newUsers.js +1 -1
  23. package/dist/auth/newUsers.js.map +1 -1
  24. package/dist/config.d.ts +89 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +52 -35
  27. package/dist/config.js.map +1 -1
  28. package/dist/context.js +2 -2
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/generated.d.ts +540 -1595
  31. package/dist/generated/generated.d.ts.map +1 -1
  32. package/dist/generated/generated.js +2755 -16857
  33. package/dist/generated/generated.js.map +1 -1
  34. package/dist/generic/ResolverBase.d.ts +20 -4
  35. package/dist/generic/ResolverBase.d.ts.map +1 -1
  36. package/dist/generic/ResolverBase.js +58 -21
  37. package/dist/generic/ResolverBase.js.map +1 -1
  38. package/dist/generic/RunViewResolver.d.ts +4 -0
  39. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  40. package/dist/generic/RunViewResolver.js +31 -0
  41. package/dist/generic/RunViewResolver.js.map +1 -1
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +183 -54
  45. package/dist/index.js.map +1 -1
  46. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  47. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  48. package/dist/integration/RSUProgressBridge.js +230 -0
  49. package/dist/integration/RSUProgressBridge.js.map +1 -0
  50. package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
  51. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  52. package/dist/integration/SchemaRefreshLaunch.js +50 -0
  53. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  54. package/dist/logging/secretRedactor.d.ts +9 -0
  55. package/dist/logging/secretRedactor.d.ts.map +1 -1
  56. package/dist/logging/secretRedactor.js +39 -0
  57. package/dist/logging/secretRedactor.js.map +1 -1
  58. package/dist/middleware/BaseServerMiddleware.d.ts +5 -0
  59. package/dist/middleware/BaseServerMiddleware.d.ts.map +1 -1
  60. package/dist/middleware/BaseServerMiddleware.js +5 -0
  61. package/dist/middleware/BaseServerMiddleware.js.map +1 -1
  62. package/dist/providerConfigUnits.d.ts +20 -0
  63. package/dist/providerConfigUnits.d.ts.map +1 -0
  64. package/dist/providerConfigUnits.js +22 -0
  65. package/dist/providerConfigUnits.js.map +1 -0
  66. package/dist/realtimeWidget/WidgetSessionService.js +1 -1
  67. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  68. package/dist/realtimeWidget/widgetGuestElevation.js +1 -1
  69. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  70. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  71. package/dist/resolvers/ActionResolver.js +12 -0
  72. package/dist/resolvers/ActionResolver.js.map +1 -1
  73. package/dist/resolvers/ComponentRegistryResolver.js +1 -1
  74. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  75. package/dist/resolvers/EntityPermissionResolver.js +1 -1
  76. package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
  77. package/dist/resolvers/FileResolver.d.ts +34 -4
  78. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  79. package/dist/resolvers/FileResolver.js +218 -28
  80. package/dist/resolvers/FileResolver.js.map +1 -1
  81. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +85 -3
  82. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  83. package/dist/resolvers/IntegrationDiscoveryResolver.js +302 -82
  84. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  85. package/dist/resolvers/PotentialDuplicateRecordResolver.js +1 -1
  86. package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
  87. package/dist/resolvers/QueryResolver.d.ts +5 -4
  88. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  89. package/dist/resolvers/QueryResolver.js +29 -14
  90. package/dist/resolvers/QueryResolver.js.map +1 -1
  91. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  93. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  94. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  95. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
  96. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  97. package/dist/resolvers/RealtimeClientSessionResolver.js +5 -0
  98. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  99. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  100. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  101. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  102. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  103. package/dist/resolvers/SearchEntitiesResolver.js +1 -1
  104. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -1
  105. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SqlLoggingConfigResolver.js +1 -1
  107. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  108. package/dist/resolvers/SyncRolesUsersResolver.js +2 -2
  109. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  110. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  111. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  112. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  113. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  114. package/dist/resolvers/UserFavoriteResolver.js +1 -1
  115. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  116. package/dist/resolvers/UserResolver.d.ts +19 -17
  117. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  118. package/dist/resolvers/UserResolver.js +66 -67
  119. package/dist/resolvers/UserResolver.js.map +1 -1
  120. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  121. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  122. package/dist/resolvers/currentUserRoles.js +36 -0
  123. package/dist/resolvers/currentUserRoles.js.map +1 -0
  124. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  125. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  126. package/dist/rest/MediaAccessKeys.js +42 -1
  127. package/dist/rest/MediaAccessKeys.js.map +1 -1
  128. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  129. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  130. package/dist/rest/MediaStreamHandler.js +83 -2
  131. package/dist/rest/MediaStreamHandler.js.map +1 -1
  132. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  133. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  134. package/dist/rest/OAuthCallbackHandler.js +130 -44
  135. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  136. package/dist/rest/SignatureWebhookHandler.js +1 -1
  137. package/dist/rest/SignatureWebhookHandler.js.map +1 -1
  138. package/dist/rest/UploadTokenManager.d.ts +90 -0
  139. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  140. package/dist/rest/UploadTokenManager.js +156 -0
  141. package/dist/rest/UploadTokenManager.js.map +1 -0
  142. package/dist/services/DurableEntityActionTaskSubmitter.d.ts +26 -0
  143. package/dist/services/DurableEntityActionTaskSubmitter.d.ts.map +1 -0
  144. package/dist/services/DurableEntityActionTaskSubmitter.js +55 -0
  145. package/dist/services/DurableEntityActionTaskSubmitter.js.map +1 -0
  146. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  147. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  148. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  149. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  150. package/dist/services/ScheduledJobsService.js +1 -1
  151. package/dist/services/ScheduledJobsService.js.map +1 -1
  152. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  153. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  154. package/dist/services/StartTaskGraphDispatcher.js +37 -2
  155. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  156. package/dist/services/TaskGraphActionRunner.d.ts +34 -0
  157. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -0
  158. package/dist/services/TaskGraphActionRunner.js +95 -0
  159. package/dist/services/TaskGraphActionRunner.js.map +1 -0
  160. package/dist/services/TaskGraphAgentRunner.d.ts +20 -0
  161. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  162. package/dist/services/TaskGraphAgentRunner.js +53 -0
  163. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  164. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  165. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  166. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  167. package/dist/services/TaskGraphPromptRunner.d.ts +31 -0
  168. package/dist/services/TaskGraphPromptRunner.d.ts.map +1 -0
  169. package/dist/services/TaskGraphPromptRunner.js +134 -0
  170. package/dist/services/TaskGraphPromptRunner.js.map +1 -0
  171. package/dist/telephony/RingCentralTelephonyService.js +1 -1
  172. package/dist/telephony/RingCentralTelephonyService.js.map +1 -1
  173. package/dist/telephony/TwilioTelephonyRouter.js +1 -1
  174. package/dist/telephony/TwilioTelephonyRouter.js.map +1 -1
  175. package/dist/telephony/VonageTelephonyRouter.js +1 -1
  176. package/dist/telephony/VonageTelephonyRouter.js.map +1 -1
  177. package/dist/types.d.ts +6 -0
  178. package/dist/types.d.ts.map +1 -1
  179. package/dist/types.js.map +1 -1
  180. package/package.json +91 -91
  181. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  182. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  183. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  184. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  185. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  186. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  187. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  188. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  189. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  190. package/src/__tests__/RealtimeClientSessionResolver.test.ts +1 -1
  191. package/src/__tests__/ResolverBase.frozenRecordMapping.test.ts +133 -0
  192. package/src/__tests__/ResolverBase.transportMapping.test.ts +289 -0
  193. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  194. package/src/__tests__/SessionManager.test.ts +1 -1
  195. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  196. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  197. package/src/__tests__/WidgetSessionService.test.ts +1 -1
  198. package/src/__tests__/config-units.test.ts +74 -0
  199. package/src/__tests__/databaseAbstraction.test.ts +1 -1
  200. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  201. package/src/__tests__/magicLinkService.provisionLog.test.ts +1 -1
  202. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  203. package/src/__tests__/newUsers.test.ts +726 -0
  204. package/src/__tests__/resolverBase.rls.test.ts +3 -0
  205. package/src/__tests__/secretRedactor.failOpen.test.ts +135 -0
  206. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  207. package/src/__tests__/widgetGuestElevation.test.ts +1 -1
  208. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  209. package/src/auth/AuthProviderEngine.ts +253 -0
  210. package/src/auth/exampleNewUserSubClass.ts +1 -1
  211. package/src/auth/index.ts +69 -21
  212. package/src/auth/initializeProviders.ts +128 -7
  213. package/src/auth/magicLink/MagicLinkService.ts +1 -1
  214. package/src/auth/newUsers.ts +1 -1
  215. package/src/config.ts +55 -36
  216. package/src/context.ts +2 -2
  217. package/src/generated/generated.ts +2038 -10529
  218. package/src/generic/ResolverBase.ts +63 -21
  219. package/src/generic/RunViewResolver.ts +31 -0
  220. package/src/index.ts +199 -56
  221. package/src/integration/RSUProgressBridge.ts +248 -0
  222. package/src/integration/SchemaRefreshLaunch.ts +77 -0
  223. package/src/logging/secretRedactor.ts +43 -0
  224. package/src/middleware/BaseServerMiddleware.ts +5 -0
  225. package/src/providerConfigUnits.ts +21 -0
  226. package/src/realtimeWidget/WidgetSessionService.ts +1 -1
  227. package/src/realtimeWidget/widgetGuestElevation.ts +1 -1
  228. package/src/resolvers/ActionResolver.ts +13 -0
  229. package/src/resolvers/ComponentRegistryResolver.ts +1 -1
  230. package/src/resolvers/EntityPermissionResolver.ts +1 -1
  231. package/src/resolvers/FileResolver.ts +200 -40
  232. package/src/resolvers/IntegrationDiscoveryResolver.ts +328 -70
  233. package/src/resolvers/PotentialDuplicateRecordResolver.ts +1 -1
  234. package/src/resolvers/QueryResolver.ts +25 -11
  235. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  236. package/src/resolvers/RealtimeClientSessionResolver.ts +13 -0
  237. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  238. package/src/resolvers/SearchEntitiesResolver.ts +1 -1
  239. package/src/resolvers/SqlLoggingConfigResolver.ts +1 -2
  240. package/src/resolvers/SyncRolesUsersResolver.ts +2 -2
  241. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  242. package/src/resolvers/UserFavoriteResolver.ts +1 -1
  243. package/src/resolvers/UserResolver.ts +99 -60
  244. package/src/resolvers/currentUserRoles.ts +47 -0
  245. package/src/rest/MediaAccessKeys.ts +47 -1
  246. package/src/rest/MediaStreamHandler.ts +98 -3
  247. package/src/rest/OAuthCallbackHandler.ts +150 -42
  248. package/src/rest/SignatureWebhookHandler.ts +1 -1
  249. package/src/rest/UploadTokenManager.ts +211 -0
  250. package/src/services/DurableEntityActionTaskSubmitter.ts +78 -0
  251. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  252. package/src/services/ScheduledJobsService.ts +1 -1
  253. package/src/services/StartTaskGraphDispatcher.ts +38 -1
  254. package/src/services/TaskGraphActionRunner.ts +99 -0
  255. package/src/services/TaskGraphAgentRunner.ts +58 -1
  256. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  257. package/src/services/TaskGraphPromptRunner.ts +144 -0
  258. package/src/telephony/RingCentralTelephonyService.ts +1 -1
  259. package/src/telephony/TwilioTelephonyRouter.ts +1 -1
  260. package/src/telephony/VonageTelephonyRouter.ts +1 -1
  261. package/src/types.ts +6 -0
@@ -0,0 +1,289 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect } from 'vitest';
5
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { MJUserViewEntityExtended } from '@memberjunction/core-entities';
7
+ import { FieldMapper } from '@memberjunction/graphql-dataprovider';
8
+ import { ResolverBase } from '../generic/ResolverBase.js';
9
+ import type { UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * Regression guard for the server-cache corruption behind
13
+ * "Field _mj__CreatedAt does not exist on MJ: Template Categories".
14
+ *
15
+ * GraphQL reserves the `__` prefix, so MJ renames `__mj_*` columns to the
16
+ * transport alias `_mj__*` on the way out. `ResolverBase` used to apply that
17
+ * rename IN PLACE over `result.Results` — but those are the data provider's own
18
+ * row objects, and the server's cache holds them BY REFERENCE. Preparing one
19
+ * GraphQL response therefore rewrote the keys inside the live cache, and every
20
+ * subsequent read served from it handed the client transport-shaped rows that
21
+ * `BaseEntity.SetMany` rejects. Process-wide cache ⇒ one response poisoned every
22
+ * later request, across all workers.
23
+ *
24
+ * The fix is copy-then-map. The first block exercises the REAL resolver path (so a
25
+ * revert to in-place mapping fails here); the second pins the two `FieldMapper`
26
+ * invariants that make the fix correct.
27
+ *
28
+ * These copies are now LOAD-BEARING rather than merely defensive: `LocalCacheManager`
29
+ * deep-freezes cached rows on reference-sharing storage providers, so mapping in place
30
+ * would throw a `TypeError` instead of silently corrupting the cache. The third block
31
+ * pins that the copy-then-map pattern still works on frozen input — the shape the cache
32
+ * actually hands out — and that in-place mapping is now refused outright.
33
+ */
34
+
35
+ /** A cache-held row, shaped the way the provider hands them back. */
36
+ type CachedRow = Record<string, unknown>;
37
+
38
+ /**
39
+ * Reaches the protected `RunViewGenericInternal` and records what
40
+ * `ArrayFilterEncryptedFieldsForAPI` was handed — the second in-place mutator that
41
+ * must also see copies, and stubbed here so the assertion needs no live Metadata.
42
+ */
43
+ class Probe extends ResolverBase {
44
+ public filteredRows: Record<string, unknown>[] | null = null;
45
+
46
+ protected override async ArrayFilterEncryptedFieldsForAPI(
47
+ _entityName: string,
48
+ dataObjectArray: Record<string, unknown>[]
49
+ ): Promise<Record<string, unknown>[]> {
50
+ this.filteredRows = dataObjectArray;
51
+ return dataObjectArray;
52
+ }
53
+
54
+ public Run(provider: DatabaseProviderBase, viewInfo: MJUserViewEntityExtended, userPayload: UserPayload) {
55
+ return this.RunViewGenericInternal(
56
+ provider,
57
+ viewInfo,
58
+ '', // extraFilter
59
+ '', // orderBy
60
+ '', // userSearchString
61
+ undefined, // excludeUserViewRunID
62
+ undefined, // overrideExcludeFilter
63
+ undefined, // saveViewResults
64
+ undefined, // fields
65
+ undefined, // ignoreMaxRows
66
+ undefined, // excludeDataFromAllPriorViewRuns
67
+ undefined, // forceAuditLog
68
+ undefined, // auditLogDescription
69
+ 'simple', // resultType
70
+ userPayload,
71
+ undefined, // maxRows
72
+ undefined // startRow
73
+ );
74
+ }
75
+ }
76
+
77
+ const ENTITY_NAME = 'MJ: Template Categories';
78
+
79
+ /** Minimal provider: the entity lookup + the RunView the resolver awaits. */
80
+ function fakeProvider(rows: CachedRow[]): DatabaseProviderBase {
81
+ return {
82
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
83
+ RunView: async (_params: RunViewParams): Promise<RunViewResult> =>
84
+ ({ Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult),
85
+ } as unknown as DatabaseProviderBase;
86
+ }
87
+
88
+ const fakeViewInfo = () =>
89
+ ({ ID: 'view-1', Name: 'Test View', Entity: ENTITY_NAME } as unknown as MJUserViewEntityExtended);
90
+
91
+ /** `userRecord` short-circuits the UserCache lookup; no apiKeyHash skips the scope check. */
92
+ const fakePayload = () =>
93
+ ({ email: 'tester@example.com', userRecord: { Email: 'tester@example.com' } as UserInfo } as UserPayload);
94
+
95
+ describe('ResolverBase.RunViewGenericInternal — cache safety', () => {
96
+ it('leaves the provider\'s (cache-held) rows untouched while returning transport keys', async () => {
97
+ const cachedRow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
98
+ const probe = new Probe();
99
+
100
+ const result = await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
101
+
102
+ // The pre-fix code renamed these keys in place — this is the assertion that fails on a revert.
103
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
104
+ expect(cachedRow.__mj_UpdatedAt).toBe('T1');
105
+ expect(cachedRow._mj__CreatedAt).toBeUndefined();
106
+
107
+ // The outgoing rows carry the GraphQL-legal aliases, and are NOT the cached objects.
108
+ const wire = result?.Results as Record<string, unknown>[];
109
+ expect(wire[0]._mj__CreatedAt).toBe('T0');
110
+ expect(wire[0]).not.toBe(cachedRow);
111
+ });
112
+
113
+ it('hands the encrypted-field filter the copies, not the cached rows', async () => {
114
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
115
+ const probe = new Probe();
116
+
117
+ await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
118
+
119
+ expect(probe.filteredRows).not.toBeNull();
120
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
121
+ });
122
+ });
123
+
124
+ describe('FieldMapper transport mapping — the invariants the fix rests on', () => {
125
+ it('MapFields MUTATES its argument (the hazard the resolver must not expose the cache to)', () => {
126
+ // Documents WHY the resolver must copy. If this ever becomes non-mutating,
127
+ // the copy in ResolverBase is redundant rather than load-bearing — and this
128
+ // test failing is the signal to revisit it.
129
+ const row: Record<string, unknown> = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' };
130
+
131
+ new FieldMapper().MapFields(row);
132
+
133
+ expect(row.__mj_CreatedAt).toBeUndefined();
134
+ expect(row._mj__CreatedAt).toBe('T0');
135
+ });
136
+
137
+ it('post-map mutation of the copy cannot reach the cached row', () => {
138
+ // ArrayFilterEncryptedFieldsForAPI redacts in place after mapping; on the
139
+ // pre-fix code that stripped secrets out of the CACHED row too.
140
+ const mapper = new FieldMapper();
141
+ const cachedRow: Record<string, unknown> = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
142
+
143
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
144
+ wireRow.Secret = null; // stand-in for the encrypted-field filter
145
+
146
+ expect(cachedRow.Secret).toBe('plaintext');
147
+ expect(wireRow.Secret).toBeNull();
148
+ });
149
+
150
+ it('round-trips a mapped copy back to entity field names', () => {
151
+ // The client's ConvertBackToMJFields must undo exactly what the server did.
152
+ const mapper = new FieldMapper();
153
+ const original: Record<string, unknown> = { ID: 'a1', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
154
+
155
+ const wire = mapper.MapFields({ ...original })!;
156
+ const back = mapper.ReverseMapFields({ ...wire });
157
+
158
+ expect(back).toEqual(original);
159
+ });
160
+
161
+ it('leaves rows without __mj_ fields structurally unchanged', () => {
162
+ const mapper = new FieldMapper();
163
+ const row = { ID: 'a1', Name: 'Cat' };
164
+
165
+ expect(mapper.MapFields({ ...row })).toEqual(row);
166
+ });
167
+ });
168
+
169
+ describe('transport mapping against FROZEN cache rows', () => {
170
+ // The cache deep-freezes rows it hands out (LocalCacheManager freeze-on-write), so the
171
+ // resolver's input is frozen in the deployment that matters — MJAPI on the in-memory
172
+ // storage provider. That turns the copy from a precaution into a requirement.
173
+ it('copy-then-map works on a frozen row', () => {
174
+ const mapper = new FieldMapper();
175
+ const cachedRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
176
+
177
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
178
+
179
+ expect(wireRow._mj__CreatedAt).toBe('T0');
180
+ expect(wireRow.__mj_CreatedAt).toBeUndefined();
181
+ // The copy is mutable, so the encrypted-field filter can still redact in place.
182
+ expect(Object.isFrozen(wireRow)).toBe(false);
183
+ // And the frozen cached row is untouched.
184
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
185
+ });
186
+
187
+ it('in-place mapping of a frozen row THROWS — silent corruption is now impossible', () => {
188
+ // This is the safety net the freeze adds on top of the copy: if a future refactor
189
+ // reverts to in-place mapping, it fails loudly at the offending line instead of
190
+ // quietly rewriting process-wide state.
191
+ const mapper = new FieldMapper();
192
+ const cachedRow = Object.freeze({ ID: 'a1', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
193
+
194
+ expect(() => mapper.MapFields(cachedRow)).toThrow(TypeError);
195
+ });
196
+ });
197
+
198
+ /**
199
+ * The BATCH path (`RunViewsGenericInternal`), which the single-view tests above do not reach
200
+ * (PR #3425 review, finding M8).
201
+ *
202
+ * This is the path multi-view clients actually take — MJExplorer batches its view loads — so
203
+ * leaving it uncovered meant the original corruption bug could be reintroduced on the more
204
+ * heavily used of the two legs with CI still green.
205
+ */
206
+ class BatchProbe extends ResolverBase {
207
+ public filteredRows: Record<string, unknown>[] | null = null;
208
+
209
+ protected override async ArrayFilterEncryptedFieldsForAPI(
210
+ _entityName: string,
211
+ dataObjectArray: Record<string, unknown>[]
212
+ ): Promise<Record<string, unknown>[]> {
213
+ this.filteredRows = dataObjectArray;
214
+ return dataObjectArray;
215
+ }
216
+
217
+ public RunBatch(provider: DatabaseProviderBase, viewInfos: MJUserViewEntityExtended[], userPayload: UserPayload) {
218
+ return this.RunViewsGenericInternal(
219
+ viewInfos.map(viewInfo => ({ provider, viewInfo, userPayload, resultType: 'simple' })) as never
220
+ );
221
+ }
222
+ }
223
+
224
+ /** Batch provider: `RunViews` returns one result per param, in order. */
225
+ function fakeBatchProvider(rowsPerView: CachedRow[][]): DatabaseProviderBase {
226
+ return {
227
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
228
+ EntityByName: (name: string) => (name === ENTITY_NAME ? { Name: ENTITY_NAME } : undefined),
229
+ RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> =>
230
+ params.map((_p, i) => {
231
+ const rows = rowsPerView[i] ?? [];
232
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
233
+ }),
234
+ } as unknown as DatabaseProviderBase;
235
+ }
236
+
237
+ describe('ResolverBase.RunViewsGenericInternal (batch) — cache safety', () => {
238
+ it('leaves every view\'s cache-held rows untouched while returning transport keys', async () => {
239
+ const viewARow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
240
+ const viewBRow: CachedRow = { ID: 'b1', Name: 'Dog', __mj_CreatedAt: 'T2', __mj_UpdatedAt: 'T3' };
241
+ const probe = new BatchProbe();
242
+
243
+ const results = await probe.RunBatch(
244
+ fakeBatchProvider([[viewARow], [viewBRow]]),
245
+ [fakeViewInfo(), fakeViewInfo()],
246
+ fakePayload()
247
+ );
248
+
249
+ // Both source rows keep entity field names — the assertion that fails on a revert
250
+ // to in-place mapping in the batch loop.
251
+ expect(viewARow.__mj_CreatedAt).toBe('T0');
252
+ expect(viewARow._mj__CreatedAt).toBeUndefined();
253
+ expect(viewBRow.__mj_CreatedAt).toBe('T2');
254
+ expect(viewBRow._mj__CreatedAt).toBeUndefined();
255
+
256
+ // ...and both outgoing views carry the aliases, on objects that are not the cached ones.
257
+ const wireA = results[0].Results as Record<string, unknown>[];
258
+ const wireB = results[1].Results as Record<string, unknown>[];
259
+ expect(wireA[0]._mj__CreatedAt).toBe('T0');
260
+ expect(wireB[0]._mj__CreatedAt).toBe('T2');
261
+ expect(wireA[0]).not.toBe(viewARow);
262
+ expect(wireB[0]).not.toBe(viewBRow);
263
+ });
264
+
265
+ it('maps FROZEN cache rows rather than throwing on them', async () => {
266
+ // The shape the cache actually hands out on a hit.
267
+ const frozenRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as CachedRow;
268
+ const probe = new BatchProbe();
269
+
270
+ const results = await probe.RunBatch(
271
+ fakeBatchProvider([[frozenRow]]),
272
+ [fakeViewInfo()],
273
+ fakePayload()
274
+ );
275
+
276
+ expect((results[0].Results as Record<string, unknown>[])[0]._mj__CreatedAt).toBe('T0');
277
+ expect(frozenRow.__mj_CreatedAt).toBe('T0');
278
+ });
279
+
280
+ it('hands the encrypted-field filter copies, not the cached rows', async () => {
281
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
282
+ const probe = new BatchProbe();
283
+
284
+ await probe.RunBatch(fakeBatchProvider([[cachedRow]]), [fakeViewInfo()], fakePayload());
285
+
286
+ expect(probe.filteredRows).not.toBeNull();
287
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
288
+ });
289
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildUpdateConnectionMessage } from '../integration/SchemaRefreshLaunch.js';
3
+ import type { SchemaRefreshSummaryLike } from '../integration/SchemaRefreshLaunch.js';
4
+
5
+ /**
6
+ * A detached schema refresh returns placeholder zero counts because it hasn't run yet. The one
7
+ * thing this message must never do is report those zeros as findings — "0 created, 0 updated"
8
+ * reads as "the refresh found nothing", which is the opposite of "still running".
9
+ */
10
+ function summary(over: Partial<SchemaRefreshSummaryLike> = {}): SchemaRefreshSummaryLike {
11
+ return {
12
+ RunID: 'connector-1783000000000-abc123',
13
+ InProgress: false,
14
+ Succeeded: true,
15
+ ObjectsCreated: 12,
16
+ ObjectsUpdated: 3,
17
+ UnresolvedObjects: ['Widgets'],
18
+ ...over,
19
+ };
20
+ }
21
+
22
+ describe('BuildCreateConnectionMessage', () => {
23
+ it('reports the run ID to tail — and NO counts — for a detached refresh', () => {
24
+ const msg = BuildCreateConnectionMessage(true, summary({
25
+ InProgress: true,
26
+ ObjectsCreated: 0,
27
+ ObjectsUpdated: 0,
28
+ UnresolvedObjects: [],
29
+ }));
30
+
31
+ expect(msg).toContain('connector-1783000000000-abc123');
32
+ expect(msg).toMatch(/running/i);
33
+ // The placeholder zeros must not surface as results.
34
+ expect(msg).not.toMatch(/\d+ created/);
35
+ expect(msg).not.toMatch(/\d+ updated/);
36
+ expect(msg).not.toMatch(/PK-unresolved/);
37
+ });
38
+
39
+ it('reports real counts once the refresh has completed inline', () => {
40
+ const msg = BuildCreateConnectionMessage(true, summary());
41
+
42
+ expect(msg).toContain('12 created');
43
+ expect(msg).toContain('3 updated');
44
+ expect(msg).toContain('1 PK-unresolved');
45
+ expect(msg).not.toMatch(/running/i);
46
+ // A completed refresh needs no run ID in the message — the counts ARE the answer.
47
+ expect(msg).not.toContain('connector-1783000000000-abc123');
48
+ });
49
+
50
+ it('omits the test-passed clause when no connection test was requested', () => {
51
+ expect(BuildCreateConnectionMessage(false, summary())).not.toMatch(/test passed/);
52
+ expect(BuildCreateConnectionMessage(true, summary())).toMatch(/test passed/);
53
+ });
54
+
55
+ it('falls back to the test-only message when no refresh was requested', () => {
56
+ expect(BuildCreateConnectionMessage(true, undefined)).toBe('Connection created and test passed');
57
+ });
58
+
59
+ it('counts PK-unresolved objects, not just reports presence', () => {
60
+ const msg = BuildCreateConnectionMessage(false, summary({ UnresolvedObjects: ['A', 'B', 'C'] }));
61
+ expect(msg).toContain('3 PK-unresolved');
62
+ });
63
+ });
64
+
65
+ /**
66
+ * A pipeline that fails at ConnectionTest RETURNS — it does not throw — with every count at zero.
67
+ * Verified live: a real HubSpot refresh whose credential check failed produced
68
+ * "Updated, schema refresh: 0 created, 0 updated, 0 PK-unresolved", indistinguishable from a clean
69
+ * no-op run. These tests pin the distinction.
70
+ */
71
+ describe('failed-refresh reporting', () => {
72
+ const failed = summary({
73
+ Succeeded: false,
74
+ FailureMessage: 'ConnectionTest failed: No HubSpot credentials found',
75
+ ObjectsCreated: 0,
76
+ ObjectsUpdated: 0,
77
+ UnresolvedObjects: [],
78
+ });
79
+
80
+ it('never reports a failed refresh as a zero-count success (create)', () => {
81
+ const msg = BuildCreateConnectionMessage(true, failed);
82
+ expect(msg).toMatch(/FAILED/);
83
+ expect(msg).toContain('No HubSpot credentials found');
84
+ expect(msg).not.toMatch(/0 created/);
85
+ });
86
+
87
+ it('never reports a failed refresh as a zero-count success (update)', () => {
88
+ const msg = BuildUpdateConnectionMessage(failed);
89
+ expect(msg).toMatch(/FAILED/);
90
+ expect(msg).toContain('No HubSpot credentials found');
91
+ expect(msg).not.toMatch(/0 created/);
92
+ });
93
+
94
+ it('points at the run when the pipeline reported no reason', () => {
95
+ const msg = BuildUpdateConnectionMessage(summary({ Succeeded: false, FailureMessage: undefined }));
96
+ expect(msg).toContain('connector-1783000000000-abc123');
97
+ expect(msg).toMatch(/no reason reported/);
98
+ });
99
+
100
+ it('still reports counts for a refresh that genuinely succeeded (update)', () => {
101
+ const msg = BuildUpdateConnectionMessage(summary());
102
+ expect(msg).toContain('12 created');
103
+ expect(msg).toContain('3 updated');
104
+ expect(msg).not.toMatch(/FAILED/);
105
+ });
106
+ });
107
+
108
+ describe('BuildDetachedRefreshMessage', () => {
109
+ it('names the run to tail', () => {
110
+ const msg = BuildDetachedRefreshMessage('connector-42-zz');
111
+ expect(msg).toContain('connector-42-zz');
112
+ expect(msg).toMatch(/running/i);
113
+ });
114
+ });
@@ -43,7 +43,7 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
43
43
  // --- Partial mock: control ONLY UserCache.GetSystemUser (the scoped-anonymous elevation seam,
44
44
  // issue #3371); everything else in the data provider stays real ---
45
45
  const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
46
- vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => {
46
+ vi.mock('@memberjunction/generic-database-provider', async (importOriginal) => {
47
47
  const actual = await importOriginal<Record<string, unknown>>();
48
48
  return {
49
49
  ...actual,
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { LogError } from '@memberjunction/core';
3
+ import {
4
+ AssertRoleLoadSucceeded,
5
+ RequireContextUser,
6
+ RequireLoadedUser,
7
+ RequireRoleProvider,
8
+ UserIdExtraFilter,
9
+ } from '../resolvers/currentUserRoles.js';
10
+
11
+ vi.mock('@memberjunction/core', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('@memberjunction/core')>();
13
+ return {
14
+ ...actual,
15
+ LogError: vi.fn(),
16
+ };
17
+ });
18
+
19
+ describe('CurrentUser role-load guards', () => {
20
+ afterEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ describe('RequireLoadedUser', () => {
25
+ it('returns the user when ID is present', () => {
26
+ const user = { ID: 'u-1', Email: 'a@b.com' };
27
+ expect(RequireLoadedUser(user, 'a@b.com')).toBe(user);
28
+ });
29
+
30
+ it('throws when the email fallback finds no user', () => {
31
+ expect(() => RequireLoadedUser(null, 'missing@example.com'))
32
+ .toThrow("CurrentUser: no user found for email 'missing@example.com'");
33
+ expect(() => RequireLoadedUser({ Email: 'x' }, undefined))
34
+ .toThrow("CurrentUser: no user found for email ''");
35
+ });
36
+ });
37
+
38
+ describe('RequireContextUser', () => {
39
+ it('returns the session user when present', () => {
40
+ const user = { ID: 'u-1' };
41
+ expect(RequireContextUser(user)).toBe(user);
42
+ });
43
+
44
+ it('throws when GetUserFromPayload would have handed undefined to RunView', () => {
45
+ expect(() => RequireContextUser(undefined))
46
+ .toThrow('CurrentUser: authenticated context user is required to load roles');
47
+ });
48
+ });
49
+
50
+ describe('RequireRoleProvider', () => {
51
+ it('throws when no provider is available', () => {
52
+ expect(() => RequireRoleProvider(null))
53
+ .toThrow('CurrentUser: no data provider available to load roles');
54
+ });
55
+ });
56
+
57
+ describe('UserIdExtraFilter', () => {
58
+ it('wraps a UUID in a UserID equality filter', () => {
59
+ expect(UserIdExtraFilter('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'))
60
+ .toBe("UserID='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'");
61
+ });
62
+
63
+ it('quote-escapes a value that contains a single quote', () => {
64
+ expect(UserIdExtraFilter("O'Brien")).toBe("UserID='O''Brien'");
65
+ });
66
+ });
67
+
68
+ describe('AssertRoleLoadSucceeded', () => {
69
+ it('is a no-op on Success', () => {
70
+ expect(() => AssertRoleLoadSucceeded({ Success: true }, 'u-1')).not.toThrow();
71
+ expect(LogError).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('logs and throws on failure instead of returning an empty role set', () => {
75
+ expect(() => AssertRoleLoadSucceeded({ Success: false, ErrorMessage: 'permission denied' }, 'u-1'))
76
+ .toThrow('CurrentUser: failed to load roles: permission denied');
77
+ expect(LogError).toHaveBeenCalledWith(
78
+ 'CurrentUser: failed to load roles for user u-1: permission denied',
79
+ );
80
+ });
81
+
82
+ it('uses a fallback message when ErrorMessage is missing', () => {
83
+ expect(() => AssertRoleLoadSucceeded({ Success: false }, 'u-1'))
84
+ .toThrow('CurrentUser: failed to load roles: unknown error');
85
+ });
86
+ });
87
+ });