@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
@@ -461,6 +461,61 @@ describe('SessionManager.Heartbeat', () => {
461
461
  });
462
462
  });
463
463
 
464
+ describe('SessionManager.Heartbeat — bounded heartbeatLastWrite (Round 11 memory-leak fix)', () => {
465
+ // Regression coverage: heartbeatLastWrite used to be a plain, unbounded `Map`. SessionManager
466
+ // is constructed fresh by every resolver that needs one (AgentSessionResolver,
467
+ // RealtimeClientSessionResolver, RealtimeBridgeResolver) plus SessionJanitor's own instance, so
468
+ // each is itself a process-lifetime singleton with its own copy of the map. A session's
469
+ // heartbeats land through one resolver's instance, but the overwhelmingly common close path —
470
+ // a crashed tab, a dropped connection, anything that never round-trips an explicit close
471
+ // mutation — is reconciled by SessionJanitor's sweeps through ITS OWN SessionManager, which
472
+ // never touches the originating instance's map, so the entry was never deleted. The fix bounds
473
+ // the map with MJLruCache(maxSize 5_000, ttlMs 4h) so that scenario is capped instead of
474
+ // growing forever.
475
+
476
+ beforeEach(() => {
477
+ vi.useFakeTimers();
478
+ });
479
+
480
+ afterEach(() => {
481
+ vi.useRealTimers();
482
+ });
483
+
484
+ it('forces a fresh write once the 4h TTL elapses, even though CloseSession never ran on this instance', async () => {
485
+ const session = makeSessionEntity({ ID: 'session-ttl', Status: 'Active' });
486
+ const { provider } = makeProvider(() => session);
487
+ const mgr = new SessionManager();
488
+ const user = makeUser();
489
+
490
+ const first = await mgr.Heartbeat('session-ttl', user, provider);
491
+ expect(first).toBe(true);
492
+ expect(session.Save).toHaveBeenCalledTimes(1);
493
+
494
+ // Still within the coalescing window — no second write.
495
+ const second = await mgr.Heartbeat('session-ttl', user, provider);
496
+ expect(second).toBe(true);
497
+ expect(session.Save).toHaveBeenCalledTimes(1);
498
+
499
+ // Advance past the cache's 4h TTL backstop (well beyond the 3s coalescing window too).
500
+ vi.advanceTimersByTime(4 * 60 * 60 * 1000 + 1);
501
+
502
+ const third = await mgr.Heartbeat('session-ttl', user, provider);
503
+ expect(third).toBe(true);
504
+ // The TTL-expired entry forces a write again, exactly as a never-before-seen session would —
505
+ // proving the cache entry does not live forever when nothing on this instance ever calls
506
+ // CloseSession for it.
507
+ expect(session.Save).toHaveBeenCalledTimes(2);
508
+ });
509
+
510
+ it('is backed by a bounded MJLruCache, not a plain unbounded Map (structural regression guard)', () => {
511
+ const mgr = new SessionManager();
512
+ const internal = (mgr as unknown as { heartbeatLastWrite: { MaxSize: number; Size: number } })
513
+ .heartbeatLastWrite;
514
+ expect(internal.MaxSize).toBe(5_000);
515
+ expect(internal.Size).toBe(0);
516
+ });
517
+ });
518
+
464
519
  describe('SessionManager.MarkIdle', () => {
465
520
  it('transitions Active → Idle', async () => {
466
521
  const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
@@ -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
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tests for the REST view-operations layer (rest/ViewOperationsHandler.ts).
3
+ *
4
+ * Covers entity resolution, read-permission enforcement BEFORE any query
5
+ * executes (including the batch fail-fast contract), parameter sanitization
6
+ * (Fields/MaxRows/StartRow coercion, ResultType defaulting, EntityName
7
+ * requirement), and the error branches — plus documentation-style assertions
8
+ * for the case-sensitive entity lookup and the unescaped filter interpolation
9
+ * in getEntityViews, in the style of multiTenancy.security.test.ts.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+
13
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
14
+ const { mockRunViewFn, mockRunViewsFn, mockLogError, mockEntityList } = vi.hoisted(() => ({
15
+ mockRunViewFn: vi.fn(),
16
+ mockRunViewsFn: vi.fn(),
17
+ mockLogError: vi.fn(),
18
+ mockEntityList: [] as Array<{ Name: string; GetUserPermisions: (u: unknown) => { CanRead: boolean } }>,
19
+ }));
20
+
21
+ vi.mock('@memberjunction/core', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('@memberjunction/core')>();
23
+ class MockMetadata {
24
+ public get Entities() {
25
+ return mockEntityList;
26
+ }
27
+ }
28
+ class MockRunView {
29
+ public RunView(params: unknown, contextUser?: unknown): Promise<unknown> {
30
+ return mockRunViewFn(params, contextUser);
31
+ }
32
+ public RunViews(params: unknown[], contextUser?: unknown): Promise<unknown> {
33
+ return mockRunViewsFn(params, contextUser);
34
+ }
35
+ }
36
+ return {
37
+ ...actual,
38
+ Metadata: MockMetadata,
39
+ RunView: MockRunView,
40
+ LogError: mockLogError,
41
+ };
42
+ });
43
+
44
+ import type { RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
45
+ import { ViewOperationsHandler } from '../rest/ViewOperationsHandler.js';
46
+
47
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
48
+
49
+ function registerEntity(name: string, canRead: boolean): void {
50
+ mockEntityList.push({ Name: name, GetUserPermisions: () => ({ CanRead: canRead }) });
51
+ }
52
+
53
+ function makeUser(): UserInfo {
54
+ return { ID: 'user-1', Name: 'Reader' } as unknown as UserInfo;
55
+ }
56
+
57
+ const OK_RESULT = { Success: true, Results: [{ ID: '1' }], ErrorMessage: undefined };
58
+
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ mockEntityList.length = 0;
62
+ registerEntity('Customers', true);
63
+ registerEntity('Restricted Ledger', false);
64
+ mockRunViewFn.mockResolvedValue(OK_RESULT);
65
+ mockRunViewsFn.mockResolvedValue([OK_RESULT]);
66
+ });
67
+
68
+ // ─── runView ────────────────────────────────────────────────────────────────
69
+
70
+ describe('ViewOperationsHandler.runView', () => {
71
+ it('executes the view for a readable entity and returns the RunViewResult', async () => {
72
+ const user = makeUser();
73
+ const params: RunViewParams = { EntityName: 'Customers' };
74
+
75
+ const outcome = await ViewOperationsHandler.runView(params, user);
76
+
77
+ expect(outcome.success).toBe(true);
78
+ expect(outcome.result).toBe(OK_RESULT as unknown as RunViewResult);
79
+ expect(mockRunViewFn).toHaveBeenCalledWith(params, user);
80
+ });
81
+
82
+ it('fails when the entity does not exist — and never executes the view', async () => {
83
+ const outcome = await ViewOperationsHandler.runView({ EntityName: 'Ghost' }, makeUser());
84
+
85
+ expect(outcome.success).toBe(false);
86
+ expect(outcome.error).toBe(`Entity 'Ghost' not found`);
87
+ expect(mockRunViewFn).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('KNOWN GAP: entity lookup is case-sensitive (Entities.find ===), unlike EntityByName', async () => {
91
+ // The data-access convention is md.EntityByName (case-insensitive, trimmed).
92
+ // This handler scans Entities with strict equality, so a client sending
93
+ // 'customers' gets "not found" even though the entity exists. Pinned so a
94
+ // migration to EntityByName consciously flips this test.
95
+ const outcome = await ViewOperationsHandler.runView({ EntityName: 'customers' }, makeUser());
96
+
97
+ expect(outcome.success).toBe(false);
98
+ expect(outcome.error).toBe(`Entity 'customers' not found`);
99
+ });
100
+
101
+ it('denies before executing when the user lacks read permission', async () => {
102
+ const outcome = await ViewOperationsHandler.runView({ EntityName: 'Restricted Ledger' }, makeUser());
103
+
104
+ expect(outcome.success).toBe(false);
105
+ expect(outcome.error).toBe('User Reader does not have permission to read Restricted Ledger records');
106
+ expect(mockRunViewFn).not.toHaveBeenCalled();
107
+ });
108
+
109
+ describe('parameter sanitization', () => {
110
+ it('defaults ResultType to "simple" when unset and preserves an explicit one', async () => {
111
+ const defaulted: RunViewParams = { EntityName: 'Customers' };
112
+ await ViewOperationsHandler.runView(defaulted, makeUser());
113
+ expect((mockRunViewFn.mock.calls[0][0] as RunViewParams).ResultType).toBe('simple');
114
+
115
+ const explicit: RunViewParams = { EntityName: 'Customers', ResultType: 'entity_object' };
116
+ await ViewOperationsHandler.runView(explicit, makeUser());
117
+ expect((mockRunViewFn.mock.calls[1][0] as RunViewParams).ResultType).toBe('entity_object');
118
+ });
119
+
120
+ it('splits comma-separated Fields strings and coerces numeric strings for MaxRows/StartRow', async () => {
121
+ // REST clients send query-string-shaped values; the handler normalizes in place
122
+ const params = {
123
+ EntityName: 'Customers',
124
+ Fields: 'ID,Name,Email' as unknown as string[],
125
+ MaxRows: '50' as unknown as number,
126
+ StartRow: '10' as unknown as number,
127
+ } as RunViewParams;
128
+
129
+ const outcome = await ViewOperationsHandler.runView(params, makeUser());
130
+
131
+ expect(outcome.success).toBe(true);
132
+ const executed = mockRunViewFn.mock.calls[0][0] as RunViewParams;
133
+ expect(executed.Fields).toEqual(['ID', 'Name', 'Email']);
134
+ expect(executed.MaxRows).toBe(50);
135
+ expect(executed.StartRow).toBe(10);
136
+ });
137
+
138
+ it('rejects a missing EntityName at the lookup step — before any query executes', async () => {
139
+ // Note: sanitizeRunViewParams' "EntityName is required" branch is unreachable
140
+ // through the public entry points, because every caller does the metadata
141
+ // lookup FIRST and `find(e => e.Name === undefined)` misses. The effective
142
+ // behavior (still a safe failure, different message) is pinned here.
143
+ const outcome = await ViewOperationsHandler.runView({} as RunViewParams, makeUser());
144
+
145
+ expect(outcome.success).toBe(false);
146
+ expect(outcome.error).toBe(`Entity 'undefined' not found`);
147
+ expect(mockRunViewFn).not.toHaveBeenCalled();
148
+ });
149
+ });
150
+ });
151
+
152
+ // ─── runViews (batch) ───────────────────────────────────────────────────────
153
+
154
+ describe('ViewOperationsHandler.runViews', () => {
155
+ it('validates EVERY entry before executing ANY view (fail-fast, no partial execution)', async () => {
156
+ const params: RunViewParams[] = [
157
+ { EntityName: 'Customers' },
158
+ { EntityName: 'Restricted Ledger' }, // second entry is unauthorized
159
+ ];
160
+
161
+ const outcome = await ViewOperationsHandler.runViews(params, makeUser());
162
+
163
+ expect(outcome.success).toBe(false);
164
+ expect(outcome.error).toBe('User Reader does not have permission to read Restricted Ledger records');
165
+ expect(mockRunViewsFn).not.toHaveBeenCalled(); // nothing ran — not even the allowed first view
166
+ });
167
+
168
+ it('fails the whole batch when any entity is unknown', async () => {
169
+ const outcome = await ViewOperationsHandler.runViews(
170
+ [{ EntityName: 'Customers' }, { EntityName: 'Ghost' }],
171
+ makeUser(),
172
+ );
173
+
174
+ expect(outcome.success).toBe(false);
175
+ expect(outcome.error).toBe(`Entity 'Ghost' not found`);
176
+ expect(mockRunViewsFn).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('sanitizes each entry and executes the batch through RunViews', async () => {
180
+ registerEntity('Orders', true);
181
+ const batchResults = [OK_RESULT, OK_RESULT];
182
+ mockRunViewsFn.mockResolvedValue(batchResults);
183
+ const params: RunViewParams[] = [
184
+ { EntityName: 'Customers' },
185
+ { EntityName: 'Orders', MaxRows: '5' as unknown as number },
186
+ ];
187
+
188
+ const outcome = await ViewOperationsHandler.runViews(params, makeUser());
189
+
190
+ expect(outcome.success).toBe(true);
191
+ expect(outcome.results).toBe(batchResults as unknown as RunViewResult[]);
192
+ const executed = mockRunViewsFn.mock.calls[0][0] as RunViewParams[];
193
+ expect(executed[0].ResultType).toBe('simple');
194
+ expect(executed[1].MaxRows).toBe(5);
195
+ });
196
+ });
197
+
198
+ // ─── listEntities ───────────────────────────────────────────────────────────
199
+
200
+ describe('ViewOperationsHandler.listEntities', () => {
201
+ it('returns the RunViewResult for a readable entity', async () => {
202
+ const result = await ViewOperationsHandler.listEntities({ EntityName: 'Customers' }, makeUser());
203
+
204
+ expect(result).toBe(OK_RESULT as unknown as RunViewResult);
205
+ });
206
+
207
+ it('throws (for the router to map) when the entity is unknown', async () => {
208
+ await expect(ViewOperationsHandler.listEntities({ EntityName: 'Ghost' }, makeUser()))
209
+ .rejects.toThrow(`Entity 'Ghost' not found`);
210
+ expect(mockRunViewFn).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('throws on missing read permission without executing', async () => {
214
+ await expect(ViewOperationsHandler.listEntities({ EntityName: 'Restricted Ledger' }, makeUser()))
215
+ .rejects.toThrow('does not have permission to read Restricted Ledger');
216
+ expect(mockRunViewFn).not.toHaveBeenCalled();
217
+ expect(mockLogError).toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ // ─── getEntityViews ─────────────────────────────────────────────────────────
222
+
223
+ describe('ViewOperationsHandler.getEntityViews', () => {
224
+ it('queries MJ: User Views filtered to the entity and formats the results', async () => {
225
+ const createdAt = new Date('2026-01-01T00:00:00Z');
226
+ mockRunViewFn.mockResolvedValue({
227
+ Success: true,
228
+ Results: [{
229
+ ID: 'v1', Name: 'My View', Description: 'desc', IsShared: true, CreatedAt: createdAt,
230
+ InternalColumn: 'must not leak',
231
+ }],
232
+ });
233
+
234
+ const outcome = await ViewOperationsHandler.getEntityViews('Customers', makeUser());
235
+
236
+ expect(outcome.success).toBe(true);
237
+ expect(outcome.views).toEqual([
238
+ { ID: 'v1', Name: 'My View', Description: 'desc', IsShared: true, CreatedAt: createdAt },
239
+ ]);
240
+ const executed = mockRunViewFn.mock.calls[0][0] as RunViewParams;
241
+ expect(executed.EntityName).toBe('MJ: User Views');
242
+ expect(executed.ExtraFilter).toBe(`Entity = 'Customers'`);
243
+ });
244
+
245
+ it('fails for unknown entities and unauthorized readers without querying', async () => {
246
+ const unknown = await ViewOperationsHandler.getEntityViews('Ghost', makeUser());
247
+ const denied = await ViewOperationsHandler.getEntityViews('Restricted Ledger', makeUser());
248
+
249
+ expect(unknown.success).toBe(false);
250
+ expect(unknown.error).toBe(`Entity 'Ghost' not found`);
251
+ expect(denied.success).toBe(false);
252
+ expect(denied.error).toContain('does not have permission');
253
+ expect(mockRunViewFn).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it('propagates the RunView ErrorMessage when the view query fails', async () => {
257
+ mockRunViewFn.mockResolvedValue({ Success: false, Results: [], ErrorMessage: 'timeout talking to DB' });
258
+
259
+ const outcome = await ViewOperationsHandler.getEntityViews('Customers', makeUser());
260
+
261
+ expect(outcome.success).toBe(false);
262
+ expect(outcome.error).toBe('timeout talking to DB');
263
+ });
264
+
265
+ it('KNOWN GAP: the entity name is interpolated into ExtraFilter without escaping', async () => {
266
+ // Reachable only for names registered in metadata, so a hostile value requires a
267
+ // hostile entity name — but an entity legitimately named with an apostrophe
268
+ // (e.g. "O'Brien Records") already produces a broken/injectable filter today.
269
+ registerEntity("O'Brien Records", true);
270
+ mockRunViewFn.mockResolvedValue({ Success: true, Results: [] });
271
+
272
+ await ViewOperationsHandler.getEntityViews("O'Brien Records", makeUser());
273
+
274
+ const executed = mockRunViewFn.mock.calls[0][0] as RunViewParams;
275
+ expect(executed.ExtraFilter).toBe(`Entity = 'O'Brien Records'`); // quote NOT doubled
276
+ });
277
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Config-contract regression test for the metadata-cache refresh interval units.
3
+ *
4
+ * MJServer's `databaseSettings.metadataCacheRefreshInterval` is configured in
5
+ * MILLISECONDS (default 180000 = 3 minutes; METADATA_CACHE_REFRESH_INTERVAL env
6
+ * override). SQLServerProviderConfigData's third constructor parameter is
7
+ * `checkRefreshIntervalSeconds` — SECONDS. Commit 645c5a5e8 fixed MJServer
8
+ * handing the raw millisecond value across that seam, which turned the intended
9
+ * 3-minute refresh cadence into ~50 hours.
10
+ *
11
+ * Both provider bootstrap call sites in src/index.ts now route through the pure
12
+ * `MetadataCacheRefreshIntervalSeconds` helper (src/providerConfigUnits.ts).
13
+ * This suite pins:
14
+ * 1. the ms→s conversion itself, and
15
+ * 2. the handoff contract — the value stored on a REAL
16
+ * SQLServerProviderConfigData (deep-imported so the test does not drag in
17
+ * the provider package's heavy index graph) is in the unit the parameter
18
+ * declares.
19
+ */
20
+ import { describe, it, expect } from 'vitest';
21
+ import type sql from 'mssql';
22
+ // Deep import: the package index pulls the full provider (+aiengine/queue) graph,
23
+ // which is far too heavy for a unit test. types.js only depends on core + mssql.
24
+ import { SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider/dist/types.js';
25
+ import { MetadataCacheRefreshIntervalSeconds } from '../providerConfigUnits.js';
26
+
27
+ /** The shipped default for databaseSettings.metadataCacheRefreshInterval (ms) — see src/config.ts. */
28
+ const DEFAULT_INTERVAL_MS = 180000;
29
+
30
+ describe('MetadataCacheRefreshIntervalSeconds (ms → s conversion)', () => {
31
+ it('converts the shipped 180000ms default to 180 seconds (3 minutes, not ~50 hours)', () => {
32
+ const seconds = MetadataCacheRefreshIntervalSeconds(DEFAULT_INTERVAL_MS);
33
+
34
+ expect(seconds).toBe(180);
35
+ // The regression fixed by 645c5a5e8: the raw ms value must never appear on
36
+ // the seconds side of the seam. 180000 "seconds" is a 50-hour cadence.
37
+ expect(seconds).not.toBe(DEFAULT_INTERVAL_MS);
38
+ expect(seconds * 1000).toBe(DEFAULT_INTERVAL_MS); // exact round-trip, no truncation surprise
39
+ });
40
+
41
+ it('preserves 0 (auto-refresh disabled stays disabled)', () => {
42
+ expect(MetadataCacheRefreshIntervalSeconds(0)).toBe(0);
43
+ });
44
+
45
+ it('converts arbitrary env-override values proportionally', () => {
46
+ expect(MetadataCacheRefreshIntervalSeconds(60000)).toBe(60);
47
+ expect(MetadataCacheRefreshIntervalSeconds(1500)).toBe(1.5); // sub-second precision retained
48
+ });
49
+ });
50
+
51
+ describe('handoff contract with SQLServerProviderConfigData', () => {
52
+ // The constructor only stores the pool reference; no connection is opened.
53
+ const stubPool = {} as unknown as sql.ConnectionPool;
54
+
55
+ it('stores the derived value as CheckRefreshIntervalSeconds — the effective value is in SECONDS', () => {
56
+ const config = new SQLServerProviderConfigData(
57
+ stubPool,
58
+ '__mj',
59
+ MetadataCacheRefreshIntervalSeconds(DEFAULT_INTERVAL_MS),
60
+ );
61
+
62
+ expect(config.CheckRefreshIntervalSeconds).toBe(180);
63
+ expect(config.CheckRefreshIntervalSeconds).not.toBe(DEFAULT_INTERVAL_MS);
64
+ });
65
+
66
+ it('a disabled (0ms) interval reaches the provider as 0 — matching the provider default', () => {
67
+ const derived = new SQLServerProviderConfigData(stubPool, '__mj', MetadataCacheRefreshIntervalSeconds(0));
68
+ const omitted = new SQLServerProviderConfigData(stubPool, '__mj');
69
+
70
+ expect(derived.CheckRefreshIntervalSeconds).toBe(0);
71
+ // The provider's own default is 0 (auto refresh disabled) — the two paths agree.
72
+ expect(omitted.CheckRefreshIntervalSeconds).toBe(0);
73
+ });
74
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for `refreshAuthProviders` — the runtime rebuild of the provider registry.
5
+ *
6
+ * The scenario that matters: the factory caches issuer→provider resolutions, so a catalog edit
7
+ * requires a full clear — but a clear also drops everything the CONFIG path registered
8
+ * (mj.config.cjs providers, magic-link). A refresh that re-registered only the metadata rows
9
+ * would silently lock those users out. These tests pin the two-source rebuild order:
10
+ * clear → config-declared providers → metadata rows layered on top.
11
+ */
12
+
13
+ const { registerMock, createProviderMock, clearMock, discoverMock } = vi.hoisted(() => ({
14
+ registerMock: vi.fn(),
15
+ createProviderMock: vi.fn(),
16
+ clearMock: vi.fn(),
17
+ discoverMock: vi.fn(() => [])
18
+ }));
19
+
20
+ vi.mock('@memberjunction/auth-providers', () => ({
21
+ AuthProviderFactory: {
22
+ Instance: {
23
+ register: registerMock,
24
+ clear: clearMock
25
+ },
26
+ createProvider: createProviderMock,
27
+ DiscoverFromEnvironment: discoverMock
28
+ }
29
+ }));
30
+
31
+ vi.mock('../config.js', () => ({
32
+ configInfo: {
33
+ authProviders: [
34
+ {
35
+ name: 'config-okta',
36
+ type: 'okta',
37
+ issuer: 'https://config.okta.com',
38
+ audience: 'api://default',
39
+ jwksUri: 'https://config.okta.com/keys'
40
+ }
41
+ ]
42
+ }
43
+ }));
44
+
45
+ vi.mock('@memberjunction/credentials', () => ({
46
+ CredentialEngine: {
47
+ Instance: {
48
+ Config: vi.fn(async () => undefined),
49
+ getCredential: vi.fn()
50
+ }
51
+ }
52
+ }));
53
+
54
+ vi.mock('@memberjunction/core-entities', () => ({}));
55
+
56
+ vi.mock('@memberjunction/core', () => {
57
+ const instances = new Map<unknown, unknown>();
58
+ return {
59
+ BaseEngine: class {
60
+ protected async Load(): Promise<void> {
61
+ /* no-op: tests inject rows directly */
62
+ }
63
+ protected GetConfigData<T>(propertyName: string): T[] {
64
+ return (this as unknown as Record<string, T[]>)[propertyName] ?? [];
65
+ }
66
+ public static getInstance<T>(this: new () => T): T {
67
+ if (!instances.has(this)) {
68
+ instances.set(this, new this());
69
+ }
70
+ return instances.get(this) as T;
71
+ }
72
+ },
73
+ RegisterForStartup: () => (target: unknown) => target,
74
+ LogError: vi.fn(),
75
+ LogStatus: vi.fn(),
76
+ LogStatusEx: vi.fn()
77
+ };
78
+ });
79
+
80
+ import { AuthProviderEngine } from '../auth/AuthProviderEngine.js';
81
+ import { refreshAuthProviders } from '../auth/initializeProviders.js';
82
+
83
+ /** Minimal stand-in for a generated MJAuthenticationProviderEntity row. */
84
+ function metadataRow(name: string, driverClass: string) {
85
+ return {
86
+ Name: name,
87
+ DriverClass: driverClass,
88
+ Issuer: null,
89
+ Audience: null,
90
+ JWKSUri: null,
91
+ ClientID: null,
92
+ Domain: null,
93
+ Scopes: null,
94
+ AdditionalConfiguration: null,
95
+ ClientConfiguration: null,
96
+ CredentialID: null,
97
+ Credential: null
98
+ };
99
+ }
100
+
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ createProviderMock.mockImplementation((config: unknown) => ({ config }));
104
+ (AuthProviderEngine.Instance as unknown as { _providers: unknown[] })._providers = [];
105
+ });
106
+
107
+ describe('refreshAuthProviders', () => {
108
+ it('re-registers the config-declared providers after clearing, so a refresh cannot lock config-path users out', async () => {
109
+ await refreshAuthProviders();
110
+
111
+ expect(clearMock).toHaveBeenCalledTimes(1);
112
+ expect(createProviderMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'config-okta' }));
113
+ expect(registerMock).toHaveBeenCalled();
114
+ // The config provider must land AFTER the clear, not survive from before it.
115
+ expect(Math.min(...registerMock.mock.invocationCallOrder)).toBeGreaterThan(clearMock.mock.invocationCallOrder[0]);
116
+ });
117
+
118
+ it('aborts BEFORE touching the registry when reading the catalog is permission-denied', async () => {
119
+ // GetConfigData throws PermissionConstrainedError when the load was skipped for permission
120
+ // reasons. That failure must surface while the registry still serves the previous provider
121
+ // set — throwing after the clear would deregister every metadata IdP until process restart.
122
+ const providersSpy = vi.spyOn(AuthProviderEngine.Instance, 'Providers', 'get').mockImplementation(() => {
123
+ throw new Error('PermissionConstrainedError: AuthProviderEngine load skipped');
124
+ });
125
+
126
+ try {
127
+ await expect(refreshAuthProviders()).rejects.toThrow('PermissionConstrainedError');
128
+ expect(clearMock).not.toHaveBeenCalled();
129
+ expect(registerMock).not.toHaveBeenCalled();
130
+ } finally {
131
+ providersSpy.mockRestore();
132
+ }
133
+ });
134
+
135
+ it('layers the metadata rows on top of the config-declared providers, in that order', async () => {
136
+ (AuthProviderEngine.Instance as unknown as { _providers: unknown[] })._providers = [metadataRow('Catalog WorkOS', 'workos')];
137
+
138
+ const count = await refreshAuthProviders();
139
+
140
+ expect(count).toBe(1);
141
+ expect(registerMock).toHaveBeenCalledTimes(2);
142
+ const registeredNames = createProviderMock.mock.calls.map((call) => (call[0] as { name: string }).name);
143
+ // Config first, metadata second — a name collision must resolve to the metadata row.
144
+ expect(registeredNames).toEqual(['config-okta', 'Catalog WorkOS']);
145
+ });
146
+ });
@@ -15,12 +15,6 @@ vi.mock('@memberjunction/server-bootstrap/mj-class-registrations', () => ({}));
15
15
  vi.mock('../generated/class-registrations-manifest.js', () => ({}));
16
16
 
17
17
  describe('MJAPI', () => {
18
- it('should have a valid package structure', () => {
19
- // This test validates that the mock setup works correctly,
20
- // confirming that the imports in index.ts reference real modules
21
- expect(true).toBe(true);
22
- });
23
-
24
18
  it('should use createMJServer for bootstrapping', async () => {
25
19
  const { createMJServer } = await import('@memberjunction/server-bootstrap');
26
20
  expect(createMJServer).toBeDefined();