@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,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the REST router layer (rest/RESTEndpointHandler.ts).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - entity resolution + allow/deny listing (isEntityAllowed): wildcards,
|
|
6
|
+
* schema filters, precedence, and fail-closed behavior for unknown entities,
|
|
7
|
+
* - the auth guard (extractMJUser → 401) and entity-access middleware (403),
|
|
8
|
+
* - HTTP status mapping per verb (200/201/204/400/403/404/500),
|
|
9
|
+
* - batch runViews pre-filtering (no handler call when everything is blocked),
|
|
10
|
+
* - the error-handling middleware.
|
|
11
|
+
*
|
|
12
|
+
* The CRUD/View implementation modules are mocked at their module boundary
|
|
13
|
+
* (they have their own dedicated suites); @memberjunction/core's Metadata is
|
|
14
|
+
* mocked for entity metadata. Private router methods are reached through the
|
|
15
|
+
* codebase's established narrowly-typed `as unknown as` view pattern
|
|
16
|
+
* (see OAuthCallbackHandler.xss.test.ts).
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
19
|
+
import type express from 'express';
|
|
20
|
+
|
|
21
|
+
// ─── Hoisted mocks ──────────────────────────────────────────────────────────
|
|
22
|
+
const {
|
|
23
|
+
mockCrudCreate, mockCrudGet, mockCrudUpdate, mockCrudDelete,
|
|
24
|
+
mockOpsRunView, mockOpsRunViews, mockOpsListEntities,
|
|
25
|
+
mockLogError, mockEntityList,
|
|
26
|
+
} = vi.hoisted(() => ({
|
|
27
|
+
mockCrudCreate: vi.fn(),
|
|
28
|
+
mockCrudGet: vi.fn(),
|
|
29
|
+
mockCrudUpdate: vi.fn(),
|
|
30
|
+
mockCrudDelete: vi.fn(),
|
|
31
|
+
mockOpsRunView: vi.fn(),
|
|
32
|
+
mockOpsRunViews: vi.fn(),
|
|
33
|
+
mockOpsListEntities: vi.fn(),
|
|
34
|
+
mockLogError: vi.fn(),
|
|
35
|
+
mockEntityList: [] as Array<{
|
|
36
|
+
Name: string;
|
|
37
|
+
SchemaName: string;
|
|
38
|
+
GetUserPermisions: (u: unknown) => { CanRead: boolean };
|
|
39
|
+
Fields: Array<Record<string, unknown>>;
|
|
40
|
+
}>,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock('../rest/EntityCRUDHandler.js', () => ({
|
|
44
|
+
EntityCRUDHandler: {
|
|
45
|
+
createEntity: mockCrudCreate,
|
|
46
|
+
getEntity: mockCrudGet,
|
|
47
|
+
updateEntity: mockCrudUpdate,
|
|
48
|
+
deleteEntity: mockCrudDelete,
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
vi.mock('../rest/ViewOperationsHandler.js', () => ({
|
|
53
|
+
ViewOperationsHandler: {
|
|
54
|
+
runView: mockOpsRunView,
|
|
55
|
+
runViews: mockOpsRunViews,
|
|
56
|
+
listEntities: mockOpsListEntities,
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
61
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
62
|
+
class MockMetadata {
|
|
63
|
+
public get Entities() {
|
|
64
|
+
return mockEntityList;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...actual,
|
|
69
|
+
Metadata: MockMetadata,
|
|
70
|
+
LogError: mockLogError,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
import type { EntityDeleteOptions, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
75
|
+
import { RESTEndpointHandler, RESTEndpointHandlerOptions } from '../rest/RESTEndpointHandler.js';
|
|
76
|
+
|
|
77
|
+
// ─── Internals view + req/res doubles ───────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
interface HandlerInternals {
|
|
80
|
+
isEntityAllowed(entityName: string): boolean;
|
|
81
|
+
checkEntityAccess(req: express.Request, res: express.Response, next: express.NextFunction): void;
|
|
82
|
+
extractMJUser(req: express.Request, res: express.Response, next: express.NextFunction): void;
|
|
83
|
+
errorHandler(err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void;
|
|
84
|
+
getCurrentUser(req: express.Request, res: express.Response): Promise<void>;
|
|
85
|
+
getEntityList(req: express.Request, res: express.Response): Promise<void>;
|
|
86
|
+
getEntity(req: express.Request, res: express.Response): Promise<void>;
|
|
87
|
+
createEntity(req: express.Request, res: express.Response): Promise<void>;
|
|
88
|
+
updateEntity(req: express.Request, res: express.Response): Promise<void>;
|
|
89
|
+
deleteEntity(req: express.Request, res: express.Response): Promise<void>;
|
|
90
|
+
runView(req: express.Request, res: express.Response): Promise<void>;
|
|
91
|
+
runViews(req: express.Request, res: express.Response): Promise<void>;
|
|
92
|
+
getEntityFieldMetadata(req: express.Request, res: express.Response): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeHandler(options: RESTEndpointHandlerOptions = {}): HandlerInternals {
|
|
96
|
+
return new RESTEndpointHandler(options) as unknown as HandlerInternals;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const MJ_USER = {
|
|
100
|
+
ID: 'user-1',
|
|
101
|
+
Name: 'Testy',
|
|
102
|
+
Email: 't@example.com',
|
|
103
|
+
FirstName: 'Testy',
|
|
104
|
+
LastName: 'McTester',
|
|
105
|
+
UserRoles: [{ RoleID: 'r1', Role: 'UI', UserID: 'user-1', InternalFlag: 'secret' }],
|
|
106
|
+
} as unknown as UserInfo;
|
|
107
|
+
|
|
108
|
+
interface ReqShape {
|
|
109
|
+
params?: Record<string, string>;
|
|
110
|
+
query?: Record<string, string>;
|
|
111
|
+
body?: unknown;
|
|
112
|
+
mjUser?: UserInfo;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeReq(shape: ReqShape = {}): express.Request {
|
|
116
|
+
return {
|
|
117
|
+
params: shape.params ?? {},
|
|
118
|
+
query: shape.query ?? {},
|
|
119
|
+
body: shape.body ?? {},
|
|
120
|
+
mjUser: shape.mjUser ?? MJ_USER,
|
|
121
|
+
} as unknown as express.Request;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface CapturedRes {
|
|
125
|
+
res: express.Response;
|
|
126
|
+
statusCode: () => number;
|
|
127
|
+
jsonBody: () => unknown;
|
|
128
|
+
sendCalled: () => boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makeRes(): CapturedRes {
|
|
132
|
+
let status = 200;
|
|
133
|
+
let body: unknown;
|
|
134
|
+
let sent = false;
|
|
135
|
+
const res = {
|
|
136
|
+
status(code: number) {
|
|
137
|
+
status = code;
|
|
138
|
+
return res;
|
|
139
|
+
},
|
|
140
|
+
json(data: unknown) {
|
|
141
|
+
body = data;
|
|
142
|
+
return res;
|
|
143
|
+
},
|
|
144
|
+
send(data?: unknown) {
|
|
145
|
+
sent = true;
|
|
146
|
+
if (data !== undefined) body = data;
|
|
147
|
+
return res;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
res: res as unknown as express.Response,
|
|
152
|
+
statusCode: () => status,
|
|
153
|
+
jsonBody: () => body,
|
|
154
|
+
sendCalled: () => sent,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function registerEntity(name: string, schemaName = 'crm', canRead = true): void {
|
|
159
|
+
mockEntityList.push({
|
|
160
|
+
Name: name,
|
|
161
|
+
SchemaName: schemaName,
|
|
162
|
+
GetUserPermisions: () => ({ CanRead: canRead }),
|
|
163
|
+
Fields: [],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
vi.clearAllMocks();
|
|
169
|
+
mockEntityList.length = 0;
|
|
170
|
+
registerEntity('Users', 'crm');
|
|
171
|
+
registerEntity('UserRoles', 'crm');
|
|
172
|
+
registerEntity('Secrets', 'vault');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── isEntityAllowed: entity resolution + allow/deny lists ─────────────────
|
|
176
|
+
|
|
177
|
+
describe('RESTEndpointHandler.isEntityAllowed', () => {
|
|
178
|
+
it('fails closed for entities that do not exist in metadata', () => {
|
|
179
|
+
const handler = makeHandler();
|
|
180
|
+
expect(handler.isEntityAllowed('NotARealEntity')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('resolves entities case-insensitively against metadata and allows all by default', () => {
|
|
184
|
+
const handler = makeHandler();
|
|
185
|
+
expect(handler.isEntityAllowed('users')).toBe(true);
|
|
186
|
+
expect(handler.isEntityAllowed('USERS')).toBe(true);
|
|
187
|
+
expect(handler.isEntityAllowed('Secrets')).toBe(true); // no options → everything allowed
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('blocks entities in excluded schemas (case-insensitive), with schema exclusion taking top precedence', () => {
|
|
191
|
+
const schemaOnly = makeHandler({ excludeSchemas: ['VAULT'] });
|
|
192
|
+
expect(schemaOnly.isEntityAllowed('Secrets')).toBe(false);
|
|
193
|
+
expect(schemaOnly.isEntityAllowed('Users')).toBe(true); // other schemas unaffected
|
|
194
|
+
|
|
195
|
+
// Even an explicit entity include cannot override a schema exclusion
|
|
196
|
+
const withInclude = makeHandler({ excludeSchemas: ['vault'], includeEntities: ['secrets'] });
|
|
197
|
+
expect(withInclude.isEntityAllowed('Secrets')).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('restricts to included schemas when includeSchemas is set', () => {
|
|
201
|
+
const handler = makeHandler({ includeSchemas: ['crm'] });
|
|
202
|
+
expect(handler.isEntityAllowed('Users')).toBe(true);
|
|
203
|
+
expect(handler.isEntityAllowed('Secrets')).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('blocks lowercase exact-name exclusions and wildcard exclusions of any case', () => {
|
|
207
|
+
const lowercase = makeHandler({ excludeEntities: ['secrets'] });
|
|
208
|
+
expect(lowercase.isEntityAllowed('Secrets')).toBe(false);
|
|
209
|
+
|
|
210
|
+
const wildcard = makeHandler({ excludeEntities: ['Secret*'] });
|
|
211
|
+
expect(wildcard.isEntityAllowed('Secrets')).toBe(false);
|
|
212
|
+
expect(wildcard.isEntityAllowed('Users')).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('SECURITY GAP: a mixed-case exact exclusion silently fails to exclude', () => {
|
|
216
|
+
// isEntityAllowed lowercases the entity name but compares it with === against
|
|
217
|
+
// the RAW configured pattern (only the wildcard branch lowercases patterns).
|
|
218
|
+
// An operator writing excludeEntities: ['Secrets'] — the natural casing —
|
|
219
|
+
// gets NO exclusion. Pinned so a normalization fix flips this test.
|
|
220
|
+
const handler = makeHandler({ excludeEntities: ['Secrets'] });
|
|
221
|
+
expect(handler.isEntityAllowed('Secrets')).toBe(true); // exclusion ineffective!
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('exclusions override inclusions for the same entity', () => {
|
|
225
|
+
const handler = makeHandler({ includeEntities: ['user*'], excludeEntities: ['userroles'] });
|
|
226
|
+
expect(handler.isEntityAllowed('Users')).toBe(true);
|
|
227
|
+
expect(handler.isEntityAllowed('UserRoles')).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('an include list denies everything not on it, with wildcard support', () => {
|
|
231
|
+
const handler = makeHandler({ includeEntities: ['user*'] });
|
|
232
|
+
expect(handler.isEntityAllowed('Users')).toBe(true);
|
|
233
|
+
expect(handler.isEntityAllowed('UserRoles')).toBe(true);
|
|
234
|
+
expect(handler.isEntityAllowed('Secrets')).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('SECURITY GAP: a mixed-case exact inclusion fails to include (same raw-pattern comparison)', () => {
|
|
238
|
+
const handler = makeHandler({ includeEntities: ['Users'] });
|
|
239
|
+
expect(handler.isEntityAllowed('Users')).toBe(false); // include list active, but never matches
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── Middleware ─────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe('RESTEndpointHandler middleware', () => {
|
|
246
|
+
describe('extractMJUser (auth guard)', () => {
|
|
247
|
+
it('returns 401 and stops the chain when upstream auth attached no mjUser', () => {
|
|
248
|
+
const handler = makeHandler();
|
|
249
|
+
const req = { params: {}, query: {}, body: {} } as unknown as express.Request; // no mjUser
|
|
250
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
251
|
+
const next = vi.fn();
|
|
252
|
+
|
|
253
|
+
handler.extractMJUser(req, res, next);
|
|
254
|
+
|
|
255
|
+
expect(statusCode()).toBe(401);
|
|
256
|
+
expect(jsonBody()).toEqual({ error: 'Authentication required' });
|
|
257
|
+
expect(next).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('continues without overwriting an existing mjUser', () => {
|
|
261
|
+
const handler = makeHandler();
|
|
262
|
+
const req = makeReq();
|
|
263
|
+
const next = vi.fn();
|
|
264
|
+
|
|
265
|
+
handler.extractMJUser(req, makeRes().res, next);
|
|
266
|
+
|
|
267
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect((req as unknown as Record<string, unknown>)['mjUser']).toBe(MJ_USER);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('checkEntityAccess', () => {
|
|
273
|
+
it('rejects blocked entities with 403 before any handler runs', () => {
|
|
274
|
+
const handler = makeHandler({ excludeEntities: ['secrets'] });
|
|
275
|
+
const req = makeReq({ params: { entityName: 'Secrets' } });
|
|
276
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
277
|
+
const next = vi.fn();
|
|
278
|
+
|
|
279
|
+
handler.checkEntityAccess(req, res, next);
|
|
280
|
+
|
|
281
|
+
expect(statusCode()).toBe(403);
|
|
282
|
+
expect(jsonBody()).toMatchObject({
|
|
283
|
+
error: `Access to entity 'Secrets' is not allowed through the REST API`,
|
|
284
|
+
});
|
|
285
|
+
expect(next).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('passes allowed entities through', () => {
|
|
289
|
+
const handler = makeHandler({ excludeEntities: ['secrets'] });
|
|
290
|
+
const next = vi.fn();
|
|
291
|
+
|
|
292
|
+
handler.checkEntityAccess(makeReq({ params: { entityName: 'Users' } }), makeRes().res, next);
|
|
293
|
+
|
|
294
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('passes through when no entityName param is present', () => {
|
|
298
|
+
const handler = makeHandler({ includeEntities: ['nothing'] });
|
|
299
|
+
const next = vi.fn();
|
|
300
|
+
|
|
301
|
+
handler.checkEntityAccess(makeReq({ params: {} }), makeRes().res, next);
|
|
302
|
+
|
|
303
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('errorHandler', () => {
|
|
308
|
+
it('maps UnauthorizedError to 401 Invalid token', () => {
|
|
309
|
+
const handler = makeHandler();
|
|
310
|
+
const err = new Error('bad creds');
|
|
311
|
+
err.name = 'UnauthorizedError';
|
|
312
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
313
|
+
|
|
314
|
+
handler.errorHandler(err, makeReq(), res, vi.fn());
|
|
315
|
+
|
|
316
|
+
expect(statusCode()).toBe(401);
|
|
317
|
+
expect(jsonBody()).toEqual({ error: 'Invalid token' });
|
|
318
|
+
expect(mockLogError).toHaveBeenCalledWith(err);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('maps any other error to 500 with its message (or a generic fallback)', () => {
|
|
322
|
+
const handler = makeHandler();
|
|
323
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
324
|
+
|
|
325
|
+
handler.errorHandler(new Error('kaboom'), makeReq(), res, vi.fn());
|
|
326
|
+
expect(statusCode()).toBe(500);
|
|
327
|
+
expect(jsonBody()).toEqual({ error: 'kaboom' });
|
|
328
|
+
|
|
329
|
+
const bare = makeRes();
|
|
330
|
+
handler.errorHandler(new Error(''), makeReq(), bare.res, vi.fn());
|
|
331
|
+
expect(bare.statusCode()).toBe(500);
|
|
332
|
+
expect(bare.jsonBody()).toEqual({ error: 'Internal server error' });
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─── Verb routes: status mapping + delegation ───────────────────────────────
|
|
338
|
+
|
|
339
|
+
describe('RESTEndpointHandler entity routes', () => {
|
|
340
|
+
describe('getEntity', () => {
|
|
341
|
+
it('returns the entity JSON on success, forwarding the include list', async () => {
|
|
342
|
+
mockCrudGet.mockResolvedValue({ success: true, entity: { ID: '1', Name: 'Row' } });
|
|
343
|
+
const handler = makeHandler();
|
|
344
|
+
const req = makeReq({ params: { entityName: 'Users', id: '1' }, query: { include: 'Orders,Notes' } });
|
|
345
|
+
const { res, jsonBody, statusCode } = makeRes();
|
|
346
|
+
|
|
347
|
+
await handler.getEntity(req, res);
|
|
348
|
+
|
|
349
|
+
expect(statusCode()).toBe(200);
|
|
350
|
+
expect(jsonBody()).toEqual({ ID: '1', Name: 'Row' });
|
|
351
|
+
expect(mockCrudGet).toHaveBeenCalledWith('Users', '1', ['Orders', 'Notes'], MJ_USER);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('maps "not found" failures to 404 and other failures to 400', async () => {
|
|
355
|
+
const handler = makeHandler();
|
|
356
|
+
|
|
357
|
+
mockCrudGet.mockResolvedValue({ success: false, error: 'Users with ID 9 not found' });
|
|
358
|
+
const missing = makeRes();
|
|
359
|
+
await handler.getEntity(makeReq({ params: { entityName: 'Users', id: '9' } }), missing.res);
|
|
360
|
+
expect(missing.statusCode()).toBe(404);
|
|
361
|
+
|
|
362
|
+
mockCrudGet.mockResolvedValue({ success: false, error: 'User Testy does not have permission to read Users records' });
|
|
363
|
+
const denied = makeRes();
|
|
364
|
+
await handler.getEntity(makeReq({ params: { entityName: 'Users', id: '9' } }), denied.res);
|
|
365
|
+
expect(denied.statusCode()).toBe(400);
|
|
366
|
+
expect(denied.jsonBody()).toEqual({ error: 'User Testy does not have permission to read Users records' });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('returns 500 when the CRUD layer throws', async () => {
|
|
370
|
+
mockCrudGet.mockRejectedValue(new Error('infrastructure down'));
|
|
371
|
+
const handler = makeHandler();
|
|
372
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
373
|
+
|
|
374
|
+
await handler.getEntity(makeReq({ params: { entityName: 'Users', id: '1' } }), res);
|
|
375
|
+
|
|
376
|
+
expect(statusCode()).toBe(500);
|
|
377
|
+
expect(jsonBody()).toEqual({ error: 'infrastructure down' });
|
|
378
|
+
expect(mockLogError).toHaveBeenCalled();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('createEntity', () => {
|
|
383
|
+
it('returns 201 with the created entity', async () => {
|
|
384
|
+
mockCrudCreate.mockResolvedValue({ success: true, entity: { ID: 'new-1' } });
|
|
385
|
+
const handler = makeHandler();
|
|
386
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
387
|
+
|
|
388
|
+
await handler.createEntity(makeReq({ params: { entityName: 'Users' }, body: { Name: 'N' } }), res);
|
|
389
|
+
|
|
390
|
+
expect(statusCode()).toBe(201);
|
|
391
|
+
expect(jsonBody()).toEqual({ ID: 'new-1' });
|
|
392
|
+
expect(mockCrudCreate).toHaveBeenCalledWith('Users', { Name: 'N' }, MJ_USER);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('returns 400 with error + details on failure (including permission denials)', async () => {
|
|
396
|
+
mockCrudCreate.mockResolvedValue({
|
|
397
|
+
success: false,
|
|
398
|
+
error: 'User Testy does not have permission to create Users records',
|
|
399
|
+
details: { code: 'PERM' },
|
|
400
|
+
});
|
|
401
|
+
const handler = makeHandler();
|
|
402
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
403
|
+
|
|
404
|
+
await handler.createEntity(makeReq({ params: { entityName: 'Users' }, body: {} }), res);
|
|
405
|
+
|
|
406
|
+
expect(statusCode()).toBe(400);
|
|
407
|
+
expect(jsonBody()).toEqual({
|
|
408
|
+
error: 'User Testy does not have permission to create Users records',
|
|
409
|
+
details: { code: 'PERM' },
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('updateEntity', () => {
|
|
415
|
+
it('returns the updated entity on success and maps not-found to 404', async () => {
|
|
416
|
+
const handler = makeHandler();
|
|
417
|
+
|
|
418
|
+
mockCrudUpdate.mockResolvedValue({ success: true, entity: { ID: '1', Name: 'Updated' } });
|
|
419
|
+
const ok = makeRes();
|
|
420
|
+
await handler.updateEntity(makeReq({ params: { entityName: 'Users', id: '1' }, body: { Name: 'Updated' } }), ok.res);
|
|
421
|
+
expect(ok.jsonBody()).toEqual({ ID: '1', Name: 'Updated' });
|
|
422
|
+
expect(mockCrudUpdate).toHaveBeenCalledWith('Users', '1', { Name: 'Updated' }, MJ_USER);
|
|
423
|
+
|
|
424
|
+
mockCrudUpdate.mockResolvedValue({ success: false, error: 'Users with ID 9 not found' });
|
|
425
|
+
const missing = makeRes();
|
|
426
|
+
await handler.updateEntity(makeReq({ params: { entityName: 'Users', id: '9' }, body: {} }), missing.res);
|
|
427
|
+
expect(missing.statusCode()).toBe(404);
|
|
428
|
+
|
|
429
|
+
mockCrudUpdate.mockResolvedValue({ success: false, error: 'Validation failed', details: undefined });
|
|
430
|
+
const invalid = makeRes();
|
|
431
|
+
await handler.updateEntity(makeReq({ params: { entityName: 'Users', id: '1' }, body: {} }), invalid.res);
|
|
432
|
+
expect(invalid.statusCode()).toBe(400);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('deleteEntity', () => {
|
|
437
|
+
it('returns 204 with an empty body on success, parsing delete options from the query string', async () => {
|
|
438
|
+
mockCrudDelete.mockResolvedValue({ success: true });
|
|
439
|
+
const handler = makeHandler();
|
|
440
|
+
const req = makeReq({
|
|
441
|
+
params: { entityName: 'Users', id: '1' },
|
|
442
|
+
query: { options: JSON.stringify({ SkipEntityActions: true, SkipEntityAIActions: 1 }) },
|
|
443
|
+
});
|
|
444
|
+
const { res, statusCode, sendCalled, jsonBody } = makeRes();
|
|
445
|
+
|
|
446
|
+
await handler.deleteEntity(req, res);
|
|
447
|
+
|
|
448
|
+
expect(statusCode()).toBe(204);
|
|
449
|
+
expect(sendCalled()).toBe(true);
|
|
450
|
+
expect(jsonBody()).toBeUndefined();
|
|
451
|
+
const [entityName, id, options, user] = mockCrudDelete.mock.calls[0] as [string, string, EntityDeleteOptions, UserInfo];
|
|
452
|
+
expect(entityName).toBe('Users');
|
|
453
|
+
expect(id).toBe('1');
|
|
454
|
+
expect(options.SkipEntityActions).toBe(true);
|
|
455
|
+
expect(options.SkipEntityAIActions).toBe(true); // coerced with !!
|
|
456
|
+
expect(user).toBe(MJ_USER);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('maps not-found to 404 and other failures to 400', async () => {
|
|
460
|
+
const handler = makeHandler();
|
|
461
|
+
|
|
462
|
+
mockCrudDelete.mockResolvedValue({ success: false, error: 'Users with ID 9 not found' });
|
|
463
|
+
const missing = makeRes();
|
|
464
|
+
await handler.deleteEntity(makeReq({ params: { entityName: 'Users', id: '9' } }), missing.res);
|
|
465
|
+
expect(missing.statusCode()).toBe(404);
|
|
466
|
+
|
|
467
|
+
mockCrudDelete.mockResolvedValue({ success: false, error: 'User Testy does not have permission to delete Users records' });
|
|
468
|
+
const denied = makeRes();
|
|
469
|
+
await handler.deleteEntity(makeReq({ params: { entityName: 'Users', id: '1' } }), denied.res);
|
|
470
|
+
expect(denied.statusCode()).toBe(400);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('returns 500 (not a crash) for malformed options JSON', async () => {
|
|
474
|
+
const handler = makeHandler();
|
|
475
|
+
const { res, statusCode } = makeRes();
|
|
476
|
+
|
|
477
|
+
await handler.deleteEntity(
|
|
478
|
+
makeReq({ params: { entityName: 'Users', id: '1' }, query: { options: '{not json' } }),
|
|
479
|
+
res,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect(statusCode()).toBe(500);
|
|
483
|
+
expect(mockCrudDelete).not.toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe('getEntityList', () => {
|
|
488
|
+
it('parses query-string filters into RunViewParams for the view layer', async () => {
|
|
489
|
+
mockOpsListEntities.mockResolvedValue({ Success: true, Results: [] });
|
|
490
|
+
const handler = makeHandler();
|
|
491
|
+
const req = makeReq({
|
|
492
|
+
params: { entityName: 'Users' },
|
|
493
|
+
query: { filter: `Status='Active'`, orderBy: 'Name', fields: 'ID,Name', maxRows: '25', startRow: '50' },
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await handler.getEntityList(req, makeRes().res);
|
|
497
|
+
|
|
498
|
+
expect(mockOpsListEntities).toHaveBeenCalledWith(
|
|
499
|
+
{
|
|
500
|
+
EntityName: 'Users',
|
|
501
|
+
ExtraFilter: `Status='Active'`,
|
|
502
|
+
OrderBy: 'Name',
|
|
503
|
+
Fields: ['ID', 'Name'],
|
|
504
|
+
MaxRows: 25,
|
|
505
|
+
StartRow: 50,
|
|
506
|
+
},
|
|
507
|
+
MJ_USER,
|
|
508
|
+
);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('maps a throwing view layer (e.g. permission denial) to 500', async () => {
|
|
512
|
+
mockOpsListEntities.mockRejectedValue(new Error('does not have permission to read Users records'));
|
|
513
|
+
const handler = makeHandler();
|
|
514
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
515
|
+
|
|
516
|
+
await handler.getEntityList(makeReq({ params: { entityName: 'Users' } }), res);
|
|
517
|
+
|
|
518
|
+
expect(statusCode()).toBe(500);
|
|
519
|
+
expect(jsonBody()).toEqual({ error: 'does not have permission to read Users records' });
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ─── View routes ────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
describe('RESTEndpointHandler view routes', () => {
|
|
527
|
+
describe('runView', () => {
|
|
528
|
+
it('merges the route entity name with body params and returns the result', async () => {
|
|
529
|
+
mockOpsRunView.mockResolvedValue({ success: true, result: { Success: true, Results: [{ ID: '1' }] } });
|
|
530
|
+
const handler = makeHandler();
|
|
531
|
+
const req = makeReq({ params: { entityName: 'Users' }, body: { ExtraFilter: `ID='1'`, MaxRows: 10 } });
|
|
532
|
+
const { res, jsonBody } = makeRes();
|
|
533
|
+
|
|
534
|
+
await handler.runView(req, res);
|
|
535
|
+
|
|
536
|
+
expect(mockOpsRunView).toHaveBeenCalledWith(
|
|
537
|
+
{ EntityName: 'Users', ExtraFilter: `ID='1'`, MaxRows: 10 },
|
|
538
|
+
MJ_USER,
|
|
539
|
+
);
|
|
540
|
+
expect(jsonBody()).toEqual({ Success: true, Results: [{ ID: '1' }] });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('maps view-layer failures to 400', async () => {
|
|
544
|
+
mockOpsRunView.mockResolvedValue({ success: false, error: 'does not have permission' });
|
|
545
|
+
const handler = makeHandler();
|
|
546
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
547
|
+
|
|
548
|
+
await handler.runView(makeReq({ params: { entityName: 'Users' }, body: {} }), res);
|
|
549
|
+
|
|
550
|
+
expect(statusCode()).toBe(400);
|
|
551
|
+
expect(jsonBody()).toEqual({ error: 'does not have permission' });
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe('runViews (batch)', () => {
|
|
556
|
+
it('rejects a non-array params payload with 400', async () => {
|
|
557
|
+
const handler = makeHandler();
|
|
558
|
+
const { res, statusCode, jsonBody } = makeRes();
|
|
559
|
+
|
|
560
|
+
await handler.runViews(makeReq({ body: { params: { EntityName: 'Users' } } }), res);
|
|
561
|
+
|
|
562
|
+
expect(statusCode()).toBe(400);
|
|
563
|
+
expect(jsonBody()).toEqual({ error: 'params must be an array of RunViewParams' });
|
|
564
|
+
expect(mockOpsRunViews).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('returns 403 WITHOUT invoking the view layer when every requested entity is blocked', async () => {
|
|
568
|
+
const handler = makeHandler({ includeEntities: ['users'] });
|
|
569
|
+
const { res, statusCode } = makeRes();
|
|
570
|
+
|
|
571
|
+
await handler.runViews(
|
|
572
|
+
makeReq({ body: { params: [{ EntityName: 'Secrets' }, { EntityName: 'NotReal' }] } }),
|
|
573
|
+
res,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
expect(statusCode()).toBe(403);
|
|
577
|
+
expect(mockOpsRunViews).not.toHaveBeenCalled();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('silently drops blocked entities and executes only the allowed remainder', async () => {
|
|
581
|
+
mockOpsRunViews.mockResolvedValue({ success: true, results: [{ Success: true, Results: [] }] });
|
|
582
|
+
const handler = makeHandler({ excludeEntities: ['secrets'] });
|
|
583
|
+
const req = makeReq({ body: { params: [{ EntityName: 'Users' }, { EntityName: 'Secrets' }] } });
|
|
584
|
+
const { res, jsonBody } = makeRes();
|
|
585
|
+
|
|
586
|
+
await handler.runViews(req, res);
|
|
587
|
+
|
|
588
|
+
const [filtered, user] = mockOpsRunViews.mock.calls[0] as [RunViewParams[], UserInfo];
|
|
589
|
+
expect(filtered).toEqual([{ EntityName: 'Users' }]);
|
|
590
|
+
expect(user).toBe(MJ_USER);
|
|
591
|
+
expect(jsonBody()).toEqual([{ Success: true, Results: [] }]);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('maps view-layer batch failures to 400', async () => {
|
|
595
|
+
mockOpsRunViews.mockResolvedValue({ success: false, error: 'batch failed' });
|
|
596
|
+
const handler = makeHandler();
|
|
597
|
+
const { res, statusCode } = makeRes();
|
|
598
|
+
|
|
599
|
+
await handler.runViews(makeReq({ body: { params: [{ EntityName: 'Users' }] } }), res);
|
|
600
|
+
|
|
601
|
+
expect(statusCode()).toBe(400);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ─── User + metadata routes ─────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
describe('RESTEndpointHandler user and metadata routes', () => {
|
|
609
|
+
it('getCurrentUser returns only the safe projection of the user (no extra role fields)', async () => {
|
|
610
|
+
const handler = makeHandler();
|
|
611
|
+
const { res, jsonBody } = makeRes();
|
|
612
|
+
|
|
613
|
+
await handler.getCurrentUser(makeReq(), res);
|
|
614
|
+
|
|
615
|
+
expect(jsonBody()).toEqual({
|
|
616
|
+
ID: 'user-1',
|
|
617
|
+
Name: 'Testy',
|
|
618
|
+
Email: 't@example.com',
|
|
619
|
+
FirstName: 'Testy',
|
|
620
|
+
LastName: 'McTester',
|
|
621
|
+
UserRoles: [{ RoleID: 'r1', Role: 'UI' }], // InternalFlag stripped
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe('getEntityFieldMetadata', () => {
|
|
626
|
+
it('404s for unknown entities and 403s when the user cannot read the entity', async () => {
|
|
627
|
+
const handler = makeHandler();
|
|
628
|
+
|
|
629
|
+
const missing = makeRes();
|
|
630
|
+
await handler.getEntityFieldMetadata(makeReq({ params: { entityName: 'Nope' } }), missing.res);
|
|
631
|
+
expect(missing.statusCode()).toBe(404);
|
|
632
|
+
|
|
633
|
+
registerEntity('Locked', 'crm', false);
|
|
634
|
+
const denied = makeRes();
|
|
635
|
+
await handler.getEntityFieldMetadata(makeReq({ params: { entityName: 'Locked' } }), denied.res);
|
|
636
|
+
expect(denied.statusCode()).toBe(403);
|
|
637
|
+
expect(denied.jsonBody()).toEqual({ error: 'Permission denied' });
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('returns the mapped field metadata for readable entities', async () => {
|
|
641
|
+
mockEntityList.length = 0;
|
|
642
|
+
mockEntityList.push({
|
|
643
|
+
Name: 'Users',
|
|
644
|
+
SchemaName: 'crm',
|
|
645
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
646
|
+
Fields: [{
|
|
647
|
+
Name: 'ID', DisplayName: 'ID', Description: 'PK', Type: 'uniqueidentifier',
|
|
648
|
+
AllowsNull: false, IsPrimaryKey: true, IsUnique: true, MaxLength: 16,
|
|
649
|
+
DefaultValue: null, CodeName: 'ID', TSType: 'string', SecretInternal: 'x',
|
|
650
|
+
}],
|
|
651
|
+
});
|
|
652
|
+
const handler = makeHandler();
|
|
653
|
+
const { res, jsonBody } = makeRes();
|
|
654
|
+
|
|
655
|
+
await handler.getEntityFieldMetadata(makeReq({ params: { entityName: 'Users' } }), res);
|
|
656
|
+
|
|
657
|
+
expect(jsonBody()).toEqual([{
|
|
658
|
+
Name: 'ID',
|
|
659
|
+
DisplayName: 'ID',
|
|
660
|
+
Description: 'PK',
|
|
661
|
+
Type: 'uniqueidentifier',
|
|
662
|
+
IsRequired: true, // AllowsNull === false
|
|
663
|
+
IsPrimaryKey: true,
|
|
664
|
+
IsUnique: true,
|
|
665
|
+
MaxLength: 16,
|
|
666
|
+
DefaultValue: null,
|
|
667
|
+
CodeName: 'ID',
|
|
668
|
+
TSType: 'string',
|
|
669
|
+
}]);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
});
|