@memberjunction/server 5.51.2 → 5.51.4
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 +34 -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 +136 -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/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/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -1
- package/dist/config.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/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3262 -3273
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +170 -15
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +5 -4
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +58 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +42 -3
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +148 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +287 -30
- 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.map +1 -1
- package/dist/index.js +58 -50
- 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/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 +2 -2
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
- 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 +39 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +97 -157
- 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 +14 -4
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +18 -6
- package/dist/resolvers/EntityRecordNameResolver.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/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +13 -16
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
- 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 +24 -27
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +30 -40
- 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/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +90 -112
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.js +8 -12
- package/dist/resolvers/VersionHistoryResolver.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/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +17 -15
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +90 -89
- 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 +84 -0
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- 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 +136 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
- package/src/__tests__/SessionManager.test.ts +88 -13
- package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/resolverBase.rls.test.ts +2 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +24 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +34 -1
- package/src/context.ts +25 -34
- package/src/generated/generated.ts +3262 -3273
- package/src/generic/CacheInvalidationResolver.ts +206 -15
- package/src/generic/FireAndForgetHeartbeat.ts +10 -4
- package/src/generic/PushStatusResolver.ts +79 -4
- package/src/generic/ResolverBase.ts +339 -31
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +65 -52
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
- package/src/resolvers/ActionResolver.ts +85 -22
- package/src/resolvers/AdhocQueryResolver.ts +110 -177
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +24 -7
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/MCPResolver.ts +13 -15
- package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
- package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
- package/src/resolvers/RunAIAgentResolver.ts +30 -40
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/RunTestResolver.ts +90 -112
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskOrchestrator.ts +17 -17
- 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,132 @@
|
|
|
1
|
+
// type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
|
|
2
|
+
// loaded. MUST precede any import that pulls in the resolver file.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `RunTemplate` renders a stored template on demand. Its API-key scope check is a no-op for JWT,
|
|
7
|
+
* magic-link and widget sessions, so the resolver applies its own rule. These tests pin that it
|
|
8
|
+
* refuses scope-limited sessions and callers who cannot read templates before it loads anything.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
// As in RunDecisionResolver.test.ts: vitest's esbuild transform does not apply
|
|
13
|
+
// `emitDecoratorMetadata`, so the decorators are no-ops and the resolver runs as a plain class.
|
|
14
|
+
vi.mock('type-graphql', () => {
|
|
15
|
+
const noopDecorator = () => () => undefined;
|
|
16
|
+
return {
|
|
17
|
+
Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
|
|
18
|
+
ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
|
|
19
|
+
Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
|
|
20
|
+
Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
|
|
21
|
+
createUnionType: () => class {},
|
|
22
|
+
AuthorizationError: class AuthorizationError extends Error {
|
|
23
|
+
constructor(message?: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'AuthorizationError';
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
Float: class {}, Int: class {}, ID: class {},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { EntityInfo, UserInfo, UserRoleInfo } from '@memberjunction/core';
|
|
33
|
+
import { RunTemplateResolver } from '../resolvers/RunTemplateResolver.js';
|
|
34
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
35
|
+
|
|
36
|
+
const UI_ROLE_ID = '0b9b7b55-0000-4000-8000-0000000000a1';
|
|
37
|
+
const TEMPLATE_ID = '0b9b7b55-0000-4000-8000-0000000000c1';
|
|
38
|
+
|
|
39
|
+
/** An entity whose read permission is granted to the given roles. */
|
|
40
|
+
function entityReadableBy(name: string, roleIds: string[]): EntityInfo {
|
|
41
|
+
const entityId = `entity-${name}`;
|
|
42
|
+
return new EntityInfo({
|
|
43
|
+
ID: entityId, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: `vw${name}`, IncludeInAPI: true,
|
|
44
|
+
EntityPermissions: roleIds.map((roleId, index) => ({
|
|
45
|
+
ID: `${entityId}-permission-${index}`, EntityID: entityId, RoleID: roleId,
|
|
46
|
+
CanCreate: false, CanRead: true, CanUpdate: false, CanDelete: false, Type: 'Allow',
|
|
47
|
+
})),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Metadata plus the one data call the resolver makes first. The data call fails on purpose, so a
|
|
53
|
+
* test can tell whether the resolver got past authorization without needing a database.
|
|
54
|
+
*/
|
|
55
|
+
class FakeProvider {
|
|
56
|
+
public readonly EntityObjectRequests: string[] = [];
|
|
57
|
+
|
|
58
|
+
constructor(private readonly entities: EntityInfo[]) {}
|
|
59
|
+
|
|
60
|
+
public EntityByName(name: string): EntityInfo | undefined {
|
|
61
|
+
return this.entities.find((e) => e.Name.trim().toLowerCase() === name.trim().toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async GetEntityObject(entityName: string): Promise<never> {
|
|
65
|
+
this.EntityObjectRequests.push(entityName);
|
|
66
|
+
throw new Error(`reached data access for ${entityName}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeUser(roleIds: string[]): UserInfo {
|
|
71
|
+
const userId = '0b9b7b55-0000-4000-8000-0000000000b1';
|
|
72
|
+
return new UserInfo(null, {
|
|
73
|
+
ID: userId, Name: 'Template Runner', Email: 'runner@example.com',
|
|
74
|
+
UserRoles: roleIds.map((roleId) => new UserRoleInfo({ UserID: userId, RoleID: roleId })),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const readableEverywhere = () => [
|
|
79
|
+
entityReadableBy('MJ: Templates', [UI_ROLE_ID]),
|
|
80
|
+
entityReadableBy('MJ: Template Contents', [UI_ROLE_ID]),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** A JWT-style session (no API key), so the API-key scope check is a no-op. */
|
|
84
|
+
function contextFor(user: UserInfo, provider: FakeProvider): AppContext {
|
|
85
|
+
const userPayload: UserPayload = { email: user.Email, userRecord: user, sessionId: 'session-1' };
|
|
86
|
+
return { userPayload, providers: [{ type: 'Read-Write', provider }] } as unknown as AppContext;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const runTemplate = (user: UserInfo, provider: FakeProvider) =>
|
|
90
|
+
new RunTemplateResolver().RunTemplate(TEMPLATE_ID, contextFor(user, provider));
|
|
91
|
+
|
|
92
|
+
describe('RunTemplate authorization', () => {
|
|
93
|
+
it('refuses an anonymous magic-link guest before loading the template', async () => {
|
|
94
|
+
const provider = new FakeProvider(readableEverywhere());
|
|
95
|
+
const guest = makeUser([UI_ROLE_ID]);
|
|
96
|
+
guest.IsMagicLinkAnonymous = true;
|
|
97
|
+
|
|
98
|
+
const result = await runTemplate(guest, provider);
|
|
99
|
+
|
|
100
|
+
expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
|
|
101
|
+
expect(provider.EntityObjectRequests).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('refuses a resource-scoped magic-link session before loading the template', async () => {
|
|
105
|
+
const provider = new FakeProvider(readableEverywhere());
|
|
106
|
+
const scoped = makeUser([UI_ROLE_ID]);
|
|
107
|
+
scoped.MagicLinkScope = { ResourceID: '0b9b7b55-0000-4000-8000-0000000000d1', ResourceType: 'Dashboards' };
|
|
108
|
+
|
|
109
|
+
const result = await runTemplate(scoped, provider);
|
|
110
|
+
|
|
111
|
+
expect(result).toMatchObject({ success: false, error: 'Running templates is not permitted for scope-limited sessions' });
|
|
112
|
+
expect(provider.EntityObjectRequests).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('refuses a user without read permission on MJ: Templates before loading the template', async () => {
|
|
116
|
+
const provider = new FakeProvider(readableEverywhere());
|
|
117
|
+
|
|
118
|
+
const result = await runTemplate(makeUser([]), provider);
|
|
119
|
+
|
|
120
|
+
expect(result).toMatchObject({ success: false, error: 'You do not have permission to read MJ: Templates' });
|
|
121
|
+
expect(provider.EntityObjectRequests).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('lets a user who can read templates and their content through to the template load', async () => {
|
|
125
|
+
const provider = new FakeProvider(readableEverywhere());
|
|
126
|
+
|
|
127
|
+
const result = await runTemplate(makeUser([UI_ROLE_ID]), provider);
|
|
128
|
+
|
|
129
|
+
expect(result).toMatchObject({ success: false, error: 'reached data access for MJ: Templates' });
|
|
130
|
+
expect(provider.EntityObjectRequests).toEqual(['MJ: Templates']);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
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/sqlserver-dataprovider';
|
|
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
|
+
GetUserRowLevelSecurityWhereClause: () => '',
|
|
42
|
+
} as unknown as EntityInfo;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Provider double: the metadata the screen reads, and a cache-check method that records calls. */
|
|
46
|
+
function fakeProvider() {
|
|
47
|
+
const calls: RunViewWithCacheCheckParams[][] = [];
|
|
48
|
+
const entities = [
|
|
49
|
+
entity('MJ: User Views', '__mj', 'vwUserViews'),
|
|
50
|
+
entity('MJ: Users', '__mj', 'vwUsers'),
|
|
51
|
+
];
|
|
52
|
+
const provider = {
|
|
53
|
+
Entities: entities,
|
|
54
|
+
EntityByName: (name: string) => entities.find((e) => e.Name === name),
|
|
55
|
+
RunViewsWithCacheCheck: async (params: RunViewWithCacheCheckParams[]) => {
|
|
56
|
+
calls.push(params);
|
|
57
|
+
return { success: true, results: [] };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { provider: provider as unknown as DatabaseProviderBase, calls };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function context(provider: DatabaseProviderBase, payload: Partial<UserPayload> = {}): AppContext {
|
|
64
|
+
const userPayload = { email: USER.Email, userRecord: USER, ...payload } as UserPayload;
|
|
65
|
+
return { providers: [{ type: 'Read-Only', provider }], userPayload } as unknown as AppContext;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cacheCheckInput(params: Partial<RunDynamicViewInput>): RunViewWithCacheCheckInput[] {
|
|
69
|
+
const dynamic = Object.assign(new RunDynamicViewInput(), { EntityName: 'MJ: User Views', ...params });
|
|
70
|
+
return [Object.assign(new RunViewWithCacheCheckInput(), { params: dynamic })];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('RunViewsWithCacheCheck — GraphQL-boundary clause screen', () => {
|
|
74
|
+
it('refuses a stacked statement hidden behind a bracket identifier, before the provider runs', async () => {
|
|
75
|
+
const { provider, calls } = fakeProvider();
|
|
76
|
+
|
|
77
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
78
|
+
cacheCheckInput({ ExtraFilter: BRACKET_STACKED }),
|
|
79
|
+
context(provider),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(calls).toHaveLength(0);
|
|
83
|
+
expect(result.success).toBe(false);
|
|
84
|
+
expect(result.errorMessage).toMatch(/Invalid ExtraFilter: multiple statements/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('refuses a subquery against a base table', async () => {
|
|
88
|
+
const { provider, calls } = fakeProvider();
|
|
89
|
+
|
|
90
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
91
|
+
cacheCheckInput({ ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')` }),
|
|
92
|
+
context(provider),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(calls).toHaveLength(0);
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
expect(result.errorMessage).toMatch(/entity base view/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('screens OrderBy and OverrideExcludeFilter too', async () => {
|
|
101
|
+
for (const params of [
|
|
102
|
+
{ OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
|
|
103
|
+
{ OverrideExcludeFilter: BRACKET_STACKED },
|
|
104
|
+
]) {
|
|
105
|
+
const { provider, calls } = fakeProvider();
|
|
106
|
+
|
|
107
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(cacheCheckInput(params), context(provider));
|
|
108
|
+
|
|
109
|
+
expect(calls).toHaveLength(0);
|
|
110
|
+
expect(result.success).toBe(false);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('passes a filter that only reads entity base views through to the provider', async () => {
|
|
115
|
+
const { provider, calls } = fakeProvider();
|
|
116
|
+
const filter = `UserID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'o''brien@example.com')`;
|
|
117
|
+
|
|
118
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
119
|
+
cacheCheckInput({ ExtraFilter: filter, OrderBy: '[Name] DESC' }),
|
|
120
|
+
context(provider),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
expect(calls).toHaveLength(1);
|
|
125
|
+
expect(calls[0][0].params.ExtraFilter).toBe(filter);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('RunViewsWithCacheCheck — API-key scope', () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
authorize.mockReset();
|
|
132
|
+
vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue({ ID: 'system', Email: 'system@example.com' } as UserInfo);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('refuses an API key without the batch view scope, before the provider runs', async () => {
|
|
136
|
+
authorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
|
|
137
|
+
const { provider, calls } = fakeProvider();
|
|
138
|
+
|
|
139
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
140
|
+
cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
|
|
141
|
+
context(provider, { apiKeyHash: 'hash-of-a-narrow-key' }),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(calls).toHaveLength(0);
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
expect(result.errorMessage).toMatch(/'view:batch' scope/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('lets an API key holding the scope through', async () => {
|
|
150
|
+
authorize.mockImplementation(async (_hash: string, _app: string, scope: string) => ({
|
|
151
|
+
Allowed: scope === 'view:batch',
|
|
152
|
+
Reason: scope === 'view:batch' ? 'granted' : 'not full access',
|
|
153
|
+
}));
|
|
154
|
+
const { provider, calls } = fakeProvider();
|
|
155
|
+
|
|
156
|
+
const result = await new RunViewResolver().RunViewsWithCacheCheck(
|
|
157
|
+
cacheCheckInput({ ExtraFilter: `Name = 'x'` }),
|
|
158
|
+
context(provider, { apiKeyHash: 'hash-of-a-batch-key' }),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result.success).toBe(true);
|
|
162
|
+
expect(calls).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -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 () => {
|
|
@@ -527,3 +555,50 @@ describe('SessionManager — server-side channel plugin lifecycle notifications'
|
|
|
527
555
|
expect(session.Status).toBe('Closed');
|
|
528
556
|
});
|
|
529
557
|
});
|
|
558
|
+
|
|
559
|
+
// A session's Config is writable by its owner (generic CRUD on MJ: AI Agent Sessions), so the run
|
|
560
|
+
// ids in it are acted on only when they are UUIDs of records that belong to the session.
|
|
561
|
+
describe('SessionManager — run ids in the owner-writable session Config', () => {
|
|
562
|
+
const SESSION_ID = 'c3c3c3c3-1000-4000-8000-000000000001';
|
|
563
|
+
const FOREIGN_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f1';
|
|
564
|
+
const FOREIGN_PROMPT_RUN_ID = 'c3c3c3c3-1000-4000-8000-0000000000f2';
|
|
565
|
+
const FOREIGN_STEP_ID = 'c3c3c3c3-1000-4000-8000-0000000000f3';
|
|
566
|
+
/** Harmless text that is not a UUID and would change the meaning of a quoted filter. */
|
|
567
|
+
const NOT_A_UUID = "x' OR '1'='1";
|
|
568
|
+
|
|
569
|
+
function issuedFilters(): string[] {
|
|
570
|
+
return runViewMock.mock.calls.map(([params]) => (params as { ExtraFilter?: string }).ExtraFilter ?? '');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
it('CloseSession does not finalize run ids that are not records of the session', async () => {
|
|
574
|
+
const session = makeSessionEntity({
|
|
575
|
+
ID: SESSION_ID,
|
|
576
|
+
Status: 'Active',
|
|
577
|
+
Config_: JSON.stringify({
|
|
578
|
+
targetAgentID: 't1',
|
|
579
|
+
coAgentRunID: FOREIGN_RUN_ID,
|
|
580
|
+
promptRunID: FOREIGN_PROMPT_RUN_ID,
|
|
581
|
+
coAgentRunStepID: FOREIGN_STEP_ID,
|
|
582
|
+
}),
|
|
583
|
+
});
|
|
584
|
+
const { provider } = makeProvider(() => session);
|
|
585
|
+
|
|
586
|
+
await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
|
|
587
|
+
|
|
588
|
+
expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('CloseSession does not finalize run ids that are not UUIDs, and never puts them in a filter', async () => {
|
|
592
|
+
const session = makeSessionEntity({
|
|
593
|
+
ID: SESSION_ID,
|
|
594
|
+
Status: 'Active',
|
|
595
|
+
Config_: JSON.stringify({ targetAgentID: 't1', coAgentRunID: NOT_A_UUID, promptRunID: NOT_A_UUID }),
|
|
596
|
+
});
|
|
597
|
+
const { provider } = makeProvider(() => session);
|
|
598
|
+
|
|
599
|
+
await new SessionManager().CloseSession(SESSION_ID, makeUser(), provider);
|
|
600
|
+
|
|
601
|
+
expect(finalizeCoAgentRunMock).not.toHaveBeenCalled();
|
|
602
|
+
expect(issuedFilters().filter((f) => f.includes(NOT_A_UUID))).toEqual([]);
|
|
603
|
+
});
|
|
604
|
+
});
|