@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,255 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import type { IMetadataProvider, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
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
|
+
/** Stubs the cached system user; lts/5's UserCache has no SetUsers. */
|
|
32
|
+
let getSystemUserSpy: ReturnType<typeof vi.spyOn>;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
36
|
+
getSystemUserSpy = vi.spyOn(UserCache.Instance, 'GetSystemUser').mockReturnValue(undefined as unknown as UserInfo);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('SessionRunIDVerifier.Verify', () => {
|
|
44
|
+
it('keeps nothing, and runs no lookup, when no claim is a UUID', async () => {
|
|
45
|
+
const { provider, runViews } = makeProvider({});
|
|
46
|
+
|
|
47
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
48
|
+
SESSION_ID,
|
|
49
|
+
{ CoAgentRunID: "x' OR '1'='1", PromptRunID: 'prompt-run-1', PendingFeedbackRunID: '' },
|
|
50
|
+
USER,
|
|
51
|
+
provider,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({});
|
|
55
|
+
expect(runViews).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('looks up the claimed runs within the session and keeps only the ones it finds', async () => {
|
|
59
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
60
|
+
|
|
61
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
62
|
+
SESSION_ID,
|
|
63
|
+
{ CoAgentRunID: CO_RUN_ID, PendingFeedbackRunID: PAUSED_RUN_ID },
|
|
64
|
+
USER,
|
|
65
|
+
provider,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result.CoAgentRunID).toBe(CO_RUN_ID);
|
|
69
|
+
expect(result.PendingFeedbackRunID).toBeUndefined();
|
|
70
|
+
const [runsView] = issuedViews(runViews);
|
|
71
|
+
expect(runsView.EntityName).toBe('MJ: AI Agent Runs');
|
|
72
|
+
expect(runsView.ExtraFilter).toBe(`AgentSessionID='${SESSION_ID}' AND ID IN ('${CO_RUN_ID}', '${PAUSED_RUN_ID}')`);
|
|
73
|
+
// No system user is cached here, so the lookup runs as the acting user.
|
|
74
|
+
expect(runViews.mock.calls[0][1]).toBe(USER);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('runs the lookup as the system user when one is available', async () => {
|
|
78
|
+
// Row-level security can hide a session's own runs from its owner: a web-widget guest's
|
|
79
|
+
// delegated runs carry no ConversationID, so the guest's run filter does not match them.
|
|
80
|
+
const systemUser = { ID: UserCache.Instance.SYSTEM_USER_ID, Email: 'system@example.com' } as unknown as UserInfo;
|
|
81
|
+
getSystemUserSpy.mockReturnValue(systemUser);
|
|
82
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID }] });
|
|
83
|
+
|
|
84
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
|
|
85
|
+
|
|
86
|
+
expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
|
|
87
|
+
expect(runViews.mock.calls[0][1]).toBe(systemUser);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('keeps the prompt run and step that belong to the session\'s co-agent run', async () => {
|
|
91
|
+
const { provider, runViews } = makeProvider({
|
|
92
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
|
|
93
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
97
|
+
SESSION_ID,
|
|
98
|
+
{ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
|
|
99
|
+
USER,
|
|
100
|
+
provider,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(result).toEqual({ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: undefined });
|
|
104
|
+
const stepsView = issuedViews(runViews).find((view) => view.EntityName === 'MJ: AI Agent Run Steps');
|
|
105
|
+
expect(stepsView?.ExtraFilter).toBe(`AgentRunID='${CO_RUN_ID}' AND (ID='${STEP_ID}' OR TargetLogID='${PROMPT_RUN_ID}')`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('drops a prompt run that is the target of a step other than a Prompt step', async () => {
|
|
109
|
+
const { provider } = makeProvider({
|
|
110
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }],
|
|
111
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Tool', TargetLogID: PROMPT_RUN_ID }],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
|
|
115
|
+
|
|
116
|
+
expect(result.PromptRunID).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('drops the prompt run and step when the co-agent run is not the session\'s', async () => {
|
|
120
|
+
const { provider } = makeProvider({
|
|
121
|
+
'MJ: AI Agent Runs': [],
|
|
122
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await new SessionRunIDVerifier().Verify(
|
|
126
|
+
SESSION_ID,
|
|
127
|
+
{ CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID },
|
|
128
|
+
USER,
|
|
129
|
+
provider,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('matches ids case-insensitively', async () => {
|
|
136
|
+
const { provider } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: PAUSED_RUN_ID.toUpperCase() }] });
|
|
137
|
+
|
|
138
|
+
const result = await new SessionRunIDVerifier().Verify(SESSION_ID, { PendingFeedbackRunID: PAUSED_RUN_ID }, USER, provider);
|
|
139
|
+
|
|
140
|
+
expect(result.PendingFeedbackRunID).toBe(PAUSED_RUN_ID);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('looks up the same claims again on every call', async () => {
|
|
144
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
145
|
+
const verifier = new SessionRunIDVerifier();
|
|
146
|
+
|
|
147
|
+
await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
148
|
+
const second = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
149
|
+
|
|
150
|
+
expect(second.CoAgentRunID).toBe(CO_RUN_ID);
|
|
151
|
+
expect(runViews).toHaveBeenCalledTimes(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('reads the runs and steps from the database, not from a cached view', async () => {
|
|
155
|
+
const { provider, runViews } = makeProvider({ 'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }] });
|
|
156
|
+
|
|
157
|
+
await new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID }, USER, provider);
|
|
158
|
+
|
|
159
|
+
const views = issuedViews(runViews);
|
|
160
|
+
expect(views.map((view) => view.EntityName)).toEqual(['MJ: AI Agent Runs', 'MJ: AI Agent Run Steps']);
|
|
161
|
+
expect(views.every((view) => view.BypassCache === true)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps nothing when a lookup fails, and looks up again next time', async () => {
|
|
165
|
+
const { provider, runViews } = makeProvider({}, false);
|
|
166
|
+
const verifier = new SessionRunIDVerifier();
|
|
167
|
+
|
|
168
|
+
const first = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
169
|
+
await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
170
|
+
|
|
171
|
+
expect(first).toEqual({});
|
|
172
|
+
expect(runViews).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('keeps nothing, without throwing, when the provider throws', async () => {
|
|
176
|
+
const provider = { RunViews: vi.fn(async () => { throw new Error('connection lost'); }) } as unknown as IMetadataProvider;
|
|
177
|
+
|
|
178
|
+
await expect(new SessionRunIDVerifier().Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider)).resolves.toEqual({});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A run's `AgentSessionID` and a step's `AgentRunID` and `TargetLogID` can change after a lookup (the
|
|
184
|
+
* generated update mutations accept them), so each call must decide from what the rows say now.
|
|
185
|
+
*/
|
|
186
|
+
describe('SessionRunIDVerifier.Verify — relationships that change between calls', () => {
|
|
187
|
+
const CLAIMS = { CoAgentRunID: CO_RUN_ID, PromptRunID: PROMPT_RUN_ID, CoAgentRunStepID: STEP_ID, PendingFeedbackRunID: PAUSED_RUN_ID };
|
|
188
|
+
|
|
189
|
+
/** Both runs are records of the session, and the step is the co-agent run's Prompt step for the prompt run. */
|
|
190
|
+
function linkedRows(): Rows {
|
|
191
|
+
return {
|
|
192
|
+
'MJ: AI Agent Runs': [{ ID: CO_RUN_ID }, { ID: PAUSED_RUN_ID }],
|
|
193
|
+
'MJ: AI Agent Run Steps': [{ ID: STEP_ID, StepType: 'Prompt', TargetLogID: PROMPT_RUN_ID }],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('drops runs that stopped being records of the session since the last call', async () => {
|
|
198
|
+
const rows = linkedRows();
|
|
199
|
+
const { provider } = makeProvider(rows);
|
|
200
|
+
const verifier = new SessionRunIDVerifier();
|
|
201
|
+
const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
202
|
+
|
|
203
|
+
// The runs were moved to another session, unlinked or deleted.
|
|
204
|
+
rows['MJ: AI Agent Runs'] = [];
|
|
205
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
206
|
+
|
|
207
|
+
expect(first).toEqual(CLAIMS);
|
|
208
|
+
expect(second).toEqual({ CoAgentRunID: undefined, PromptRunID: undefined, CoAgentRunStepID: undefined, PendingFeedbackRunID: undefined });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('drops the prompt run and step once the step is no longer linked to the co-agent run', async () => {
|
|
212
|
+
const rows = linkedRows();
|
|
213
|
+
const { provider } = makeProvider(rows);
|
|
214
|
+
const verifier = new SessionRunIDVerifier();
|
|
215
|
+
const first = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
216
|
+
|
|
217
|
+
rows['MJ: AI Agent Run Steps'] = [];
|
|
218
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
219
|
+
|
|
220
|
+
expect(first).toEqual(CLAIMS);
|
|
221
|
+
expect(second).toEqual({ ...CLAIMS, PromptRunID: undefined, CoAgentRunStepID: undefined });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('still keeps a relationship that has not changed on the next call', async () => {
|
|
225
|
+
const { provider } = makeProvider(linkedRows());
|
|
226
|
+
const verifier = new SessionRunIDVerifier();
|
|
227
|
+
|
|
228
|
+
await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
229
|
+
const second = await verifier.Verify(SESSION_ID, CLAIMS, USER, provider);
|
|
230
|
+
|
|
231
|
+
expect(second).toEqual(CLAIMS);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('SessionRunIDVerifier.Verify — lookups as the acting user', () => {
|
|
236
|
+
it('does not give a user the result of a lookup made for another user', async () => {
|
|
237
|
+
// No system user is cached, so each lookup runs as the acting user, and only USER can see the run.
|
|
238
|
+
const otherUser = { ID: 'd3d3d3d3-0000-4000-8000-0000000000bb' } as unknown as UserInfo;
|
|
239
|
+
const runViews = vi.fn(async (paramsList: RunViewParams[], user: UserInfo) =>
|
|
240
|
+
paramsList.map((params) => {
|
|
241
|
+
const visible = user === USER && params.EntityName === 'MJ: AI Agent Runs';
|
|
242
|
+
return { Success: true, Results: visible ? [{ ID: CO_RUN_ID }] : [], ErrorMessage: '' };
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
const provider = { RunViews: runViews } as unknown as IMetadataProvider;
|
|
246
|
+
const verifier = new SessionRunIDVerifier();
|
|
247
|
+
|
|
248
|
+
const forUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, USER, provider);
|
|
249
|
+
const forOtherUser = await verifier.Verify(SESSION_ID, { CoAgentRunID: CO_RUN_ID }, otherUser, provider);
|
|
250
|
+
|
|
251
|
+
expect(forUser.CoAgentRunID).toBe(CO_RUN_ID);
|
|
252
|
+
expect(forOtherUser.CoAgentRunID).toBeUndefined();
|
|
253
|
+
expect(runViews.mock.calls[1]?.[1]).toBe(otherUser);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// type-graphql decorators in the resolver graph need the Reflect.metadata polyfill at import time.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
/**
|
|
4
|
+
* `UpdateMJDataContextItem` goes through `ResolverBase.UpdateRecord`. `MJ: Data Context Items` does
|
|
5
|
+
* not track record changes, so without special handling a client that sends `OldValues___` decides
|
|
6
|
+
* the record's starting state. It could repeat its new SQL as the old value, so the SQL field does
|
|
7
|
+
* not look changed. Or it could leave `Type` and `SQL` out entirely, so they are never loaded and
|
|
8
|
+
* the stored procedure keeps the stored SQL item. The server entity class for the item must still
|
|
9
|
+
* refuse a non-Owner's save of a SQL item on every one of these paths.
|
|
10
|
+
*
|
|
11
|
+
* The resolver base and the entity class are real. The provider hands out real entity objects,
|
|
12
|
+
* loads a fixed stored row, and records each save instead of writing it.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
15
|
+
import { EntityInfo, Metadata, UserInfo, UserRoleInfo } from '@memberjunction/core';
|
|
16
|
+
import type { BaseEntity, DatabaseProviderBase, IEntityDataProvider, IMetadataProvider } from '@memberjunction/core';
|
|
17
|
+
import { MJDataContextItemEntityServer } from '@memberjunction/core-entities-server';
|
|
18
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
19
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
20
|
+
import type { UserPayload } from '../types.js';
|
|
21
|
+
|
|
22
|
+
const ENTITY_ID = 'E0000000-0000-4000-8000-0000000000D2';
|
|
23
|
+
const UI_ROLE_ID = 'E1000000-0000-4000-8000-000000000001';
|
|
24
|
+
const DATA_CONTEXT_ID = 'B0000000-0000-4000-8000-000000000001';
|
|
25
|
+
const OTHER_DATA_CONTEXT_ID = 'B0000000-0000-4000-8000-000000000002';
|
|
26
|
+
const ITEM_ID = 'C0000000-0000-4000-8000-000000000001';
|
|
27
|
+
const STORED_SQL = 'SELECT 1 AS One';
|
|
28
|
+
const NEW_SQL = 'SELECT 2 AS Two';
|
|
29
|
+
|
|
30
|
+
interface StoredRow {
|
|
31
|
+
ID: string;
|
|
32
|
+
DataContextID: string;
|
|
33
|
+
Type: string;
|
|
34
|
+
SQL: string | null;
|
|
35
|
+
DataJSON: string | null;
|
|
36
|
+
Description: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A SQL item in someone else's data context. */
|
|
40
|
+
const STORED_SQL_ITEM: StoredRow = {
|
|
41
|
+
ID: ITEM_ID, DataContextID: DATA_CONTEXT_ID, Type: 'sql', SQL: STORED_SQL, DataJSON: null, Description: 'Revenue',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** An item of another type, with no SQL. */
|
|
45
|
+
const STORED_ENTITY_ITEM: StoredRow = {
|
|
46
|
+
ID: ITEM_ID, DataContextID: DATA_CONTEXT_ID, Type: 'full_entity', SQL: null, DataJSON: null, Description: 'Notes',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `MJ: Data Context Items` as the database describes it: record changes and field-level security
|
|
51
|
+
* off, the API open, and the UI role holding every permission with no row-level filter.
|
|
52
|
+
*/
|
|
53
|
+
const ITEM_ENTITY = new EntityInfo({
|
|
54
|
+
ID: ENTITY_ID,
|
|
55
|
+
Name: 'MJ: Data Context Items',
|
|
56
|
+
SchemaName: '__mj',
|
|
57
|
+
BaseTable: 'DataContextItem',
|
|
58
|
+
BaseView: 'vwDataContextItems',
|
|
59
|
+
Status: 'Active',
|
|
60
|
+
IncludeInAPI: true,
|
|
61
|
+
AllowCreateAPI: true,
|
|
62
|
+
AllowUpdateAPI: true,
|
|
63
|
+
AllowDeleteAPI: true,
|
|
64
|
+
TrackRecordChanges: false,
|
|
65
|
+
EnableFieldLevelSecurity: false,
|
|
66
|
+
EntityPermissions: [{
|
|
67
|
+
ID: 'P0000000-0000-4000-8000-000000000001', EntityID: ENTITY_ID, RoleID: UI_ROLE_ID,
|
|
68
|
+
CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true,
|
|
69
|
+
}],
|
|
70
|
+
EntityFields: [
|
|
71
|
+
{ Name: 'ID', Type: 'uniqueidentifier', AllowsNull: false, IsPrimaryKey: true },
|
|
72
|
+
{ Name: 'DataContextID', Type: 'uniqueidentifier', AllowsNull: false },
|
|
73
|
+
{ Name: 'Type', Type: 'nvarchar', AllowsNull: false },
|
|
74
|
+
{ Name: 'SQL', Type: 'nvarchar', AllowsNull: true },
|
|
75
|
+
{ Name: 'DataJSON', Type: 'nvarchar', AllowsNull: true },
|
|
76
|
+
{ Name: 'Description', Type: 'nvarchar', AllowsNull: true },
|
|
77
|
+
].map((c, i) => ({
|
|
78
|
+
ID: `F0000000-0000-4000-8000-00000000000${i}`,
|
|
79
|
+
EntityID: ENTITY_ID,
|
|
80
|
+
Sequence: i + 1,
|
|
81
|
+
Length: 0,
|
|
82
|
+
Precision: 0,
|
|
83
|
+
Scale: 0,
|
|
84
|
+
AllowUpdateAPI: true,
|
|
85
|
+
...c,
|
|
86
|
+
})),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** A UI-role user who is not an administrator. */
|
|
90
|
+
const UI_USER = new UserInfo(null, {
|
|
91
|
+
ID: 'A0000000-0000-4000-8000-000000000002',
|
|
92
|
+
Email: 'ui-user@example.com',
|
|
93
|
+
Type: 'User',
|
|
94
|
+
UserRoles: [new UserRoleInfo({ UserID: 'A0000000-0000-4000-8000-000000000002', RoleID: UI_ROLE_ID })],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const PAYLOAD: UserPayload = { email: UI_USER.Email, userRecord: UI_USER, sessionId: 'test-session' };
|
|
98
|
+
const PUB_SUB = { publish: async (): Promise<void> => undefined } as unknown as PubSubEngine;
|
|
99
|
+
|
|
100
|
+
/** What one save would have written. */
|
|
101
|
+
interface RecordedSave {
|
|
102
|
+
DataContextID: string;
|
|
103
|
+
SQL: string | null;
|
|
104
|
+
DataJSON: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A database provider whose load returns `stored`, and which records each save it receives. */
|
|
108
|
+
function recordingDatabase(stored: StoredRow): { Saves: RecordedSave[]; Provider: DatabaseProviderBase } {
|
|
109
|
+
const saves: RecordedSave[] = [];
|
|
110
|
+
const entityData = {
|
|
111
|
+
async Load(): Promise<Record<string, unknown>> {
|
|
112
|
+
return { ...stored };
|
|
113
|
+
},
|
|
114
|
+
async Save(entity: BaseEntity): Promise<Record<string, unknown>> {
|
|
115
|
+
const item = entity as MJDataContextItemEntityServer;
|
|
116
|
+
saves.push({ DataContextID: item.DataContextID, SQL: item.SQL, DataJSON: item.DataJSON });
|
|
117
|
+
return entity.GetAll();
|
|
118
|
+
},
|
|
119
|
+
} as unknown as IEntityDataProvider;
|
|
120
|
+
const database = {
|
|
121
|
+
async GetEntityObject(_entityName: string, user: UserInfo): Promise<MJDataContextItemEntityServer> {
|
|
122
|
+
const item = new MJDataContextItemEntityServer(ITEM_ENTITY, entityData);
|
|
123
|
+
item.ContextCurrentUser = user;
|
|
124
|
+
return item;
|
|
125
|
+
},
|
|
126
|
+
} as unknown as DatabaseProviderBase;
|
|
127
|
+
return { Saves: saves, Provider: database };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Builds an `OldValues___` array from a plain object. */
|
|
131
|
+
function oldValues(values: Record<string, string | null>): Array<{ Key: string; Value: string | null }> {
|
|
132
|
+
return Object.entries(values).map(([Key, Value]) => ({ Key, Value }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Exposes ResolverBase's generic update path, which the generated `UpdateMJDataContextItem` mutation calls. */
|
|
136
|
+
class UpdateHarness extends ResolverBase {
|
|
137
|
+
/** Runs the update and reports how it ended: 'saved', or the message it was refused with. */
|
|
138
|
+
public async Update(input: Record<string, unknown>, provider: DatabaseProviderBase): Promise<string> {
|
|
139
|
+
try {
|
|
140
|
+
await this.UpdateRecord('MJ: Data Context Items', input, provider, PAYLOAD, PUB_SUB);
|
|
141
|
+
return 'saved';
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return e instanceof Error ? e.message : String(e);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const harness = new UpdateHarness();
|
|
149
|
+
|
|
150
|
+
describe('UpdateMJDataContextItem — a non-Owner may not change a SQL item', () => {
|
|
151
|
+
it('refuses new SQL when the update loads the stored row (no OldValues___)', async () => {
|
|
152
|
+
const db = recordingDatabase(STORED_SQL_ITEM);
|
|
153
|
+
|
|
154
|
+
const outcome = await harness.Update({ ...STORED_SQL_ITEM, SQL: NEW_SQL }, db.Provider);
|
|
155
|
+
|
|
156
|
+
expect(db.Saves).toEqual([]);
|
|
157
|
+
expect(outcome).toMatch(/Owner-type/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('refuses new SQL when OldValues___ repeats the new SQL, so the field does not look changed', async () => {
|
|
161
|
+
const db = recordingDatabase(STORED_SQL_ITEM);
|
|
162
|
+
const input = {
|
|
163
|
+
...STORED_SQL_ITEM,
|
|
164
|
+
SQL: NEW_SQL,
|
|
165
|
+
Description: 'Renamed',
|
|
166
|
+
OldValues___: oldValues({ ID: ITEM_ID, DataContextID: DATA_CONTEXT_ID, Type: 'sql', SQL: NEW_SQL, Description: 'Revenue' }),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const outcome = await harness.Update(input, db.Provider);
|
|
170
|
+
|
|
171
|
+
expect(db.Saves).toEqual([]);
|
|
172
|
+
expect(outcome).toMatch(/Owner-type/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('refuses moving a SQL item to another data context when Type and SQL are left out of the input and OldValues___', async () => {
|
|
176
|
+
const db = recordingDatabase(STORED_SQL_ITEM);
|
|
177
|
+
const input = {
|
|
178
|
+
ID: ITEM_ID,
|
|
179
|
+
DataContextID: OTHER_DATA_CONTEXT_ID,
|
|
180
|
+
OldValues___: oldValues({ ID: ITEM_ID, DataContextID: DATA_CONTEXT_ID }),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const outcome = await harness.Update(input, db.Provider);
|
|
184
|
+
|
|
185
|
+
expect(db.Saves).toEqual([]);
|
|
186
|
+
expect(outcome).toMatch(/Owner-type/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('refuses changing the cached DataJSON of a SQL item when Type and SQL are left out of the input and OldValues___', async () => {
|
|
190
|
+
const db = recordingDatabase(STORED_SQL_ITEM);
|
|
191
|
+
const input = {
|
|
192
|
+
ID: ITEM_ID,
|
|
193
|
+
DataJSON: '[{"One":2}]',
|
|
194
|
+
OldValues___: oldValues({ ID: ITEM_ID, DataJSON: null }),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const outcome = await harness.Update(input, db.Provider);
|
|
198
|
+
|
|
199
|
+
expect(db.Saves).toEqual([]);
|
|
200
|
+
expect(outcome).toMatch(/Owner-type/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('still saves a non-Owner\'s change to an item of another type that sends OldValues___', async () => {
|
|
204
|
+
// The success path maps the saved row back through the process-wide metadata.
|
|
205
|
+
vi.spyOn(Metadata, 'Provider', 'get').mockReturnValue(
|
|
206
|
+
{ Entities: [ITEM_ENTITY], EntityByName: () => ITEM_ENTITY } as unknown as IMetadataProvider,
|
|
207
|
+
);
|
|
208
|
+
const db = recordingDatabase(STORED_ENTITY_ITEM);
|
|
209
|
+
const input = {
|
|
210
|
+
ID: ITEM_ID,
|
|
211
|
+
Description: 'Renamed',
|
|
212
|
+
OldValues___: oldValues({ ID: ITEM_ID, Description: 'Notes' }),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const outcome = await harness.Update(input, db.Provider);
|
|
216
|
+
|
|
217
|
+
expect(outcome).toBe('saved');
|
|
218
|
+
expect(db.Saves).toEqual([{ DataContextID: DATA_CONTEXT_ID, SQL: null, DataJSON: null }]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REST view routes (`POST /views/:entityName`, `POST /views/batch`, `GET /entities/:entityName`)
|
|
3
|
+
* hand client `ExtraFilter` / `OrderBy` / `OverrideExcludeFilter` text to `RunView` through
|
|
4
|
+
* `ViewOperationsHandler`. These tests pin that the text passes the same base-view AST screen as
|
|
5
|
+
* the GraphQL RunView resolvers before any view runs. Payloads are harmless (stacked `SELECT`s);
|
|
6
|
+
* the point is only whether they reach `RunView`.
|
|
7
|
+
*
|
|
8
|
+
* `@memberjunction/core`'s `Metadata` and `RunView` are replaced at the module boundary, as in
|
|
9
|
+
* ViewOperationsHandler.test.ts; the screen itself runs unmodified.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
const { mockRunViewFn, mockRunViewsFn, mockEntityList } = vi.hoisted(() => ({
|
|
14
|
+
mockRunViewFn: vi.fn(),
|
|
15
|
+
mockRunViewsFn: vi.fn(),
|
|
16
|
+
mockEntityList: [] as Array<{
|
|
17
|
+
Name: string;
|
|
18
|
+
SchemaName: string;
|
|
19
|
+
BaseView: string;
|
|
20
|
+
GetUserPermisions: (u: unknown) => { CanRead: boolean };
|
|
21
|
+
GetUserRowLevelSecurityWhereClause: () => string;
|
|
22
|
+
}>,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
26
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
27
|
+
class MockMetadata {
|
|
28
|
+
public get Entities() {
|
|
29
|
+
return mockEntityList;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class MockRunView {
|
|
33
|
+
public RunView(params: unknown, contextUser?: unknown): Promise<unknown> {
|
|
34
|
+
return mockRunViewFn(params, contextUser);
|
|
35
|
+
}
|
|
36
|
+
public RunViews(params: unknown[], contextUser?: unknown): Promise<unknown> {
|
|
37
|
+
return mockRunViewsFn(params, contextUser);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ...actual, Metadata: MockMetadata, RunView: MockRunView, LogError: vi.fn() };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
import type { BaseEntity, PlatformSQL, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
44
|
+
import { ViewOperationsHandler } from '../rest/ViewOperationsHandler.js';
|
|
45
|
+
|
|
46
|
+
const BRACKET_STACKED =
|
|
47
|
+
"1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM sales.vwCustomers WHERE (1 = (SELECT 1 AS [b'])";
|
|
48
|
+
|
|
49
|
+
const USER = { ID: 'user-1', Name: 'Reader' } as unknown as UserInfo;
|
|
50
|
+
const OK_RESULT = { Success: true, Results: [{ ID: '1' }] };
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
mockEntityList.length = 0;
|
|
55
|
+
const unscoped = { GetUserRowLevelSecurityWhereClause: () => '' };
|
|
56
|
+
mockEntityList.push(
|
|
57
|
+
{ Name: 'Customers', SchemaName: 'sales', BaseView: 'vwCustomers', GetUserPermisions: () => ({ CanRead: true }), ...unscoped },
|
|
58
|
+
{ Name: 'Ledger', SchemaName: 'sales', BaseView: 'vwLedger', GetUserPermisions: () => ({ CanRead: false }), ...unscoped },
|
|
59
|
+
{
|
|
60
|
+
Name: 'Orders',
|
|
61
|
+
SchemaName: 'sales',
|
|
62
|
+
BaseView: 'vwOrders',
|
|
63
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
64
|
+
GetUserRowLevelSecurityWhereClause: () => "(OwnerID = 'user-1')",
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
mockRunViewFn.mockResolvedValue(OK_RESULT);
|
|
68
|
+
mockRunViewsFn.mockResolvedValue([OK_RESULT]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('ViewOperationsHandler — REST clause screen', () => {
|
|
72
|
+
it('RunView refuses a stacked statement hidden behind a bracket identifier, before RunView', async () => {
|
|
73
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER);
|
|
74
|
+
|
|
75
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
76
|
+
expect(outcome.success).toBe(false);
|
|
77
|
+
expect(outcome.error).toMatch(/Invalid ExtraFilter: multiple statements/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('RunView screens OrderBy and OverrideExcludeFilter too', async () => {
|
|
81
|
+
const cases: RunViewParams[] = [
|
|
82
|
+
{ EntityName: 'Customers', OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
|
|
83
|
+
{ EntityName: 'Customers', OverrideExcludeFilter: BRACKET_STACKED },
|
|
84
|
+
];
|
|
85
|
+
for (const params of cases) {
|
|
86
|
+
const outcome = await ViewOperationsHandler.runView(params, USER);
|
|
87
|
+
expect(outcome.success).toBe(false);
|
|
88
|
+
}
|
|
89
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('RunView refuses a subquery against a base table or an entity the caller cannot read', async () => {
|
|
93
|
+
for (const filter of [
|
|
94
|
+
`EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')`,
|
|
95
|
+
`ID IN (SELECT CustomerID FROM sales.vwLedger)`,
|
|
96
|
+
]) {
|
|
97
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
98
|
+
expect(outcome.success).toBe(false);
|
|
99
|
+
}
|
|
100
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('RunView refuses a subquery over an entity that is row-level-security filtered for the caller', async () => {
|
|
104
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: `ID IN (SELECT CustomerID FROM sales.vwOrders)` }, USER);
|
|
105
|
+
|
|
106
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
107
|
+
expect(outcome.success).toBe(false);
|
|
108
|
+
expect(outcome.error).toMatch(/'Orders' is row-level-security filtered for you/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('RunView screens every variant of a platform-specific filter', async () => {
|
|
112
|
+
const filter: PlatformSQL = { default: `Name = 'x'`, sqlserver: BRACKET_STACKED };
|
|
113
|
+
|
|
114
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
115
|
+
|
|
116
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
117
|
+
expect(outcome.success).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('RunViews refuses the whole batch when one entry carries a payload', async () => {
|
|
121
|
+
const outcome = await ViewOperationsHandler.runViews(
|
|
122
|
+
[{ EntityName: 'Customers' }, { EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }],
|
|
123
|
+
USER,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(mockRunViewsFn).not.toHaveBeenCalled();
|
|
127
|
+
expect(outcome.success).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('ListEntities refuses a payload in the query-string filter', async () => {
|
|
131
|
+
await expect(
|
|
132
|
+
ViewOperationsHandler.listEntities({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER),
|
|
133
|
+
).rejects.toThrow(/multiple statements/);
|
|
134
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('refuses a ViewEntity that was not loaded as an entity (its ID would be spliced into SQL)', async () => {
|
|
138
|
+
const forged = { ID: '0) ; SELECT 1 AS [x] ; SELECT 1 WHERE (1=1', EntityID: 'any' } as unknown as BaseEntity;
|
|
139
|
+
|
|
140
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ViewEntity: forged, ExcludeDataFromAllPriorViewRuns: true }, USER);
|
|
141
|
+
|
|
142
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
143
|
+
expect(outcome.success).toBe(false);
|
|
144
|
+
expect(outcome.error).toMatch(/ViewEntity is not accepted over REST/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('passes a filter that only reads entity base views through to RunView', async () => {
|
|
148
|
+
const filter = `Name = 'O''Brien' AND ID IN (SELECT ID FROM sales.vwCustomers WHERE Region = 'West')`;
|
|
149
|
+
|
|
150
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter, OrderBy: '[Name] DESC' }, USER);
|
|
151
|
+
|
|
152
|
+
expect(outcome.success).toBe(true);
|
|
153
|
+
expect((mockRunViewFn.mock.calls[0][0] as RunViewParams).ExtraFilter).toBe(filter);
|
|
154
|
+
});
|
|
155
|
+
});
|