@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,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Every agent-run entry of `RunAIAgentResolver` tells the run whether it may read the reserved scope
|
|
3
|
+
* and agent-type keys from a client's `data` (`ExecuteAgentParams.TrustReservedRunData`, A12.15).
|
|
4
|
+
*
|
|
5
|
+
* The decision is pinned by `agent-run-data-guard.test.ts`, and what an untrusted run loses by the ai-core-plus and
|
|
6
|
+
* ai-agents suites (`BaseAgent.Execute` drops the keys). These tests pin the call sites: the synchronous and
|
|
7
|
+
* fire-and-forget paths of `RunAIAgent` and `RunAIAgentFromConversationDetail`, the widget-guest path (judged on
|
|
8
|
+
* the guest's own payload, not the system principal it is elevated to), the trusted callers, and an API key with and
|
|
9
|
+
* without the `agent:trusted` scope — each through the real `executeAIAgent`, up to the params handed to
|
|
10
|
+
* `AgentRunner`. The resolver forwards `data` as sent.
|
|
11
|
+
*/
|
|
12
|
+
import 'reflect-metadata'; // must precede any type-graphql decorator evaluation
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
15
|
+
|
|
16
|
+
// type-graphql's real decorators need emitDecoratorMetadata, which vitest's esbuild transform
|
|
17
|
+
// does not emit; the schema is irrelevant here, so every decorator is a no-op.
|
|
18
|
+
vi.mock('type-graphql', () => {
|
|
19
|
+
const decoratorFactory = (..._args: unknown[]) => (..._decorated: unknown[]) => undefined;
|
|
20
|
+
const exportNames = [
|
|
21
|
+
'Resolver', 'Query', 'Mutation', 'Subscription', 'Arg', 'Args', 'ArgsType', 'Ctx', 'Root',
|
|
22
|
+
'Info', 'Field', 'FieldResolver', 'ObjectType', 'InputType', 'InterfaceType', 'Authorized',
|
|
23
|
+
'UseMiddleware', 'Extensions', 'Directive', 'ID', 'Int', 'Float', 'GraphQLISODateTime',
|
|
24
|
+
'GraphQLTimestamp', 'registerEnumType', 'createMethodDecorator', 'createParamDecorator',
|
|
25
|
+
'buildSchema', 'buildSchemaSync', 'PubSub',
|
|
26
|
+
];
|
|
27
|
+
return Object.fromEntries(exportNames.map((name) => [name, decoratorFactory]));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const hoisted = vi.hoisted(() => ({
|
|
31
|
+
runAgentInConversation: vi.fn(),
|
|
32
|
+
resolveWidgetGuestRunContext: vi.fn(),
|
|
33
|
+
authorize: vi.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Only the key engine is stubbed: Authorize allows `agent:trusted` for the key whose hash is 'hash-trusted'.
|
|
37
|
+
vi.mock('@memberjunction/api-keys', async (importOriginal) => ({
|
|
38
|
+
...(await importOriginal<typeof import('@memberjunction/api-keys')>()),
|
|
39
|
+
GetAPIKeyEngine: () => ({ Authorize: hoisted.authorize }),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@memberjunction/ai-agents', () => ({
|
|
43
|
+
AgentRunner: class {
|
|
44
|
+
RunAgentInConversation(...args: unknown[]) {
|
|
45
|
+
return hoisted.runAgentInConversation(...args);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
ArtifactToolManager: class {},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// Only the widget lookup is stubbed; ElevateUserPayload is the real one, so the widget-guest test runs
|
|
52
|
+
// against the elevated system-user payload the resolver really builds.
|
|
53
|
+
vi.mock('../realtimeWidget/widgetGuestElevation.js', async (importOriginal) => ({
|
|
54
|
+
...(await importOriginal<typeof import('../realtimeWidget/widgetGuestElevation.js')>()),
|
|
55
|
+
ResolveWidgetGuestRunContext: hoisted.resolveWidgetGuestRunContext,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
59
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
60
|
+
import { RunAIAgentResolver } from '../resolvers/RunAIAgentResolver.js';
|
|
61
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
62
|
+
|
|
63
|
+
const SCOPE_RECORD_ID = 'B1A2C3D4-0000-4000-8000-00000000A115';
|
|
64
|
+
const ORDINARY = { conversationId: 'conv-1', latestMessageId: 'detail-1', invocationReason: 'user-message' };
|
|
65
|
+
const CLIENT_DATA = JSON.stringify({
|
|
66
|
+
...ORDINARY,
|
|
67
|
+
PrimaryScopeRecordID: SCOPE_RECORD_ID,
|
|
68
|
+
PrimaryScopeEntityName: 'MJ: Companies',
|
|
69
|
+
SecondaryScopes: { TeamID: 'team-other' },
|
|
70
|
+
__agentTypePromptParams: { enableTaskGraphs: true },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const USER = { ID: 'user-1', Email: 'browser.user@example.com' };
|
|
74
|
+
const SYSTEM_USER = { ID: 'system-user', Email: 'system@example.com' };
|
|
75
|
+
const GUEST = { ID: 'guest-1', Email: 'anonymous@magic-link.local', IsMagicLinkAnonymous: true };
|
|
76
|
+
|
|
77
|
+
type Fn = ReturnType<typeof vi.fn>;
|
|
78
|
+
|
|
79
|
+
/** The private members this test reaches, through an explicit shape (no `any`). */
|
|
80
|
+
interface ResolverSeams {
|
|
81
|
+
CheckAPIKeyScopeAuthorization: Fn;
|
|
82
|
+
GetUserFromPayload: Fn;
|
|
83
|
+
loadConversationHistoryWithAttachments: Fn;
|
|
84
|
+
validateAgent: Fn;
|
|
85
|
+
persistInFlightAgentFailure: Fn;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pubSub = { publish: vi.fn().mockResolvedValue(undefined) } as unknown as PubSubEngine;
|
|
89
|
+
|
|
90
|
+
function makeResolver(): { resolver: RunAIAgentResolver; seams: ResolverSeams } {
|
|
91
|
+
const resolver = new RunAIAgentResolver();
|
|
92
|
+
const seams = resolver as unknown as ResolverSeams;
|
|
93
|
+
seams.CheckAPIKeyScopeAuthorization = vi.fn().mockResolvedValue(undefined);
|
|
94
|
+
seams.GetUserFromPayload = vi.fn((payload: UserPayload) => payload.userRecord);
|
|
95
|
+
seams.loadConversationHistoryWithAttachments = vi.fn().mockResolvedValue([{ role: 'user', content: 'hi' }]);
|
|
96
|
+
seams.validateAgent = vi.fn().mockResolvedValue({ ID: 'agent-1', Name: 'Test Agent' });
|
|
97
|
+
seams.persistInFlightAgentFailure = vi.fn().mockResolvedValue(undefined);
|
|
98
|
+
return { resolver, seams };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeContext(userPayload: Partial<UserPayload>): AppContext {
|
|
102
|
+
const provider = {
|
|
103
|
+
GetEntityObject: vi.fn().mockResolvedValue({ ConversationID: 'conv-1', Load: vi.fn().mockResolvedValue(true) }),
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
userPayload: { sessionId: 'session-1', email: USER.Email, userRecord: USER, ...userPayload } as UserPayload,
|
|
107
|
+
providers: [{ type: 'Read-Write', provider }],
|
|
108
|
+
dataSource: {},
|
|
109
|
+
} as unknown as AppContext;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The params the n-th run was handed by the real executeAIAgent (what AgentRunner.RunAgentInConversation received). */
|
|
113
|
+
interface HandedParams {
|
|
114
|
+
data: Record<string, unknown>;
|
|
115
|
+
TrustReservedRunData?: boolean;
|
|
116
|
+
contextUser: { ID: string };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Waits for the n-th run to start (the fire-and-forget path starts it after the resolver has returned). */
|
|
120
|
+
async function handedParams(n = 0): Promise<HandedParams> {
|
|
121
|
+
await vi.waitFor(() => expect(hoisted.runAgentInConversation.mock.calls.length).toBeGreaterThan(n));
|
|
122
|
+
return hoisted.runAgentInConversation.mock.calls[n][0] as HandedParams;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runAIAgent(resolver: RunAIAgentResolver, ctx: AppContext, fireAndForget?: boolean) {
|
|
126
|
+
return resolver.RunAIAgent(
|
|
127
|
+
'agent-1', ctx, '[{"role":"user","content":"hi"}]', 'session-1', pubSub, CLIENT_DATA,
|
|
128
|
+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
|
129
|
+
fireAndForget
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function runFromDetail(resolver: RunAIAgentResolver, ctx: AppContext, fireAndForget?: boolean) {
|
|
134
|
+
return resolver.RunAIAgentFromConversationDetail(
|
|
135
|
+
'detail-1', 'agent-1', ctx, 'session-1', pubSub, undefined, CLIENT_DATA,
|
|
136
|
+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, fireAndForget
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
hoisted.resolveWidgetGuestRunContext.mockReset().mockResolvedValue(null);
|
|
142
|
+
// Stop right after the run's params are captured — the rest of the run is out of scope.
|
|
143
|
+
hoisted.runAgentInConversation.mockReset().mockRejectedValue(new Error('stop here'));
|
|
144
|
+
hoisted.authorize.mockReset().mockImplementation(async (apiKeyHash: string, _app: string, scopePath: string) =>
|
|
145
|
+
apiKeyHash === 'hash-trusted' && scopePath === 'agent:trusted'
|
|
146
|
+
? { Allowed: true, MatchedKeyRule: { ScopePath: scopePath }, EvaluatedRules: [] }
|
|
147
|
+
: { Allowed: false, Reason: 'No matching key scope rules', EvaluatedRules: [] });
|
|
148
|
+
vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue(SYSTEM_USER as UserInfo);
|
|
149
|
+
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
150
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
vi.restoreAllMocks();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('RunAIAgent — reserved run-data keys', () => {
|
|
158
|
+
it('marks the run untrusted for an interactive user and forwards data as sent', async () => {
|
|
159
|
+
const { resolver } = makeResolver();
|
|
160
|
+
|
|
161
|
+
await runAIAgent(resolver, makeContext({}));
|
|
162
|
+
|
|
163
|
+
const params = await handedParams();
|
|
164
|
+
expect(params.TrustReservedRunData).toBe(false);
|
|
165
|
+
expect(params.data).toEqual(JSON.parse(CLIENT_DATA));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('marks it untrusted on the fire-and-forget path too', async () => {
|
|
169
|
+
const { resolver } = makeResolver();
|
|
170
|
+
|
|
171
|
+
await runAIAgent(resolver, makeContext({}), true);
|
|
172
|
+
|
|
173
|
+
expect((await handedParams()).TrustReservedRunData).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('marks it trusted for an API key holding agent:trusted, on both paths', async () => {
|
|
177
|
+
const { resolver } = makeResolver();
|
|
178
|
+
const ctx = makeContext({ apiKeyId: 'key-1', apiKeyHash: 'hash-trusted' });
|
|
179
|
+
|
|
180
|
+
await runAIAgent(resolver, ctx);
|
|
181
|
+
await runAIAgent(resolver, ctx, true);
|
|
182
|
+
|
|
183
|
+
expect((await handedParams(0)).TrustReservedRunData).toBe(true);
|
|
184
|
+
expect((await handedParams(1)).TrustReservedRunData).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('marks it untrusted for an API key without agent:trusted, on both paths', async () => {
|
|
188
|
+
const { resolver } = makeResolver();
|
|
189
|
+
const ctx = makeContext({ apiKeyId: 'key-2', apiKeyHash: 'hash-plain' });
|
|
190
|
+
|
|
191
|
+
await runAIAgent(resolver, ctx);
|
|
192
|
+
await runAIAgent(resolver, ctx, true);
|
|
193
|
+
|
|
194
|
+
expect((await handedParams(0)).TrustReservedRunData).toBe(false);
|
|
195
|
+
expect((await handedParams(1)).TrustReservedRunData).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('RunAIAgentFromConversationDetail — reserved run-data keys', () => {
|
|
200
|
+
it('marks the run untrusted for an interactive user on both paths', async () => {
|
|
201
|
+
const { resolver } = makeResolver();
|
|
202
|
+
|
|
203
|
+
await runFromDetail(resolver, makeContext({}));
|
|
204
|
+
await runFromDetail(resolver, makeContext({}), true);
|
|
205
|
+
|
|
206
|
+
expect((await handedParams(0)).TrustReservedRunData).toBe(false);
|
|
207
|
+
expect((await handedParams(1)).TrustReservedRunData).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('marks a widget-guest run untrusted although it executes as the system user', async () => {
|
|
211
|
+
const { resolver } = makeResolver();
|
|
212
|
+
hoisted.resolveWidgetGuestRunContext.mockResolvedValue({
|
|
213
|
+
ElevatedUser: SYSTEM_USER,
|
|
214
|
+
Widget: {},
|
|
215
|
+
PinnedAgentId: 'pinned-agent',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await runFromDetail(resolver, makeContext({ email: GUEST.Email, userRecord: GUEST }));
|
|
219
|
+
|
|
220
|
+
const params = await handedParams();
|
|
221
|
+
expect(params.contextUser.ID).toBe(SYSTEM_USER.ID); // the premise: the run is elevated
|
|
222
|
+
expect(params.TrustReservedRunData).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('marks it trusted for the system user, on both paths', async () => {
|
|
226
|
+
const { resolver } = makeResolver();
|
|
227
|
+
const ctx = makeContext({ isSystemUser: true, email: SYSTEM_USER.Email, userRecord: SYSTEM_USER });
|
|
228
|
+
|
|
229
|
+
await runFromDetail(resolver, ctx);
|
|
230
|
+
await runFromDetail(resolver, ctx, true);
|
|
231
|
+
|
|
232
|
+
expect((await handedParams(0)).TrustReservedRunData).toBe(true);
|
|
233
|
+
expect((await handedParams(1)).TrustReservedRunData).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('marks it trusted for an API key holding agent:trusted, and untrusted for one without', async () => {
|
|
237
|
+
const { resolver } = makeResolver();
|
|
238
|
+
|
|
239
|
+
await runFromDetail(resolver, makeContext({ apiKeyId: 'key-1', apiKeyHash: 'hash-trusted' }));
|
|
240
|
+
await runFromDetail(resolver, makeContext({ apiKeyId: 'key-2', apiKeyHash: 'hash-plain' }));
|
|
241
|
+
|
|
242
|
+
expect((await handedParams(0)).TrustReservedRunData).toBe(true);
|
|
243
|
+
expect((await handedParams(1)).TrustReservedRunData).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('RunAIAgentSystemUser — reserved run-data keys', () => {
|
|
248
|
+
it('marks the run trusted for the system user it admits, and forwards data as sent', async () => {
|
|
249
|
+
const { resolver } = makeResolver();
|
|
250
|
+
const ctx = makeContext({ isSystemUser: true, email: SYSTEM_USER.Email, userRecord: SYSTEM_USER });
|
|
251
|
+
|
|
252
|
+
await resolver.RunAIAgentSystemUser('agent-1', ctx, '[]', 'session-1', pubSub, CLIENT_DATA);
|
|
253
|
+
|
|
254
|
+
const params = await handedParams();
|
|
255
|
+
expect(params.TrustReservedRunData).toBe(true);
|
|
256
|
+
expect(params.data).toEqual(JSON.parse(CLIENT_DATA));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `RunViewsWithCacheCheck` is the transport `RunViews` switches to when any view in the batch is
|
|
7
|
+
* `CacheLocal`, so it carries the same client-supplied clauses as `RunViews`. These tests pin that
|
|
8
|
+
* it applies the same GraphQL-boundary protections before the provider is called: the base-view
|
|
9
|
+
* AST screen on ExtraFilter / OrderBy / OverrideExcludeFilter, and the API-key scope check.
|
|
10
|
+
*
|
|
11
|
+
* Every payload here is harmless (stacked `SELECT`s); the point is only whether it reaches the
|
|
12
|
+
* provider. The provider's own keyword denylist accepts the bracket-identifier payload, because a
|
|
13
|
+
* quote inside `[a']` hides the rest of the text from its literal stripper.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { authorize } = vi.hoisted(() => ({ authorize: vi.fn() }));
|
|
17
|
+
|
|
18
|
+
vi.mock('@memberjunction/api-keys', async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import('@memberjunction/api-keys')>();
|
|
20
|
+
return { ...actual, GetAPIKeyEngine: () => ({ Authorize: authorize }) };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
import type { DatabaseProviderBase, EntityInfo, RunViewWithCacheCheckParams, UserInfo } from '@memberjunction/core';
|
|
24
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
25
|
+
import { RunDynamicViewInput, RunViewResolver, RunViewWithCacheCheckInput } from '../generic/RunViewResolver.js';
|
|
26
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
27
|
+
|
|
28
|
+
/** A stacked statement hidden from the keyword denylist by a quote inside a bracket identifier. */
|
|
29
|
+
const BRACKET_STACKED =
|
|
30
|
+
"1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM __mj.vwUserViews WHERE (1 = (SELECT 1 AS [b'])";
|
|
31
|
+
|
|
32
|
+
const USER = { ID: 'user-1', Email: 'reader@example.com', Name: 'Reader' } as UserInfo;
|
|
33
|
+
|
|
34
|
+
function entity(name: string, schema: string, baseView: string): EntityInfo {
|
|
35
|
+
return {
|
|
36
|
+
Name: name,
|
|
37
|
+
SchemaName: schema,
|
|
38
|
+
BaseView: baseView,
|
|
39
|
+
PrimaryKeys: [{ Name: 'ID' }],
|
|
40
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
41
|
+
GetEffectiveRowFilterWhereClause: () => '',
|
|
42
|
+
GetDeniedReadFields: () => new Set<string>(),
|
|
43
|
+
} as unknown as EntityInfo;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Provider double: the metadata the screen reads, and a cache-check method that records calls. */
|
|
47
|
+
function fakeProvider() {
|
|
48
|
+
const calls: RunViewWithCacheCheckParams[][] = [];
|
|
49
|
+
const entities = [
|
|
50
|
+
entity('MJ: User Views', '__mj', 'vwUserViews'),
|
|
51
|
+
entity('MJ: Users', '__mj', 'vwUsers'),
|
|
52
|
+
];
|
|
53
|
+
const provider = {
|
|
54
|
+
Entities: entities,
|
|
55
|
+
EntityByName: (name: string) => entities.find((e) => e.Name === name),
|
|
56
|
+
RunViewsWithCacheCheck: async (params: RunViewWithCacheCheckParams[]) => {
|
|
57
|
+
calls.push(params);
|
|
58
|
+
return { success: true, results: [] };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return { provider: provider as unknown as DatabaseProviderBase, calls };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function context(provider: DatabaseProviderBase, payload: Partial<UserPayload> = {}): AppContext {
|
|
65
|
+
const userPayload = { email: USER.Email, userRecord: USER, ...payload } as UserPayload;
|
|
66
|
+
return { providers: [{ type: 'Read-Only', provider }], userPayload } as unknown as AppContext;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cacheCheckInput(params: Partial<RunDynamicViewInput>): RunViewWithCacheCheckInput[] {
|
|
70
|
+
const dynamic = Object.assign(new RunDynamicViewInput(), { EntityName: 'MJ: User Views', ...params });
|
|
71
|
+
return [Object.assign(new RunViewWithCacheCheckInput(), { params: dynamic })];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('RunViewsWithCacheCheck — GraphQL-boundary clause screen', () => {
|
|
75
|
+
it('refuses a stacked statement hidden behind a bracket identifier, before the provider runs', async () => {
|
|
76
|
+
const { provider, calls } = fakeProvider();
|
|
77
|
+
|
|
78
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
79
|
+
cacheCheckInput({ ExtraFilter: BRACKET_STACKED }),
|
|
80
|
+
context(provider),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(calls).toHaveLength(0);
|
|
84
|
+
expect(result.success).toBe(false);
|
|
85
|
+
expect(result.errorMessage).toMatch(/Invalid ExtraFilter: multiple statements/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses a subquery against a base table', async () => {
|
|
89
|
+
const { provider, calls } = fakeProvider();
|
|
90
|
+
|
|
91
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
92
|
+
cacheCheckInput({ ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')` }),
|
|
93
|
+
context(provider),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(calls).toHaveLength(0);
|
|
97
|
+
expect(result.success).toBe(false);
|
|
98
|
+
expect(result.errorMessage).toMatch(/entity base view/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('screens OrderBy and OverrideExcludeFilter too', async () => {
|
|
102
|
+
for (const params of [
|
|
103
|
+
{ OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
|
|
104
|
+
{ OverrideExcludeFilter: BRACKET_STACKED },
|
|
105
|
+
]) {
|
|
106
|
+
const { provider, calls } = fakeProvider();
|
|
107
|
+
|
|
108
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(cacheCheckInput(params), context(provider));
|
|
109
|
+
|
|
110
|
+
expect(calls).toHaveLength(0);
|
|
111
|
+
expect(result.success).toBe(false);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('passes a filter that only reads entity base views through to the provider', async () => {
|
|
116
|
+
const { provider, calls } = fakeProvider();
|
|
117
|
+
const filter = `UserID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'o''brien@example.com')`;
|
|
118
|
+
|
|
119
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
120
|
+
cacheCheckInput({ ExtraFilter: filter, OrderBy: '[Name] DESC' }),
|
|
121
|
+
context(provider),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(result.success).toBe(true);
|
|
125
|
+
expect(calls).toHaveLength(1);
|
|
126
|
+
expect(calls[0][0].params.ExtraFilter).toBe(filter);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('RunViewsWithCacheCheck — API-key scope', () => {
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
authorize.mockReset();
|
|
133
|
+
vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue({ ID: 'system', Email: 'system@example.com' } as UserInfo);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses an API key without the batch view scope, before the provider runs', async () => {
|
|
137
|
+
authorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
|
|
138
|
+
const { provider, calls } = fakeProvider();
|
|
139
|
+
|
|
140
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
141
|
+
cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
|
|
142
|
+
context(provider, { apiKeyHash: 'hash-of-a-narrow-key' }),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(calls).toHaveLength(0);
|
|
146
|
+
expect(result.success).toBe(false);
|
|
147
|
+
expect(result.errorMessage).toMatch(/'view:batch' scope/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('lets an API key holding the scope through', async () => {
|
|
151
|
+
authorize.mockImplementation(async (_hash: string, _app: string, scope: string) => ({
|
|
152
|
+
Allowed: scope === 'view:batch',
|
|
153
|
+
Reason: scope === 'view:batch' ? 'granted' : 'not full access',
|
|
154
|
+
}));
|
|
155
|
+
const { provider, calls } = fakeProvider();
|
|
156
|
+
|
|
157
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
158
|
+
cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
|
|
159
|
+
context(provider, { apiKeyHash: 'hash-of-a-batch-key' }),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result.success).toBe(true);
|
|
163
|
+
expect(calls).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for SearchKnowledgeStreamResolver's wire mapping: a `provider` event is progress only.
|
|
5
|
+
*
|
|
6
|
+
* A provider's hits arrive before the engine's permission pass — rows the caller's row filters would drop,
|
|
7
|
+
* external-index hits not yet verified against the entity they name, content whose origin the caller may not
|
|
8
|
+
* read. The engine no longer puts them on its `provider` events, and the resolver does not forward them either,
|
|
9
|
+
* whatever the event holds: the client gets ProviderName, DurationMs and ResultCount, and results only in
|
|
10
|
+
* `fused`/`final`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { streamEvents, streamCalls, resolveEffectivePermission, logForbiddenSearch } = vi.hoisted(() => ({
|
|
14
|
+
streamEvents: { current: [] as unknown[] },
|
|
15
|
+
streamCalls: { count: 0 },
|
|
16
|
+
resolveEffectivePermission: vi.fn(),
|
|
17
|
+
logForbiddenSearch: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@memberjunction/search-engine', () => ({
|
|
21
|
+
SearchEngine: {
|
|
22
|
+
Instance: {
|
|
23
|
+
streamSearch: async function* () {
|
|
24
|
+
streamCalls.count++;
|
|
25
|
+
for (const ev of streamEvents.current) yield ev;
|
|
26
|
+
},
|
|
27
|
+
LogForbiddenSearch: logForbiddenSearch,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
GetSearchScopePermissionResolver: () => ({ ResolveEffectivePermission: resolveEffectivePermission }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
34
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
35
|
+
return { ...actual, LogError: vi.fn(), LogStatus: vi.fn() };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Mock type-graphql to avoid the reflect-metadata dependency (the SearchKnowledgeResolver tests do the same).
|
|
39
|
+
vi.mock('type-graphql', () => {
|
|
40
|
+
const noop = () => () => undefined;
|
|
41
|
+
const passthrough = () => (target: unknown) => target;
|
|
42
|
+
return {
|
|
43
|
+
Resolver: passthrough, Mutation: noop, Query: noop, Subscription: noop, Arg: noop, Args: noop, Ctx: noop,
|
|
44
|
+
ObjectType: passthrough, InputType: passthrough, ArgsType: passthrough, Field: noop,
|
|
45
|
+
Float: Number, Int: Number, ID: String, Authorized: noop, PubSub: noop, Root: noop,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
50
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
51
|
+
import { SearchKnowledgeStreamResolver, SearchStreamNotification } from '../resolvers/SearchKnowledgeStreamResolver.js';
|
|
52
|
+
import type { UserPayload } from '../types.js';
|
|
53
|
+
|
|
54
|
+
class TestStreamResolver extends SearchKnowledgeStreamResolver {
|
|
55
|
+
protected override GetUserFromPayload(_userPayload: UserPayload): UserInfo | undefined {
|
|
56
|
+
return { ID: 'test-user-id', Email: 'test@test.com', Name: 'Test User' } as UserInfo;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hit = (recordID: string) => ({
|
|
61
|
+
ID: `r-${recordID}`, EntityName: 'Customers', RecordID: recordID, SourceType: 'fulltext', ResultType: 'entity-record',
|
|
62
|
+
Title: `title of ${recordID}`, Snippet: `snippet of ${recordID}`, Score: 0.5, ScoreBreakdown: {}, Tags: [],
|
|
63
|
+
MatchedAt: new Date(), RawMetadata: `{"secret":"${recordID}"}`, ProviderId: 'prov-azure',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Run a stream to completion and return what the resolver published, in order. */
|
|
67
|
+
async function publishedFor(events: unknown[]): Promise<SearchStreamNotification[]> {
|
|
68
|
+
streamEvents.current = events;
|
|
69
|
+
const published: SearchStreamNotification[] = [];
|
|
70
|
+
const pubSub = { publish: vi.fn(async (_topic: string, payload: SearchStreamNotification) => { published.push(payload); }) };
|
|
71
|
+
const start = await new TestStreamResolver().StreamScopedSearch(
|
|
72
|
+
'budget', 20, undefined, undefined, undefined, undefined,
|
|
73
|
+
pubSub as unknown as PubSubEngine, { userPayload: {} } as never,
|
|
74
|
+
);
|
|
75
|
+
expect(start.Success).toBe(true);
|
|
76
|
+
await vi.waitFor(() => expect(published.some(p => p.Phase === 'final' || p.Phase === 'error')).toBe(true),
|
|
77
|
+
{ timeout: 500, interval: 5 });
|
|
78
|
+
return published;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('SearchKnowledgeStreamResolver — provider events are progress only', () => {
|
|
82
|
+
beforeEach(() => { vi.clearAllMocks(); });
|
|
83
|
+
|
|
84
|
+
it('publishes the count and no result, even when a provider event carries hits', async () => {
|
|
85
|
+
// An engine that (wrongly) put hits on a provider event: the resolver still must not forward them.
|
|
86
|
+
const published = await publishedFor([
|
|
87
|
+
{ phase: 'provider', providerName: 'FullText', results: [hit('row-filtered')], resultCount: 1, durationMs: 7 },
|
|
88
|
+
{ phase: 'fused', results: [] },
|
|
89
|
+
{ phase: 'final', results: [], sourceCounts: { Vector: 0, FullText: 1, Entity: 0, Storage: 0 }, elapsedMs: 9 },
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const progress = published.find(p => p.Phase === 'provider');
|
|
93
|
+
expect(progress).toMatchObject({ Phase: 'provider', ProviderName: 'FullText', DurationMs: 7, ResultCount: 1, Results: [] });
|
|
94
|
+
expect(JSON.stringify(progress)).not.toContain('row-filtered');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('carries results on final, mapped as the synchronous resolver maps them', async () => {
|
|
98
|
+
const published = await publishedFor([
|
|
99
|
+
{ phase: 'provider', providerName: 'FullText', results: [], resultCount: 1, durationMs: 7 },
|
|
100
|
+
{ phase: 'fused', results: [hit('readable')] },
|
|
101
|
+
{ phase: 'final', results: [hit('readable')], sourceCounts: { Vector: 0, FullText: 1, Entity: 0, Storage: 0 }, elapsedMs: 9 },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const final = published.find(p => p.Phase === 'final');
|
|
105
|
+
expect(final?.Results?.map(r => r.RecordID)).toEqual(['readable']);
|
|
106
|
+
expect(final?.ResultCount).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('SearchKnowledgeStreamResolver — the scope permission gate runs before the stream starts', () => {
|
|
111
|
+
const verdict = (Allowed: boolean, Level: string) => ({ Allowed, Level, Source: 'NoGrant', Reason: 'judged in test' });
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
streamEvents.current = [{ phase: 'final', results: [], sourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 }, elapsedMs: 1 }];
|
|
115
|
+
streamCalls.count = 0;
|
|
116
|
+
resolveEffectivePermission.mockReset();
|
|
117
|
+
logForbiddenSearch.mockReset();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
async function start(scopeIDs: string[]) {
|
|
121
|
+
const pubSub = { publish: vi.fn(async () => undefined) };
|
|
122
|
+
const result = await new TestStreamResolver().StreamScopedSearch(
|
|
123
|
+
'budget', 20, undefined, scopeIDs, undefined, undefined,
|
|
124
|
+
pubSub as unknown as PubSubEngine, { userPayload: {} } as never,
|
|
125
|
+
);
|
|
126
|
+
return { result, pubSub };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it('refuses a denied scope, logs it, and never starts the stream or publishes', async () => {
|
|
130
|
+
resolveEffectivePermission.mockResolvedValue(verdict(false, 'None'));
|
|
131
|
+
|
|
132
|
+
const { result, pubSub } = await start(['scope-a']);
|
|
133
|
+
|
|
134
|
+
expect(result.Success).toBe(false);
|
|
135
|
+
expect(result.ErrorMessage).toMatch(/^Forbidden: refused for this user on scope 'scope-a'/);
|
|
136
|
+
expect(logForbiddenSearch).toHaveBeenCalledTimes(1);
|
|
137
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
138
|
+
expect(streamCalls.count).toBe(0);
|
|
139
|
+
expect(pubSub.publish).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('refuses a Read-level grant: Read is metadata only, not search', async () => {
|
|
143
|
+
resolveEffectivePermission.mockResolvedValue(verdict(true, 'Read'));
|
|
144
|
+
|
|
145
|
+
const { result } = await start(['scope-a']);
|
|
146
|
+
|
|
147
|
+
expect(result.Success).toBe(false);
|
|
148
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
149
|
+
expect(streamCalls.count).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('judges every requested scope and starts the stream when all may be searched', async () => {
|
|
153
|
+
resolveEffectivePermission.mockResolvedValue(verdict(true, 'Search'));
|
|
154
|
+
|
|
155
|
+
const { result } = await start(['scope-a', 'scope-b']);
|
|
156
|
+
|
|
157
|
+
expect(result.Success).toBe(true);
|
|
158
|
+
expect(resolveEffectivePermission).toHaveBeenCalledTimes(2);
|
|
159
|
+
await vi.waitFor(() => expect(streamCalls.count).toBe(1), { timeout: 500, interval: 5 });
|
|
160
|
+
});
|
|
161
|
+
});
|