@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.
- package/LICENSE +180 -4
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +56 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +501 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2711 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +50 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +59 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/config.ts +55 -36
- package/src/generated/generated.ts +1911 -10551
- package/src/generic/ResolverBase.ts +11 -4
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +108 -15
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +77 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- package/src/types.ts +6 -0
|
@@ -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();
|