@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4

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 (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Worker poll-loop pins (tasks.md PR 1 item 8).
3
+ *
4
+ * The worker is the thing that lets a sync outlive the request that asked for it. What must
5
+ * hold: it stays OFF unless explicitly enabled; it never runs more than its configured
6
+ * concurrency; overlapping timer ticks can't stack; losing a claim race is normal traffic,
7
+ * not an error; and stopping the poll loop must never abandon a run that is already
8
+ * executing (its lease is being renewed — killing it here would only strand the row).
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+
12
+ // The service pulls in config.ts (which eagerly validates DB env at module load) plus the
13
+ // integration engine, so stub both — this suite exercises loop behavior only.
14
+ vi.mock('../config.js', () => ({ configInfo: {} }));
15
+
16
+ const mockUsers: Array<{ ID: string; Email: string }> = [];
17
+ // UserCache lives in generic-database-provider, not sqlserver-dataprovider — it moved there in the
18
+ // platform split. Mocking the old package silently left the service reading the REAL (empty) cache,
19
+ // so all 13 tests failed on "System user not found" rather than on anything they were asserting.
20
+ vi.mock('@memberjunction/generic-database-provider', () => ({
21
+ UserCache: {
22
+ get Users() { return mockUsers; },
23
+ Instance: { get Users() { return mockUsers; } },
24
+ },
25
+ }));
26
+
27
+ const mockPollQueuedRuns = vi.fn();
28
+ const mockExecuteQueuedRun = vi.fn();
29
+ const mockConfig = vi.fn().mockResolvedValue(undefined);
30
+ vi.mock('@memberjunction/integration-engine', () => ({
31
+ IntegrationEngine: {
32
+ PollQueuedRuns: (...args: unknown[]) => mockPollQueuedRuns(...args),
33
+ Instance: {
34
+ Config: (...args: unknown[]) => mockConfig(...args),
35
+ ExecuteQueuedRun: (...args: unknown[]) => mockExecuteQueuedRun(...args),
36
+ },
37
+ },
38
+ }));
39
+
40
+ import { IntegrationSyncWorkerService } from '../services/IntegrationSyncWorkerService.js';
41
+ import type { IntegrationSyncWorkerConfig } from '../config.js';
42
+
43
+ function makeConfig(overrides: Partial<IntegrationSyncWorkerConfig> = {}): IntegrationSyncWorkerConfig {
44
+ return {
45
+ enabled: true,
46
+ systemUserEmail: 'system@memberjunction.org',
47
+ pollingIntervalMs: 15_000,
48
+ maxConcurrentRuns: 3,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ /** A never-settling ExecuteQueuedRun, so a run stays "in flight" for as long as the test wants. */
54
+ function pendingRun() {
55
+ let release: (v: { Success: boolean; RecordsProcessed: number }) => void = () => undefined;
56
+ const promise = new Promise<{ Success: boolean; RecordsProcessed: number }>(res => { release = res; });
57
+ return { promise, release: () => release({ Success: true, RecordsProcessed: 1 }) };
58
+ }
59
+
60
+ describe('IntegrationSyncWorkerService', () => {
61
+ beforeEach(() => {
62
+ vi.useFakeTimers();
63
+ mockUsers.length = 0;
64
+ mockUsers.push({ ID: 'sys-1', Email: 'system@memberjunction.org' });
65
+ mockPollQueuedRuns.mockReset().mockResolvedValue([]);
66
+ mockExecuteQueuedRun.mockReset().mockResolvedValue({ Success: true, RecordsProcessed: 0 });
67
+ mockConfig.mockClear();
68
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
69
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
70
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ vi.restoreAllMocks();
76
+ });
77
+
78
+ describe('opt-in', () => {
79
+ it('is disabled by default, so existing deployments are unaffected', async () => {
80
+ const svc = new IntegrationSyncWorkerService(makeConfig({ enabled: false }));
81
+ expect(svc.IsEnabled).toBe(false);
82
+ await svc.Initialize();
83
+ svc.Start();
84
+ expect(svc.IsRunning).toBe(false);
85
+ await vi.advanceTimersByTimeAsync(60_000);
86
+ expect(mockPollQueuedRuns).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('refuses to start before Initialize resolved the system user', () => {
90
+ const svc = new IntegrationSyncWorkerService(makeConfig());
91
+ expect(() => svc.Start()).toThrow('Not initialized');
92
+ });
93
+
94
+ it('fails loudly when the configured system user does not exist', async () => {
95
+ mockUsers.length = 0;
96
+ const svc = new IntegrationSyncWorkerService(makeConfig({ systemUserEmail: 'nobody@example.com' }));
97
+ await expect(svc.Initialize()).rejects.toThrow('nobody@example.com');
98
+ });
99
+
100
+ it('matches the system user case-insensitively and configures the engine as that user', async () => {
101
+ mockUsers.length = 0;
102
+ mockUsers.push({ ID: 'sys-1', Email: 'System@MemberJunction.ORG' });
103
+ const svc = new IntegrationSyncWorkerService(makeConfig());
104
+ await svc.Initialize();
105
+ expect(mockConfig).toHaveBeenCalledWith(false, { ID: 'sys-1', Email: 'System@MemberJunction.ORG' });
106
+ });
107
+ });
108
+
109
+ describe('polling', () => {
110
+ async function started(config: Partial<IntegrationSyncWorkerConfig> = {}) {
111
+ const svc = new IntegrationSyncWorkerService(makeConfig(config));
112
+ await svc.Initialize();
113
+ svc.Start();
114
+ await vi.advanceTimersByTimeAsync(0); // let the immediate first pass run
115
+ return svc;
116
+ }
117
+
118
+ it('drains an already-populated queue immediately rather than waiting a full interval', async () => {
119
+ const svc = await started();
120
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(1);
121
+ svc.Stop();
122
+ });
123
+
124
+ it('keeps polling on the configured interval', async () => {
125
+ const svc = await started({ pollingIntervalMs: 5_000 });
126
+ await vi.advanceTimersByTimeAsync(15_000);
127
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(4); // immediate + 3 ticks
128
+ svc.Stop();
129
+ });
130
+
131
+ it('asks only for as many runs as it has free slots', async () => {
132
+ const first = pendingRun();
133
+ mockPollQueuedRuns.mockResolvedValueOnce([{ ID: 'r1', CompanyIntegrationID: 'c1' }]);
134
+ mockExecuteQueuedRun.mockReturnValueOnce(first.promise);
135
+
136
+ const svc = await started({ maxConcurrentRuns: 3, pollingIntervalMs: 1_000 });
137
+ expect(mockPollQueuedRuns.mock.calls[0][1]).toBe(3);
138
+
139
+ await vi.advanceTimersByTimeAsync(1_000);
140
+ expect(mockPollQueuedRuns.mock.calls[1][1]).toBe(2); // one slot is occupied
141
+ expect(svc.InFlightCount).toBe(1);
142
+
143
+ first.release();
144
+ await vi.advanceTimersByTimeAsync(1_000);
145
+ expect(svc.InFlightCount).toBe(0);
146
+ svc.Stop();
147
+ });
148
+
149
+ it('does not poll at all while every slot is busy', async () => {
150
+ const held = [pendingRun(), pendingRun()];
151
+ mockPollQueuedRuns.mockResolvedValueOnce([
152
+ { ID: 'r1', CompanyIntegrationID: 'c1' },
153
+ { ID: 'r2', CompanyIntegrationID: 'c2' },
154
+ ]);
155
+ mockExecuteQueuedRun.mockReturnValueOnce(held[0].promise).mockReturnValueOnce(held[1].promise);
156
+
157
+ const svc = await started({ maxConcurrentRuns: 2, pollingIntervalMs: 1_000 });
158
+ expect(svc.InFlightCount).toBe(2);
159
+
160
+ await vi.advanceTimersByTimeAsync(5_000);
161
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(1); // saturated — never asked again
162
+
163
+ held.forEach(h => h.release());
164
+ await vi.advanceTimersByTimeAsync(1_000);
165
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(2);
166
+ svc.Stop();
167
+ });
168
+
169
+ it('never starts the same run twice, even if a slow poll returns it again', async () => {
170
+ const held = pendingRun();
171
+ mockPollQueuedRuns.mockResolvedValue([{ ID: 'r1', CompanyIntegrationID: 'c1' }]);
172
+ mockExecuteQueuedRun.mockReturnValueOnce(held.promise);
173
+
174
+ const svc = await started({ maxConcurrentRuns: 3, pollingIntervalMs: 1_000 });
175
+ await vi.advanceTimersByTimeAsync(3_000);
176
+
177
+ expect(mockExecuteQueuedRun).toHaveBeenCalledTimes(1);
178
+ expect(svc.InFlightCount).toBe(1);
179
+ held.release();
180
+ svc.Stop();
181
+ });
182
+
183
+ it('suppresses overlapping passes so a slow query cannot stack up timers', async () => {
184
+ let releasePoll: (v: unknown[]) => void = () => undefined;
185
+ mockPollQueuedRuns.mockReturnValueOnce(new Promise(res => { releasePoll = res; }));
186
+
187
+ const svc = await started({ pollingIntervalMs: 1_000 });
188
+ await vi.advanceTimersByTimeAsync(5_000);
189
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(1); // still waiting on the first
190
+
191
+ releasePoll([]);
192
+ await vi.advanceTimersByTimeAsync(1_000);
193
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(2);
194
+ svc.Stop();
195
+ });
196
+
197
+ it('keeps polling after a failed pass instead of wedging the loop', async () => {
198
+ mockPollQueuedRuns.mockRejectedValueOnce(new Error('db down'));
199
+ const svc = await started({ pollingIntervalMs: 1_000 });
200
+ await vi.advanceTimersByTimeAsync(1_000);
201
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(2);
202
+ svc.Stop();
203
+ });
204
+ });
205
+
206
+ describe('run outcomes', () => {
207
+ async function startWithOneCandidate(result: unknown, reject = false) {
208
+ mockPollQueuedRuns.mockResolvedValueOnce([{ ID: 'r1', CompanyIntegrationID: 'c1' }]);
209
+ if (reject) mockExecuteQueuedRun.mockRejectedValueOnce(result);
210
+ else mockExecuteQueuedRun.mockResolvedValueOnce(result);
211
+ const svc = new IntegrationSyncWorkerService(makeConfig());
212
+ await svc.Initialize();
213
+ svc.Start();
214
+ await vi.advanceTimersByTimeAsync(0);
215
+ return svc;
216
+ }
217
+
218
+ it('executes a claimed run as the system user and frees the slot afterwards', async () => {
219
+ const svc = await startWithOneCandidate({ Success: true, RecordsProcessed: 12 });
220
+ expect(mockExecuteQueuedRun).toHaveBeenCalledWith('r1', { ID: 'sys-1', Email: 'system@memberjunction.org' });
221
+ expect(svc.InFlightCount).toBe(0);
222
+ svc.Stop();
223
+ });
224
+
225
+ it('treats a lost claim race as ordinary traffic, not an error', async () => {
226
+ const svc = await startWithOneCandidate({
227
+ Success: false,
228
+ ErrorMessage: "Run r1 is 'In Progress', not 'Queued' — another worker already took it",
229
+ RecordsProcessed: 0,
230
+ });
231
+ expect(console.error).not.toHaveBeenCalled();
232
+ expect(svc.InFlightCount).toBe(0);
233
+ svc.Stop();
234
+ });
235
+
236
+ it('frees the slot even when the run throws', async () => {
237
+ const svc = await startWithOneCandidate(new Error('connector exploded'), true);
238
+ expect(svc.InFlightCount).toBe(0);
239
+ svc.Stop();
240
+ });
241
+ });
242
+
243
+ describe('Stop', () => {
244
+ it('halts polling but leaves in-flight runs to finish — their leases are still being renewed', async () => {
245
+ const held = pendingRun();
246
+ mockPollQueuedRuns.mockResolvedValueOnce([{ ID: 'r1', CompanyIntegrationID: 'c1' }]);
247
+ mockExecuteQueuedRun.mockReturnValueOnce(held.promise);
248
+
249
+ const svc = new IntegrationSyncWorkerService(makeConfig({ pollingIntervalMs: 1_000 }));
250
+ await svc.Initialize();
251
+ svc.Start();
252
+ await vi.advanceTimersByTimeAsync(0);
253
+ expect(svc.InFlightCount).toBe(1);
254
+
255
+ svc.Stop();
256
+ expect(svc.IsRunning).toBe(false);
257
+ expect(svc.InFlightCount).toBe(1); // NOT abandoned
258
+
259
+ await vi.advanceTimersByTimeAsync(10_000);
260
+ expect(mockPollQueuedRuns).toHaveBeenCalledTimes(1);
261
+
262
+ held.release();
263
+ await vi.advanceTimersByTimeAsync(0);
264
+ expect(svc.InFlightCount).toBe(0);
265
+ });
266
+
267
+ it('is idempotent', async () => {
268
+ const svc = new IntegrationSyncWorkerService(makeConfig());
269
+ await svc.Initialize();
270
+ svc.Start();
271
+ svc.Stop();
272
+ expect(() => svc.Stop()).not.toThrow();
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { OAuthCallbackHandler, OAuthCallbackHandlerOptions } from '../rest/OAuthCallbackHandler.js';
3
+
4
+ /**
5
+ * The OAuth callback redirects to a caller-supplied `frontendReturnUrl`. Without an origin check
6
+ * that is an open redirect issued *from the trusted MJAPI origin* — a phishing primitive that
7
+ * inherits MJAPI's reputation and any origin-scoped trust a user or downstream system places in it.
8
+ *
9
+ * The guard is reached through a narrowly-typed view of the instance, following the same pattern as
10
+ * OAuthCallbackHandler.xss.test.ts (the method is private by design).
11
+ */
12
+ interface RedirectGuard {
13
+ isFrontendReturnUrlAllowed(url: URL): boolean;
14
+ parseAllowedFrontendReturnUrl(frontendReturnUrl: string): URL | null;
15
+ isFrontendReturnUrlAcceptable(frontendReturnUrl: unknown): boolean;
16
+ }
17
+
18
+ const BASE_OPTIONS: Omit<OAuthCallbackHandlerOptions, 'allowedFrontendOrigins'> = {
19
+ publicUrl: 'https://api.example.test',
20
+ successRedirectUrl: 'https://api.example.test/oauth/success',
21
+ errorRedirectUrl: 'https://api.example.test/oauth/error',
22
+ };
23
+
24
+ function makeGuard(allowedFrontendOrigins: string[]): RedirectGuard {
25
+ const handler = new OAuthCallbackHandler({ ...BASE_OPTIONS, allowedFrontendOrigins });
26
+ return handler as unknown as RedirectGuard;
27
+ }
28
+
29
+ describe('OAuthCallbackHandler open-redirect protection', () => {
30
+ describe('with an explicit origin allowlist', () => {
31
+ const allowed = ['https://app.example.test', 'https://admin.example.test'];
32
+
33
+ it.each([
34
+ 'https://evil.test/steal',
35
+ 'https://app.example.test.evil.test/steal',
36
+ 'http://app.example.test', // scheme mismatch — origin includes the scheme
37
+ 'https://app.example.test:8443', // port mismatch — origin includes the port
38
+ ])('rejects %s', (url) => {
39
+ expect(makeGuard(allowed).parseAllowedFrontendReturnUrl(url)).toBeNull();
40
+ });
41
+
42
+ it.each([
43
+ 'https://app.example.test/done',
44
+ 'https://admin.example.test/oauth/landing?keep=1',
45
+ ])('allows %s', (url) => {
46
+ expect(makeGuard(allowed).parseAllowedFrontendReturnUrl(url)).not.toBeNull();
47
+ });
48
+
49
+ it('allows the built-in redirect origin even when it is not in the allowlist', () => {
50
+ // The success/error pages MJAPI itself serves must always remain reachable.
51
+ expect(
52
+ makeGuard(allowed).parseAllowedFrontendReturnUrl('https://api.example.test/oauth/success')
53
+ ).not.toBeNull();
54
+ });
55
+
56
+ it('rejects an unparseable URL', () => {
57
+ expect(makeGuard(allowed).parseAllowedFrontendReturnUrl('not-a-url')).toBeNull();
58
+ });
59
+
60
+ it('rejects a javascript: pseudo-URL', () => {
61
+ // Parses fine as a URL, but its origin is "null" and must not match an allowlist entry.
62
+ expect(makeGuard(allowed).parseAllowedFrontendReturnUrl('javascript:alert(1)')).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe('allowlist edge values', () => {
67
+ it("allows any origin when the allowlist is ['*'] (MJ's default CORS posture)", () => {
68
+ expect(makeGuard(['*']).parseAllowedFrontendReturnUrl('https://evil.test/steal')).not.toBeNull();
69
+ });
70
+
71
+ it('an empty allowlist denies every external origin (fails closed)', () => {
72
+ expect(makeGuard([]).parseAllowedFrontendReturnUrl('https://app.example.test/done')).toBeNull();
73
+ });
74
+
75
+ it('an empty allowlist still permits the built-in redirect origin', () => {
76
+ // Otherwise MJAPI could not reach its own success/error pages.
77
+ expect(
78
+ makeGuard([]).parseAllowedFrontendReturnUrl('https://api.example.test/oauth/success')
79
+ ).not.toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('request-boundary check used by /oauth/initiate', () => {
84
+ const guard = () => makeGuard(['https://app.example.test']);
85
+
86
+ it('accepts an allowed origin', () => {
87
+ expect(guard().isFrontendReturnUrlAcceptable('https://app.example.test/done')).toBe(true);
88
+ });
89
+
90
+ it('rejects a disallowed origin', () => {
91
+ expect(guard().isFrontendReturnUrlAcceptable('https://evil.test/steal')).toBe(false);
92
+ });
93
+
94
+ it.each([
95
+ ['an array', ['https://app.example.test/done']],
96
+ ['an object', { url: 'https://app.example.test/done' }],
97
+ ['a number', 42],
98
+ ])('rejects %s rather than coercing it', (_label, value) => {
99
+ expect(guard().isFrontendReturnUrlAcceptable(value)).toBe(false);
100
+ });
101
+
102
+ /**
103
+ * REGRESSION GUARD: frontendReturnUrl is optional, and every downstream consumer decides
104
+ * whether to use it with a truthiness check. If this boundary check rejected absent values,
105
+ * a client that omits the field — or sends null / "" for it, which is what an unset form
106
+ * field and most serializers produce — would start getting a 400 on a request that has
107
+ * always worked. Absent means "no return URL", not "invalid return URL".
108
+ */
109
+ it.each([
110
+ ['undefined', undefined],
111
+ ['null', null],
112
+ ['an empty string', ''],
113
+ ])('accepts %s — an absent return URL is not an invalid one', (_label, value) => {
114
+ expect(guard().isFrontendReturnUrlAcceptable(value)).toBe(true);
115
+ });
116
+ });
117
+ });
@@ -16,7 +16,10 @@ interface PageRenderer {
16
16
  }
17
17
 
18
18
  function makeRenderer(): PageRenderer {
19
- const handler = new OAuthCallbackHandler({ publicUrl: 'https://example.test' });
19
+ const handler = new OAuthCallbackHandler({
20
+ publicUrl: 'https://example.test',
21
+ allowedFrontendOrigins: ['*'],
22
+ });
20
23
  return handler as unknown as PageRenderer;
21
24
  }
22
25