@memberjunction/server 6.1.4 → 6.1.6
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 +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.js.map +1 -0
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +55 -38
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +15 -11
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -34
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +3417 -3428
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +44 -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/ResolverBase.d.ts +95 -11
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +186 -76
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -45
- package/dist/index.js.map +1 -1
- package/dist/isolatedProvider.d.ts +20 -0
- package/dist/isolatedProvider.d.ts.map +1 -0
- package/dist/isolatedProvider.js +22 -0
- package/dist/isolatedProvider.js.map +1 -0
- 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/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +92 -166
- package/dist/resolvers/AdhocQueryResolver.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 +8 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +11 -48
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.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 +13 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +6 -2
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.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 +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +21 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +43 -16
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SessionManager.test.ts +94 -17
- package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
- package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +230 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -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__/newUsers.test.ts +62 -5
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +23 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +61 -39
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +25 -34
- package/src/generated/graphql-schemas/__mj.ts +3417 -3428
- package/src/generic/CacheInvalidationResolver.ts +74 -19
- package/src/generic/ResolverBase.ts +207 -80
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +49 -46
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +105 -187
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +11 -52
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/SyncDataResolver.ts +6 -2
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +45 -16
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -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
|
+
});
|
|
@@ -27,15 +27,18 @@ vi.mock('@memberjunction/ai-agents', () => ({
|
|
|
27
27
|
},
|
|
28
28
|
}));
|
|
29
29
|
|
|
30
|
-
// --- Mock RunView.FromMetadataProvider so channel sweeps are controllable,
|
|
31
|
-
// rest of @memberjunction/core intact. ---
|
|
30
|
+
// --- Mock RunView.FromMetadataProvider so channel sweeps and run-id lookups are controllable,
|
|
31
|
+
// while leaving the rest of @memberjunction/core intact. RunViews runs each view through runViewMock. ---
|
|
32
32
|
const runViewMock = vi.fn();
|
|
33
33
|
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
34
34
|
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
35
35
|
return {
|
|
36
36
|
...actual,
|
|
37
37
|
RunView: {
|
|
38
|
-
FromMetadataProvider: () => ({
|
|
38
|
+
FromMetadataProvider: () => ({
|
|
39
|
+
RunView: runViewMock,
|
|
40
|
+
RunViews: (paramsList: unknown[]) => Promise.all(paramsList.map((params) => runViewMock(params))),
|
|
41
|
+
}),
|
|
39
42
|
},
|
|
40
43
|
};
|
|
41
44
|
});
|
|
@@ -100,6 +103,27 @@ function makeSessionEntity(overrides: Partial<FakeEntity> = {}): FakeEntity {
|
|
|
100
103
|
};
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/** Co-agent observability ids as a voice session's Config holds them. */
|
|
107
|
+
const CO_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000c0';
|
|
108
|
+
const PROMPT_RUN_ID = 'a5a5a5a5-0000-4000-8000-0000000000b0';
|
|
109
|
+
const RUN_STEP_ID = 'a5a5a5a5-0000-4000-8000-0000000000a0';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Makes the given runs records of the session for the run-id lookups: `runIDs` carry the session's
|
|
113
|
+
* id, and `promptRunID` (when given) is the TargetLogID of the co-agent run's Prompt step.
|
|
114
|
+
*/
|
|
115
|
+
function mockSessionRunRecords(runIDs: string[], promptRunID?: string): void {
|
|
116
|
+
runViewMock.mockImplementation(async (params: { EntityName?: string }) => {
|
|
117
|
+
if (params.EntityName === 'MJ: AI Agent Runs') {
|
|
118
|
+
return { Success: true, Results: runIDs.map((ID) => ({ ID })) };
|
|
119
|
+
}
|
|
120
|
+
if (params.EntityName === 'MJ: AI Agent Run Steps') {
|
|
121
|
+
return { Success: true, Results: promptRunID ? [{ ID: RUN_STEP_ID, StepType: 'Prompt', TargetLogID: promptRunID }] : [] };
|
|
122
|
+
}
|
|
123
|
+
return { Success: true, Results: [] };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
beforeEach(() => {
|
|
104
128
|
hasPermissionMock.mockReset();
|
|
105
129
|
finalizeCoAgentRunMock.mockClear();
|
|
@@ -228,8 +252,9 @@ describe('SessionManager.CloseSession', () => {
|
|
|
228
252
|
const session = makeSessionEntity({
|
|
229
253
|
ID: 'session-voice',
|
|
230
254
|
Status: 'Active',
|
|
231
|
-
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID:
|
|
255
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
|
|
232
256
|
});
|
|
257
|
+
mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
|
|
233
258
|
const { provider } = makeProvider(() => session);
|
|
234
259
|
const mgr = new SessionManager();
|
|
235
260
|
const user = makeUser();
|
|
@@ -238,7 +263,7 @@ describe('SessionManager.CloseSession', () => {
|
|
|
238
263
|
|
|
239
264
|
expect(ok).toBe(true);
|
|
240
265
|
expect(finalizeCoAgentRunMock).toHaveBeenCalledTimes(1);
|
|
241
|
-
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(
|
|
266
|
+
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
|
|
242
267
|
});
|
|
243
268
|
|
|
244
269
|
it('threads the co-agent run-step id into finalize when the session config carries it', async () => {
|
|
@@ -247,11 +272,12 @@ describe('SessionManager.CloseSession', () => {
|
|
|
247
272
|
Status: 'Active',
|
|
248
273
|
Config_: JSON.stringify({
|
|
249
274
|
targetAgentID: 't1',
|
|
250
|
-
coAgentRunID:
|
|
251
|
-
promptRunID:
|
|
252
|
-
coAgentRunStepID:
|
|
275
|
+
coAgentRunID: CO_RUN_ID,
|
|
276
|
+
promptRunID: PROMPT_RUN_ID,
|
|
277
|
+
coAgentRunStepID: RUN_STEP_ID,
|
|
253
278
|
}),
|
|
254
279
|
});
|
|
280
|
+
mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
|
|
255
281
|
const { provider } = makeProvider(() => session);
|
|
256
282
|
const mgr = new SessionManager();
|
|
257
283
|
const user = makeUser();
|
|
@@ -259,7 +285,7 @@ describe('SessionManager.CloseSession', () => {
|
|
|
259
285
|
const ok = await mgr.CloseSession('session-voice-step', user, provider);
|
|
260
286
|
|
|
261
287
|
expect(ok).toBe(true);
|
|
262
|
-
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(
|
|
288
|
+
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, RUN_STEP_ID);
|
|
263
289
|
});
|
|
264
290
|
|
|
265
291
|
it('finalizes as the SYSTEM user when a scoped anonymous owner closes their session (issue #3371)', async () => {
|
|
@@ -274,8 +300,9 @@ describe('SessionManager.CloseSession', () => {
|
|
|
274
300
|
const session = makeSessionEntity({
|
|
275
301
|
ID: 'session-anon',
|
|
276
302
|
Status: 'Active',
|
|
277
|
-
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID:
|
|
303
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
|
|
278
304
|
});
|
|
305
|
+
mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
|
|
279
306
|
const { provider } = makeProvider(() => session);
|
|
280
307
|
const mgr = new SessionManager();
|
|
281
308
|
|
|
@@ -284,7 +311,7 @@ describe('SessionManager.CloseSession', () => {
|
|
|
284
311
|
expect(ok).toBe(true);
|
|
285
312
|
// The session-close writes themselves stay on the caller — only finalize elevates.
|
|
286
313
|
expect(session.Status).toBe('Closed');
|
|
287
|
-
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(
|
|
314
|
+
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, systemUser, provider, true, null);
|
|
288
315
|
});
|
|
289
316
|
|
|
290
317
|
it('FAILS CLOSED — finalizes as the anonymous caller when no system user is available', async () => {
|
|
@@ -297,14 +324,15 @@ describe('SessionManager.CloseSession', () => {
|
|
|
297
324
|
const session = makeSessionEntity({
|
|
298
325
|
ID: 'session-anon-2',
|
|
299
326
|
Status: 'Active',
|
|
300
|
-
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID:
|
|
327
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: null }),
|
|
301
328
|
});
|
|
329
|
+
mockSessionRunRecords([CO_RUN_ID]);
|
|
302
330
|
const { provider } = makeProvider(() => session);
|
|
303
331
|
const mgr = new SessionManager();
|
|
304
332
|
|
|
305
333
|
await mgr.CloseSession('session-anon-2', anonUser, provider);
|
|
306
334
|
|
|
307
|
-
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(
|
|
335
|
+
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, null, anonUser, provider, true, null);
|
|
308
336
|
});
|
|
309
337
|
|
|
310
338
|
it('does not finalize when the session config has no run ids (target only)', async () => {
|
|
@@ -334,8 +362,9 @@ describe('SessionManager.CloseSession', () => {
|
|
|
334
362
|
const session = makeSessionEntity({
|
|
335
363
|
ID: 'session-injected',
|
|
336
364
|
Status: 'Active',
|
|
337
|
-
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID:
|
|
365
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
|
|
338
366
|
});
|
|
367
|
+
mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
|
|
339
368
|
const { provider } = makeProvider(() => session);
|
|
340
369
|
const mgr = new SessionManager(injectedService);
|
|
341
370
|
const user = makeUser();
|
|
@@ -345,7 +374,7 @@ describe('SessionManager.CloseSession', () => {
|
|
|
345
374
|
expect(ok).toBe(true);
|
|
346
375
|
// The finalize call landed on the INJECTED instance, never the module-default one.
|
|
347
376
|
expect(injectedFinalizeMock).toHaveBeenCalledTimes(1);
|
|
348
|
-
expect(injectedFinalizeMock).toHaveBeenCalledWith(
|
|
377
|
+
expect(injectedFinalizeMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
|
|
349
378
|
expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
|
|
350
379
|
});
|
|
351
380
|
|
|
@@ -353,8 +382,9 @@ describe('SessionManager.CloseSession', () => {
|
|
|
353
382
|
const session = makeSessionEntity({
|
|
354
383
|
ID: 'session-default-svc',
|
|
355
384
|
Status: 'Active',
|
|
356
|
-
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID:
|
|
385
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: CO_RUN_ID, promptRunID: PROMPT_RUN_ID }),
|
|
357
386
|
});
|
|
387
|
+
mockSessionRunRecords([CO_RUN_ID], PROMPT_RUN_ID);
|
|
358
388
|
const { provider } = makeProvider(() => session);
|
|
359
389
|
const mgr = new SessionManager(); // no injection — e.g. SessionJanitor, telephony services
|
|
360
390
|
const user = makeUser();
|
|
@@ -362,7 +392,7 @@ describe('SessionManager.CloseSession', () => {
|
|
|
362
392
|
const ok = await mgr.CloseSession('session-default-svc', user, provider);
|
|
363
393
|
|
|
364
394
|
expect(ok).toBe(true);
|
|
365
|
-
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(
|
|
395
|
+
expect(finalizeCoAgentRunMock).toHaveBeenCalledWith(CO_RUN_ID, PROMPT_RUN_ID, user, provider, true, null);
|
|
366
396
|
});
|
|
367
397
|
|
|
368
398
|
it('stamps CloseReason = Explicit by default (untouched call sites get the right reason)', async () => {
|
|
@@ -626,3 +656,50 @@ describe('SessionManager — server-side channel plugin lifecycle notifications'
|
|
|
626
656
|
expect(session.Status).toBe('Closed');
|
|
627
657
|
});
|
|
628
658
|
});
|
|
659
|
+
|
|
660
|
+
// A session's Config is writable by its owner (generic CRUD on MJ: AI Agent Sessions), so the run
|
|
661
|
+
// ids in it are acted on only when they are UUIDs of records that belong to the session.
|
|
662
|
+
describe('SessionManager — run ids in the owner-writable session Config', () => {
|
|
663
|
+
const SESSION_ID = 'c3c3c3c3-1000-4000-8000-000000000001';
|
|
664
|
+
const FOREIGN_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f1';
|
|
665
|
+
const FOREIGN_PROMPT_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f2';
|
|
666
|
+
const FOREIGN_STEP_ID = 'c3c3c3c3-1000-4000-8000-0000000000f3';
|
|
667
|
+
/** Harmless text that is not a UUID and would change the meaning of a quoted filter. */
|
|
668
|
+
const NOT_A_UUID = "x' OR '1'='1";
|
|
669
|
+
|
|
670
|
+
function issuedFilters(): string[] {
|
|
671
|
+
return runViewMock.mock.calls.map(([params]) => (params as { ExtraFilter?: string }).ExtraFilter ?? '');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
it('CloseSession does not finalize run ids that are not records of the session', async () => {
|
|
675
|
+
const session = makeSessionEntity({
|
|
676
|
+
ID: SESSION_ID,
|
|
677
|
+
Status: 'Active',
|
|
678
|
+
Config_: JSON.stringify({
|
|
679
|
+
targetAgentID: 't1',
|
|
680
|
+
coAgentRunID: FOREIGN_RUN_ID,
|
|
681
|
+
promptRunID: FOREIGN_PROMPT_RUN_ID,
|
|
682
|
+
coAgentRunStepID: FOREIGN_STEP_ID,
|
|
683
|
+
}),
|
|
684
|
+
});
|
|
685
|
+
const { provider } = makeProvider(() => session);
|
|
686
|
+
|
|
687
|
+
await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
|
|
688
|
+
|
|
689
|
+
expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('CloseSession does not finalize run ids that are not UUIDs, and never puts them in a filter', async () => {
|
|
693
|
+
const session = makeSessionEntity({
|
|
694
|
+
ID: SESSION_ID,
|
|
695
|
+
Status: 'Active',
|
|
696
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: NOT_A_UUID, promptRunID: NOT_A_UUID }),
|
|
697
|
+
});
|
|
698
|
+
const { provider } = makeProvider(() => session);
|
|
699
|
+
|
|
700
|
+
await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
|
|
701
|
+
|
|
702
|
+
expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
|
|
703
|
+
expect(issuedFilters().filter((f) => f.includes(NOT_A_UUID))).toEqual([]);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { IMetadataProvider, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
4
|
+
import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
|
|
5
|
+
|
|
6
|
+
const SESSION_ID = 'd3d3d3d3-0000-4000-8000-000000000001';
|
|
7
|
+
const CO_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000002';
|
|
8
|
+
const PROMPT_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000003';
|
|
9
|
+
const STEP_ID = 'd3d3d3d3-0000-4000-8000-000000000004';
|
|
10
|
+
const PAUSED_RUN_ID = 'd3d3d3d3-0000-4000-8000-000000000005';
|
|
11
|
+
const USER = { ID: 'd3d3d3d3-0000-4000-8000-0000000000aa' } as unknown as UserInfo;
|
|
12
|
+
|
|
13
|
+
/** Rows each lookup returns, keyed by entity name. */
|
|
14
|
+
type Rows = Record<string, Array<Record<string, unknown>>>;
|
|
15
|
+
|
|
16
|
+
function makeProvider(rows: Rows, success = true): { provider: IMetadataProvider; runViews: ReturnType<typeof vi.fn> } {
|
|
17
|
+
const runViews = vi.fn(async (paramsList: RunViewParams[]) =>
|
|
18
|
+
paramsList.map((params) => ({
|
|
19
|
+
Success: success,
|
|
20
|
+
Results: success ? rows[params.EntityName ?? ''] ?? [] : [],
|
|
21
|
+
ErrorMessage: success ? '' : 'lookup failed',
|
|
22
|
+
})),
|
|
23
|
+
);
|
|
24
|
+
return { provider: { RunViews: runViews } as unknown as IMetadataProvider, runViews };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function issuedViews(runViews: ReturnType<typeof vi.fn>): RunViewParams[] {
|
|
28
|
+
return runViews.mock.calls.flatMap(([paramsList]) => paramsList as RunViewParams[]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
UserCache.Instance.SetUsers([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('SessionRunIDVerifier.Verify', () => {
|
|
40
|
+
it('keeps nothing, and runs no lookup, when no claim is a UUID', async () => {
|
|
41
|
+
const { provider, runViews } = makeProvider({});
|
|
42
|
+
|
|
43
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
44
|
+
SESSION_ID,
|
|
45
|
+
{ CoAgentRunID: "x' OR '1'='1", PromptRunID: 'prompt-run-1', PendingFeedbackRunID: '' },
|
|
46
|
+
USER,
|
|
47
|
+
provider,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({});
|
|
51
|
+
expect(runViews).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('looks up the claimed runs within the session and keeps only the ones it finds', async () => {
|
|
55
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
56
|
+
|
|
57
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
58
|
+
SESSION_ID,
|
|
59
|
+
{ CoAgentRunID: CO_RUN_ID, PendingFeedbackRunID: PAUSED_RUN_ID },
|
|
60
|
+
USER,
|
|
61
|
+
provider,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(result.CoAgentRunID).toBe(CO_RUN_ID);
|
|
65
|
+
expect(result.PendingFeedbackRunID).toBeUndefined();
|
|
66
|
+
const [runsView] = issuedViews(runViews);
|
|
67
|
+
expect(runsView.EntityName).toBe('MJ: AI Agent Runs');
|
|
68
|
+
expect(runsView.ExtraFilter).toBe(`AgentSessionID='${SESSION_ID}' AND ID IN ('${CO_RUN_ID}', '${PAUSED_RUN_ID}')`);
|
|
69
|
+
// No system user is cached here, so the lookup runs as the acting user.
|
|
70
|
+
expect(runViews.mock.calls[0][1]).toBe(USER);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('runs the lookup as the system user when one is available', async () => {
|
|
74
|
+
// Row-level security can hide a session's own runs from its owner: a web-widget guest's
|
|
75
|
+
// delegated runs carry no ConversationID, so the guest's run filter does not match them.
|
|
76
|
+
const systemUser = { ID: UserCache.Instance.SYSTEM_USER_ID, Email: 'system@example.com' } as unknown as UserInfo;
|
|
77
|
+
UserCache.Instance.SetUsers([systemUser]);
|
|
78
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID }] });
|
|
79
|
+
|
|
80
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
|
|
81
|
+
|
|
82
|
+
expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
|
|
83
|
+
expect(runViews.mock.calls[0][1]).toBe(systemUser);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps the prompt run and step that belong to the session\'s co-agent run', async () => {
|
|
87
|
+
const { provider, runViews } = makeProvider({
|
|
88
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
|
|
89
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
93
|
+
SESSION_ID,
|
|
94
|
+
{ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
|
|
95
|
+
USER,
|
|
96
|
+
provider,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual({ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: undefined });
|
|
100
|
+
const stepsView = issuedViews(runViews).find((view) => view.EntityName === 'MJ: AI Agent Run Steps');
|
|
101
|
+
expect(stepsView?.ExtraFilter).toBe(`AgentRunID='${CO_RUN_ID}' AND (ID='${STEP_ID}' OR TargetLogID='${PROMPT_RUN_ID}')`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('drops a prompt run that is the target of a step other than a Prompt step', async () => {
|
|
105
|
+
const { provider } = makeProvider({
|
|
106
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
|
|
107
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Tool', TargetLogID: PROMPT_RUN_ID }],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
|
|
111
|
+
|
|
112
|
+
expect(result.PromptRunID).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('drops the prompt run and step when the co-agent run is not the session\'s', async () => {
|
|
116
|
+
const { provider } = makeProvider({
|
|
117
|
+
'MJ: AI Agent Runs': [],
|
|
118
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
122
|
+
SESSION_ID,
|
|
123
|
+
{ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
|
|
124
|
+
USER,
|
|
125
|
+
provider,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('matches ids case-insensitively', async () => {
|
|
132
|
+
const { provider } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID.toUpperCase() }] });
|
|
133
|
+
|
|
134
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
|
|
135
|
+
|
|
136
|
+
expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('looks up the same claims again on every call', async () => {
|
|
140
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
141
|
+
const verifier = new SessionRunIDVerifier();
|
|
142
|
+
|
|
143
|
+
await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
144
|
+
const second = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
145
|
+
|
|
146
|
+
expect(second.CoAgentRunID).toBe(CO_RUN_ID);
|
|
147
|
+
expect(runViews).toHaveBeenCalledTimes(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('reads the runs and steps from the database, not from a cached view', async () => {
|
|
151
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
152
|
+
|
|
153
|
+
await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
|
|
154
|
+
|
|
155
|
+
const views = issuedViews(runViews);
|
|
156
|
+
expect(views.map((view) => view.EntityName)).toEqual(['MJ: AI Agent Runs', 'MJ: AI Agent Run Steps']);
|
|
157
|
+
expect(views.every((view) => view.BypassCache === true)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('keeps nothing when a lookup fails, and looks up again next time', async () => {
|
|
161
|
+
const { provider, runViews } = makeProvider({}, false);
|
|
162
|
+
const verifier = new SessionRunIDVerifier();
|
|
163
|
+
|
|
164
|
+
const first = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
165
|
+
await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
166
|
+
|
|
167
|
+
expect(first).toEqual({});
|
|
168
|
+
expect(runViews).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps nothing, without throwing, when the provider throws', async () => {
|
|
172
|
+
const provider = { RunViews: vi.fn(async () => { throw new Error('connection lost'); }) } as unknown as IMetadataProvider;
|
|
173
|
+
|
|
174
|
+
await expect(new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider)).resolves.toEqual({});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A run's `AgentSessionID` and a step's `AgentRunID` and `TargetLogID` can change after a lookup (the
|
|
180
|
+
* generated update mutations accept them), so each call must decide from what the rows say now.
|
|
181
|
+
*/
|
|
182
|
+
describe('SessionRunIDVerifier.Verify — relationships that change between calls', () => {
|
|
183
|
+
const CLAIMS = { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: PAUSED_RUN_ID };
|
|
184
|
+
|
|
185
|
+
/** Both runs are records of the session, and the step is the co-agent run's Prompt step for the prompt run. */
|
|
186
|
+
function linkedRows(): Rows {
|
|
187
|
+
return {
|
|
188
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }, { ID: PAUSED_RUN_ID }],
|
|
189
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
it('drops runs that stopped being records of the session since the last call', async () => {
|
|
194
|
+
const rows = linkedRows();
|
|
195
|
+
const { provider } = makeProvider(rows);
|
|
196
|
+
const verifier = new SessionRunIDVerifier();
|
|
197
|
+
const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
198
|
+
|
|
199
|
+
// The runs were moved to another session, unlinked or deleted.
|
|
200
|
+
rows['MJ: AI Agent Runs'] = [];
|
|
201
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
202
|
+
|
|
203
|
+
expect(first).toEqual(CLAIMS);
|
|
204
|
+
expect(second).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('drops the prompt run and step once the step is no longer linked to the co-agent run', async () => {
|
|
208
|
+
const rows = linkedRows();
|
|
209
|
+
const { provider } = makeProvider(rows);
|
|
210
|
+
const verifier = new SessionRunIDVerifier();
|
|
211
|
+
const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
212
|
+
|
|
213
|
+
rows['MJ: AI Agent Run Steps'] = [];
|
|
214
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
215
|
+
|
|
216
|
+
expect(first).toEqual(CLAIMS);
|
|
217
|
+
expect(second).toEqual({ ...CLAIMS, PromptRunID: undefined, CoAgentRunStepID: undefined });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('still keeps a relationship that has not changed on the next call', async () => {
|
|
221
|
+
const { provider } = makeProvider(linkedRows());
|
|
222
|
+
const verifier = new SessionRunIDVerifier();
|
|
223
|
+
|
|
224
|
+
await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
225
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
226
|
+
|
|
227
|
+
expect(second).toEqual(CLAIMS);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('SessionRunIDVerifier.Verify — lookups as the acting user', () => {
|
|
232
|
+
it('does not give a user the result of a lookup made for another user', async () => {
|
|
233
|
+
// No system user is cached, so each lookup runs as the acting user, and only USER can see the run.
|
|
234
|
+
const otherUser = { ID: 'd3d3d3d3-0000-4000-8000-0000000000bb' } as unknown as UserInfo;
|
|
235
|
+
const runViews = vi.fn(async (paramsList: RunViewParams[], user: UserInfo) =>
|
|
236
|
+
paramsList.map((params) => {
|
|
237
|
+
const visible = user === USER && params.EntityName === 'MJ: AI Agent Runs';
|
|
238
|
+
return { Success: true, Results: visible ? [{ ID: CO_RUN_ID }] : [], ErrorMessage: '' };
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
const provider = { RunViews: runViews } as unknown as IMetadataProvider;
|
|
242
|
+
const verifier = new SessionRunIDVerifier();
|
|
243
|
+
|
|
244
|
+
const forUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
245
|
+
const forOtherUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, otherUser, provider);
|
|
246
|
+
|
|
247
|
+
expect(forUser.CoAgentRunID).toBe(CO_RUN_ID);
|
|
248
|
+
expect(forOtherUser.CoAgentRunID).toBeUndefined();
|
|
249
|
+
expect(runViews.mock.calls[1]?.[1]).toBe(otherUser);
|
|
250
|
+
});
|
|
251
|
+
});
|