@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.5
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/README.md +20 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.d.ts +91 -30
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +311 -81
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +13 -1
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/apiRoot.d.ts +32 -0
- package/dist/apiRoot.d.ts.map +1 -0
- package/dist/apiRoot.js +67 -0
- package/dist/apiRoot.js.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.js +25 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
- package/dist/auth/LoginScreenEngine.d.ts +22 -0
- package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
- package/dist/auth/LoginScreenEngine.js +57 -0
- package/dist/auth/LoginScreenEngine.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/config.d.ts +136 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -4
- package/dist/config.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +142 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1750 -7
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/PushStatusResolver.js +1 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +103 -44
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +264 -305
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/globalCors.d.ts +33 -0
- package/dist/globalCors.d.ts.map +1 -0
- package/dist/globalCors.js +58 -0
- package/dist/globalCors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -160
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
- package/dist/realtimeSessions/index.d.ts +2 -0
- package/dist/realtimeSessions/index.d.ts.map +1 -1
- package/dist/realtimeSessions/index.js +2 -0
- package/dist/realtimeSessions/index.js.map +1 -1
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +7 -3
- package/dist/realtimeSessions/sessionAccess.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.js +9 -7
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +3 -2
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +18 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +82 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +279 -101
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +6 -3
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +13 -0
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +35 -6
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +65 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +102 -24
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
- package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
- package/dist/resolvers/agent-run-data-guard.js +72 -0
- package/dist/resolvers/agent-run-data-guard.js.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.js +383 -0
- package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +21 -12
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +6 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +6 -0
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/serverExtensionReservedRoots.d.ts +12 -10
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +14 -17
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/dist/services/MJStorageBlobStore.d.ts +8 -1
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
- package/dist/services/MJStorageBlobStore.js +25 -19
- package/dist/services/MJStorageBlobStore.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +2 -2
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +5 -3
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -5
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +157 -9
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +54 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/sharedCache.d.ts.map +1 -1
- package/dist/sharedCache.js +3 -0
- package/dist/sharedCache.js.map +1 -1
- package/package.json +99 -98
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
- package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/LoginScreenEngine.test.ts +86 -0
- package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
- package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +209 -6
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
- package/src/__tests__/SessionJanitor.test.ts +365 -10
- package/src/__tests__/SessionManager.test.ts +27 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/globalCors.test.ts +217 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
- package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +13 -1
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +26 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/config.ts +46 -5
- package/src/coreRoutes.ts +166 -0
- package/src/generated/graphql-schemas/__mj.ts +1273 -10
- package/src/generic/CacheInvalidationResolver.ts +73 -18
- package/src/generic/PushStatusResolver.ts +1 -1
- package/src/generic/ResolverBase.ts +304 -325
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +116 -174
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
- package/src/realtimeSessions/sessionAccess.ts +7 -3
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
- package/src/resolvers/FileResolver.ts +321 -106
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
- package/src/resolvers/QueryResolver.ts +11 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
- package/src/resolvers/RunAIAgentResolver.ts +39 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +68 -13
- package/src/resolvers/TransactionGroupResolver.ts +131 -27
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/RealtimeRecordingHandler.ts +464 -0
- package/src/rest/ViewOperationsHandler.ts +29 -13
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- package/src/services/MJStorageBlobStore.ts +31 -30
- package/src/services/StartTaskGraphDispatcher.ts +2 -2
- package/src/services/TaskGraphActionRunner.ts +25 -5
- package/src/services/TaskGraphAgentRunner.ts +188 -11
- package/src/services/TaskGraphContinuationDeliverer.ts +704 -14
- package/src/sharedCache.ts +3 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REST view routes (`POST /views/:entityName`, `POST /views/batch`, `GET /entities/:entityName`)
|
|
3
|
+
* hand client `ExtraFilter` / `OrderBy` / `OverrideExcludeFilter` text to `RunView` through
|
|
4
|
+
* `ViewOperationsHandler`. These tests pin that the text passes the same base-view AST screen as
|
|
5
|
+
* the GraphQL RunView resolvers before any view runs. Payloads are harmless (stacked `SELECT`s);
|
|
6
|
+
* the point is only whether they reach `RunView`.
|
|
7
|
+
*
|
|
8
|
+
* `@memberjunction/core`'s `Metadata` and `RunView` are replaced at the module boundary, as in
|
|
9
|
+
* ViewOperationsHandler.test.ts; the screen itself runs unmodified.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
const { mockRunViewFn, mockRunViewsFn, mockEntityList } = vi.hoisted(() => ({
|
|
14
|
+
mockRunViewFn: vi.fn(),
|
|
15
|
+
mockRunViewsFn: vi.fn(),
|
|
16
|
+
mockEntityList: [] as Array<{
|
|
17
|
+
Name: string;
|
|
18
|
+
SchemaName: string;
|
|
19
|
+
BaseView: string;
|
|
20
|
+
GetUserPermisions: (u: unknown) => { CanRead: boolean };
|
|
21
|
+
GetEffectiveRowFilterWhereClause: () => string;
|
|
22
|
+
GetDeniedReadFields: () => Set<string>;
|
|
23
|
+
}>,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
28
|
+
class MockMetadata {
|
|
29
|
+
public get Entities() {
|
|
30
|
+
return mockEntityList;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class MockRunView {
|
|
34
|
+
public RunView(params: unknown, contextUser?: unknown): Promise<unknown> {
|
|
35
|
+
return mockRunViewFn(params, contextUser);
|
|
36
|
+
}
|
|
37
|
+
public RunViews(params: unknown[], contextUser?: unknown): Promise<unknown> {
|
|
38
|
+
return mockRunViewsFn(params, contextUser);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ...actual, Metadata: MockMetadata, RunView: MockRunView, LogError: vi.fn() };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
import type { BaseEntity, PlatformSQL, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
45
|
+
import { ViewOperationsHandler } from '../rest/ViewOperationsHandler.js';
|
|
46
|
+
|
|
47
|
+
const BRACKET_STACKED =
|
|
48
|
+
"1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM sales.vwCustomers WHERE (1 = (SELECT 1 AS [b'])";
|
|
49
|
+
|
|
50
|
+
const USER = { ID: 'user-1', Name: 'Reader' } as unknown as UserInfo;
|
|
51
|
+
const OK_RESULT = { Success: true, Results: [{ ID: '1' }] };
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
mockEntityList.length = 0;
|
|
56
|
+
const unscoped = { GetEffectiveRowFilterWhereClause: () => '', GetDeniedReadFields: () => new Set<string>() };
|
|
57
|
+
mockEntityList.push(
|
|
58
|
+
{ Name: 'Customers', SchemaName: 'sales', BaseView: 'vwCustomers', GetUserPermisions: () => ({ CanRead: true }), ...unscoped },
|
|
59
|
+
{ Name: 'Ledger', SchemaName: 'sales', BaseView: 'vwLedger', GetUserPermisions: () => ({ CanRead: false }), ...unscoped },
|
|
60
|
+
{
|
|
61
|
+
Name: 'Orders',
|
|
62
|
+
SchemaName: 'sales',
|
|
63
|
+
BaseView: 'vwOrders',
|
|
64
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
65
|
+
GetEffectiveRowFilterWhereClause: () => "(OwnerID = 'user-1')",
|
|
66
|
+
GetDeniedReadFields: () => new Set<string>(),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
mockRunViewFn.mockResolvedValue(OK_RESULT);
|
|
70
|
+
mockRunViewsFn.mockResolvedValue([OK_RESULT]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('ViewOperationsHandler — REST clause screen', () => {
|
|
74
|
+
it('RunView refuses a stacked statement hidden behind a bracket identifier, before RunView', async () => {
|
|
75
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER);
|
|
76
|
+
|
|
77
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
78
|
+
expect(outcome.success).toBe(false);
|
|
79
|
+
expect(outcome.error).toMatch(/Invalid ExtraFilter: multiple statements/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('RunView screens OrderBy and OverrideExcludeFilter too', async () => {
|
|
83
|
+
const cases: RunViewParams[] = [
|
|
84
|
+
{ EntityName: 'Customers', OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
|
|
85
|
+
{ EntityName: 'Customers', OverrideExcludeFilter: BRACKET_STACKED },
|
|
86
|
+
];
|
|
87
|
+
for (const params of cases) {
|
|
88
|
+
const outcome = await ViewOperationsHandler.RunView(params, USER);
|
|
89
|
+
expect(outcome.success).toBe(false);
|
|
90
|
+
}
|
|
91
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('RunView refuses a subquery against a base table or an entity the caller cannot read', async () => {
|
|
95
|
+
for (const filter of [
|
|
96
|
+
`EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')`,
|
|
97
|
+
`ID IN (SELECT CustomerID FROM sales.vwLedger)`,
|
|
98
|
+
]) {
|
|
99
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
100
|
+
expect(outcome.success).toBe(false);
|
|
101
|
+
}
|
|
102
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('RunView refuses a subquery over an entity that is row-level-security filtered for the caller', async () => {
|
|
106
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ExtraFilter: `ID IN (SELECT CustomerID FROM sales.vwOrders)` }, USER);
|
|
107
|
+
|
|
108
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
109
|
+
expect(outcome.success).toBe(false);
|
|
110
|
+
expect(outcome.error).toMatch(/'Orders' is row-level-security filtered for you/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('RunView screens every variant of a platform-specific filter', async () => {
|
|
114
|
+
const filter: PlatformSQL = { default: `Name = 'x'`, sqlserver: BRACKET_STACKED };
|
|
115
|
+
|
|
116
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
117
|
+
|
|
118
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
119
|
+
expect(outcome.success).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('RunViews refuses the whole batch when one entry carries a payload', async () => {
|
|
123
|
+
const outcome = await ViewOperationsHandler.RunViews(
|
|
124
|
+
[{ EntityName: 'Customers' }, { EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }],
|
|
125
|
+
USER,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(mockRunViewsFn).not.toHaveBeenCalled();
|
|
129
|
+
expect(outcome.success).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ListEntities refuses a payload in the query-string filter', async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
ViewOperationsHandler.ListEntities({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER),
|
|
135
|
+
).rejects.toThrow(/multiple statements/);
|
|
136
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('refuses a ViewEntity that was not loaded as an entity (its ID would be spliced into SQL)', async () => {
|
|
140
|
+
const forged = { ID: '0) ; SELECT 1 AS [x] ; SELECT 1 WHERE (1=1', EntityID: 'any' } as unknown as BaseEntity;
|
|
141
|
+
|
|
142
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ViewEntity: forged, ExcludeDataFromAllPriorViewRuns: true }, USER);
|
|
143
|
+
|
|
144
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
145
|
+
expect(outcome.success).toBe(false);
|
|
146
|
+
expect(outcome.error).toMatch(/ViewEntity is not accepted over REST/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('passes a filter that only reads entity base views through to RunView', async () => {
|
|
150
|
+
const filter = `Name = 'O''Brien' AND ID IN (SELECT ID FROM sales.vwCustomers WHERE Region = 'West')`;
|
|
151
|
+
|
|
152
|
+
const outcome = await ViewOperationsHandler.RunView({ EntityName: 'Customers', ExtraFilter: filter, OrderBy: '[Name] DESC' }, USER);
|
|
153
|
+
|
|
154
|
+
expect(outcome.success).toBe(true);
|
|
155
|
+
expect((mockRunViewFn.mock.calls[0][0] as RunViewParams).ExtraFilter).toBe(filter);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Which agent-run resolver callers may set a run's scope or agent-type params through the `data`
|
|
3
|
+
* argument (A12.15): the value the resolvers pass as `ExecuteAgentParams.TrustReservedRunData`.
|
|
4
|
+
*
|
|
5
|
+
* Pins who is trusted (the system user, an API key that holds the `agent:trusted` scope) and who is not (an
|
|
6
|
+
* interactive user, a key without that scope, and every widget-guest run even though it executes as the system
|
|
7
|
+
* user). What an untrusted run then loses is pinned where the rule is applied: `WithAgentRunDataTrustApplied` in
|
|
8
|
+
* ai-core-plus and `BaseAgent.Execute` in ai-agents.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
12
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
13
|
+
|
|
14
|
+
const hoisted = vi.hoisted(() => ({ authorize: vi.fn() }));
|
|
15
|
+
|
|
16
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
17
|
+
GetAPIKeyEngine: () => ({ Authorize: hoisted.authorize }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { IsTrustedAgentRunCaller, TRUSTED_RUN_DATA_SCOPE, type ClientAgentRunCaller } from '../resolvers/agent-run-data-guard.js';
|
|
21
|
+
import { ElevateUserPayload } from '../realtimeWidget/widgetGuestElevation.js';
|
|
22
|
+
import type { UserPayload } from '../types.js';
|
|
23
|
+
|
|
24
|
+
function payload(fields: Partial<UserPayload>): UserPayload {
|
|
25
|
+
return { email: 'someone@example.com', userRecord: { ID: 'user-1' }, sessionId: 'session-1', ...fields };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SYSTEM_USER = { ID: 'system-user', Email: 'system@example.com' } as UserInfo;
|
|
29
|
+
|
|
30
|
+
/** A browser session (JWT): neither the system user nor an API key. */
|
|
31
|
+
const INTERACTIVE = payload({ email: 'browser.user@example.com' });
|
|
32
|
+
/** The system API key (x-mj-api-key), as context.ts builds it. */
|
|
33
|
+
const SYSTEM_KEY = payload({ email: 'system@example.com', isSystemUser: true, apiKey: 'system-key' });
|
|
34
|
+
/** An `mj_sk_*` user API key an administrator granted `agent:trusted`, as context.ts builds it. */
|
|
35
|
+
const TRUSTED_KEY = payload({ email: 'integration@example.com', apiKeyId: 'key-1', apiKeyHash: 'hash-trusted' });
|
|
36
|
+
/** An `mj_sk_*` key a user minted for themselves: it may run agents, nothing more. */
|
|
37
|
+
const PLAIN_KEY = payload({ email: 'browser.user@example.com', apiKeyId: 'key-2', apiKeyHash: 'hash-plain' });
|
|
38
|
+
/** A key holding `full_access`, which does not include `agent:trusted`. */
|
|
39
|
+
const FULL_ACCESS_KEY = payload({ email: 'admin@example.com', apiKeyId: 'key-3', apiKeyHash: 'hash-full' });
|
|
40
|
+
/** A public web-widget guest. */
|
|
41
|
+
const WIDGET_GUEST = payload({ email: 'anonymous@magic-link.local' });
|
|
42
|
+
|
|
43
|
+
/** Each key's `MJ: API Key Scopes` rows, by scope path. */
|
|
44
|
+
const KEY_SCOPES: Record<string, string[]> = {
|
|
45
|
+
'hash-trusted': ['agent:execute', 'agent:trusted'],
|
|
46
|
+
'hash-plain': ['agent:execute'],
|
|
47
|
+
'hash-full': ['full_access'],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** APIKeyEngine.Authorize as the scope evaluator answers it: allowed only by a matching key rule. */
|
|
51
|
+
async function authorizeByKeyScopes(apiKeyHash: string, _app: string, scopePath: string) {
|
|
52
|
+
return KEY_SCOPES[apiKeyHash]?.includes(scopePath)
|
|
53
|
+
? { Allowed: true, Reason: 'Key allows access', MatchedKeyRule: { ScopePath: scopePath }, EvaluatedRules: [] }
|
|
54
|
+
: { Allowed: false, Reason: 'No matching key scope rules', EvaluatedRules: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function caller(userPayload: UserPayload, isWidgetGuestRun = false): ClientAgentRunCaller {
|
|
58
|
+
return { UserPayload: userPayload, IsWidgetGuestRun: isWidgetGuestRun, AgentID: 'agent-1' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
hoisted.authorize.mockReset().mockImplementation(authorizeByKeyScopes);
|
|
63
|
+
vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue(SYSTEM_USER);
|
|
64
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('IsTrustedAgentRunCaller', () => {
|
|
72
|
+
it('names the agent:trusted scope', () => {
|
|
73
|
+
expect(TRUSTED_RUN_DATA_SCOPE).toBe('agent:trusted');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('trusts the system user', async () => {
|
|
77
|
+
expect(await IsTrustedAgentRunCaller(caller(SYSTEM_KEY))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('trusts an API key that holds agent:trusted, checked for the agent being run', async () => {
|
|
81
|
+
expect(await IsTrustedAgentRunCaller(caller(TRUSTED_KEY))).toBe(true);
|
|
82
|
+
expect(hoisted.authorize).toHaveBeenCalledWith(
|
|
83
|
+
'hash-trusted', 'MJAPI', 'agent:trusted', 'agent-1', SYSTEM_USER, expect.anything(), { skipLogging: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not trust an API key without agent:trusted: it is treated like its user\'s browser session', async () => {
|
|
87
|
+
expect(await IsTrustedAgentRunCaller(caller(PLAIN_KEY))).toBe(false);
|
|
88
|
+
expect(await IsTrustedAgentRunCaller(caller(INTERACTIVE))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not trust a full_access key: the key must hold agent:trusted itself', async () => {
|
|
92
|
+
expect(await IsTrustedAgentRunCaller(caller(FULL_ACCESS_KEY))).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not count an allow that matched no key rule (enforcement off, or a no-scopes default of allow)', async () => {
|
|
96
|
+
hoisted.authorize.mockResolvedValue({ Allowed: true, Reason: 'Enforcement disabled', EvaluatedRules: [] });
|
|
97
|
+
|
|
98
|
+
expect(await IsTrustedAgentRunCaller(caller(PLAIN_KEY))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('fails closed when the scope check cannot run', async () => {
|
|
102
|
+
hoisted.authorize.mockRejectedValue(new Error('engine not configured'));
|
|
103
|
+
expect(await IsTrustedAgentRunCaller(caller(TRUSTED_KEY))).toBe(false);
|
|
104
|
+
|
|
105
|
+
hoisted.authorize.mockImplementation(authorizeByKeyScopes);
|
|
106
|
+
vi.mocked(UserCache.Instance.GetSystemUser).mockReturnValue(undefined as unknown as UserInfo);
|
|
107
|
+
expect(await IsTrustedAgentRunCaller(caller(TRUSTED_KEY))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not trust an interactive (browser) user, and never asks the key engine', async () => {
|
|
111
|
+
expect(await IsTrustedAgentRunCaller(caller(INTERACTIVE))).toBe(false);
|
|
112
|
+
expect(hoisted.authorize).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not trust a widget-guest run even when the elevated principal is the system user', async () => {
|
|
116
|
+
const elevated = ElevateUserPayload(WIDGET_GUEST, SYSTEM_USER);
|
|
117
|
+
expect(elevated.isSystemUser).toBe(true); // the premise: elevation alone would look trusted
|
|
118
|
+
|
|
119
|
+
expect(await IsTrustedAgentRunCaller(caller(elevated, true))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not trust a widget-guest run on a trusted key either', async () => {
|
|
123
|
+
expect(await IsTrustedAgentRunCaller(caller(TRUSTED_KEY, true))).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('treats an explicit isSystemUser:false, an empty key hash or a non-system api key as untrusted', async () => {
|
|
127
|
+
expect(await IsTrustedAgentRunCaller(caller(payload({ isSystemUser: false, apiKeyId: 'key-1', apiKeyHash: '' })))).toBe(false);
|
|
128
|
+
expect(await IsTrustedAgentRunCaller(caller(payload({ apiKey: 'not-the-system-key' })))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot test for the API-root rule (MJ #5674): mounts every core route through the same functions
|
|
3
|
+
* `serve()` calls, on a bare Express app, and checks where they landed.
|
|
4
|
+
*
|
|
5
|
+
* - `MJAPI_BASE_PATH=/api` → every registered path starts with `/api/`, and a real request to
|
|
6
|
+
* `/healthcheck` (origin root) 404s.
|
|
7
|
+
* - `MJAPI_BASE_PATH=/` → the paths are byte-identical to the root-mounted ones MJAPI always used, whatever
|
|
8
|
+
* `GRAPHQL_ROOT_PATH` is (it is only Apollo's path).
|
|
9
|
+
*
|
|
10
|
+
* The router factories are stubbed (they need keys, DB, providers); the MOUNT code is real.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
import express, { type Application, type Router } from 'express';
|
|
14
|
+
import type { AddressInfo } from 'node:net';
|
|
15
|
+
import type { ServerExtensionLoader } from '@memberjunction/server-extensions-core';
|
|
16
|
+
|
|
17
|
+
const mockConfig = vi.hoisted(() => ({
|
|
18
|
+
configInfo: {
|
|
19
|
+
graphqlRootPath: '/',
|
|
20
|
+
apiBasePath: '/',
|
|
21
|
+
publicUrl: '',
|
|
22
|
+
baseUrl: 'http://localhost',
|
|
23
|
+
graphqlPort: 4000,
|
|
24
|
+
cors: {},
|
|
25
|
+
realtime: { enabled: true, identityVerification: { enabled: true } },
|
|
26
|
+
magicLink: { enabled: true },
|
|
27
|
+
widget: { enabled: true },
|
|
28
|
+
} as Record<string, unknown>,
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('../config.js', () => mockConfig);
|
|
31
|
+
|
|
32
|
+
// vi.mock factories are hoisted above the imports, so the stubs must be hoisted with them.
|
|
33
|
+
const { stubRouter, stubHandler } = await vi.hoisted(async () => {
|
|
34
|
+
const { Router: makeRouter } = await import('express');
|
|
35
|
+
const router = (): Router => makeRouter();
|
|
36
|
+
return { stubRouter: router, stubHandler: () => ({ publicRouter: router(), authenticatedRouter: router() }) };
|
|
37
|
+
});
|
|
38
|
+
vi.mock('../rest/OAuthCallbackHandler.js', () => ({
|
|
39
|
+
CreateOAuthCallbackHandler: () => ({ callbackRouter: stubRouter(), authenticatedRouter: stubRouter() }),
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('../rest/SignatureWebhookHandler.js', () => ({ CreateSignatureWebhookHandler: stubRouter }));
|
|
42
|
+
vi.mock('../rest/MediaStreamHandler.js', () => ({ CreateMediaStreamRouter: stubRouter }));
|
|
43
|
+
vi.mock('../rest/RealtimeSdpBrokerHandler.js', () => ({ CreateRealtimeSdpBrokerRouter: stubRouter }));
|
|
44
|
+
vi.mock('../auth/AuthProviderCatalogRouter.js', () => ({ CreateAuthProviderCatalogRouter: stubRouter, AUTH_CATALOG_MOUNT_PATH: '/auth' }));
|
|
45
|
+
vi.mock('../auth/magicLink/index.js', () => ({
|
|
46
|
+
CreateMagicLinkHandler: stubHandler,
|
|
47
|
+
CreateMagicLinkJwksRouter: stubRouter,
|
|
48
|
+
RegisterMagicLinkAuthProvider: () => undefined,
|
|
49
|
+
MAGIC_LINK_MOUNT_PATH: '/magic-link',
|
|
50
|
+
}));
|
|
51
|
+
vi.mock('../realtimeWidget/index.js', () => ({ CreateWidgetHandler: stubHandler, WIDGET_MOUNT_PATH: '/widget' }));
|
|
52
|
+
vi.mock('../realtimeSessions/index.js', () => ({
|
|
53
|
+
CreateRealtimeVerifyRouter: stubRouter,
|
|
54
|
+
REALTIME_VERIFY_MOUNT_PATH: '/realtime/verify',
|
|
55
|
+
RealtimeSessionVerificationService: { Instance: { IsEnabled: true } },
|
|
56
|
+
}));
|
|
57
|
+
vi.mock('@memberjunction/ai', () => ({ REALTIME_SDP_EXCHANGE_PATH: '/realtime/sdp-exchange' }));
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
MountHealthcheckRoute,
|
|
61
|
+
MountPublicCoreRoutes,
|
|
62
|
+
MountExtensionHealthRoute,
|
|
63
|
+
MountAuthCatalogRoute,
|
|
64
|
+
MountAuthenticatedCoreRoutes,
|
|
65
|
+
} from '../coreRoutes.js';
|
|
66
|
+
import { ApiRoute } from '../apiRoot.js';
|
|
67
|
+
import { CoreReservedServerExtensionRoots } from '../serverExtensionReservedRoots.js';
|
|
68
|
+
|
|
69
|
+
const MOUNT_METHODS = ['use', 'get', 'post', 'put', 'patch', 'delete', 'all'] as const;
|
|
70
|
+
const log = { LogIf: () => undefined };
|
|
71
|
+
const loader = { HealthCheckAll: async () => [] } as unknown as ServerExtensionLoader;
|
|
72
|
+
|
|
73
|
+
/** Builds an app the way serve() does and returns it plus every path it registered, in order. */
|
|
74
|
+
function bootCoreRoutes(): { app: Application; paths: string[] } {
|
|
75
|
+
const app = express();
|
|
76
|
+
const paths: string[] = [];
|
|
77
|
+
for (const method of MOUNT_METHODS) {
|
|
78
|
+
const original = app[method].bind(app) as (...args: unknown[]) => Application;
|
|
79
|
+
(app as unknown as Record<string, unknown>)[method] = (...args: unknown[]) => {
|
|
80
|
+
if (typeof args[0] === 'string') paths.push(args[0]);
|
|
81
|
+
return original(...args);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
MountHealthcheckRoute(app);
|
|
85
|
+
const routers = MountPublicCoreRoutes(app, 'http://localhost:4000/', log);
|
|
86
|
+
MountExtensionHealthRoute(app, loader);
|
|
87
|
+
MountAuthCatalogRoute(app, log);
|
|
88
|
+
MountAuthenticatedCoreRoutes(app, routers, log);
|
|
89
|
+
return { app, paths };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function statusOf(app: Application, path: string): Promise<number> {
|
|
93
|
+
const server = app.listen(0);
|
|
94
|
+
try {
|
|
95
|
+
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
|
|
96
|
+
const { port } = server.address() as AddressInfo;
|
|
97
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
98
|
+
return res.status;
|
|
99
|
+
} finally {
|
|
100
|
+
server.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ROOT_MOUNTS_BEFORE_5674 = [
|
|
105
|
+
'/healthcheck',
|
|
106
|
+
'/oauth',
|
|
107
|
+
'/esignature',
|
|
108
|
+
'/media',
|
|
109
|
+
'/realtime/sdp-exchange',
|
|
110
|
+
'/magic-link',
|
|
111
|
+
'/widget',
|
|
112
|
+
'/realtime/verify',
|
|
113
|
+
'/health/extensions',
|
|
114
|
+
'/auth',
|
|
115
|
+
'/oauth',
|
|
116
|
+
'/magic-link',
|
|
117
|
+
'/widget',
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
describe('core routes under MJAPI_BASE_PATH=/api', () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
mockConfig.configInfo.apiBasePath = '/api';
|
|
123
|
+
mockConfig.configInfo.magicLink = { enabled: true };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('registers every route under /api/', () => {
|
|
127
|
+
const { paths } = bootCoreRoutes();
|
|
128
|
+
expect(paths.length).toBe(ROOT_MOUNTS_BEFORE_5674.length);
|
|
129
|
+
for (const path of paths) {
|
|
130
|
+
expect(path, `${path} is outside MJAPI_BASE_PATH`).toMatch(/^\/api\//);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('also mounts the widget JWKS fallback under /api/ when magic-link is off', () => {
|
|
135
|
+
mockConfig.configInfo.magicLink = { enabled: false };
|
|
136
|
+
const { paths } = bootCoreRoutes();
|
|
137
|
+
expect(paths).toContain('/api/magic-link');
|
|
138
|
+
expect(paths.every((p) => p.startsWith('/api/'))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('answers at /api/healthcheck and 404s at the origin root', async () => {
|
|
142
|
+
const { app } = bootCoreRoutes();
|
|
143
|
+
expect(await statusOf(app, '/api/healthcheck')).toBe(200);
|
|
144
|
+
expect(await statusOf(app, '/healthcheck')).toBe(404);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('reserves every mounted prefix for extensions (relative to MJAPI_BASE_PATH)', () => {
|
|
148
|
+
const reserved = CoreReservedServerExtensionRoots();
|
|
149
|
+
for (const path of bootCoreRoutes().paths) {
|
|
150
|
+
const relative = path.slice('/api'.length);
|
|
151
|
+
const covered = reserved.some((r) => relative === r || relative.startsWith(`${r}/`));
|
|
152
|
+
expect(covered, `core mount ${path} is not in CoreReservedServerExtensionRoots()`).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('backward compatibility at MJAPI_BASE_PATH=/', () => {
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
mockConfig.configInfo.apiBasePath = '/';
|
|
160
|
+
mockConfig.configInfo.graphqlRootPath = '/';
|
|
161
|
+
mockConfig.configInfo.publicUrl = '';
|
|
162
|
+
mockConfig.configInfo.magicLink = { enabled: true };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('mounts byte-identical paths', () => {
|
|
166
|
+
expect(bootCoreRoutes().paths).toEqual(ROOT_MOUNTS_BEFORE_5674);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('ignores GRAPHQL_ROOT_PATH: it is only where Apollo listens', () => {
|
|
170
|
+
mockConfig.configInfo.graphqlRootPath = '/api/';
|
|
171
|
+
expect(bootCoreRoutes().paths).toEqual(ROOT_MOUNTS_BEFORE_5674);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('builds byte-identical URLs', () => {
|
|
175
|
+
expect(ApiRoute('/magic-link/redeem')).toBe('/magic-link/redeem');
|
|
176
|
+
expect(ApiRoute('/media/upload-stage')).toBe('/media/upload-stage');
|
|
177
|
+
expect(ApiRoute('/media/abc?token=t', { absolute: true })).toBe('http://localhost:4000/media/abc?token=t');
|
|
178
|
+
mockConfig.configInfo.publicUrl = 'https://api.example.com';
|
|
179
|
+
expect(ApiRoute('/media/abc', { absolute: true })).toBe('https://api.example.com/media/abc');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('ApiRoute under a base path', () => {
|
|
184
|
+
it('prefixes paths and appends to MJAPI_PUBLIC_URL without doubling the root', () => {
|
|
185
|
+
mockConfig.configInfo.apiBasePath = '/api/';
|
|
186
|
+
mockConfig.configInfo.publicUrl = 'https://example.com/api/';
|
|
187
|
+
expect(ApiRoute('/media')).toBe('/api/media');
|
|
188
|
+
expect(ApiRoute('/media/abc', { absolute: true })).toBe('https://example.com/api/media/abc');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('falls back to baseUrl:port + MJAPI_BASE_PATH when MJAPI_PUBLIC_URL is unset', () => {
|
|
192
|
+
mockConfig.configInfo.apiBasePath = '/api/';
|
|
193
|
+
mockConfig.configInfo.graphqlRootPath = '/';
|
|
194
|
+
mockConfig.configInfo.publicUrl = '';
|
|
195
|
+
expect(ApiRoute('/magic-link/redeem', { absolute: true })).toBe('http://localhost:4000/api/magic-link/redeem');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects a subpath without a leading slash', () => {
|
|
199
|
+
expect(() => ApiRoute('media')).toThrow(/must start with '\/'/);
|
|
200
|
+
expect(() => ApiRoute('media', { absolute: true })).toThrow(/must start with '\/'/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
5
5
|
import {
|
|
6
6
|
ConfigureRecordDataBroadcast,
|
|
7
7
|
MayBroadcastRecordData,
|
|
8
|
+
ReadIsRowFiltered,
|
|
9
|
+
ShapeCacheInvalidationEvent,
|
|
8
10
|
} from '../generic/CacheInvalidationResolver.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -175,3 +177,65 @@ describe('cacheInvalidationFilter', () => {
|
|
|
175
177
|
expect(filter({ payload: { ...event(''), entityName: '' } as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
176
178
|
});
|
|
177
179
|
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Row-level narrowing (MJ#5241): a subscriber who reads the entity only through a row-level security filter learns that a
|
|
183
|
+
* row changed, not which one; a subscriber with an unfiltered read keeps the key; a subscriber with no read is the entity-level
|
|
184
|
+
* filter's business and gets nothing here to shape.
|
|
185
|
+
*/
|
|
186
|
+
describe('ReadIsRowFiltered and ShapeCacheInvalidationEvent', () => {
|
|
187
|
+
const FILTERED_ROLE = 'role-participant';
|
|
188
|
+
const OPEN_ROLE = 'role-staff';
|
|
189
|
+
const entity = {
|
|
190
|
+
Permissions: [
|
|
191
|
+
{ RoleID: FILTERED_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: 'filter-1' },
|
|
192
|
+
{ RoleID: OPEN_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: null },
|
|
193
|
+
{ RoleID: 'role-denied', CanRead: true, Type: 'Deny', ReadRLSFilterID: null },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const payload = {
|
|
197
|
+
entityName: 'Spaces',
|
|
198
|
+
primaryKeyValues: 'ID|abc',
|
|
199
|
+
action: 'save',
|
|
200
|
+
sourceServerId: 'srv',
|
|
201
|
+
timestamp: new Date('2026-10-07T00:00:00Z'),
|
|
202
|
+
originSessionId: 'sess',
|
|
203
|
+
recordData: '{"Name":"x"}',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
it('is row-filtered when every read the user holds carries a filter', () => {
|
|
207
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }] })).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('is not row-filtered when any read the user holds is unfiltered, whichever role brings it', () => {
|
|
211
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: OPEN_ROLE }] })).toBe(false);
|
|
212
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }, { RoleID: OPEN_ROLE }] })).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('is not row-filtered when the user holds no read at all (the entity-level filter owns that case), and a Deny row grants nothing', () => {
|
|
216
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-stranger' }] })).toBe(false);
|
|
217
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-denied' }] })).toBe(false);
|
|
218
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: null })).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('matches role ids without regard to case or whitespace', () => {
|
|
222
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: ` ${FILTERED_ROLE.toUpperCase()} ` }] })).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('withholds the key and the row from a row-filtered subscriber, and keeps the rest of the event', () => {
|
|
226
|
+
const shaped = ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, entity);
|
|
227
|
+
expect(shaped.PrimaryKeyValues).toBeUndefined();
|
|
228
|
+
expect(shaped.RecordData).toBeUndefined();
|
|
229
|
+
expect(shaped.EntityName).toBe('Spaces');
|
|
230
|
+
expect(shaped.Action).toBe('save');
|
|
231
|
+
expect(shaped.SourceServerID).toBe('srv');
|
|
232
|
+
expect(shaped.OriginSessionID).toBe('sess');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('delivers the event whole to an unfiltered reader, and when the entity or the user is unknown', () => {
|
|
236
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
237
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).RecordData).toBe('{"Name":"x"}');
|
|
238
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, null).PrimaryKeyValues).toBe('ID|abc');
|
|
239
|
+
expect(ShapeCacheInvalidationEvent(payload, undefined, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
240
|
+
});
|
|
241
|
+
});
|