@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.5
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 +20 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.d.ts +91 -30
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +311 -81
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +13 -1
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/apiRoot.d.ts +32 -0
- package/dist/apiRoot.d.ts.map +1 -0
- package/dist/apiRoot.js +67 -0
- package/dist/apiRoot.js.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.js +25 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
- package/dist/auth/LoginScreenEngine.d.ts +22 -0
- package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
- package/dist/auth/LoginScreenEngine.js +57 -0
- package/dist/auth/LoginScreenEngine.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/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/config.d.ts +136 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -4
- package/dist/config.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +142 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1750 -7
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +43 -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/PushStatusResolver.js +1 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +103 -44
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +264 -305
- 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/globalCors.d.ts +33 -0
- package/dist/globalCors.d.ts.map +1 -0
- package/dist/globalCors.js +58 -0
- package/dist/globalCors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -160
- 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/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
- package/dist/realtimeSessions/index.d.ts +2 -0
- package/dist/realtimeSessions/index.d.ts.map +1 -1
- package/dist/realtimeSessions/index.js +2 -0
- package/dist/realtimeSessions/index.js.map +1 -1
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts +30 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +7 -3
- package/dist/realtimeSessions/sessionAccess.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.js +9 -7
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
- package/dist/resolvers/ConversationRunEventsResolver.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 +2 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +3 -2
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +18 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +82 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +279 -101
- package/dist/resolvers/FileResolver.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 +6 -3
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +4 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +13 -0
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +35 -6
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +65 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +102 -24
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
- package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
- package/dist/resolvers/agent-run-data-guard.js +72 -0
- package/dist/resolvers/agent-run-data-guard.js.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.js +383 -0
- package/dist/rest/RealtimeRecordingHandler.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 +21 -12
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +6 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +6 -0
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/serverExtensionReservedRoots.d.ts +12 -10
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +14 -17
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/dist/services/MJStorageBlobStore.d.ts +8 -1
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
- package/dist/services/MJStorageBlobStore.js +25 -19
- package/dist/services/MJStorageBlobStore.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +2 -2
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +5 -3
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -5
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +157 -9
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +54 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/sharedCache.d.ts.map +1 -1
- package/dist/sharedCache.js +3 -0
- package/dist/sharedCache.js.map +1 -1
- package/package.json +99 -98
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
- package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/LoginScreenEngine.test.ts +86 -0
- package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
- package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +209 -6
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
- package/src/__tests__/SessionJanitor.test.ts +365 -10
- package/src/__tests__/SessionManager.test.ts +27 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/globalCors.test.ts +217 -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__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
- package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +13 -1
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +26 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/config.ts +46 -5
- package/src/coreRoutes.ts +166 -0
- package/src/generated/graphql-schemas/__mj.ts +1273 -10
- package/src/generic/CacheInvalidationResolver.ts +73 -18
- package/src/generic/PushStatusResolver.ts +1 -1
- package/src/generic/ResolverBase.ts +304 -325
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +116 -174
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
- package/src/realtimeSessions/sessionAccess.ts +7 -3
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
- package/src/resolvers/FileResolver.ts +321 -106
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
- package/src/resolvers/QueryResolver.ts +11 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
- package/src/resolvers/RunAIAgentResolver.ts +39 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +68 -13
- package/src/resolvers/TransactionGroupResolver.ts +131 -27
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/RealtimeRecordingHandler.ts +464 -0
- package/src/rest/ViewOperationsHandler.ts +29 -13
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- package/src/services/MJStorageBlobStore.ts +31 -30
- package/src/services/StartTaskGraphDispatcher.ts +2 -2
- package/src/services/TaskGraphActionRunner.ts +25 -5
- package/src/services/TaskGraphAgentRunner.ts +188 -11
- package/src/services/TaskGraphContinuationDeliverer.ts +704 -14
- package/src/sharedCache.ts +3 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MJStorageBlobStore (the conversation-attachment blob seam) goes from a file ID to bytes, a signed URL or a delete. The
|
|
3
|
+
* `MJ: Files` row names only a storage PROVIDER, so each path resolves through `FileStorageEngine.ResolveFileObject` —
|
|
4
|
+
* the storage gate (the caller's access on the account, and the tracked-file rule). These tests pin that download and
|
|
5
|
+
* URL ask for Read, delete asks for Write, and a refusal reads, signs and deletes nothing (the seam never throws).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const { resolveFileObject, getObject, createUrl, deleteObject } = vi.hoisted(() => ({
|
|
10
|
+
resolveFileObject: vi.fn(),
|
|
11
|
+
getObject: vi.fn(async () => Buffer.from('bytes')),
|
|
12
|
+
createUrl: vi.fn(async () => 'https://signed.example/x'),
|
|
13
|
+
deleteObject: vi.fn(async () => true),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('@memberjunction/storage', () => ({
|
|
17
|
+
FileStorageEngine: { Instance: { ResolveFileObject: resolveFileObject } },
|
|
18
|
+
StorageAccessEvaluator: { Instance: { AssertTrackedObjectsReadable: vi.fn() } },
|
|
19
|
+
FileStorageBase: class {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
23
|
+
import { MJStorageBlobStore } from '../services/MJStorageBlobStore.js';
|
|
24
|
+
|
|
25
|
+
const caller = { ID: 'user-1', UserRoles: [] } as unknown as UserInfo;
|
|
26
|
+
const fileRow = {
|
|
27
|
+
ID: 'file-1', ProviderID: 'prov-1', ProviderKey: 'conversation-attachments/a.png', Name: 'a.png',
|
|
28
|
+
Load: async () => true, Delete: vi.fn(async () => true),
|
|
29
|
+
};
|
|
30
|
+
const provider = { GetEntityObject: async () => fileRow } as unknown as IMetadataProvider;
|
|
31
|
+
const driver = { GetObject: getObject, CreatePreAuthDownloadUrl: createUrl, DeleteObject: deleteObject };
|
|
32
|
+
|
|
33
|
+
describe('MJStorageBlobStore — every path goes through the storage gate', () => {
|
|
34
|
+
const store = new MJStorageBlobStore();
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
resolveFileObject.mockResolvedValue({ Account: { ID: 'acct-1' }, Driver: driver, ObjectKey: fileRow.ProviderKey });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('Download and GetDownloadUrl ask for Read, as the caller', async () => {
|
|
42
|
+
expect(await store.Download('file-1', caller, provider)).toBe(Buffer.from('bytes').toString('base64'));
|
|
43
|
+
expect(await store.GetDownloadUrl('file-1', caller, provider)).toBe('https://signed.example/x');
|
|
44
|
+
expect(resolveFileObject.mock.calls.map(c => c[2])).toEqual(['Read', 'Read']);
|
|
45
|
+
expect(resolveFileObject.mock.calls.every(c => c[1] === caller)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('Delete asks for Write', async () => {
|
|
49
|
+
expect(await store.Delete('file-1', caller, provider)).toBe(true);
|
|
50
|
+
expect(resolveFileObject.mock.calls[0][2]).toBe('Write');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('a refusal reads, signs and deletes nothing — and the seam still does not throw', async () => {
|
|
54
|
+
resolveFileObject.mockRejectedValue(new Error('You do not have access to this file or it does not exist.'));
|
|
55
|
+
expect(await store.Download('file-1', caller, provider)).toBeNull();
|
|
56
|
+
expect(await store.GetDownloadUrl('file-1', caller, provider)).toBeNull();
|
|
57
|
+
expect(await store.Delete('file-1', caller, provider)).toBe(false);
|
|
58
|
+
expect(getObject).not.toHaveBeenCalled();
|
|
59
|
+
expect(createUrl).not.toHaveBeenCalled();
|
|
60
|
+
expect(deleteObject).not.toHaveBeenCalled();
|
|
61
|
+
expect(fileRow.Delete).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { logErrorMock, logStatusMock } = vi.hoisted(() => ({
|
|
4
|
+
logErrorMock: vi.fn(),
|
|
5
|
+
logStatusMock: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
9
|
+
return { ...actual, LogError: logErrorMock, LogStatus: logStatusMock };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
|
|
13
|
+
import { ShutdownRegistry } from '@memberjunction/global';
|
|
14
|
+
import type {
|
|
15
|
+
AssembleRealtimeRecordingInput, AssembleRealtimeRecordingOutcome, IRealtimeRecordingLedger,
|
|
16
|
+
} from '@memberjunction/ai-agents';
|
|
17
|
+
import { RecordingAssemblyScheduler } from '../realtimeSessions/RecordingAssemblyScheduler.js';
|
|
18
|
+
|
|
19
|
+
const SESSION = '11111111-1111-4111-8111-111111111111';
|
|
20
|
+
const OTHER = '22222222-2222-4222-8222-222222222222';
|
|
21
|
+
const GRACE_MS = 90_000;
|
|
22
|
+
const SYSTEM_USER = { ID: 'system-user' } as unknown as UserInfo;
|
|
23
|
+
const PROVIDER = {} as unknown as DatabaseProviderBase;
|
|
24
|
+
|
|
25
|
+
/** A ledger whose claim hands out increasing versions; only ClaimAssembly is used by the scheduler. */
|
|
26
|
+
function makeLedger(claim: (sessionID: string) => Promise<number | null>): IRealtimeRecordingLedger {
|
|
27
|
+
return { ClaimAssembly: vi.fn(claim) } as unknown as IRealtimeRecordingLedger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('RecordingAssemblyScheduler', () => {
|
|
31
|
+
let assemble: ReturnType<typeof vi.fn<(input: AssembleRealtimeRecordingInput) => Promise<AssembleRealtimeRecordingOutcome>>>;
|
|
32
|
+
let ledger: IRealtimeRecordingLedger;
|
|
33
|
+
let version: number;
|
|
34
|
+
|
|
35
|
+
function start(): void {
|
|
36
|
+
RecordingAssemblyScheduler.Instance.Start({
|
|
37
|
+
Provider: PROVIDER,
|
|
38
|
+
SystemUser: SYSTEM_USER,
|
|
39
|
+
GraceMs: () => GRACE_MS,
|
|
40
|
+
CreateLedger: () => ledger,
|
|
41
|
+
Assemble: assemble,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.useFakeTimers();
|
|
47
|
+
logErrorMock.mockReset();
|
|
48
|
+
logStatusMock.mockReset();
|
|
49
|
+
version = 0;
|
|
50
|
+
ledger = makeLedger(async () => ++version);
|
|
51
|
+
assemble = vi.fn(async () => 'Complete' as AssembleRealtimeRecordingOutcome);
|
|
52
|
+
start();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await RecordingAssemblyScheduler.Instance.Shutdown();
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('claims and assembles when the timer fires, passing ForcePartial and the claimed version', async () => {
|
|
61
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, true);
|
|
62
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
63
|
+
expect(assemble).not.toHaveBeenCalled();
|
|
64
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
65
|
+
expect(ledger.ClaimAssembly).toHaveBeenCalledWith(SESSION, null);
|
|
66
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(assemble.mock.calls[0][0]).toMatchObject({
|
|
68
|
+
SessionID: SESSION, Version: 1, Ledger: ledger, ContextUser: SYSTEM_USER, Provider: PROVIDER, ForcePartial: true,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps one timer per session: a sooner request replaces a later one, a later one is dropped', async () => {
|
|
73
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, GRACE_MS, true);
|
|
74
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
75
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 5000, true);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
77
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(assemble.mock.calls[0][0].ForcePartial).toBe(false);
|
|
79
|
+
|
|
80
|
+
await vi.advanceTimersByTimeAsync(GRACE_MS * 2);
|
|
81
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps the stronger ForcePartial when two requests fire at the same time', async () => {
|
|
85
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, false);
|
|
86
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, true);
|
|
87
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
88
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(assemble.mock.calls[0][0].ForcePartial).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('times sessions independently', async () => {
|
|
93
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, false);
|
|
94
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(OTHER, 2000, false);
|
|
95
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
96
|
+
expect(assemble.mock.calls.map(call => call[0].SessionID)).toEqual([SESSION, OTHER]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('re-schedules a Deferred assembly after the grace with ForcePartial', async () => {
|
|
100
|
+
assemble.mockResolvedValueOnce('Deferred');
|
|
101
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
102
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
103
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
await vi.advanceTimersByTimeAsync(GRACE_MS - 1);
|
|
106
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
107
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
108
|
+
expect(assemble).toHaveBeenCalledTimes(2);
|
|
109
|
+
expect(assemble.mock.calls[1][0].ForcePartial).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not assemble when the claim is lost, and logs it', async () => {
|
|
113
|
+
ledger = makeLedger(async () => null);
|
|
114
|
+
start();
|
|
115
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
116
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
117
|
+
expect(assemble).not.toHaveBeenCalled();
|
|
118
|
+
expect(logStatusMock.mock.calls.some(call => String(call[0]).includes(SESSION))).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('never runs the same session twice at once; a request during a run re-runs it afterwards', async () => {
|
|
122
|
+
let release: (outcome: AssembleRealtimeRecordingOutcome) => void = () => undefined;
|
|
123
|
+
assemble.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
|
|
124
|
+
|
|
125
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
126
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
127
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
128
|
+
|
|
129
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, true);
|
|
130
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
131
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
132
|
+
expect(assemble).toHaveBeenCalledTimes(1);
|
|
133
|
+
|
|
134
|
+
release('Complete');
|
|
135
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
136
|
+
expect(assemble).toHaveBeenCalledTimes(2);
|
|
137
|
+
expect(assemble.mock.calls[1][0].ForcePartial).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('logs Retry and claim failures with the session id', async () => {
|
|
141
|
+
assemble.mockResolvedValueOnce('Retry');
|
|
142
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 0, false);
|
|
143
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
144
|
+
expect(logErrorMock.mock.calls.some(call => String(call[0]).includes(SESSION))).toBe(true);
|
|
145
|
+
|
|
146
|
+
logErrorMock.mockReset();
|
|
147
|
+
ledger = makeLedger(async () => { throw new Error('db down'); });
|
|
148
|
+
start();
|
|
149
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(OTHER, 0, false);
|
|
150
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
151
|
+
expect(logErrorMock.mock.calls.some(call => String(call[0]).includes(OTHER) && String(call[0]).includes('db down'))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('unrefs its timers so they never keep the process alive', () => {
|
|
155
|
+
const unref = vi.fn();
|
|
156
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
157
|
+
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void, ms?: number) => {
|
|
158
|
+
const handle = realSetTimeout(callback, ms);
|
|
159
|
+
return Object.assign(handle, { unref: () => { unref(); return handle; } });
|
|
160
|
+
}) as typeof setTimeout);
|
|
161
|
+
try {
|
|
162
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, false);
|
|
163
|
+
expect(unref).toHaveBeenCalledTimes(1);
|
|
164
|
+
} finally {
|
|
165
|
+
spy.mockRestore();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('registers for shutdown and clears pending timers on Shutdown; later requests are logged and dropped', async () => {
|
|
170
|
+
expect(ShutdownRegistry.Instance.List()).toContain(RecordingAssemblyScheduler.Instance);
|
|
171
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(SESSION, 1000, false);
|
|
172
|
+
await RecordingAssemblyScheduler.Instance.Shutdown();
|
|
173
|
+
RecordingAssemblyScheduler.Instance.ScheduleAssembly(OTHER, 0, false);
|
|
174
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
175
|
+
expect(assemble).not.toHaveBeenCalled();
|
|
176
|
+
expect(logErrorMock.mock.calls.some(call => String(call[0]).includes(OTHER))).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Reflect.metadata polyfill at import time.
|
|
3
3
|
import 'reflect-metadata';
|
|
4
4
|
import { describe, it, expect } from 'vitest';
|
|
5
|
-
import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
5
|
+
import type { DatabasePlatform, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
6
|
+
import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
6
7
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -25,8 +26,9 @@ type EntityShape = {
|
|
|
25
26
|
deniedFields?: string[];
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
function fakeProvider(entities: EntityShape[]): IMetadataProvider {
|
|
29
|
+
function fakeProvider(entities: EntityShape[], platform?: DatabasePlatform): IMetadataProvider {
|
|
29
30
|
return {
|
|
31
|
+
PlatformKey: platform,
|
|
30
32
|
Entities: entities.map((e) => ({
|
|
31
33
|
Name: e.Name,
|
|
32
34
|
SchemaName: e.SchemaName,
|
|
@@ -44,6 +46,12 @@ class Probe extends ResolverBase {
|
|
|
44
46
|
public Screen(sql: string, provider: IMetadataProvider, user: UserInfo | undefined = USER) {
|
|
45
47
|
return this.assertFullQueryUsesReadableEntityViews(sql, provider, user, 'ad-hoc SQL');
|
|
46
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Renders the SQL the way `ExecuteAdhocQuery` does, then screens the rendered text. */
|
|
51
|
+
public ScreenRendered(sql: string, platform: DatabasePlatform, provider: IMetadataProvider) {
|
|
52
|
+
const rendered = RenderPipeline.Run(sql, { Platform: platform, ContextUser: USER, RequireReadStatement: true });
|
|
53
|
+
return this.Screen(rendered.Trace.AfterTemplates, provider);
|
|
54
|
+
}
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const PUBLIC_FOO: EntityShape = { Name: 'Public Foo', SchemaName: 'public', BaseView: 'vwFoo' };
|
|
@@ -88,9 +96,132 @@ describe('assertFullQueryUsesReadableEntityViews', () => {
|
|
|
88
96
|
).toThrow(/'Bar' is row-level-security filtered/);
|
|
89
97
|
});
|
|
90
98
|
|
|
99
|
+
it('applies the scope check to a CASE subquery in the select list', () => {
|
|
100
|
+
const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
|
|
101
|
+
expect(() =>
|
|
102
|
+
new Probe().Screen('SELECT CASE WHEN (SELECT COUNT(*) FROM public.vwBar) > 0 THEN 1 ELSE 0 END AS x FROM public.vwFoo', provider),
|
|
103
|
+
).toThrow(/'Bar' is row-level-security filtered/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('applies the scope check to every FROM item when an alias repeats', () => {
|
|
107
|
+
const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
|
|
108
|
+
expect(() =>
|
|
109
|
+
new Probe().Screen('SELECT a.ID FROM public.vwBar a WHERE a.ID IN (SELECT a.ID FROM public.vwFoo a)', provider),
|
|
110
|
+
).toThrow(/'Bar' is row-level-security filtered/);
|
|
111
|
+
});
|
|
112
|
+
|
|
91
113
|
it('exempts CTE names the statement defines', () => {
|
|
92
114
|
expect(() =>
|
|
93
115
|
new Probe().Screen('WITH x AS (SELECT ID FROM public.vwFoo) SELECT ID FROM x', fakeProvider([PUBLIC_FOO])),
|
|
94
116
|
).not.toThrow();
|
|
95
117
|
});
|
|
96
118
|
});
|
|
119
|
+
|
|
120
|
+
describe('assertFullQueryUsesReadableEntityViews — CTE names', () => {
|
|
121
|
+
// The parser renames bracket identifiers it cannot read and maps the new names back. The CTE
|
|
122
|
+
// exemption must only cover names the statement declares, so a name of the renamed form,
|
|
123
|
+
// quoted or bare, is refused. Orders is row-filtered for the caller; Accounts is not.
|
|
124
|
+
const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
|
|
125
|
+
const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
|
|
126
|
+
const DIALECTS: Array<{ Name: string; Platform: DatabasePlatform; Quote: (name: string) => string }> = [
|
|
127
|
+
{ Name: 'SQL Server', Platform: 'sqlserver', Quote: (name) => `[${name}]` },
|
|
128
|
+
{ Name: 'PostgreSQL', Platform: 'postgresql', Quote: (name) => `"${name}"` },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const { Name, Platform, Quote } of DIALECTS) {
|
|
132
|
+
const provider = fakeProvider([ACCOUNTS, ORDERS], Platform);
|
|
133
|
+
|
|
134
|
+
it(`refuses a CTE name reserved for the SQL parser, before reading any view (${Name})`, () => {
|
|
135
|
+
const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), _mjid_1 AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwOrders')}`;
|
|
136
|
+
|
|
137
|
+
expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it(`refuses a reserved name in any case or quoting (${Name})`, () => {
|
|
141
|
+
for (const name of ['_MJID_1', Quote('_mjid_1'), '_mjid2_0']) {
|
|
142
|
+
const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), ${name} AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwAccounts')}`;
|
|
143
|
+
expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it(`still exempts an ordinary quoted CTE name and screens its body (${Name})`, () => {
|
|
148
|
+
const cte = Quote('Recent Accounts');
|
|
149
|
+
const reads = (view: string) => `WITH ${cte} AS (SELECT ID FROM ${Quote(view)}) SELECT ID FROM ${cte}`;
|
|
150
|
+
|
|
151
|
+
expect(() => new Probe().ScreenRendered(reads('vwAccounts'), Platform, provider)).not.toThrow();
|
|
152
|
+
expect(() => new Probe().ScreenRendered(reads('vwOrders'), Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it(`still refuses a row-filtered view read next to an ordinary quoted CTE (${Name})`, () => {
|
|
156
|
+
const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x) SELECT * FROM ${Quote('vwOrders')}`;
|
|
157
|
+
|
|
158
|
+
expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('assertFullQueryUsesReadableEntityViews — the CTE exemption covers only a CTE in scope', () => {
|
|
164
|
+
// Orders is row-filtered for the caller; Accounts is not. Each refused statement reads the real
|
|
165
|
+
// Orders view at a point where no CTE of that name is in scope, so the read must be checked.
|
|
166
|
+
const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
|
|
167
|
+
const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
|
|
168
|
+
const ROW_FILTERED = /'Orders' is row-level-security filtered/;
|
|
169
|
+
const providers: Record<DatabasePlatform, IMetadataProvider> = {
|
|
170
|
+
sqlserver: fakeProvider([ACCOUNTS, ORDERS], 'sqlserver'),
|
|
171
|
+
postgresql: fakeProvider([ACCOUNTS, ORDERS], 'postgresql'),
|
|
172
|
+
};
|
|
173
|
+
const screen = (sql: string, platform: DatabasePlatform) => () => new Probe().ScreenRendered(sql, platform, providers[platform]);
|
|
174
|
+
|
|
175
|
+
it('refuses a schema-qualified read of a view a CTE is named after (SQL Server dbo, PostgreSQL)', () => {
|
|
176
|
+
expect(screen('WITH vwOrders AS (SELECT 1 AS y) SELECT * FROM dbo.vwOrders', 'sqlserver')).toThrow(ROW_FILTERED);
|
|
177
|
+
expect(screen('WITH "vwOrders" AS (SELECT 1 AS y) SELECT * FROM dbo."vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('refuses a read outside the subquery or branch whose WITH declares the name (PostgreSQL)', () => {
|
|
181
|
+
const statements = [
|
|
182
|
+
'SELECT o.* FROM (WITH "vwOrders" AS (SELECT 1 AS a) SELECT * FROM "vwOrders") s CROSS JOIN "vwOrders" o',
|
|
183
|
+
'SELECT "ID" FROM "vwAccounts" WHERE EXISTS (WITH "vwOrders" AS (SELECT 1 AS a) SELECT 1 FROM "vwOrders") AND "ID" IN (SELECT "ID" FROM "vwOrders")',
|
|
184
|
+
'WITH a AS (WITH "vwOrders" AS (SELECT 1 AS x) SELECT * FROM "vwOrders") SELECT * FROM a, "vwOrders"',
|
|
185
|
+
'SELECT * FROM ((WITH "vwOrders" AS (SELECT 1 AS "ID") SELECT "ID" FROM "vwOrders") UNION SELECT "ID" FROM "vwOrders") u',
|
|
186
|
+
];
|
|
187
|
+
for (const sql of statements) {
|
|
188
|
+
expect(screen(sql, 'postgresql')).toThrow(ROW_FILTERED);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('refuses a read in a CTE body that does not see that CTE yet (PostgreSQL without RECURSIVE, SQL Server)', () => {
|
|
193
|
+
expect(screen('WITH "vwOrders" AS (SELECT * FROM "vwOrders") SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
|
|
194
|
+
expect(screen('WITH a AS (SELECT * FROM "vwOrders"), "vwOrders" AS (SELECT 1 AS y) SELECT * FROM a', 'postgresql')).toThrow(ROW_FILTERED);
|
|
195
|
+
expect(screen('WITH a AS (SELECT * FROM vwOrders), vwOrders AS (SELECT 1 AS y) SELECT * FROM a', 'sqlserver')).toThrow(ROW_FILTERED);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('refuses a quoted read whose name differs from the CTE name once PostgreSQL case rules apply', () => {
|
|
199
|
+
expect(screen('WITH vworders AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
|
|
200
|
+
expect(screen('WITH "VWORDERS" AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('still exempts ordinary CTE references (SQL Server)', () => {
|
|
204
|
+
const statements = [
|
|
205
|
+
'WITH Recent AS (SELECT ID FROM vwAccounts) SELECT ID FROM recent',
|
|
206
|
+
'WITH r AS (SELECT ID FROM vwAccounts UNION ALL SELECT ID FROM r WHERE 1 = 0) SELECT ID FROM r',
|
|
207
|
+
'WITH a AS (SELECT ID FROM vwAccounts), b AS (SELECT ID FROM a) SELECT ID FROM b WHERE ID IN (SELECT ID FROM a UNION SELECT ID FROM a)',
|
|
208
|
+
];
|
|
209
|
+
for (const sql of statements) {
|
|
210
|
+
expect(screen(sql, 'sqlserver')).not.toThrow();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('still exempts ordinary CTE references (PostgreSQL)', () => {
|
|
215
|
+
const statements = [
|
|
216
|
+
'WITH recent AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM Recent',
|
|
217
|
+
'WITH "Recent" AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM "Recent"',
|
|
218
|
+
'WITH RECURSIVE r AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT n FROM r',
|
|
219
|
+
'WITH RECURSIVE a AS (SELECT * FROM b), b AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM a',
|
|
220
|
+
'SELECT * FROM (WITH x AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM x) s',
|
|
221
|
+
'WITH a AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM (SELECT "ID" FROM a) s WHERE "ID" IN (SELECT "ID" FROM a UNION SELECT "ID" FROM a)',
|
|
222
|
+
];
|
|
223
|
+
for (const sql of statements) {
|
|
224
|
+
expect(screen(sql, 'postgresql')).not.toThrow();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -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
|
+
});
|