@memberjunction/server 6.1.0-edge.2 → 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 (162) 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/index.d.ts +11 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +56 -13
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/initializeProviders.d.ts +64 -1
  15. package/dist/auth/initializeProviders.d.ts.map +1 -1
  16. package/dist/auth/initializeProviders.js +117 -5
  17. package/dist/auth/initializeProviders.js.map +1 -1
  18. package/dist/config.d.ts +89 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +52 -35
  21. package/dist/config.js.map +1 -1
  22. package/dist/generated/generated.d.ts +501 -1597
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +2711 -17021
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +1 -1
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +7 -4
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +31 -0
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +99 -14
  37. package/dist/index.js.map +1 -1
  38. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  39. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  40. package/dist/integration/RSUProgressBridge.js +230 -0
  41. package/dist/integration/RSUProgressBridge.js.map +1 -0
  42. package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
  43. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  44. package/dist/integration/SchemaRefreshLaunch.js +50 -0
  45. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  46. package/dist/providerConfigUnits.d.ts +20 -0
  47. package/dist/providerConfigUnits.d.ts.map +1 -0
  48. package/dist/providerConfigUnits.js +22 -0
  49. package/dist/providerConfigUnits.js.map +1 -0
  50. package/dist/resolvers/FileResolver.d.ts +34 -4
  51. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  52. package/dist/resolvers/FileResolver.js +218 -28
  53. package/dist/resolvers/FileResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts +5 -4
  59. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  60. package/dist/resolvers/QueryResolver.js +29 -14
  61. package/dist/resolvers/QueryResolver.js.map +1 -1
  62. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  63. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  64. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  65. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  67. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  68. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  69. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  70. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  71. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  72. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  73. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  74. package/dist/resolvers/UserResolver.d.ts +19 -17
  75. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  76. package/dist/resolvers/UserResolver.js +66 -67
  77. package/dist/resolvers/UserResolver.js.map +1 -1
  78. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  79. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  80. package/dist/resolvers/currentUserRoles.js +36 -0
  81. package/dist/resolvers/currentUserRoles.js.map +1 -0
  82. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  83. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  84. package/dist/rest/MediaAccessKeys.js +42 -1
  85. package/dist/rest/MediaAccessKeys.js.map +1 -1
  86. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  87. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  88. package/dist/rest/MediaStreamHandler.js +83 -2
  89. package/dist/rest/MediaStreamHandler.js.map +1 -1
  90. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  91. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  92. package/dist/rest/OAuthCallbackHandler.js +129 -43
  93. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  94. package/dist/rest/UploadTokenManager.d.ts +90 -0
  95. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  96. package/dist/rest/UploadTokenManager.js +156 -0
  97. package/dist/rest/UploadTokenManager.js.map +1 -0
  98. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  99. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  100. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  101. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  102. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  103. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  104. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  105. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  106. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  107. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  108. package/dist/services/TaskGraphAgentRunner.js +23 -0
  109. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  110. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  111. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  112. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  113. package/dist/types.d.ts +6 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/dist/types.js.map +1 -1
  116. package/package.json +91 -91
  117. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  118. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  119. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  120. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  121. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  122. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  123. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  124. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  125. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  126. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  127. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  128. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  129. package/src/__tests__/config-units.test.ts +74 -0
  130. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  131. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  132. package/src/__tests__/newUsers.test.ts +726 -0
  133. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  134. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  135. package/src/auth/AuthProviderEngine.ts +253 -0
  136. package/src/auth/index.ts +59 -15
  137. package/src/auth/initializeProviders.ts +128 -7
  138. package/src/config.ts +55 -36
  139. package/src/generated/generated.ts +1911 -10551
  140. package/src/generic/ResolverBase.ts +11 -4
  141. package/src/generic/RunViewResolver.ts +31 -0
  142. package/src/index.ts +108 -15
  143. package/src/integration/RSUProgressBridge.ts +248 -0
  144. package/src/integration/SchemaRefreshLaunch.ts +77 -0
  145. package/src/providerConfigUnits.ts +21 -0
  146. package/src/resolvers/FileResolver.ts +200 -40
  147. package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
  148. package/src/resolvers/QueryResolver.ts +25 -11
  149. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  150. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  151. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  152. package/src/resolvers/UserResolver.ts +99 -60
  153. package/src/resolvers/currentUserRoles.ts +47 -0
  154. package/src/rest/MediaAccessKeys.ts +47 -1
  155. package/src/rest/MediaStreamHandler.ts +98 -3
  156. package/src/rest/OAuthCallbackHandler.ts +149 -41
  157. package/src/rest/UploadTokenManager.ts +211 -0
  158. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  159. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  160. package/src/services/TaskGraphAgentRunner.ts +23 -0
  161. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  162. package/src/types.ts +6 -0
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for the metadata-driven auth provider catalog.
5
+ *
6
+ * The focus is the security boundary: `GetPublicCatalog` feeds an UNAUTHENTICATED endpoint, so
7
+ * these assert what may not escape it — server-only configuration, credential linkage, and any
8
+ * non-primitive value that could carry a secret. Also covers the registration path's isolation
9
+ * (one bad row must not cost a deployment its other providers) and the config projection.
10
+ */
11
+
12
+ // vi.mock factories are hoisted above module-level consts, so the shared spies have to be
13
+ // created inside vi.hoisted() to exist by the time a factory runs.
14
+ const { registerMock, createProviderMock, clearMock, getCredentialMock } = vi.hoisted(() => ({
15
+ registerMock: vi.fn(),
16
+ createProviderMock: vi.fn(),
17
+ clearMock: vi.fn(),
18
+ getCredentialMock: vi.fn()
19
+ }));
20
+
21
+ vi.mock('@memberjunction/auth-providers', () => ({
22
+ AuthProviderFactory: {
23
+ Instance: {
24
+ register: registerMock,
25
+ clear: clearMock
26
+ },
27
+ createProvider: createProviderMock
28
+ }
29
+ }));
30
+
31
+ vi.mock('@memberjunction/credentials', () => ({
32
+ CredentialEngine: {
33
+ Instance: {
34
+ Config: vi.fn(async () => undefined),
35
+ getCredential: getCredentialMock
36
+ }
37
+ }
38
+ }));
39
+
40
+ vi.mock('@memberjunction/core-entities', () => ({}));
41
+
42
+ vi.mock('@memberjunction/core', () => ({
43
+ BaseEngine: class {
44
+ protected async Load(): Promise<void> {
45
+ /* no-op: tests inject rows directly */
46
+ }
47
+ protected GetConfigData<T>(propertyName: string): T[] {
48
+ return (this as unknown as Record<string, T[]>)[propertyName] ?? [];
49
+ }
50
+ public static getInstance<T>(this: new () => T): T {
51
+ return new this();
52
+ }
53
+ },
54
+ RegisterForStartup: () => (target: unknown) => target,
55
+ LogError: vi.fn(),
56
+ LogStatus: vi.fn(),
57
+ LogStatusEx: vi.fn()
58
+ }));
59
+
60
+ import { AuthProviderEngine } from '../auth/AuthProviderEngine.js';
61
+
62
+ /** Minimal stand-in for a generated MJAuthenticationProviderEntity row. */
63
+ type Row = {
64
+ Name: string;
65
+ DriverClass: string;
66
+ Issuer: string | null;
67
+ Audience: string | null;
68
+ JWKSUri: string | null;
69
+ ClientID: string | null;
70
+ Domain: string | null;
71
+ Scopes: string | null;
72
+ AdditionalConfiguration: string | null;
73
+ ClientConfiguration: string | null;
74
+ CredentialID: string | null;
75
+ Credential: string | null;
76
+ Status: string;
77
+ IsDefault: boolean;
78
+ ClientVisible: boolean;
79
+ DisplayName: string | null;
80
+ Icon: string | null;
81
+ Sequence: number;
82
+ };
83
+
84
+ function row(overrides: Partial<Row> & { Name: string; DriverClass: string }): Row {
85
+ return {
86
+ Issuer: null,
87
+ Audience: null,
88
+ JWKSUri: null,
89
+ ClientID: null,
90
+ Domain: null,
91
+ Scopes: null,
92
+ AdditionalConfiguration: null,
93
+ ClientConfiguration: null,
94
+ CredentialID: null,
95
+ Credential: null,
96
+ Status: 'Active',
97
+ IsDefault: false,
98
+ ClientVisible: true,
99
+ DisplayName: null,
100
+ Icon: null,
101
+ Sequence: 0,
102
+ ...overrides
103
+ };
104
+ }
105
+
106
+ /** Builds an engine whose cached rows are the supplied ones. */
107
+ function engineWith(rows: Row[]): AuthProviderEngine {
108
+ const engine = new AuthProviderEngine();
109
+ (engine as unknown as { _providers: Row[] })._providers = rows;
110
+ return engine;
111
+ }
112
+
113
+ beforeEach(() => {
114
+ vi.clearAllMocks();
115
+ createProviderMock.mockImplementation((config: unknown) => ({ config }));
116
+ });
117
+
118
+ describe('AuthProviderEngine.GetPublicCatalog', () => {
119
+ it('never publishes server-only configuration or credential linkage', () => {
120
+ const catalog = engineWith([
121
+ row({
122
+ Name: 'WorkOS Production',
123
+ DriverClass: 'workos',
124
+ ClientID: 'client_123',
125
+ Issuer: 'https://api.workos.com/user_management/client_123',
126
+ AdditionalConfiguration: JSON.stringify({ managementApiKey: 'sk_live_SECRET' }),
127
+ CredentialID: 'CRED-1',
128
+ Credential: 'WorkOS Secret'
129
+ })
130
+ ]).GetPublicCatalog();
131
+
132
+ const serialized = JSON.stringify(catalog);
133
+ expect(serialized).not.toContain('sk_live_SECRET');
134
+ expect(serialized).not.toContain('managementApiKey');
135
+ expect(serialized).not.toContain('CRED-1');
136
+ expect(catalog[0]).not.toHaveProperty('credentialID');
137
+ expect(catalog[0]).not.toHaveProperty('additionalConfiguration');
138
+ });
139
+
140
+ it('excludes providers that are not client-visible', () => {
141
+ const catalog = engineWith([
142
+ row({ Name: 'Interactive', DriverClass: 'okta' }),
143
+ row({ Name: 'Machine to machine', DriverClass: 'auth0', ClientVisible: false })
144
+ ]).GetPublicCatalog();
145
+
146
+ expect(catalog.map((p) => p.name)).toEqual(['Interactive']);
147
+ });
148
+
149
+ it('falls back to Name when DisplayName is unset', () => {
150
+ const [info] = engineWith([row({ Name: 'Corporate Azure AD', DriverClass: 'msal' })]).GetPublicCatalog();
151
+ expect(info.displayName).toBe('Corporate Azure AD');
152
+ });
153
+
154
+ it('publishes ClientConfiguration primitives for the browser driver', () => {
155
+ const [info] = engineWith([
156
+ row({
157
+ Name: 'WorkOS',
158
+ DriverClass: 'workos',
159
+ ClientConfiguration: JSON.stringify({ redirectUri: 'https://app.example.com', devMode: false })
160
+ })
161
+ ]).GetPublicCatalog();
162
+
163
+ expect(info.clientConfiguration).toEqual({ redirectUri: 'https://app.example.com', devMode: false });
164
+ });
165
+
166
+ it('drops non-primitive ClientConfiguration values so a nested blob cannot ride along', () => {
167
+ const [info] = engineWith([
168
+ row({
169
+ Name: 'WorkOS',
170
+ DriverClass: 'workos',
171
+ ClientConfiguration: JSON.stringify({ redirectUri: 'https://app.example.com', secrets: { apiKey: 'sk_live_SECRET' } })
172
+ })
173
+ ]).GetPublicCatalog();
174
+
175
+ expect(info.clientConfiguration).toEqual({ redirectUri: 'https://app.example.com' });
176
+ expect(JSON.stringify(info)).not.toContain('sk_live_SECRET');
177
+ });
178
+
179
+ it('publishes scopes pre-parsed as an array, never the raw delimited column', () => {
180
+ const [info] = engineWith([row({ Name: 'Okta', DriverClass: 'okta', Scopes: 'openid, profile email' })]).GetPublicCatalog();
181
+ expect(info.scopes).toEqual(['openid', 'profile', 'email']);
182
+ });
183
+
184
+ it('omits scopes entirely for a whitespace-only Scopes column', () => {
185
+ const [info] = engineWith([row({ Name: 'Okta', DriverClass: 'okta', Scopes: ' ' })]).GetPublicCatalog();
186
+ expect(info.scopes).toBeUndefined();
187
+ });
188
+
189
+ it('treats malformed ClientConfiguration as absent rather than failing the catalog', () => {
190
+ const [info] = engineWith([row({ Name: 'Broken', DriverClass: 'okta', ClientConfiguration: '{not json' })]).GetPublicCatalog();
191
+ expect(info.clientConfiguration).toBeUndefined();
192
+ expect(info.name).toBe('Broken');
193
+ });
194
+ });
195
+
196
+ describe('AuthProviderEngine.RegisterAll', () => {
197
+ it('registers every active provider', async () => {
198
+ const count = await engineWith([
199
+ row({ Name: 'A', DriverClass: 'okta' }),
200
+ row({ Name: 'B', DriverClass: 'auth0' })
201
+ ]).RegisterAll();
202
+
203
+ expect(count).toBe(2);
204
+ expect(registerMock).toHaveBeenCalledTimes(2);
205
+ });
206
+
207
+ it('isolates a failing row so the other providers still register', async () => {
208
+ createProviderMock.mockImplementation((config: { type: string }) => {
209
+ if (config.type === 'broken') {
210
+ throw new Error('no driver registered');
211
+ }
212
+ return { config };
213
+ });
214
+
215
+ const count = await engineWith([
216
+ row({ Name: 'Broken', DriverClass: 'broken' }),
217
+ row({ Name: 'Good', DriverClass: 'okta' })
218
+ ]).RegisterAll();
219
+
220
+ expect(count).toBe(1);
221
+ expect(registerMock).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it('projects the row onto the driver config, splitting scopes into an array', async () => {
225
+ await engineWith([
226
+ row({
227
+ Name: 'Okta Prod',
228
+ DriverClass: 'okta',
229
+ ClientID: 'cid',
230
+ Domain: 'example.okta.com',
231
+ Issuer: 'https://example.okta.com',
232
+ Audience: 'api://default',
233
+ JWKSUri: 'https://example.okta.com/keys',
234
+ Scopes: 'openid profile email'
235
+ })
236
+ ]).RegisterAll();
237
+
238
+ expect(createProviderMock).toHaveBeenCalledWith(
239
+ expect.objectContaining({
240
+ name: 'Okta Prod',
241
+ type: 'okta',
242
+ clientId: 'cid',
243
+ domain: 'example.okta.com',
244
+ issuer: 'https://example.okta.com',
245
+ audience: 'api://default',
246
+ jwksUri: 'https://example.okta.com/keys',
247
+ scopes: ['openid', 'profile', 'email']
248
+ })
249
+ );
250
+ });
251
+
252
+ it('does not let AdditionalConfiguration override a modelled column', async () => {
253
+ await engineWith([
254
+ row({
255
+ Name: 'Okta',
256
+ DriverClass: 'okta',
257
+ Issuer: 'https://real-issuer.example.com',
258
+ AdditionalConfiguration: JSON.stringify({ issuer: 'https://attacker.example.com' })
259
+ })
260
+ ]).RegisterAll();
261
+
262
+ expect(createProviderMock).toHaveBeenCalledWith(expect.objectContaining({ issuer: 'https://real-issuer.example.com' }));
263
+ });
264
+
265
+ it('merges decrypted credential values when the row links one', async () => {
266
+ getCredentialMock.mockResolvedValue({ values: { clientSecret: 'shhh' } });
267
+
268
+ await engineWith([row({ Name: 'Confidential', DriverClass: 'okta', CredentialID: 'CRED-1', Credential: 'Okta Secret' })]).RegisterAll();
269
+
270
+ expect(getCredentialMock).toHaveBeenCalledWith('Okta Secret', expect.objectContaining({ subsystem: 'Authentication' }));
271
+ expect(createProviderMock).toHaveBeenCalledWith(expect.objectContaining({ clientSecret: 'shhh' }));
272
+ });
273
+
274
+ it('does not touch CredentialEngine when no credential is linked', async () => {
275
+ await engineWith([row({ Name: 'Public JWKS', DriverClass: 'okta' })]).RegisterAll();
276
+ expect(getCredentialMock).not.toHaveBeenCalled();
277
+ });
278
+ });
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Tests for the REST entity CRUD implementation (rest/EntityCRUDHandler.ts).
3
+ *
4
+ * Verifies for every verb (create / read / update / delete):
5
+ * - permission enforcement happens BEFORE any load or mutation,
6
+ * - all mutations go through the BaseEntity object path (the entity returned
7
+ * by Metadata.GetEntityObject — NewRecord/Set/Validate/Save/Delete), never
8
+ * around it,
9
+ * - input validation (entity validation errors, save-option extraction,
10
+ * composite-key construction) and every error branch.
11
+ *
12
+ * The package boundary mocked is @memberjunction/core's Metadata; the real
13
+ * CompositeKey / EntitySaveOptions / EntityDeleteOptions / EntityPermissionType
14
+ * are used so the handler's contracts with them stay honest.
15
+ */
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+
18
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
19
+ const { mockGetEntityObject, mockLogError } = vi.hoisted(() => ({
20
+ mockGetEntityObject: vi.fn(),
21
+ mockLogError: vi.fn(),
22
+ }));
23
+
24
+ vi.mock('@memberjunction/core', async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import('@memberjunction/core')>();
26
+ class MockMetadata {
27
+ public GetEntityObject(entityName: string, contextUser?: unknown): Promise<unknown> {
28
+ return mockGetEntityObject(entityName, contextUser);
29
+ }
30
+ }
31
+ return {
32
+ ...actual,
33
+ Metadata: MockMetadata,
34
+ LogError: mockLogError,
35
+ };
36
+ });
37
+
38
+ import {
39
+ CompositeKey,
40
+ EntityDeleteOptions,
41
+ EntityPermissionType,
42
+ EntitySaveOptions,
43
+ UserInfo,
44
+ } from '@memberjunction/core';
45
+ import { EntityCRUDHandler } from '../rest/EntityCRUDHandler.js';
46
+
47
+ // ─── Mock entity ────────────────────────────────────────────────────────────
48
+
49
+ interface MockEntityConfig {
50
+ allowed?: EntityPermissionType[];
51
+ loadSuccess?: boolean;
52
+ dirty?: boolean;
53
+ validateSuccess?: boolean;
54
+ saveSuccess?: boolean;
55
+ deleteSuccess?: boolean;
56
+ primaryKeys?: Array<{ Name: string }>;
57
+ }
58
+
59
+ interface MockEntity {
60
+ CheckPermissions: ReturnType<typeof vi.fn>;
61
+ NewRecord: ReturnType<typeof vi.fn>;
62
+ Set: ReturnType<typeof vi.fn>;
63
+ Validate: ReturnType<typeof vi.fn>;
64
+ Save: ReturnType<typeof vi.fn>;
65
+ Delete: ReturnType<typeof vi.fn>;
66
+ InnerLoad: ReturnType<typeof vi.fn>;
67
+ GetDataObject: ReturnType<typeof vi.fn>;
68
+ Dirty: boolean;
69
+ LatestResult: { Message: string };
70
+ EntityInfo: { PrimaryKeys: Array<{ Name: string }> };
71
+ }
72
+
73
+ const ENTITY_DATA = { ID: 'row-1', Name: 'Loaded Row' };
74
+
75
+ function makeEntity(config: MockEntityConfig = {}): MockEntity {
76
+ const allowed = new Set(config.allowed ?? [
77
+ EntityPermissionType.Create,
78
+ EntityPermissionType.Read,
79
+ EntityPermissionType.Update,
80
+ EntityPermissionType.Delete,
81
+ ]);
82
+ return {
83
+ CheckPermissions: vi.fn((type: EntityPermissionType) => allowed.has(type)),
84
+ NewRecord: vi.fn(),
85
+ Set: vi.fn(),
86
+ Validate: vi.fn(() => ({
87
+ Success: config.validateSuccess ?? true,
88
+ Errors: config.validateSuccess === false ? [{ Message: 'Name is required' }] : [],
89
+ })),
90
+ Save: vi.fn(async () => config.saveSuccess ?? true),
91
+ Delete: vi.fn(async () => config.deleteSuccess ?? true),
92
+ InnerLoad: vi.fn(async () => config.loadSuccess ?? true),
93
+ GetDataObject: vi.fn(async () => ({ ...ENTITY_DATA })),
94
+ Dirty: config.dirty ?? true,
95
+ LatestResult: { Message: 'underlying save/delete failure' },
96
+ EntityInfo: { PrimaryKeys: config.primaryKeys ?? [{ Name: 'ID' }] },
97
+ };
98
+ }
99
+
100
+ function makeUser(): UserInfo {
101
+ return { ID: 'user-1', Name: 'Testy McTester' } as unknown as UserInfo;
102
+ }
103
+
104
+ let entity: MockEntity;
105
+
106
+ beforeEach(() => {
107
+ vi.clearAllMocks();
108
+ entity = makeEntity();
109
+ mockGetEntityObject.mockImplementation(async () => entity);
110
+ });
111
+
112
+ // ─── createEntity ───────────────────────────────────────────────────────────
113
+
114
+ describe('EntityCRUDHandler.createEntity', () => {
115
+ it('resolves the entity object for the requesting user and saves through it (never around it)', async () => {
116
+ const user = makeUser();
117
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, user);
118
+
119
+ expect(result.success).toBe(true);
120
+ expect(result.entity).toEqual(ENTITY_DATA);
121
+ // Entity-object path: acquired via Metadata for THIS user, then mutated via the object
122
+ expect(mockGetEntityObject).toHaveBeenCalledWith('Customers', user);
123
+ expect(entity.NewRecord).toHaveBeenCalledTimes(1);
124
+ expect(entity.Set).toHaveBeenCalledWith('Name', 'Acme');
125
+ expect(entity.Validate).toHaveBeenCalledTimes(1);
126
+ expect(entity.Save).toHaveBeenCalledTimes(1);
127
+ expect(entity.Save.mock.calls[0][0]).toBeInstanceOf(EntitySaveOptions);
128
+ });
129
+
130
+ it('denies without touching the record when the user lacks Create permission', async () => {
131
+ entity = makeEntity({ allowed: [EntityPermissionType.Read] });
132
+
133
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, makeUser());
134
+
135
+ expect(result.success).toBe(false);
136
+ expect(result.error).toBe('User Testy McTester does not have permission to create Customers records');
137
+ // Permission check must precede ANY mutation of the entity object
138
+ expect(entity.NewRecord).not.toHaveBeenCalled();
139
+ expect(entity.Set).not.toHaveBeenCalled();
140
+ expect(entity.Save).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('extracts save options from data.options and never writes "options" as a field', async () => {
144
+ const data: Record<string, unknown> = {
145
+ Name: 'Acme',
146
+ options: { IgnoreDirtyState: true, SkipEntityAIActions: 1, ReplayOnly: false },
147
+ };
148
+
149
+ const result = await EntityCRUDHandler.createEntity('Customers', data, makeUser());
150
+
151
+ expect(result.success).toBe(true);
152
+ const savedOptions = entity.Save.mock.calls[0][0] as EntitySaveOptions;
153
+ expect(savedOptions.IgnoreDirtyState).toBe(true);
154
+ expect(savedOptions.SkipEntityAIActions).toBe(true); // coerced with !!
155
+ expect(savedOptions.ReplayOnly).toBe(false);
156
+ const setKeys = entity.Set.mock.calls.map((call) => call[0] as string);
157
+ expect(setKeys).toEqual(['Name']); // 'options' was consumed, not persisted
158
+ });
159
+
160
+ it('returns validation errors without calling Save when Validate fails', async () => {
161
+ entity = makeEntity({ validateSuccess: false });
162
+
163
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: '' }, makeUser());
164
+
165
+ expect(result.success).toBe(false);
166
+ expect(result.error).toBe('Validation failed');
167
+ expect(result.validationErrors).toEqual([{ Message: 'Name is required' }]);
168
+ expect(entity.Save).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('surfaces the entity LatestResult when Save returns false', async () => {
172
+ entity = makeEntity({ saveSuccess: false });
173
+
174
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, makeUser());
175
+
176
+ expect(result.success).toBe(false);
177
+ expect(result.error).toBe('underlying save/delete failure');
178
+ expect(result.details).toBe(entity.LatestResult);
179
+ });
180
+
181
+ it('catches thrown errors (e.g. unknown entity) and returns them as failures', async () => {
182
+ mockGetEntityObject.mockRejectedValue(new Error('Entity Nope not found in metadata'));
183
+
184
+ const result = await EntityCRUDHandler.createEntity('Nope', {}, makeUser());
185
+
186
+ expect(result.success).toBe(false);
187
+ expect(result.error).toBe('Entity Nope not found in metadata');
188
+ expect(mockLogError).toHaveBeenCalled();
189
+ });
190
+ });
191
+
192
+ // ─── getEntity ──────────────────────────────────────────────────────────────
193
+
194
+ describe('EntityCRUDHandler.getEntity', () => {
195
+ it('loads through InnerLoad with a single-PK composite key built from the id', async () => {
196
+ const result = await EntityCRUDHandler.getEntity('Customers', 42, null, makeUser());
197
+
198
+ expect(result.success).toBe(true);
199
+ expect(result.entity).toEqual(ENTITY_DATA);
200
+ const [key, related] = entity.InnerLoad.mock.calls[0] as [CompositeKey, string[] | null];
201
+ expect(key).toBeInstanceOf(CompositeKey);
202
+ expect(key.KeyValuePairs).toEqual([{ FieldName: 'ID', Value: '42' }]); // numeric id stringified
203
+ expect(related).toBeNull();
204
+ });
205
+
206
+ it('passes requested related entities through to InnerLoad', async () => {
207
+ await EntityCRUDHandler.getEntity('Customers', 'row-1', ['Orders', 'Invoices'], makeUser());
208
+
209
+ expect(entity.InnerLoad.mock.calls[0][1]).toEqual(['Orders', 'Invoices']);
210
+ });
211
+
212
+ it('denies before loading when the user lacks Read permission', async () => {
213
+ entity = makeEntity({ allowed: [EntityPermissionType.Create] });
214
+
215
+ const result = await EntityCRUDHandler.getEntity('Customers', 'row-1', null, makeUser());
216
+
217
+ expect(result.success).toBe(false);
218
+ expect(result.error).toContain('does not have permission to read Customers');
219
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('returns a not-found error when InnerLoad fails', async () => {
223
+ entity = makeEntity({ loadSuccess: false });
224
+
225
+ const result = await EntityCRUDHandler.getEntity('Customers', 'ghost', null, makeUser());
226
+
227
+ expect(result.success).toBe(false);
228
+ expect(result.error).toBe('Customers with ID ghost not found');
229
+ });
230
+
231
+ it('fails cleanly for composite-primary-key entities instead of guessing', async () => {
232
+ entity = makeEntity({ primaryKeys: [{ Name: 'A' }, { Name: 'B' }] });
233
+
234
+ const result = await EntityCRUDHandler.getEntity('LinkTable', 'a|b', null, makeUser());
235
+
236
+ expect(result.success).toBe(false);
237
+ expect(result.error).toContain('Composite primary keys are not supported');
238
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ // ─── updateEntity ───────────────────────────────────────────────────────────
243
+
244
+ describe('EntityCRUDHandler.updateEntity', () => {
245
+ it('loads, sets fields, validates, and saves via the entity object', async () => {
246
+ const user = makeUser();
247
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'New Name' }, user);
248
+
249
+ expect(result.success).toBe(true);
250
+ expect(mockGetEntityObject).toHaveBeenCalledWith('Customers', user);
251
+ expect(entity.InnerLoad).toHaveBeenCalledTimes(1);
252
+ expect(entity.Set).toHaveBeenCalledWith('Name', 'New Name');
253
+ expect(entity.Validate).toHaveBeenCalledTimes(1);
254
+ expect(entity.Save).toHaveBeenCalledTimes(1);
255
+ expect(entity.Save.mock.calls[0][0]).toBeInstanceOf(EntitySaveOptions);
256
+ });
257
+
258
+ it('denies before loading when the user lacks Update permission', async () => {
259
+ entity = makeEntity({ allowed: [EntityPermissionType.Read] });
260
+
261
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'X' }, makeUser());
262
+
263
+ expect(result.success).toBe(false);
264
+ expect(result.error).toContain('does not have permission to update Customers');
265
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
266
+ expect(entity.Save).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it('returns not-found when the target record does not exist', async () => {
270
+ entity = makeEntity({ loadSuccess: false });
271
+
272
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'ghost', { Name: 'X' }, makeUser());
273
+
274
+ expect(result.success).toBe(false);
275
+ expect(result.error).toBe('Customers with ID ghost not found');
276
+ expect(entity.Save).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it('short-circuits a no-op update (not dirty) as success WITHOUT saving', async () => {
280
+ entity = makeEntity({ dirty: false });
281
+
282
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'Loaded Row' }, makeUser());
283
+
284
+ expect(result.success).toBe(true);
285
+ expect(result.entity).toEqual(ENTITY_DATA);
286
+ expect(entity.Save).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it('saves a clean entity anyway when options.IgnoreDirtyState is set', async () => {
290
+ entity = makeEntity({ dirty: false });
291
+
292
+ const result = await EntityCRUDHandler.updateEntity(
293
+ 'Customers', 'row-1',
294
+ { Name: 'Same', options: { IgnoreDirtyState: true } },
295
+ makeUser(),
296
+ );
297
+
298
+ expect(result.success).toBe(true);
299
+ expect(entity.Save).toHaveBeenCalledTimes(1);
300
+ expect((entity.Save.mock.calls[0][0] as EntitySaveOptions).IgnoreDirtyState).toBe(true);
301
+ });
302
+
303
+ it('returns validation errors without saving', async () => {
304
+ entity = makeEntity({ validateSuccess: false });
305
+
306
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: '' }, makeUser());
307
+
308
+ expect(result.success).toBe(false);
309
+ expect(result.validationErrors).toEqual([{ Message: 'Name is required' }]);
310
+ expect(entity.Save).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('surfaces the entity LatestResult when Save returns false', async () => {
314
+ entity = makeEntity({ saveSuccess: false });
315
+
316
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'X' }, makeUser());
317
+
318
+ expect(result.success).toBe(false);
319
+ expect(result.error).toBe('underlying save/delete failure');
320
+ });
321
+ });
322
+
323
+ // ─── deleteEntity ───────────────────────────────────────────────────────────
324
+
325
+ describe('EntityCRUDHandler.deleteEntity', () => {
326
+ it('loads then deletes via the entity object, passing the delete options through', async () => {
327
+ const options = new EntityDeleteOptions();
328
+ options.SkipEntityActions = true;
329
+
330
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', options, makeUser());
331
+
332
+ expect(result.success).toBe(true);
333
+ expect(entity.InnerLoad).toHaveBeenCalledTimes(1);
334
+ expect(entity.Delete).toHaveBeenCalledTimes(1);
335
+ expect(entity.Delete.mock.calls[0][0]).toBe(options); // the same options object, not a copy
336
+ });
337
+
338
+ it('denies before loading or deleting when the user lacks Delete permission', async () => {
339
+ entity = makeEntity({ allowed: [EntityPermissionType.Read, EntityPermissionType.Update] });
340
+
341
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
342
+
343
+ expect(result.success).toBe(false);
344
+ expect(result.error).toBe('User Testy McTester does not have permission to delete Customers records');
345
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
346
+ expect(entity.Delete).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('returns not-found without deleting when the record does not exist', async () => {
350
+ entity = makeEntity({ loadSuccess: false });
351
+
352
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'ghost', new EntityDeleteOptions(), makeUser());
353
+
354
+ expect(result.success).toBe(false);
355
+ expect(result.error).toBe('Customers with ID ghost not found');
356
+ expect(entity.Delete).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it('surfaces the entity LatestResult when Delete returns false', async () => {
360
+ entity = makeEntity({ deleteSuccess: false });
361
+
362
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
363
+
364
+ expect(result.success).toBe(false);
365
+ expect(result.error).toBe('underlying save/delete failure');
366
+ expect(result.details).toBe(entity.LatestResult);
367
+ });
368
+
369
+ it('catches thrown infrastructure errors and reports them', async () => {
370
+ mockGetEntityObject.mockRejectedValue(new Error('connection lost'));
371
+
372
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
373
+
374
+ expect(result.success).toBe(false);
375
+ expect(result.error).toBe('connection lost');
376
+ expect(mockLogError).toHaveBeenCalled();
377
+ });
378
+ });