@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REST view routes (`POST /views/:entityName`, `POST /views/batch`, `GET /entities/:entityName`)
|
|
3
|
+
* hand client `ExtraFilter` / `OrderBy` / `OverrideExcludeFilter` text to `RunView` through
|
|
4
|
+
* `ViewOperationsHandler`. These tests pin that the text passes the same base-view AST screen as
|
|
5
|
+
* the GraphQL RunView resolvers before any view runs. Payloads are harmless (stacked `SELECT`s);
|
|
6
|
+
* the point is only whether they reach `RunView`.
|
|
7
|
+
*
|
|
8
|
+
* `@memberjunction/core`'s `Metadata` and `RunView` are replaced at the module boundary, as in
|
|
9
|
+
* ViewOperationsHandler.test.ts; the screen itself runs unmodified.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
const { mockRunViewFn, mockRunViewsFn, mockEntityList } = vi.hoisted(() => ({
|
|
14
|
+
mockRunViewFn: vi.fn(),
|
|
15
|
+
mockRunViewsFn: vi.fn(),
|
|
16
|
+
mockEntityList: [] as Array<{
|
|
17
|
+
Name: string;
|
|
18
|
+
SchemaName: string;
|
|
19
|
+
BaseView: string;
|
|
20
|
+
GetUserPermisions: (u: unknown) => { CanRead: boolean };
|
|
21
|
+
GetEffectiveRowFilterWhereClause: () => string;
|
|
22
|
+
GetDeniedReadFields: () => Set<string>;
|
|
23
|
+
}>,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
28
|
+
class MockMetadata {
|
|
29
|
+
public get Entities() {
|
|
30
|
+
return mockEntityList;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class MockRunView {
|
|
34
|
+
public RunView(params: unknown, contextUser?: unknown): Promise<unknown> {
|
|
35
|
+
return mockRunViewFn(params, contextUser);
|
|
36
|
+
}
|
|
37
|
+
public RunViews(params: unknown[], contextUser?: unknown): Promise<unknown> {
|
|
38
|
+
return mockRunViewsFn(params, contextUser);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { ...actual, Metadata: MockMetadata, RunView: MockRunView, LogError: vi.fn() };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
import type { BaseEntity, PlatformSQL, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
45
|
+
import { ViewOperationsHandler } from '../rest/ViewOperationsHandler.js';
|
|
46
|
+
|
|
47
|
+
const BRACKET_STACKED =
|
|
48
|
+
"1 = (SELECT 1 AS [a'])) ; SELECT 1 AS [x] ; SELECT * FROM sales.vwCustomers WHERE (1 = (SELECT 1 AS [b'])";
|
|
49
|
+
|
|
50
|
+
const USER = { ID: 'user-1', Name: 'Reader' } as unknown as UserInfo;
|
|
51
|
+
const OK_RESULT = { Success: true, Results: [{ ID: '1' }] };
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
mockEntityList.length = 0;
|
|
56
|
+
const unscoped = { GetEffectiveRowFilterWhereClause: () => '', GetDeniedReadFields: () => new Set<string>() };
|
|
57
|
+
mockEntityList.push(
|
|
58
|
+
{ Name: 'Customers', SchemaName: 'sales', BaseView: 'vwCustomers', GetUserPermisions: () => ({ CanRead: true }), ...unscoped },
|
|
59
|
+
{ Name: 'Ledger', SchemaName: 'sales', BaseView: 'vwLedger', GetUserPermisions: () => ({ CanRead: false }), ...unscoped },
|
|
60
|
+
{
|
|
61
|
+
Name: 'Orders',
|
|
62
|
+
SchemaName: 'sales',
|
|
63
|
+
BaseView: 'vwOrders',
|
|
64
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
65
|
+
GetEffectiveRowFilterWhereClause: () => "(OwnerID = 'user-1')",
|
|
66
|
+
GetDeniedReadFields: () => new Set<string>(),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
mockRunViewFn.mockResolvedValue(OK_RESULT);
|
|
70
|
+
mockRunViewsFn.mockResolvedValue([OK_RESULT]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('ViewOperationsHandler — REST clause screen', () => {
|
|
74
|
+
it('RunView refuses a stacked statement hidden behind a bracket identifier, before RunView', async () => {
|
|
75
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER);
|
|
76
|
+
|
|
77
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
78
|
+
expect(outcome.success).toBe(false);
|
|
79
|
+
expect(outcome.error).toMatch(/Invalid ExtraFilter: multiple statements/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('RunView screens OrderBy and OverrideExcludeFilter too', async () => {
|
|
83
|
+
const cases: RunViewParams[] = [
|
|
84
|
+
{ EntityName: 'Customers', OrderBy: "[a'] ; SELECT 1 AS [x] ; SELECT 1 AS [b']" },
|
|
85
|
+
{ EntityName: 'Customers', OverrideExcludeFilter: BRACKET_STACKED },
|
|
86
|
+
];
|
|
87
|
+
for (const params of cases) {
|
|
88
|
+
const outcome = await ViewOperationsHandler.runView(params, USER);
|
|
89
|
+
expect(outcome.success).toBe(false);
|
|
90
|
+
}
|
|
91
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('RunView refuses a subquery against a base table or an entity the caller cannot read', async () => {
|
|
95
|
+
for (const filter of [
|
|
96
|
+
`EXISTS (SELECT 1 FROM __mj.[User] WHERE [Type] = 'Owner')`,
|
|
97
|
+
`ID IN (SELECT CustomerID FROM sales.vwLedger)`,
|
|
98
|
+
]) {
|
|
99
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
100
|
+
expect(outcome.success).toBe(false);
|
|
101
|
+
}
|
|
102
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('RunView refuses a subquery over an entity that is row-level-security filtered for the caller', async () => {
|
|
106
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: `ID IN (SELECT CustomerID FROM sales.vwOrders)` }, USER);
|
|
107
|
+
|
|
108
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
109
|
+
expect(outcome.success).toBe(false);
|
|
110
|
+
expect(outcome.error).toMatch(/'Orders' is row-level-security filtered for you/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('RunView screens every variant of a platform-specific filter', async () => {
|
|
114
|
+
const filter: PlatformSQL = { default: `Name = 'x'`, sqlserver: BRACKET_STACKED };
|
|
115
|
+
|
|
116
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter }, USER);
|
|
117
|
+
|
|
118
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
119
|
+
expect(outcome.success).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('RunViews refuses the whole batch when one entry carries a payload', async () => {
|
|
123
|
+
const outcome = await ViewOperationsHandler.runViews(
|
|
124
|
+
[{ EntityName: 'Customers' }, { EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }],
|
|
125
|
+
USER,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(mockRunViewsFn).not.toHaveBeenCalled();
|
|
129
|
+
expect(outcome.success).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ListEntities refuses a payload in the query-string filter', async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
ViewOperationsHandler.listEntities({ EntityName: 'Customers', ExtraFilter: BRACKET_STACKED }, USER),
|
|
135
|
+
).rejects.toThrow(/multiple statements/);
|
|
136
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('refuses a ViewEntity that was not loaded as an entity (its ID would be spliced into SQL)', async () => {
|
|
140
|
+
const forged = { ID: '0) ; SELECT 1 AS [x] ; SELECT 1 WHERE (1=1', EntityID: 'any' } as unknown as BaseEntity;
|
|
141
|
+
|
|
142
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ViewEntity: forged, ExcludeDataFromAllPriorViewRuns: true }, USER);
|
|
143
|
+
|
|
144
|
+
expect(mockRunViewFn).not.toHaveBeenCalled();
|
|
145
|
+
expect(outcome.success).toBe(false);
|
|
146
|
+
expect(outcome.error).toMatch(/ViewEntity is not accepted over REST/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('passes a filter that only reads entity base views through to RunView', async () => {
|
|
150
|
+
const filter = `Name = 'O''Brien' AND ID IN (SELECT ID FROM sales.vwCustomers WHERE Region = 'West')`;
|
|
151
|
+
|
|
152
|
+
const outcome = await ViewOperationsHandler.runView({ EntityName: 'Customers', ExtraFilter: filter, OrderBy: '[Name] DESC' }, USER);
|
|
153
|
+
|
|
154
|
+
expect(outcome.success).toBe(true);
|
|
155
|
+
expect((mockRunViewFn.mock.calls[0][0] as RunViewParams).ExtraFilter).toBe(filter);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy that decides who may run an action through a public entry point. It reads the
|
|
3
|
+
* `MJ: Action Authorizations` rows for the action, the caller's roles, one provider's authorizations
|
|
4
|
+
* and role grants, and whether the action's class authorizes its caller itself. It fails closed: an
|
|
5
|
+
* action that no authorization grants, and whose class does not authorize its caller, runs for
|
|
6
|
+
* Owners only.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
+
import { AuthorizationInfo, AuthorizationRoleInfo, Metadata, UserInfo, UserRoleInfo } from '@memberjunction/core';
|
|
10
|
+
import { EvaluateActionRunAuthorization, IsOwner, type AuthorizationMetadata } from '../auth/actionAuthorization.js';
|
|
11
|
+
|
|
12
|
+
const ANALYST_ROLE = 'A11C0000-0000-4000-8000-0000000000F1';
|
|
13
|
+
const AUDITOR_ROLE = 'A11C0000-0000-4000-8000-0000000000F2';
|
|
14
|
+
const BLOCKED_ROLE = 'A11C0000-0000-4000-8000-0000000000F3';
|
|
15
|
+
|
|
16
|
+
const DATA_TOOLS = new AuthorizationInfo({ ID: 'auth-data-tools', Name: 'Data Tools', IsActive: true });
|
|
17
|
+
const RUN_QUERIES = new AuthorizationInfo({ ID: 'auth-run-queries', Name: 'Run Queries', IsActive: true, ParentID: DATA_TOOLS.ID });
|
|
18
|
+
const RETIRED_TOOL = new AuthorizationInfo({ ID: 'auth-retired-tool', Name: 'Retired Tool', IsActive: false, ParentID: DATA_TOOLS.ID });
|
|
19
|
+
const SWITCHED_OFF = new AuthorizationInfo({ ID: 'auth-switched-off', Name: 'Switched Off', IsActive: false });
|
|
20
|
+
const AUTHORIZATIONS = [DATA_TOOLS, RUN_QUERIES, RETIRED_TOOL, SWITCHED_OFF];
|
|
21
|
+
const AUTHORIZATION_ROLES = [
|
|
22
|
+
new AuthorizationRoleInfo({ ID: 'ar-1', AuthorizationID: RUN_QUERIES.ID, RoleID: ANALYST_ROLE, Type: 'Allow' }),
|
|
23
|
+
new AuthorizationRoleInfo({ ID: 'ar-2', AuthorizationID: DATA_TOOLS.ID, RoleID: AUDITOR_ROLE, Type: 'Allow' }),
|
|
24
|
+
new AuthorizationRoleInfo({ ID: 'ar-3', AuthorizationID: RUN_QUERIES.ID, RoleID: BLOCKED_ROLE, Type: 'Deny' }),
|
|
25
|
+
new AuthorizationRoleInfo({ ID: 'ar-4', AuthorizationID: SWITCHED_OFF.ID, RoleID: ANALYST_ROLE, Type: 'Allow' }),
|
|
26
|
+
];
|
|
27
|
+
/** The provider metadata the decision reads. */
|
|
28
|
+
const METADATA: AuthorizationMetadata = { Authorizations: AUTHORIZATIONS, AuthorizationRoles: AUTHORIZATION_ROLES };
|
|
29
|
+
|
|
30
|
+
const user = (type: string, roleIDs: string[]): UserInfo =>
|
|
31
|
+
new UserInfo(null, { ID: `user-${type}-${roleIDs.join('-')}`, Type: type, UserRoles: roleIDs.map((RoleID) => new UserRoleInfo({ RoleID })) });
|
|
32
|
+
|
|
33
|
+
/** Decides for an action whose class does not authorize its caller. */
|
|
34
|
+
const decide = (caller: UserInfo, linkedAuthorizationIDs: string[]) =>
|
|
35
|
+
EvaluateActionRunAuthorization(caller, linkedAuthorizationIDs, METADATA, false);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
// The decision reads only the metadata it is given, so any read of the global provider fails the test.
|
|
40
|
+
vi.spyOn(Metadata, 'Provider', 'get').mockImplementation(() => {
|
|
41
|
+
throw new Error('The decision read the global metadata provider');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('IsOwner', () => {
|
|
46
|
+
it('recognises the Owner type, padded as the nchar column stores it', () => {
|
|
47
|
+
expect(IsOwner(user('Owner ', []))).toBe(true);
|
|
48
|
+
expect(IsOwner(user('User', [ANALYST_ROLE]))).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('EvaluateActionRunAuthorization', () => {
|
|
53
|
+
describe('Owners', () => {
|
|
54
|
+
it('lets an Owner run any action, linked or not', () => {
|
|
55
|
+
expect(decide(user('Owner ', []), []).Allowed).toBe(true);
|
|
56
|
+
expect(decide(user('Owner ', []), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('an action with no Action Authorization rows', () => {
|
|
61
|
+
it('is refused to any other user when its class does not authorize its caller', () => {
|
|
62
|
+
const decision = decide(user('User', [ANALYST_ROLE]), []);
|
|
63
|
+
|
|
64
|
+
expect(decision.Allowed).toBe(false);
|
|
65
|
+
expect(decision.Reason).toMatch(/no action authorization/i);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('runs for any user when its class authorizes its caller, so the action\'s own check decides', () => {
|
|
69
|
+
expect(EvaluateActionRunAuthorization(user('User', [ANALYST_ROLE]), [], METADATA, true).Allowed).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('an action with Action Authorization rows', () => {
|
|
74
|
+
it('is decided by its links, even when its class authorizes its caller', () => {
|
|
75
|
+
expect(EvaluateActionRunAuthorization(user('User', ['some-other-role']), [RUN_QUERIES.ID], METADATA, true).Allowed).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('runs for a user whose role holds a linked authorization', () => {
|
|
79
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('runs for a user whose role holds an ancestor of a linked authorization', () => {
|
|
83
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('is refused to a user whose roles hold none of the linked authorizations', () => {
|
|
87
|
+
expect(decide(user('User', ['some-other-role']), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('is refused when the linked authorization is inactive', () => {
|
|
91
|
+
expect(decide(user('User', [ANALYST_ROLE]), [SWITCHED_OFF.ID]).Allowed).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('is refused when the linked authorization is inactive, even to a holder of its parent', () => {
|
|
95
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RETIRED_TOOL.ID]).Allowed).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is refused when one of the user\'s roles is denied the linked authorization', () => {
|
|
99
|
+
expect(decide(user('User', [ANALYST_ROLE, BLOCKED_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('is refused when a role is denied the linked authorization, even if another role holds its parent', () => {
|
|
103
|
+
expect(decide(user('User', [AUDITOR_ROLE, BLOCKED_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('ignores a linked authorization that no longer exists in metadata', () => {
|
|
107
|
+
expect(decide(user('User', [ANALYST_ROLE]), ['auth-deleted']).Allowed).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('matches authorization IDs regardless of case', () => {
|
|
111
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID.toUpperCase()]).Allowed).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('the metadata it reads', () => {
|
|
116
|
+
it('decides from the role grants it is given, never from the global provider', () => {
|
|
117
|
+
const sameTreeWithoutGrants: AuthorizationMetadata = { Authorizations: AUTHORIZATIONS, AuthorizationRoles: [] };
|
|
118
|
+
|
|
119
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
120
|
+
expect(EvaluateActionRunAuthorization(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID], sameTreeWithoutGrants, false).Allowed).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('walks to the parent within the authorizations it is given', () => {
|
|
124
|
+
const treeWithoutParent: AuthorizationMetadata = { Authorizations: [RUN_QUERIES], AuthorizationRoles: AUTHORIZATION_ROLES };
|
|
125
|
+
|
|
126
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
127
|
+
expect(EvaluateActionRunAuthorization(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID], treeWithoutParent, false).Allowed).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ad-hoc query's timeout never exceeds the server's requestTimeout, so a caller cannot hold a
|
|
3
|
+
* read-only connection longer than any other request may.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout';
|
|
7
|
+
|
|
8
|
+
describe('ClampAdhocTimeoutSeconds', () => {
|
|
9
|
+
it('caps a longer request at requestTimeout', () => {
|
|
10
|
+
expect(ClampAdhocTimeoutSeconds(600, 30000)).toBe(30);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps a shorter request', () => {
|
|
14
|
+
expect(ClampAdhocTimeoutSeconds(5, 30000)).toBe(5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses 30 seconds when the caller names none, within the cap', () => {
|
|
18
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 30000)).toBe(30);
|
|
19
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 10000)).toBe(10);
|
|
20
|
+
expect(ClampAdhocTimeoutSeconds(0, 30000)).toBe(30);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves the request alone when the server sets no limit', () => {
|
|
24
|
+
expect(ClampAdhocTimeoutSeconds(600, 0)).toBe(600);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
5
5
|
import {
|
|
6
6
|
ConfigureRecordDataBroadcast,
|
|
7
7
|
MayBroadcastRecordData,
|
|
8
|
+
ReadIsRowFiltered,
|
|
9
|
+
ShapeCacheInvalidationEvent,
|
|
8
10
|
} from '../generic/CacheInvalidationResolver.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -175,3 +177,65 @@ describe('cacheInvalidationFilter', () => {
|
|
|
175
177
|
expect(filter({ payload: { ...event(''), entityName: '' } as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
176
178
|
});
|
|
177
179
|
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Row-level narrowing (MJ#5241): a subscriber who reads the entity only through a row-level security filter learns that a
|
|
183
|
+
* row changed, not which one; a subscriber with an unfiltered read keeps the key; a subscriber with no read is the entity-level
|
|
184
|
+
* filter's business and gets nothing here to shape.
|
|
185
|
+
*/
|
|
186
|
+
describe('ReadIsRowFiltered and ShapeCacheInvalidationEvent', () => {
|
|
187
|
+
const FILTERED_ROLE = 'role-participant';
|
|
188
|
+
const OPEN_ROLE = 'role-staff';
|
|
189
|
+
const entity = {
|
|
190
|
+
Permissions: [
|
|
191
|
+
{ RoleID: FILTERED_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: 'filter-1' },
|
|
192
|
+
{ RoleID: OPEN_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: null },
|
|
193
|
+
{ RoleID: 'role-denied', CanRead: true, Type: 'Deny', ReadRLSFilterID: null },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const payload = {
|
|
197
|
+
entityName: 'Spaces',
|
|
198
|
+
primaryKeyValues: 'ID|abc',
|
|
199
|
+
action: 'save',
|
|
200
|
+
sourceServerId: 'srv',
|
|
201
|
+
timestamp: new Date('2026-10-07T00:00:00Z'),
|
|
202
|
+
originSessionId: 'sess',
|
|
203
|
+
recordData: '{"Name":"x"}',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
it('is row-filtered when every read the user holds carries a filter', () => {
|
|
207
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }] })).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('is not row-filtered when any read the user holds is unfiltered, whichever role brings it', () => {
|
|
211
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: OPEN_ROLE }] })).toBe(false);
|
|
212
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }, { RoleID: OPEN_ROLE }] })).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('is not row-filtered when the user holds no read at all (the entity-level filter owns that case), and a Deny row grants nothing', () => {
|
|
216
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-stranger' }] })).toBe(false);
|
|
217
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-denied' }] })).toBe(false);
|
|
218
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: null })).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('matches role ids without regard to case or whitespace', () => {
|
|
222
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: ` ${FILTERED_ROLE.toUpperCase()} ` }] })).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('withholds the key and the row from a row-filtered subscriber, and keeps the rest of the event', () => {
|
|
226
|
+
const shaped = ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, entity);
|
|
227
|
+
expect(shaped.PrimaryKeyValues).toBeUndefined();
|
|
228
|
+
expect(shaped.RecordData).toBeUndefined();
|
|
229
|
+
expect(shaped.EntityName).toBe('Spaces');
|
|
230
|
+
expect(shaped.Action).toBe('save');
|
|
231
|
+
expect(shaped.SourceServerID).toBe('srv');
|
|
232
|
+
expect(shaped.OriginSessionID).toBe('sess');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('delivers the event whole to an unfiltered reader, and when the entity or the user is unknown', () => {
|
|
236
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
237
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).RecordData).toBe('{"Name":"x"}');
|
|
238
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, null).PrimaryKeyValues).toBe('ID|abc');
|
|
239
|
+
expect(ShapeCacheInvalidationEvent(payload, undefined, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactions opened outside a GraphQL request run on their OWN provider, never the shared
|
|
3
|
+
* `Metadata.Provider`.
|
|
4
|
+
*
|
|
5
|
+
* A provider's ambient transaction is per INSTANCE, and the global provider is shared by every
|
|
6
|
+
* request, scheduled job and background service. A transaction opened there nests into any other
|
|
7
|
+
* caller's, and while it is open every global-provider write from anywhere lands inside it. One left
|
|
8
|
+
* open swallows them all until the process restarts (MJ#2140). These tests pin the two call sites
|
|
9
|
+
* that used to open their transaction on the global provider: magic-link provisioning and
|
|
10
|
+
* SyncData's delete-with-filter. New-user creation is pinned in newUsers.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
import 'reflect-metadata';
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
|
|
15
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
16
|
+
UserCache: { Instance: { Users: [] } },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@memberjunction/communication-engine', () => ({
|
|
19
|
+
CommunicationEngine: { Instance: {} },
|
|
20
|
+
}));
|
|
21
|
+
vi.mock('@memberjunction/communication-types', () => ({
|
|
22
|
+
Message: class {},
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('../config.js', () => ({
|
|
25
|
+
configInfo: {},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const factory = vi.hoisted(() => ({ create: vi.fn() }));
|
|
29
|
+
vi.mock('../isolatedProvider.js', () => ({ CreateIsolatedProvider: factory.create }));
|
|
30
|
+
|
|
31
|
+
import { Metadata, RunView, type IMetadataProvider, type RoleInfo, type UserInfo } from '@memberjunction/core';
|
|
32
|
+
import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
|
|
33
|
+
import type { MagicLinkConfig } from '../config.js';
|
|
34
|
+
import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
|
|
35
|
+
import { SyncDataResolver, type ActionItemOutputType } from '../resolvers/SyncDataResolver.js';
|
|
36
|
+
|
|
37
|
+
/** A provider that records every call made on it. */
|
|
38
|
+
function recordingProvider(calls: string[], saveSucceeds = true) {
|
|
39
|
+
return {
|
|
40
|
+
BeginTransaction: vi.fn(async () => { calls.push('BeginTransaction'); }),
|
|
41
|
+
CommitTransaction: vi.fn(async () => { calls.push('CommitTransaction'); }),
|
|
42
|
+
RollbackTransaction: vi.fn(async () => { calls.push('RollbackTransaction'); }),
|
|
43
|
+
GetEntityObject: vi.fn(async (entityName: string) => {
|
|
44
|
+
calls.push(`GetEntityObject:${entityName}`);
|
|
45
|
+
const entity: Record<string, unknown> = {
|
|
46
|
+
NewRecord: () => { entity.ID = `id-${entityName}`; },
|
|
47
|
+
Save: async () => saveSucceeds,
|
|
48
|
+
GetAll: () => ({ ID: entity.ID }),
|
|
49
|
+
LatestResult: { CompleteMessage: 'save failed' },
|
|
50
|
+
};
|
|
51
|
+
return entity;
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let globalCalls: string[];
|
|
57
|
+
let isolatedCalls: string[];
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
61
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
62
|
+
globalCalls = [];
|
|
63
|
+
isolatedCalls = [];
|
|
64
|
+
Metadata.Provider = recordingProvider(globalCalls) as unknown as IMetadataProvider;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
factory.create.mockReset();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('MagicLinkService.createScopedUser', () => {
|
|
74
|
+
const invite = { Email: 'guest@example.com', RoleID: 'role-1', ApplicationID: 'app-1' } as unknown as MJMagicLinkInviteEntity;
|
|
75
|
+
const role = { ID: 'role-1', Name: 'External Guest' } as RoleInfo;
|
|
76
|
+
const contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
77
|
+
const provision = () =>
|
|
78
|
+
new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig)['createScopedUser'](invite, role, contextUser);
|
|
79
|
+
|
|
80
|
+
it('provisions inside a transaction on its own provider', async () => {
|
|
81
|
+
factory.create.mockResolvedValue(recordingProvider(isolatedCalls));
|
|
82
|
+
|
|
83
|
+
expect((await provision()).success).toBe(true);
|
|
84
|
+
expect(isolatedCalls).toEqual([
|
|
85
|
+
'BeginTransaction',
|
|
86
|
+
'GetEntityObject:MJ: Users',
|
|
87
|
+
'GetEntityObject:MJ: User Roles',
|
|
88
|
+
'GetEntityObject:MJ: User Applications',
|
|
89
|
+
'CommitTransaction',
|
|
90
|
+
]);
|
|
91
|
+
expect(globalCalls).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rolls back on its own provider when a save fails', async () => {
|
|
95
|
+
factory.create.mockResolvedValue(recordingProvider(isolatedCalls, false));
|
|
96
|
+
|
|
97
|
+
expect((await provision()).success).toBe(false);
|
|
98
|
+
expect(isolatedCalls).toEqual(['BeginTransaction', 'GetEntityObject:MJ: Users', 'RollbackTransaction']);
|
|
99
|
+
expect(globalCalls).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('SyncDataResolver.SyncSingleItemDeleteWithFilter', () => {
|
|
104
|
+
const user = { ID: 'system' } as UserInfo;
|
|
105
|
+
|
|
106
|
+
/** Rows the filtered view returns; each records its Delete on the shared call log. */
|
|
107
|
+
function stubView(rows: number, deleteSucceeds = true) {
|
|
108
|
+
const fromProvider = vi.spyOn(RunView, 'FromMetadataProvider').mockImplementation(
|
|
109
|
+
() =>
|
|
110
|
+
({
|
|
111
|
+
RunView: async () => ({
|
|
112
|
+
Success: true,
|
|
113
|
+
Results: Array.from({ length: rows }, (_, i) => ({
|
|
114
|
+
Delete: async () => { isolatedCalls.push(`Delete:${i}`); return deleteSucceeds; },
|
|
115
|
+
LatestResult: { CompleteMessage: 'delete failed' },
|
|
116
|
+
})),
|
|
117
|
+
}),
|
|
118
|
+
}) as unknown as RunView,
|
|
119
|
+
);
|
|
120
|
+
return fromProvider;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const deleteWithFilter = (providerOverride?: IMetadataProvider) => {
|
|
124
|
+
const result = { Success: false } as ActionItemOutputType;
|
|
125
|
+
const resolver = new SyncDataResolver() as unknown as {
|
|
126
|
+
SyncSingleItemDeleteWithFilter: (...args: unknown[]) => Promise<void>;
|
|
127
|
+
};
|
|
128
|
+
return resolver
|
|
129
|
+
.SyncSingleItemDeleteWithFilter('MJ: Things', "Name = 'x'", result, user, {}, providerOverride)
|
|
130
|
+
.then(() => result);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
it('with no request provider, loads AND deletes on its own provider inside one transaction', async () => {
|
|
134
|
+
const isolated = recordingProvider(isolatedCalls);
|
|
135
|
+
factory.create.mockResolvedValue(isolated);
|
|
136
|
+
const fromProvider = stubView(2);
|
|
137
|
+
|
|
138
|
+
const result = await deleteWithFilter();
|
|
139
|
+
|
|
140
|
+
expect(result.Success).toBe(true);
|
|
141
|
+
expect(fromProvider).toHaveBeenCalledWith(isolated);
|
|
142
|
+
expect(isolatedCalls).toEqual(['BeginTransaction', 'Delete:0', 'Delete:1', 'CommitTransaction']);
|
|
143
|
+
expect(globalCalls).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('with a request provider, uses it for both the read and the transaction', async () => {
|
|
147
|
+
const requestProvider = recordingProvider(isolatedCalls);
|
|
148
|
+
const fromProvider = stubView(1);
|
|
149
|
+
|
|
150
|
+
const result = await deleteWithFilter(requestProvider as unknown as IMetadataProvider);
|
|
151
|
+
|
|
152
|
+
expect(result.Success).toBe(true);
|
|
153
|
+
expect(factory.create).not.toHaveBeenCalled();
|
|
154
|
+
expect(fromProvider).toHaveBeenCalledWith(requestProvider);
|
|
155
|
+
expect(isolatedCalls).toEqual(['BeginTransaction', 'Delete:0', 'CommitTransaction']);
|
|
156
|
+
expect(globalCalls).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rolls back on the same provider when a delete fails', async () => {
|
|
160
|
+
factory.create.mockResolvedValue(recordingProvider(isolatedCalls));
|
|
161
|
+
stubView(2, false);
|
|
162
|
+
|
|
163
|
+
const result = await deleteWithFilter();
|
|
164
|
+
|
|
165
|
+
expect(result.Success).toBe(false);
|
|
166
|
+
expect(isolatedCalls).toEqual(['BeginTransaction', 'Delete:0', 'RollbackTransaction']);
|
|
167
|
+
expect(globalCalls).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|