@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,416 @@
|
|
|
1
|
+
// type-graphql decorators on the resolver need the Reflect.metadata polyfill at import time.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
/**
|
|
4
|
+
* `GetDataContextItemData` and `GetDataContextData` run the SQL stored in `sql` Data Context Items.
|
|
5
|
+
* Before it runs, that SQL is held to the rules `ExecuteAdhocQuery` applies to caller-supplied SQL:
|
|
6
|
+
* - the caller owns the data context, or is an administrator (Owner-type user);
|
|
7
|
+
* - the caller is not a scope-limited (magic-link) session;
|
|
8
|
+
* - it runs on the read-only provider only — never on the read-write pool, even when no read-only
|
|
9
|
+
* database is configured;
|
|
10
|
+
* - it is a single read statement that reads only entity base views the caller may read.
|
|
11
|
+
* Items of the other types load as before, for the calling user.
|
|
12
|
+
*
|
|
13
|
+
* The resolver runs for real against in-memory providers. `mssql` is replaced by a recorder so a
|
|
14
|
+
* test can see whether any SQL reached a raw connection pool.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
17
|
+
import { EntityInfo, Metadata, RunView, UserInfo } from '@memberjunction/core';
|
|
18
|
+
import type {
|
|
19
|
+
DatabaseProviderBase,
|
|
20
|
+
IMetadataProvider,
|
|
21
|
+
IRunViewProvider,
|
|
22
|
+
RunQueryParams,
|
|
23
|
+
RunQueryResult,
|
|
24
|
+
RunViewParams,
|
|
25
|
+
RunViewResult,
|
|
26
|
+
} from '@memberjunction/core';
|
|
27
|
+
import type { MJDataContextEntity, MJDataContextItemEntity } from '@memberjunction/core-entities';
|
|
28
|
+
import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
29
|
+
import type { AppContext } from '../types.js';
|
|
30
|
+
|
|
31
|
+
/** Every SQL text that a raw mssql connection was asked to run, and the pool it was sent to. */
|
|
32
|
+
const rawConnectionQueries: Array<{ Pool: string | undefined; SQL: string }> = [];
|
|
33
|
+
|
|
34
|
+
vi.mock('mssql', () => {
|
|
35
|
+
class RecordingRequest {
|
|
36
|
+
private readonly pool: string | undefined;
|
|
37
|
+
constructor(parent?: { Name?: string }) {
|
|
38
|
+
this.pool = parent?.Name;
|
|
39
|
+
}
|
|
40
|
+
public input(): this {
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
public async query(sqlText: string): Promise<{ recordset: Array<Record<string, unknown>> }> {
|
|
44
|
+
rawConnectionQueries.push({ Pool: this.pool, SQL: sqlText });
|
|
45
|
+
return { recordset: [{ One: 1 }] };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class ConnectionPool {}
|
|
49
|
+
return {
|
|
50
|
+
default: { ConnectionPool, Request: RecordingRequest },
|
|
51
|
+
ConnectionPool,
|
|
52
|
+
Request: RecordingRequest,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// MJAPI registers the server-side Data Context Item class at startup; this import does the same.
|
|
57
|
+
import '@memberjunction/data-context-server';
|
|
58
|
+
import { GetDataContextDataResolver } from '../resolvers/GetDataContextDataResolver.js';
|
|
59
|
+
|
|
60
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const OWNER_ID = 'A0000000-0000-4000-8000-000000000001';
|
|
63
|
+
const DATA_CONTEXT_ID = 'B0000000-0000-4000-8000-000000000001';
|
|
64
|
+
const SQL_ITEM_ID = 'C0000000-0000-4000-8000-000000000001';
|
|
65
|
+
const ENTITY_ITEM_ID = 'C0000000-0000-4000-8000-000000000002';
|
|
66
|
+
const SECOND_SQL_ITEM_ID = 'C0000000-0000-4000-8000-000000000003';
|
|
67
|
+
const NOTES_ENTITY_ID = 'E0000000-0000-4000-8000-000000000001';
|
|
68
|
+
const READ_SQL = 'SELECT 1 AS One';
|
|
69
|
+
|
|
70
|
+
type ItemRow = Pick<MJDataContextItemEntity,
|
|
71
|
+
'ID' | 'DataContextID' | 'Type' | 'SQL' | 'CodeName' | 'DataJSON' | 'EntityID' | 'ViewID' | 'QueryID' | 'RecordID'>;
|
|
72
|
+
type ContextRow = Pick<MJDataContextEntity, 'ID' | 'Name' | 'UserID'>;
|
|
73
|
+
|
|
74
|
+
function sqlItemRow(sqlText: string): ItemRow {
|
|
75
|
+
return {
|
|
76
|
+
ID: SQL_ITEM_ID, DataContextID: DATA_CONTEXT_ID, Type: 'sql', SQL: sqlText, CodeName: 'revenue',
|
|
77
|
+
DataJSON: null, EntityID: null, ViewID: null, QueryID: null, RecordID: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function entityItemRow(): ItemRow {
|
|
82
|
+
return {
|
|
83
|
+
ID: ENTITY_ITEM_ID, DataContextID: DATA_CONTEXT_ID, Type: 'full_entity', SQL: null, CodeName: 'notes',
|
|
84
|
+
DataJSON: null, EntityID: NOTES_ENTITY_ID, ViewID: null, QueryID: null, RecordID: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const DATA_CONTEXT: ContextRow = { ID: DATA_CONTEXT_ID, Name: 'Revenue review', UserID: OWNER_ID };
|
|
89
|
+
|
|
90
|
+
function user(id: string, type: 'User' | 'Owner'): UserInfo {
|
|
91
|
+
const u = new UserInfo();
|
|
92
|
+
u.ID = id;
|
|
93
|
+
u.Email = `${id.slice(-4)}@example.com`;
|
|
94
|
+
u.Type = type;
|
|
95
|
+
return u;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const OWNER = user(OWNER_ID, 'User');
|
|
99
|
+
const OTHER_USER = user('A0000000-0000-4000-8000-000000000002', 'User');
|
|
100
|
+
const ADMINISTRATOR = user('A0000000-0000-4000-8000-000000000003', 'Owner');
|
|
101
|
+
|
|
102
|
+
function scopeLimitedOwner(): UserInfo {
|
|
103
|
+
const u = user(OWNER_ID, 'User');
|
|
104
|
+
u.MagicLinkScope = { ResourceID: 'F0000000-0000-4000-8000-000000000001', ResourceType: 'Dashboards' };
|
|
105
|
+
return u;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ENTITIES: EntityInfo[] = [
|
|
109
|
+
new EntityInfo({ ID: 'E0000000-0000-4000-8000-0000000000D1', Name: 'MJ: Data Contexts', SchemaName: '__mj', BaseView: 'vwDataContexts', BaseTable: 'DataContext' }),
|
|
110
|
+
new EntityInfo({ ID: 'E0000000-0000-4000-8000-0000000000D2', Name: 'MJ: Data Context Items', SchemaName: '__mj', BaseView: 'vwDataContextItems', BaseTable: 'DataContextItem' }),
|
|
111
|
+
new EntityInfo({
|
|
112
|
+
ID: NOTES_ENTITY_ID, Name: 'Notes', SchemaName: 'crm', BaseView: 'vwNotes', BaseTable: 'Note',
|
|
113
|
+
EntityFields: [{ ID: 'F0000000-0000-4000-8000-0000000000F1', Name: 'Body', Type: 'nvarchar', Sequence: 1, Length: 0, Precision: 0, Scale: 0 }],
|
|
114
|
+
}),
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const NOTE_ROWS = [{ Body: 'Call back on Tuesday' }];
|
|
118
|
+
|
|
119
|
+
/** The result shape a provider's ad-hoc path returns for a successful run. */
|
|
120
|
+
const AD_HOC_SUCCESS: RunQueryResult = {
|
|
121
|
+
QueryID: '', QueryName: 'Ad-Hoc Query', Success: true, Results: [{ One: 1 }],
|
|
122
|
+
RowCount: 1, TotalRowCount: 1, ExecutionTime: 0, ErrorMessage: '',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
interface QueryRun {
|
|
126
|
+
SQL: string | undefined;
|
|
127
|
+
User: UserInfo | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ViewRun {
|
|
131
|
+
Params: RunViewParams;
|
|
132
|
+
User: UserInfo | undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** An in-memory database provider holding one data context and its items. */
|
|
136
|
+
class InMemoryProvider {
|
|
137
|
+
public readonly PlatformKey = 'sqlserver';
|
|
138
|
+
public readonly Entities = ENTITIES;
|
|
139
|
+
public readonly QueryRuns: QueryRun[] = [];
|
|
140
|
+
public readonly ViewRuns: ViewRun[] = [];
|
|
141
|
+
public readonly LoadedKeys: string[] = [];
|
|
142
|
+
|
|
143
|
+
constructor(private readonly items: ItemRow[]) {}
|
|
144
|
+
|
|
145
|
+
public async RunQuery(params: RunQueryParams, contextUser?: UserInfo): Promise<RunQueryResult> {
|
|
146
|
+
this.QueryRuns.push({ SQL: params.SQL, User: contextUser });
|
|
147
|
+
return AD_HOC_SUCCESS;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public async GetEntityObject<T>(entityName: string): Promise<T> {
|
|
151
|
+
const rows: Array<ItemRow | ContextRow> = entityName === 'MJ: Data Contexts' ? [DATA_CONTEXT] : this.items;
|
|
152
|
+
const record: Record<string, unknown> = {};
|
|
153
|
+
record.Load = async (key: string): Promise<boolean> => {
|
|
154
|
+
this.LoadedKeys.push(key);
|
|
155
|
+
const row = rows.find((r) => r.ID === key);
|
|
156
|
+
if (row) {
|
|
157
|
+
Object.assign(record, row);
|
|
158
|
+
}
|
|
159
|
+
return !!row;
|
|
160
|
+
};
|
|
161
|
+
return record as unknown as T;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public async RunView(params: RunViewParams, contextUser?: UserInfo): Promise<RunViewResult> {
|
|
165
|
+
this.ViewRuns.push({ Params: params, User: contextUser });
|
|
166
|
+
const results = params.EntityName === 'Notes'
|
|
167
|
+
? NOTE_ROWS
|
|
168
|
+
: this.items.filter((row) => params.ExtraFilter === `DataContextID = '${row.DataContextID}'`);
|
|
169
|
+
return { Success: true, Results: results, RowCount: results.length, TotalRowCount: results.length, ExecutionTime: 0, ErrorMessage: '', UserViewRunID: '' };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const READ_WRITE_POOL = { Name: 'read-write connection pool' };
|
|
174
|
+
const READ_ONLY_POOL = { Name: 'read-only connection pool' };
|
|
175
|
+
|
|
176
|
+
interface Server {
|
|
177
|
+
ReadWrite: InMemoryProvider;
|
|
178
|
+
ReadOnly: InMemoryProvider | null;
|
|
179
|
+
Context: AppContext;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A request context the way MJAPI builds one: the read-only provider exists only with a read-only pool. */
|
|
183
|
+
function server(caller: UserInfo, items: ItemRow[], withReadOnlyDatabase: boolean): Server {
|
|
184
|
+
const readWrite = new InMemoryProvider(items);
|
|
185
|
+
const readOnly = withReadOnlyDatabase ? new InMemoryProvider(items) : null;
|
|
186
|
+
const providers: Array<{ provider: InMemoryProvider; type: 'Read-Write' | 'Read-Only' }> = [{ provider: readWrite, type: 'Read-Write' }];
|
|
187
|
+
const dataSources: Array<{ dataSource: object; type: 'Read-Write' | 'Read-Only' }> = [{ dataSource: READ_WRITE_POOL, type: 'Read-Write' }];
|
|
188
|
+
if (readOnly) {
|
|
189
|
+
providers.push({ provider: readOnly, type: 'Read-Only' });
|
|
190
|
+
dataSources.push({ dataSource: READ_ONLY_POOL, type: 'Read-Only' });
|
|
191
|
+
}
|
|
192
|
+
// MJAPI's process-wide default provider is the read-write one.
|
|
193
|
+
vi.spyOn(Metadata, 'Provider', 'get').mockReturnValue(readWrite as unknown as IMetadataProvider);
|
|
194
|
+
vi.spyOn(RunView, 'Provider', 'get').mockReturnValue(readWrite as unknown as IRunViewProvider);
|
|
195
|
+
const context = { providers, dataSources, userPayload: { email: caller.Email, userRecord: caller, sessionId: 'test-session' } };
|
|
196
|
+
return { ReadWrite: readWrite, ReadOnly: readOnly, Context: context as unknown as AppContext };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Asserts that no SQL text reached any database: no raw connection, no provider ad-hoc run. */
|
|
200
|
+
function expectNoSQLRan(s: Server): void {
|
|
201
|
+
expect(rawConnectionQueries).toEqual([]);
|
|
202
|
+
expect(s.ReadWrite.QueryRuns).toEqual([]);
|
|
203
|
+
expect(s.ReadOnly?.QueryRuns ?? []).toEqual([]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolver = new GetDataContextDataResolver();
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
rawConnectionQueries.length = 0;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ─── GetDataContextItemData ──────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe('GetDataContextItemData — sql items', () => {
|
|
215
|
+
it('runs the owner\'s SQL on the read-only provider, for the owner', async () => {
|
|
216
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], true);
|
|
217
|
+
|
|
218
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
219
|
+
|
|
220
|
+
expect(result.Success).toBe(true);
|
|
221
|
+
expect(JSON.parse(result.Result ?? 'null')).toEqual([{ One: 1 }]);
|
|
222
|
+
expect(s.ReadOnly?.QueryRuns).toEqual([{ SQL: READ_SQL, User: OWNER }]);
|
|
223
|
+
expect(s.ReadWrite.QueryRuns).toEqual([]);
|
|
224
|
+
expect(rawConnectionQueries).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('refuses to run SQL when no read-only database is configured, instead of falling back to read-write', async () => {
|
|
228
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], false);
|
|
229
|
+
|
|
230
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
231
|
+
|
|
232
|
+
expectNoSQLRan(s);
|
|
233
|
+
expect(result.Success).toBe(false);
|
|
234
|
+
expect(result.ErrorMessage).toMatch(/read-only/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('refuses stacked statements before they reach a database', async () => {
|
|
238
|
+
const s = server(OWNER, [sqlItemRow('SELECT 1 AS One; SELECT 2 AS Two')], true);
|
|
239
|
+
|
|
240
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
241
|
+
|
|
242
|
+
expectNoSQLRan(s);
|
|
243
|
+
expect(result.Success).toBe(false);
|
|
244
|
+
expect(result.ErrorMessage).toMatch(/single read query/i);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('refuses SQL that reads a table other than an entity base view', async () => {
|
|
248
|
+
const s = server(OWNER, [sqlItemRow('SELECT Email FROM __mj.[User]')], true);
|
|
249
|
+
|
|
250
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
251
|
+
|
|
252
|
+
expectNoSQLRan(s);
|
|
253
|
+
expect(result.Success).toBe(false);
|
|
254
|
+
expect(result.ErrorMessage).toMatch(/entity base view/i);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('refuses to run another user\'s SQL', async () => {
|
|
258
|
+
const s = server(OTHER_USER, [sqlItemRow(READ_SQL)], true);
|
|
259
|
+
|
|
260
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
261
|
+
|
|
262
|
+
expectNoSQLRan(s);
|
|
263
|
+
expect(result.Success).toBe(false);
|
|
264
|
+
expect(result.ErrorMessage).toMatch(/owner/i);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('refuses to run SQL whose data context cannot be loaded, since its owner is unknown', async () => {
|
|
268
|
+
const orphan = { ...sqlItemRow(READ_SQL), DataContextID: 'B0000000-0000-4000-8000-000000000009' };
|
|
269
|
+
const s = server(OWNER, [orphan], true);
|
|
270
|
+
|
|
271
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
272
|
+
|
|
273
|
+
expectNoSQLRan(s);
|
|
274
|
+
expect(result.Success).toBe(false);
|
|
275
|
+
expect(result.ErrorMessage).toMatch(/owner/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('refuses an item whose SQL check fails without a message', async () => {
|
|
279
|
+
vi.spyOn(RenderPipeline, 'Run').mockImplementation(() => {
|
|
280
|
+
throw new Error('');
|
|
281
|
+
});
|
|
282
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], true);
|
|
283
|
+
|
|
284
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
285
|
+
|
|
286
|
+
expectNoSQLRan(s);
|
|
287
|
+
expect(result.Success).toBe(false);
|
|
288
|
+
expect(result.ErrorMessage).toBeTruthy();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('lets an administrator (Owner-type user) run another user\'s SQL, on the read-only provider', async () => {
|
|
292
|
+
const s = server(ADMINISTRATOR, [sqlItemRow(READ_SQL)], true);
|
|
293
|
+
|
|
294
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
295
|
+
|
|
296
|
+
expect(result.Success).toBe(true);
|
|
297
|
+
expect(s.ReadOnly?.QueryRuns).toEqual([{ SQL: READ_SQL, User: ADMINISTRATOR }]);
|
|
298
|
+
expect(rawConnectionQueries).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('refuses a scope-limited (magic-link) session, even for its own data context', async () => {
|
|
302
|
+
const caller = scopeLimitedOwner();
|
|
303
|
+
const s = server(caller, [sqlItemRow(READ_SQL)], true);
|
|
304
|
+
|
|
305
|
+
const result = await resolver.GetDataContextItemData(SQL_ITEM_ID, s.Context);
|
|
306
|
+
|
|
307
|
+
expectNoSQLRan(s);
|
|
308
|
+
expect(result.Success).toBe(false);
|
|
309
|
+
expect(result.ErrorMessage).toMatch(/scope-limited/i);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('GetDataContextItemData — other item types', () => {
|
|
314
|
+
it('loads an entity item for the calling user, with no ownership or read-only database needed', async () => {
|
|
315
|
+
const s = server(OTHER_USER, [entityItemRow()], false);
|
|
316
|
+
|
|
317
|
+
const result = await resolver.GetDataContextItemData(ENTITY_ITEM_ID, s.Context);
|
|
318
|
+
|
|
319
|
+
expect(result.Success).toBe(true);
|
|
320
|
+
expect(JSON.parse(result.Result ?? 'null')).toEqual(NOTE_ROWS);
|
|
321
|
+
const noteViewRuns = s.ReadWrite.ViewRuns.filter((run) => run.Params.EntityName === 'Notes');
|
|
322
|
+
expect(noteViewRuns.map((run) => run.User)).toEqual([OTHER_USER]);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ─── GetDataContextData ──────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
describe('GetDataContextData — sql items', () => {
|
|
329
|
+
it('runs the owner\'s SQL items on the read-only provider', async () => {
|
|
330
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], true);
|
|
331
|
+
|
|
332
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
333
|
+
|
|
334
|
+
expect(result.Success).toBe(true);
|
|
335
|
+
expect(result.ErrorMessages).toBeNull();
|
|
336
|
+
expect((result.Results ?? []).map((r) => JSON.parse(r ?? 'null'))).toEqual([[{ One: 1 }]]);
|
|
337
|
+
expect(s.ReadOnly?.QueryRuns).toEqual([{ SQL: READ_SQL, User: OWNER }]);
|
|
338
|
+
expect(rawConnectionQueries).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('refuses to run SQL items when no read-only database is configured', async () => {
|
|
342
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], false);
|
|
343
|
+
|
|
344
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
345
|
+
|
|
346
|
+
expectNoSQLRan(s);
|
|
347
|
+
expect(result.Success).toBe(false);
|
|
348
|
+
expect((result.ErrorMessages ?? []).join(' ')).toMatch(/read-only/i);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('refuses to run another user\'s SQL items', async () => {
|
|
352
|
+
const s = server(OTHER_USER, [sqlItemRow(READ_SQL)], true);
|
|
353
|
+
|
|
354
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
355
|
+
|
|
356
|
+
expectNoSQLRan(s);
|
|
357
|
+
expect(result.Success).toBe(false);
|
|
358
|
+
expect((result.ErrorMessages ?? []).join(' ')).toMatch(/owner/i);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('refuses a DataContextID that is not a UUID before it reaches any query', async () => {
|
|
362
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL)], true);
|
|
363
|
+
const notAnID = `${DATA_CONTEXT_ID}-1`;
|
|
364
|
+
|
|
365
|
+
const result = await resolver.GetDataContextData(notAnID, s.Context);
|
|
366
|
+
|
|
367
|
+
expect(result.Success).toBe(false);
|
|
368
|
+
const filters = [...s.ReadWrite.ViewRuns, ...(s.ReadOnly?.ViewRuns ?? [])].map((run) => run.Params.ExtraFilter ?? '');
|
|
369
|
+
const keys = [...s.ReadWrite.LoadedKeys, ...(s.ReadOnly?.LoadedKeys ?? [])];
|
|
370
|
+
expect([...filters, ...keys].filter((text) => text.includes(notAnID))).toEqual([]);
|
|
371
|
+
expectNoSQLRan(s);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('GetDataContextData — a SQL item that may not run does not stop the other items', () => {
|
|
376
|
+
it('loads the other items when no read-only database is configured', async () => {
|
|
377
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL), entityItemRow()], false);
|
|
378
|
+
|
|
379
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
380
|
+
|
|
381
|
+
expectNoSQLRan(s);
|
|
382
|
+
expect(result.Success).toBe(false);
|
|
383
|
+
expect(result.ErrorMessages?.[0]).toMatch(/read-only/i);
|
|
384
|
+
expect(result.ErrorMessages?.[1]).toBeNull();
|
|
385
|
+
expect(result.Results?.[0]).toBeNull();
|
|
386
|
+
expect(JSON.parse(result.Results?.[1] ?? 'null')).toEqual(NOTE_ROWS);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('loads the other items when the caller does not own the data context', async () => {
|
|
390
|
+
const s = server(OTHER_USER, [sqlItemRow(READ_SQL), entityItemRow()], true);
|
|
391
|
+
|
|
392
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
393
|
+
|
|
394
|
+
expectNoSQLRan(s);
|
|
395
|
+
expect(result.Success).toBe(false);
|
|
396
|
+
expect(result.ErrorMessages?.[0]).toMatch(/owner/i);
|
|
397
|
+
expect(result.ErrorMessages?.[1]).toBeNull();
|
|
398
|
+
expect(result.Results?.[0]).toBeNull();
|
|
399
|
+
expect(JSON.parse(result.Results?.[1] ?? 'null')).toEqual(NOTE_ROWS);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('screens each SQL item on its own and runs the ones that pass', async () => {
|
|
403
|
+
const stacked = { ...sqlItemRow('SELECT 1 AS One; SELECT 2 AS Two'), ID: SECOND_SQL_ITEM_ID };
|
|
404
|
+
const s = server(OWNER, [sqlItemRow(READ_SQL), stacked], true);
|
|
405
|
+
|
|
406
|
+
const result = await resolver.GetDataContextData(DATA_CONTEXT_ID, s.Context);
|
|
407
|
+
|
|
408
|
+
expect(rawConnectionQueries).toEqual([]);
|
|
409
|
+
expect(s.ReadOnly?.QueryRuns).toEqual([{ SQL: READ_SQL, User: OWNER }]);
|
|
410
|
+
expect(result.Success).toBe(false);
|
|
411
|
+
expect(result.ErrorMessages?.[0]).toBeNull();
|
|
412
|
+
expect(result.ErrorMessages?.[1]).toMatch(/single read query/i);
|
|
413
|
+
expect(JSON.parse(result.Results?.[0] ?? 'null')).toEqual([{ One: 1 }]);
|
|
414
|
+
expect(result.Results?.[1]).toBeNull();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// type-graphql decorators need the Reflect.metadata polyfill before the resolver module loads.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `PreviewListDelta` and `ComposeLists` accept `ListSourceInput{Kind:'adhoc'}`, whose `ExtraFilter`
|
|
7
|
+
* is client text that `ListOperations` hands to `RunView`. These tests drive the real resolver and
|
|
8
|
+
* the real `ListOperations` over a provider double, and pin that the filter passes the same
|
|
9
|
+
* base-view AST screen as `RunDynamicView` before any query runs. Payloads are harmless (stacked
|
|
10
|
+
* `SELECT`s); the point is only whether they reach `RunView`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DatabaseProviderBase, EntityInfo, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
14
|
+
import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
|
|
15
|
+
import { SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
16
|
+
import { SetDeltaTokenSecret } from '@memberjunction/lists';
|
|
17
|
+
import { ComposeListsInput, ComputeDeltaInput, ListOperationsResolver, ListSourceInput } from '../resolvers/ListOperationsResolver.js';
|
|
18
|
+
import type { AppContext } from '../types.js';
|
|
19
|
+
|
|
20
|
+
const BRACKET_STACKED =
|
|
21
|
+
"1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM crm.vwContacts WHERE (1 = (SELECT 1 AS [b'])";
|
|
22
|
+
|
|
23
|
+
const USER = { ID: 'user-1', Email: 'reader@example.com', Name: 'Reader' } as UserInfo;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provider double: entity metadata for the screen and ListOperations, a RunView that records its
|
|
27
|
+
* params, and one snapshot-mode list whose stored `SourceFilterSnapshot` carries `snapshotWhere`.
|
|
28
|
+
*/
|
|
29
|
+
function fakeProvider(snapshotWhere = `Status = 'Active'`) {
|
|
30
|
+
const calls: RunViewParams[] = [];
|
|
31
|
+
const contacts = {
|
|
32
|
+
ID: '6c6a8bb6-0000-4000-8000-000000000001',
|
|
33
|
+
Name: 'Contacts',
|
|
34
|
+
SchemaName: 'crm',
|
|
35
|
+
BaseView: 'vwContacts',
|
|
36
|
+
PrimaryKeys: [{ Name: 'ID' }],
|
|
37
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
38
|
+
GetUserRowLevelSecurityWhereClause: () => '',
|
|
39
|
+
} as unknown as EntityInfo;
|
|
40
|
+
const snapshotList = {
|
|
41
|
+
ID: 'list-1',
|
|
42
|
+
EntityID: contacts.ID,
|
|
43
|
+
SourceViewID: 'view-1',
|
|
44
|
+
UseSnapshot: true,
|
|
45
|
+
SourceFilterSnapshot: JSON.stringify({ v: 1, whereClause: snapshotWhere }),
|
|
46
|
+
Load: async () => true,
|
|
47
|
+
};
|
|
48
|
+
const provider = {
|
|
49
|
+
Entities: [contacts],
|
|
50
|
+
Dialect: new SQLServerDialect(),
|
|
51
|
+
TransformExternalSQLClause: (clause: string) => clause,
|
|
52
|
+
// The real provider screen, run against this double's entities and dialect.
|
|
53
|
+
ScreenClientClause(...args: Parameters<GenericDatabaseProvider['ScreenClientClause']>): void {
|
|
54
|
+
GenericDatabaseProvider.prototype.ScreenClientClause.apply(this as unknown as GenericDatabaseProvider, args);
|
|
55
|
+
},
|
|
56
|
+
EntityByName: (name: string) => (name.trim().toLowerCase() === 'contacts' ? contacts : undefined),
|
|
57
|
+
EntityByID: (id: string) => (id === contacts.ID ? contacts : undefined),
|
|
58
|
+
GetEntityObject: async () => snapshotList,
|
|
59
|
+
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
60
|
+
calls.push(params);
|
|
61
|
+
return { Success: true, Results: [{ ID: 'r1' }], RowCount: 1, TotalRowCount: 1, ErrorMessage: '' } as RunViewResult;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return { provider: provider as unknown as DatabaseProviderBase, calls };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function context(provider: DatabaseProviderBase): AppContext {
|
|
68
|
+
return {
|
|
69
|
+
providers: [{ type: 'Read-Write', provider }],
|
|
70
|
+
userPayload: { email: USER.Email, userRecord: USER },
|
|
71
|
+
} as unknown as AppContext;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function adhoc(extraFilter: string): ListSourceInput {
|
|
75
|
+
return Object.assign(new ListSourceInput(), { Kind: 'adhoc', EntityName: 'Contacts', ExtraFilter: extraFilter });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function previewInput(source: ListSourceInput): ComputeDeltaInput {
|
|
79
|
+
return Object.assign(new ComputeDeltaInput(), { Target: 'new', Source: source, Mode: 'Additive' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('ListOperationsResolver — ad-hoc source filters pass the clause screen', () => {
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
SetDeltaTokenSecret('unit-test-secret');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('PreviewListDelta refuses a stacked statement hidden behind a bracket identifier, before RunView', async () => {
|
|
88
|
+
const { provider, calls } = fakeProvider();
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
new ListOperationsResolver().PreviewListDelta(previewInput(adhoc(BRACKET_STACKED)), context(provider)),
|
|
92
|
+
).rejects.toThrow(/Invalid ExtraFilter: multiple statements/);
|
|
93
|
+
expect(calls).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('PreviewListDelta refuses a subquery against a base table', async () => {
|
|
97
|
+
const { provider, calls } = fakeProvider();
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
new ListOperationsResolver().PreviewListDelta(
|
|
101
|
+
previewInput(adhoc(`EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')`)),
|
|
102
|
+
context(provider),
|
|
103
|
+
),
|
|
104
|
+
).rejects.toThrow(/entity base view/);
|
|
105
|
+
expect(calls).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('ComposeLists never runs the refused filter', async () => {
|
|
109
|
+
const { provider, calls } = fakeProvider();
|
|
110
|
+
const input = Object.assign(new ComposeListsInput(), { Op: 'union', Inputs: [adhoc(BRACKET_STACKED), adhoc(BRACKET_STACKED)] });
|
|
111
|
+
|
|
112
|
+
await expect(new ListOperationsResolver().ComposeLists(input, context(provider))).rejects.toThrow(/multiple statements/);
|
|
113
|
+
expect(calls.map((c) => c.ExtraFilter)).not.toContain(BRACKET_STACKED);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('RefreshListFromSource screens a filter rebuilt from a stored snapshot', async () => {
|
|
117
|
+
const { provider, calls } = fakeProvider(BRACKET_STACKED);
|
|
118
|
+
|
|
119
|
+
const result = await new ListOperationsResolver().RefreshListFromSource('list-1', 'Additive', false, context(provider));
|
|
120
|
+
|
|
121
|
+
expect(calls.map((c) => c.ExtraFilter)).not.toContain(BRACKET_STACKED);
|
|
122
|
+
expect(result.Success).toBe(false);
|
|
123
|
+
expect(result.Message).toMatch(/multiple statements/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still runs an ordinary ad-hoc filter', async () => {
|
|
127
|
+
const { provider, calls } = fakeProvider();
|
|
128
|
+
const filter = `Status = 'Active' AND ID IN (SELECT ID FROM crm.vwContacts WHERE Name LIKE 'O''B%')`;
|
|
129
|
+
|
|
130
|
+
const delta = await new ListOperationsResolver().PreviewListDelta(previewInput(adhoc(filter)), context(provider));
|
|
131
|
+
|
|
132
|
+
expect(calls).toHaveLength(1);
|
|
133
|
+
expect(calls[0].ExtraFilter).toBe(filter);
|
|
134
|
+
expect(delta.ToAdd).toEqual(['r1']);
|
|
135
|
+
});
|
|
136
|
+
});
|