@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,155 @@
|
|
|
1
|
+
// ResolverBase transitively pulls in type-graphql decorators, which need the
|
|
2
|
+
// Reflect.metadata polyfill at import time.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import type { DatabaseProviderBase, IMetadataProvider, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
6
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
7
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
8
|
+
import type { RunDynamicViewInput } from '../generic/RunViewResolver.js';
|
|
9
|
+
import type { UserPayload } from '../types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The provider splices a client ExtraFilter as `(<ExtraFilter>) AND (<row-level security>)` and the
|
|
13
|
+
* tenant hook as `(<ExtraFilter>) AND (<tenant>)`. A fragment that closes a parenthesis it did not
|
|
14
|
+
* open, such as `1=1) OR (1=1`, turns that into `(1=1) OR (1=1) AND (<rls>)`. AND binds tighter than
|
|
15
|
+
* OR, so the server filter then narrows only one side of an OR the caller controls.
|
|
16
|
+
*
|
|
17
|
+
* The boundary screen parsed each fragment inside the parentheses it adds itself, which hides exactly
|
|
18
|
+
* that imbalance. These tests pin that the screen requires every fragment to be balanced on its own.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ENTITY_NAME = 'MJ: Conversations';
|
|
22
|
+
|
|
23
|
+
/** Captures what the resolver asked `RunView` for. */
|
|
24
|
+
type Captured = { params: RunViewParams | null };
|
|
25
|
+
|
|
26
|
+
function fakeProvider(captured: Captured): DatabaseProviderBase {
|
|
27
|
+
return {
|
|
28
|
+
Entities: [
|
|
29
|
+
{
|
|
30
|
+
ID: 'E1',
|
|
31
|
+
Name: ENTITY_NAME,
|
|
32
|
+
SchemaName: '__mj',
|
|
33
|
+
BaseView: 'vwConversations',
|
|
34
|
+
BaseTable: 'Conversation',
|
|
35
|
+
Fields: [{ Name: 'Name', NeedsQuotes: true }],
|
|
36
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
40
|
+
captured.params = params;
|
|
41
|
+
return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as RunViewResult;
|
|
42
|
+
},
|
|
43
|
+
} as unknown as DatabaseProviderBase;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
|
|
47
|
+
const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
|
|
48
|
+
|
|
49
|
+
/** Reaches the protected boundary screen and the dynamic-view entry point. */
|
|
50
|
+
class Probe extends ResolverBase {
|
|
51
|
+
public ScreenClause(clause: string, label: string, provider: DatabaseProviderBase): void {
|
|
52
|
+
this.assertClientClauseUsesEntityBaseViews(clause, label, provider as unknown as IMetadataProvider);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public RunDynamic(input: RunDynamicViewInput, provider: DatabaseProviderBase) {
|
|
56
|
+
return this.RunDynamicViewGeneric(input, provider, fakePayload(), undefined as unknown as PubSubEngine);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('ResolverBase boundary screen — a client filter must be one balanced expression', () => {
|
|
61
|
+
const provider = () => fakeProvider({ params: null });
|
|
62
|
+
|
|
63
|
+
// Each of these closes the parentheses the provider wraps the fragment in. All of them parse
|
|
64
|
+
// once the screen adds its own `(` … `)`, which is why the screen must check them standalone.
|
|
65
|
+
const escapes = [
|
|
66
|
+
'1=1) OR (1=1',
|
|
67
|
+
"Name = 'a') OR (Name = 'b'",
|
|
68
|
+
"[Name] = 'a') OR ([Name] = 'b'",
|
|
69
|
+
'(1=1)) OR ((1=1)',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
it.each(escapes)('rejects the ExtraFilter %j', (clause) => {
|
|
73
|
+
expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', provider())).toThrow(/parenthesis/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each(escapes)('rejects the OverrideExcludeFilter %j', (clause) => {
|
|
77
|
+
expect(() => new Probe().ScreenClause(clause, 'OverrideExcludeFilter', provider())).toThrow(/parenthesis/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects a fragment that leaves a parenthesis open', () => {
|
|
81
|
+
expect(() => new Probe().ScreenClause('(1=1', 'ExtraFilter', provider())).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('allows a balanced filter, including parentheses inside string literals', () => {
|
|
85
|
+
const p = new Probe();
|
|
86
|
+
const md = provider();
|
|
87
|
+
expect(() => p.ScreenClause("(Name = 'a' OR Name = 'b') AND (Name <> 'c')", 'ExtraFilter', md)).not.toThrow();
|
|
88
|
+
expect(() => p.ScreenClause("Name = 'a) OR (b'", 'ExtraFilter', md)).not.toThrow();
|
|
89
|
+
expect(() => p.ScreenClause("Name LIKE '%(%'", 'OverrideExcludeFilter', md)).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('RunDynamicViewGeneric never reaches RunView with an ExtraFilter that closes the wrapper', async () => {
|
|
93
|
+
const captured: Captured = { params: null };
|
|
94
|
+
const input = { EntityName: ENTITY_NAME, ExtraFilter: '1=1) OR (1=1' } as RunDynamicViewInput;
|
|
95
|
+
|
|
96
|
+
await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(/parenthesis/);
|
|
97
|
+
expect(captured.params).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('RunDynamicViewGeneric passes a balanced ExtraFilter through unchanged', async () => {
|
|
101
|
+
const captured: Captured = { params: null };
|
|
102
|
+
const input = { EntityName: ENTITY_NAME, ExtraFilter: "Name = 'a' OR Name = 'b'" } as RunDynamicViewInput;
|
|
103
|
+
|
|
104
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
105
|
+
|
|
106
|
+
expect(captured.params?.ExtraFilter).toBe("Name = 'a' OR Name = 'b'");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects a comment', () => {
|
|
110
|
+
expect(() => new Probe().ScreenClause("Name = 'a' -- note\n", 'ExtraFilter', provider())).toThrow(/comment/);
|
|
111
|
+
expect(() => new Probe().ScreenClause("Name = 'a' /* note */", 'OrderBy', provider())).toThrow(/comment/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('ResolverBase boundary screen — backslashes inside quotes', () => {
|
|
116
|
+
// The screen's parser reads a backslash before a quote inside a plain literal as an escaped
|
|
117
|
+
// quote; SQL Server and PostgreSQL read the quote as the end of the literal. Text after it can
|
|
118
|
+
// then be code the engine runs while the parser saw a string, so it escapes the screen's checks.
|
|
119
|
+
const provider = () => fakeProvider({ params: null });
|
|
120
|
+
|
|
121
|
+
it('rejects a filter whose hidden part the parser reads as a string constant', () => {
|
|
122
|
+
const hidesSelect = "Name = 'a\\' AND 1 = (SELECT 1) AND Name = ' + [b' + [c]";
|
|
123
|
+
expect(() => new Probe().ScreenClause(hidesSelect, 'ExtraFilter', provider())).toThrow(/backslash/);
|
|
124
|
+
expect(() => new Probe().ScreenClause(hidesSelect, 'OverrideExcludeFilter', provider())).toThrow(/backslash/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it.each(["Path = 'C:\\temp'", '[a\\b] = 1', '"a\\b" = 1'])('rejects a backslash inside a quoted string or identifier (%#)', (clause) => {
|
|
128
|
+
expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', provider())).toThrow(/backslash/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('allows a filter with no backslash inside quotes', () => {
|
|
132
|
+
expect(() => new Probe().ScreenClause("Path LIKE 'C:/temp%'", 'ExtraFilter', provider())).not.toThrow();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** Named so the screen reads clauses with the PostgreSQL dialect, as it does for the real provider. */
|
|
137
|
+
class PostgreSQLDataProviderFake {
|
|
138
|
+
public Entities = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('ResolverBase boundary screen — PostgreSQL', () => {
|
|
142
|
+
const pgProvider = () => new PostgreSQLDataProviderFake() as unknown as DatabaseProviderBase;
|
|
143
|
+
|
|
144
|
+
it.each([
|
|
145
|
+
['Name = $$x$$', /dollar/],
|
|
146
|
+
['Café = 1', /outside ASCII/],
|
|
147
|
+
["Name = E'x'\n'y'", /escape string/],
|
|
148
|
+
])('rejects what the lexer cannot read the way PostgreSQL does (%#)', (clause, reason) => {
|
|
149
|
+
expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', pgProvider())).toThrow(reason);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('allows text outside ASCII inside a string literal', () => {
|
|
153
|
+
expect(() => new Probe().ScreenClause("Name = 'Café'", 'ExtraFilter', pgProvider())).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
5
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
6
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
|
+
import type { RunDynamicViewInput } from '../generic/RunViewResolver.js';
|
|
8
|
+
import type { UserPayload } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `RunDynamicView` is the GraphQL entry point for a client RunView over one entity. A subquery in
|
|
12
|
+
* its ExtraFilter reads an entity's base view directly, so RunView applies neither that entity's
|
|
13
|
+
* row-level security nor its denied-field projection to the subquery. These tests pin that the
|
|
14
|
+
* boundary screen refuses such a subquery when the caller's read of that entity is narrowed (the
|
|
15
|
+
* rule the ad-hoc SQL screen applies) and still passes it when the read is not. Inputs are harmless.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** How the caller's read of an entity is narrowed: a row filter, denied fields, or neither. */
|
|
19
|
+
interface ReadScope {
|
|
20
|
+
RowFilter?: string;
|
|
21
|
+
DeniedFields?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the resolver handed to RunView; null when the request never got there. */
|
|
25
|
+
type Captured = { params: RunViewParams | null };
|
|
26
|
+
|
|
27
|
+
const USER = { ID: 'U1', Email: 'reader@example.com' } as UserInfo;
|
|
28
|
+
const OWN_ROWS = "(OwnerID = 'U1')";
|
|
29
|
+
const ORDERS_SUBQUERY = `ID IN (SELECT AccountID FROM [crm].[vwOrders] WHERE [Total] > 100)`;
|
|
30
|
+
|
|
31
|
+
/** Provider double: two entities the caller can read, each under `scopes[Name]`, and a RunView that records its params. */
|
|
32
|
+
function fakeProvider(captured: Captured, scopes: Record<string, ReadScope> = {}): DatabaseProviderBase {
|
|
33
|
+
const entity = (id: string, name: string, baseView: string) => ({
|
|
34
|
+
ID: id,
|
|
35
|
+
Name: name,
|
|
36
|
+
SchemaName: 'crm',
|
|
37
|
+
BaseView: baseView,
|
|
38
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
39
|
+
GetEffectiveRowFilterWhereClause: () => scopes[name]?.RowFilter ?? '',
|
|
40
|
+
GetDeniedReadFields: () => new Set(scopes[name]?.DeniedFields ?? []),
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
Entities: [entity('E1', 'Accounts', 'vwAccounts'), entity('E2', 'Orders', 'vwOrders')],
|
|
44
|
+
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
45
|
+
captured.params = params;
|
|
46
|
+
return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as unknown as RunViewResult;
|
|
47
|
+
},
|
|
48
|
+
} as unknown as DatabaseProviderBase;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runs `RunDynamicViewGeneric` over Accounts as USER. */
|
|
52
|
+
class Probe extends ResolverBase {
|
|
53
|
+
public RunDynamic(input: Partial<RunDynamicViewInput>, provider: DatabaseProviderBase) {
|
|
54
|
+
const payload = { email: USER.Email, userRecord: USER } as UserPayload;
|
|
55
|
+
const view = { EntityName: 'Accounts', ...input } as RunDynamicViewInput;
|
|
56
|
+
return this.RunDynamicViewGeneric(view, provider, payload, undefined as unknown as PubSubEngine);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('RunDynamicViewGeneric — a client subquery reads an entity only when the caller may read it unscoped', () => {
|
|
61
|
+
it('refuses a subquery over an entity that is row-level-security filtered for the caller, before RunView', async () => {
|
|
62
|
+
const captured: Captured = { params: null };
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
|
|
66
|
+
).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
|
|
67
|
+
expect(captured.params).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses a subquery over an entity with a field the caller may not read, before RunView', async () => {
|
|
71
|
+
const captured: Captured = { params: null };
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured, { Orders: { DeniedFields: ['total'] } })),
|
|
75
|
+
).rejects.toThrow(/'Orders' has fields you are not permitted to read/);
|
|
76
|
+
expect(captured.params).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('passes the same subquery through when the caller reads that entity unscoped', async () => {
|
|
80
|
+
const captured: Captured = { params: null };
|
|
81
|
+
|
|
82
|
+
await new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured));
|
|
83
|
+
|
|
84
|
+
expect(captured.params?.ExtraFilter).toBe(ORDERS_SUBQUERY);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("refuses a subquery over the queried entity's own base view when the caller's read of it is row-filtered", async () => {
|
|
88
|
+
const captured: Captured = { params: null };
|
|
89
|
+
const ownView = `ID IN (SELECT ID FROM [crm].[vwAccounts] WHERE [Name] LIKE 'A%')`;
|
|
90
|
+
|
|
91
|
+
await expect(
|
|
92
|
+
new Probe().RunDynamic({ ExtraFilter: ownView }, fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } })),
|
|
93
|
+
).rejects.toThrow(/'Accounts' is row-level-security filtered for you/);
|
|
94
|
+
expect(captured.params).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('refuses a CASE-WHEN subquery over a row-filtered entity, before RunView', async () => {
|
|
98
|
+
const captured: Captured = { params: null };
|
|
99
|
+
const caseWhen = `CASE WHEN (SELECT COUNT(*) FROM [crm].[vwOrders] WHERE [Total] > 100) > 0 THEN 1 ELSE 0 END = 1`;
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
new Probe().RunDynamic({ ExtraFilter: caseWhen }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
|
|
103
|
+
).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
|
|
104
|
+
expect(captured.params).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses a subquery over a row-filtered entity in a window's ORDER BY, before RunView", async () => {
|
|
108
|
+
const captured: Captured = { params: null };
|
|
109
|
+
const window = `ID IN (SELECT ID FROM (SELECT ID, ROW_NUMBER() OVER (ORDER BY (SELECT COUNT(*) FROM [crm].[vwOrders])) AS rn FROM [crm].[vwAccounts]) x)`;
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
new Probe().RunDynamic({ ExtraFilter: window }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
|
|
113
|
+
).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
|
|
114
|
+
expect(captured.params).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('still passes an ordinary filter and sort on an entity the caller reads under a row filter', async () => {
|
|
118
|
+
const captured: Captured = { params: null };
|
|
119
|
+
|
|
120
|
+
await new Probe().RunDynamic(
|
|
121
|
+
{ ExtraFilter: `[Name] = 'Acme'`, OrderBy: '[Name] DESC' },
|
|
122
|
+
fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(captured.params?.ExtraFilter).toBe(`[Name] = 'Acme'`);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -33,7 +33,16 @@ type Captured = { params: RunViewParams | null };
|
|
|
33
33
|
* its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
|
|
34
34
|
* Number and Boolean TS types).
|
|
35
35
|
*/
|
|
36
|
-
function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
|
|
36
|
+
function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = [], unreadableEntities: string[] = []): DatabaseProviderBase {
|
|
37
|
+
const withPerms = (e: Record<string, unknown>) => ({
|
|
38
|
+
...e,
|
|
39
|
+
// Real EntityInfo aggregates per-user permissions; the boundary screen consults CanRead,
|
|
40
|
+
// the row filter and the denied fields for every subquery target, so the mock answers
|
|
41
|
+
// them (an unscoped read).
|
|
42
|
+
GetUserPermisions: () => ({ CanRead: !unreadableEntities.includes(e.Name as string) }),
|
|
43
|
+
GetEffectiveRowFilterWhereClause: () => '',
|
|
44
|
+
GetDeniedReadFields: () => new Set<string>(),
|
|
45
|
+
});
|
|
37
46
|
return {
|
|
38
47
|
Entities: [
|
|
39
48
|
{
|
|
@@ -67,7 +76,7 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
|
|
|
67
76
|
{ Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
|
|
68
77
|
{ Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
|
|
69
78
|
{ Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
|
|
70
|
-
],
|
|
79
|
+
].map(withPerms),
|
|
71
80
|
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
72
81
|
captured.params = params;
|
|
73
82
|
return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
|
|
@@ -246,6 +255,19 @@ describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
|
|
|
246
255
|
expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
|
|
247
256
|
});
|
|
248
257
|
|
|
258
|
+
it('RunDynamicViewGeneric rejects a subquery into an entity the caller cannot read', async () => {
|
|
259
|
+
const captured: Captured = { params: null };
|
|
260
|
+
const input = {
|
|
261
|
+
EntityName: ENTITY_NAME,
|
|
262
|
+
ExtraFilter: `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments])`,
|
|
263
|
+
} as RunDynamicViewInput;
|
|
264
|
+
|
|
265
|
+
await expect(
|
|
266
|
+
new Probe().RunDynamic(input, fakeProvider(captured, [], ['MJ_BizApps_Tasks: Task Assignments']))
|
|
267
|
+
).rejects.toThrow(/read permission/);
|
|
268
|
+
expect(captured.params).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
249
271
|
it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
|
|
250
272
|
const captured: Captured = { params: null };
|
|
251
273
|
const input = {
|