@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.7
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 -11
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +106 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -4
- package/dist/config.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +7607 -6853
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +25141 -21645
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +152 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +389 -10
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +26 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/refusalExtensions.d.ts +52 -0
- package/dist/generic/refusalExtensions.d.ts.map +1 -0
- package/dist/generic/refusalExtensions.js +16 -0
- package/dist/generic/refusalExtensions.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -93
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +64 -7
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +2 -3
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
- package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
- package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -0
- package/src/__tests__/resolverBase.fls.test.ts +483 -0
- package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
- package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +44 -4
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
- package/src/generated/graphql-schemas/__mj.ts +18966 -16452
- package/src/generic/ResolverBase.ts +467 -9
- package/src/generic/RunViewResolver.ts +28 -2
- package/src/generic/refusalExtensions.ts +59 -0
- package/src/index.ts +154 -102
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/EntityRecordNameResolver.ts +74 -8
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
- package/src/serverExtensionReservedRoots.ts +2 -3
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
- package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
- package/dist/resolvers/TelephonyResolver.d.ts +0 -26
- package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/TelephonyResolver.js +0 -86
- package/dist/resolvers/TelephonyResolver.js.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.js +0 -86
- package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.js +0 -262
- package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.js +0 -96
- package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
- package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
- package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsService.js +0 -196
- package/dist/telephony/TeamsMeetingsService.js.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.js +0 -143
- package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
- package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
- package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyService.js +0 -193
- package/dist/telephony/TwilioTelephonyService.js.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.js +0 -201
- package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
- package/dist/telephony/VonageTelephonyService.d.ts +0 -90
- package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyService.js +0 -191
- package/dist/telephony/VonageTelephonyService.js.map +0 -1
- package/dist/telephony/calendar-scheduler.d.ts +0 -67
- package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
- package/dist/telephony/calendar-scheduler.js +0 -133
- package/dist/telephony/calendar-scheduler.js.map +0 -1
- package/dist/telephony/index.d.ts +0 -29
- package/dist/telephony/index.d.ts.map +0 -1
- package/dist/telephony/index.js +0 -38
- package/dist/telephony/index.js.map +0 -1
- package/dist/telephony/media-upgrade-router.d.ts +0 -47
- package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
- package/dist/telephony/media-upgrade-router.js +0 -72
- package/dist/telephony/media-upgrade-router.js.map +0 -1
- package/dist/telephony/ringcentral-runtime.d.ts +0 -14
- package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
- package/dist/telephony/ringcentral-runtime.js +0 -18
- package/dist/telephony/ringcentral-runtime.js.map +0 -1
- package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
- package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
- package/dist/telephony/teams-meetings-runtime.js +0 -20
- package/dist/telephony/teams-meetings-runtime.js.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
- package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
- package/dist/telephony/telephony-runtime.d.ts +0 -14
- package/dist/telephony/telephony-runtime.d.ts.map +0 -1
- package/dist/telephony/telephony-runtime.js +0 -18
- package/dist/telephony/telephony-runtime.js.map +0 -1
- package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
- package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/twilioMediaRegistry.js +0 -106
- package/dist/telephony/twilioMediaRegistry.js.map +0 -1
- package/dist/telephony/vonage-runtime.d.ts +0 -14
- package/dist/telephony/vonage-runtime.d.ts.map +0 -1
- package/dist/telephony/vonage-runtime.js +0 -18
- package/dist/telephony/vonage-runtime.js.map +0 -1
- package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
- package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/vonageMediaRegistry.js +0 -158
- package/dist/telephony/vonageMediaRegistry.js.map +0 -1
- package/src/__tests__/media-upgrade-router.test.ts +0 -43
- package/src/__tests__/teams-meetings-service.test.ts +0 -65
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
- package/src/__tests__/twilio-telephony-service.test.ts +0 -23
- package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
- package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
- package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
- package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
- package/src/resolvers/TelephonyResolver.ts +0 -63
- package/src/resolvers/VonageTelephonyResolver.ts +0 -63
- package/src/telephony/RingCentralTelephonyService.ts +0 -342
- package/src/telephony/TeamsMeetingsRouter.ts +0 -124
- package/src/telephony/TeamsMeetingsService.ts +0 -268
- package/src/telephony/TwilioTelephonyRouter.ts +0 -184
- package/src/telephony/TwilioTelephonyService.ts +0 -261
- package/src/telephony/VonageTelephonyRouter.ts +0 -239
- package/src/telephony/VonageTelephonyService.ts +0 -259
- package/src/telephony/calendar-scheduler.ts +0 -176
- package/src/telephony/index.ts +0 -43
- package/src/telephony/media-upgrade-router.ts +0 -79
- package/src/telephony/ringcentral-runtime.ts +0 -22
- package/src/telephony/teams-meetings-runtime.ts +0 -24
- package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
- package/src/telephony/telephony-runtime.ts +0 -22
- package/src/telephony/twilioMediaRegistry.ts +0 -137
- package/src/telephony/vonage-runtime.ts +0 -22
- package/src/telephony/vonageMediaRegistry.ts +0 -200
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// Reflect.metadata polyfill at import time.
|
|
3
3
|
import 'reflect-metadata';
|
|
4
4
|
import { describe, it, expect } from 'vitest';
|
|
5
|
-
import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
5
|
+
import type { DatabaseProviderBase, IMetadataProvider, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
6
6
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
7
|
import type { UserPayload } from '../types.js';
|
|
8
|
-
import type { RunViewByNameInput } from '../generic/RunViewResolver.js';
|
|
8
|
+
import type { RunDynamicViewInput, RunViewByNameInput } from '../generic/RunViewResolver.js';
|
|
9
9
|
import type { PubSubEngine } from 'type-graphql';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -37,7 +37,11 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
|
|
|
37
37
|
return {
|
|
38
38
|
Entities: [
|
|
39
39
|
{
|
|
40
|
+
ID: 'E1',
|
|
40
41
|
Name: ENTITY_NAME,
|
|
42
|
+
SchemaName: '__mj',
|
|
43
|
+
BaseView: 'vwUsers',
|
|
44
|
+
BaseTable: 'User',
|
|
41
45
|
Fields: [
|
|
42
46
|
{ Name: 'Email', NeedsQuotes: true },
|
|
43
47
|
{ Name: 'Name', NeedsQuotes: true },
|
|
@@ -47,8 +51,22 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
|
|
|
47
51
|
},
|
|
48
52
|
{
|
|
49
53
|
Name: 'MJ: User Views',
|
|
54
|
+
SchemaName: '__mj',
|
|
55
|
+
BaseView: 'vwUserViews',
|
|
50
56
|
Fields: [{ Name: 'Name', NeedsQuotes: true }],
|
|
51
57
|
},
|
|
58
|
+
{
|
|
59
|
+
Name: 'MJ_BizApps_Tasks: Task Assignments',
|
|
60
|
+
SchemaName: '__mj_BizAppsTasks',
|
|
61
|
+
BaseView: 'vwTaskAssignments',
|
|
62
|
+
BaseTable: 'TaskAssignment',
|
|
63
|
+
Fields: [{ Name: 'TaskID', NeedsQuotes: true }],
|
|
64
|
+
},
|
|
65
|
+
{ Name: 'MJ_BizApps_Tasks: Tasks', SchemaName: '__mj_BizAppsTasks', BaseView: 'vwTasks', BaseTable: 'Task', Fields: [] },
|
|
66
|
+
{ Name: 'Committees: Meetings', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMeetings', BaseTable: 'Meeting', Fields: [] },
|
|
67
|
+
{ Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
|
|
68
|
+
{ Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
|
|
69
|
+
{ Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
|
|
52
70
|
],
|
|
53
71
|
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
54
72
|
captured.params = params;
|
|
@@ -60,11 +78,27 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
|
|
|
60
78
|
const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
|
|
61
79
|
const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
|
|
62
80
|
|
|
63
|
-
/** Reaches the protected `findBy
|
|
81
|
+
/** Reaches the protected `findBy` and the client-subquery screen. */
|
|
64
82
|
class Probe extends ResolverBase {
|
|
65
83
|
public FindBy(provider: DatabaseProviderBase, entity: string, params: Record<string, unknown>) {
|
|
66
84
|
return this.findBy(provider, entity, params, fakeUser());
|
|
67
85
|
}
|
|
86
|
+
|
|
87
|
+
public ScreenClause(clause: string | undefined | null, label: string, provider: DatabaseProviderBase) {
|
|
88
|
+
return this.assertClientClauseUsesEntityBaseViews(clause, label, provider as unknown as IMetadataProvider);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public RunDynamic(input: RunDynamicViewInput, provider: DatabaseProviderBase) {
|
|
92
|
+
return this.RunDynamicViewGeneric(input, provider, fakePayload(), undefined as unknown as PubSubEngine);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The whole boundary screen, as the RunView* entry points call it. */
|
|
96
|
+
public ScreenAll(
|
|
97
|
+
clauses: { extraFilter?: string | null; orderBy?: string | null; userSearchString?: string | null; overrideExcludeFilter?: string | null },
|
|
98
|
+
provider: DatabaseProviderBase,
|
|
99
|
+
) {
|
|
100
|
+
return this.screenClientViewClauses(clauses, provider as unknown as IMetadataProvider);
|
|
101
|
+
}
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
describe('ResolverBase.findBy — ExtraFilter escaping', () => {
|
|
@@ -127,6 +161,159 @@ describe('ResolverBase.findBy — ExtraFilter escaping', () => {
|
|
|
127
161
|
});
|
|
128
162
|
});
|
|
129
163
|
|
|
164
|
+
describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
|
|
165
|
+
// Keyword SELECT/EXISTS ban (#4253) broke first-party IN (SELECT … FROM base view).
|
|
166
|
+
// The replacement parses the fragment and allows only entity BaseViews.
|
|
167
|
+
|
|
168
|
+
const provider = () => fakeProvider({ params: null });
|
|
169
|
+
|
|
170
|
+
it('rejects an EXISTS subquery against a base table', () => {
|
|
171
|
+
expect(() =>
|
|
172
|
+
new Probe().ScreenClause(`EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`, 'ExtraFilter', provider())
|
|
173
|
+
).toThrow(/entity base view/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects a scalar SELECT subquery against a non-view', () => {
|
|
177
|
+
expect(() =>
|
|
178
|
+
new Probe().ScreenClause('(SELECT COUNT(*) FROM __mj.APIKey)', 'OrderBy', provider())
|
|
179
|
+
).toThrow(/entity base view/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('allows IN (SELECT …) against an entity BaseView', () => {
|
|
183
|
+
expect(() =>
|
|
184
|
+
new Probe().ScreenClause(
|
|
185
|
+
`ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
|
|
186
|
+
'ExtraFilter',
|
|
187
|
+
provider(),
|
|
188
|
+
)
|
|
189
|
+
).not.toThrow();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('allows the restored Committees ExtraFilter shapes (Command Center, workspace, tracker)', () => {
|
|
193
|
+
const p = new Probe();
|
|
194
|
+
const md = provider();
|
|
195
|
+
const clauses = [
|
|
196
|
+
`TaskID IN (SELECT ID FROM [__mj_BizAppsTasks].[vwTasks] WHERE Status IN ('Open', 'InProgress'))`,
|
|
197
|
+
`ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
|
|
198
|
+
`MeetingID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMeetings] WHERE CommitteeID='x')`,
|
|
199
|
+
`MotionID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMotions] WHERE MeetingID = 'x')`,
|
|
200
|
+
`ID IN (SELECT m.PersonID FROM [__mj_BizAppsCommittees].[vwMemberships] m JOIN [__mj_BizAppsCommittees].[vwTerms] t ON m.TermID = t.ID WHERE t.CommitteeID = 'x' AND m.Status = 'Active')`,
|
|
201
|
+
];
|
|
202
|
+
for (const c of clauses) {
|
|
203
|
+
expect(() => p.ScreenClause(c, 'ExtraFilter', md)).not.toThrow();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('allows an ordinary comparison filter', () => {
|
|
208
|
+
expect(() =>
|
|
209
|
+
new Probe().ScreenClause(`Email = 'a@b.com' AND IsActive = 1`, 'ExtraFilter', provider())
|
|
210
|
+
).not.toThrow();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('does not false-positive on SELECT/EXISTS inside string literals', () => {
|
|
214
|
+
expect(() =>
|
|
215
|
+
new Probe().ScreenClause(`Name LIKE '%select%' OR Name = 'exists'`, 'ExtraFilter', provider())
|
|
216
|
+
).not.toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('allows empty/undefined clauses', () => {
|
|
220
|
+
expect(() => new Probe().ScreenClause('', 'ExtraFilter', provider())).not.toThrow();
|
|
221
|
+
expect(() => new Probe().ScreenClause(undefined, 'OrderBy', provider())).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('RunDynamicViewGeneric never reaches RunView when ExtraFilter hits a base table', async () => {
|
|
225
|
+
const captured: Captured = { params: null };
|
|
226
|
+
const input = {
|
|
227
|
+
EntityName: ENTITY_NAME,
|
|
228
|
+
ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`,
|
|
229
|
+
} as RunDynamicViewInput;
|
|
230
|
+
|
|
231
|
+
await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(
|
|
232
|
+
/entity base view/
|
|
233
|
+
);
|
|
234
|
+
expect(captured.params).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('RunDynamicViewGeneric passes a BaseView subquery through to RunView', async () => {
|
|
238
|
+
const captured: Captured = { params: null };
|
|
239
|
+
const input = {
|
|
240
|
+
EntityName: ENTITY_NAME,
|
|
241
|
+
ExtraFilter: `ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`,
|
|
242
|
+
} as RunDynamicViewInput;
|
|
243
|
+
|
|
244
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
245
|
+
|
|
246
|
+
expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
|
|
250
|
+
const captured: Captured = { params: null };
|
|
251
|
+
const input = {
|
|
252
|
+
EntityName: ENTITY_NAME,
|
|
253
|
+
ExtraFilter: `Email = 'a@b.com'`,
|
|
254
|
+
} as RunDynamicViewInput;
|
|
255
|
+
|
|
256
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
257
|
+
|
|
258
|
+
expect(captured.params?.ExtraFilter).toBe(`Email = 'a@b.com'`);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('ResolverBase.screenClientViewClauses — UserSearchString is free text, not a clause (#4392)', () => {
|
|
263
|
+
// The base-view AST screen wraps its argument as `SELECT 1 FROM x WHERE (<clause>)` and fails
|
|
264
|
+
// closed when that does not parse. Applied to UserSearchString it rejected everything that
|
|
265
|
+
// wasn't coincidentally valid SQL — which is most of what people type into a search box.
|
|
266
|
+
// UserSearchString never reaches SQL as a fragment: createViewUserSearchSQL builds the
|
|
267
|
+
// predicate and lands the text as an escaped literal, so the screen has nothing to screen.
|
|
268
|
+
|
|
269
|
+
const provider = () => fakeProvider({ params: null });
|
|
270
|
+
|
|
271
|
+
const searchTerms = [
|
|
272
|
+
'Marcus Chen', // a space — parses as nothing
|
|
273
|
+
"O'Leary", // unterminated literal
|
|
274
|
+
"Marcus O'Leary Chen", // both
|
|
275
|
+
'Smith, John', // punctuation
|
|
276
|
+
'50% off', // LIKE metacharacter
|
|
277
|
+
'a_b [c]', // more LIKE metacharacters
|
|
278
|
+
'select', // a keyword as a term
|
|
279
|
+
'drop table users', // keywords with spaces
|
|
280
|
+
'Union Pacific', // a real company name
|
|
281
|
+
"x' OR '1'='1", // an injection attempt — escaped as a literal downstream
|
|
282
|
+
' spaced out ',
|
|
283
|
+
'café ☕',
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
it.each(searchTerms)('lets %j through the boundary screen', (term) => {
|
|
287
|
+
expect(() => new Probe().ScreenAll({ userSearchString: term }, provider())).not.toThrow();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('still screens the three real clause fragments alongside it', () => {
|
|
291
|
+
const p = new Probe();
|
|
292
|
+
const md = provider();
|
|
293
|
+
|
|
294
|
+
expect(() =>
|
|
295
|
+
p.ScreenAll({ userSearchString: 'Marcus Chen', extraFilter: `EXISTS (SELECT 1 FROM __mj.[User])` }, md)
|
|
296
|
+
).toThrow(/entity base view/);
|
|
297
|
+
|
|
298
|
+
expect(() =>
|
|
299
|
+
p.ScreenAll({ userSearchString: 'Marcus Chen', orderBy: '(SELECT COUNT(*) FROM __mj.APIKey)' }, md)
|
|
300
|
+
).toThrow(/entity base view/);
|
|
301
|
+
|
|
302
|
+
expect(() =>
|
|
303
|
+
p.ScreenAll({ userSearchString: 'Marcus Chen', overrideExcludeFilter: `EXISTS (SELECT 1 FROM __mj.[User])` }, md)
|
|
304
|
+
).toThrow(/entity base view/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('RunDynamicViewGeneric carries a multi-word search term through to RunView', async () => {
|
|
308
|
+
const captured: Captured = { params: null };
|
|
309
|
+
const input = { EntityName: ENTITY_NAME, UserSearchString: "Marcus O'Leary" } as RunDynamicViewInput;
|
|
310
|
+
|
|
311
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
312
|
+
|
|
313
|
+
expect(captured.params?.UserSearchString).toBe("Marcus O'Leary");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
130
317
|
describe('ResolverBase.RunViewByNameGeneric — view-name escaping', () => {
|
|
131
318
|
it('escapes the client-supplied view name', async () => {
|
|
132
319
|
const captured: Captured = { params: null };
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { ReadableFieldsTransportKey } from '@memberjunction/core';
|
|
6
|
+
import type { EntityInfo, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
7
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The server states IN-BAND which fields the caller may read.
|
|
11
|
+
*
|
|
12
|
+
* Deleting a denied key from the response object is not sufficient on its own: GraphQL emits
|
|
13
|
+
* every field the client SELECTED, so a denied field the client asked for arrives as an explicit
|
|
14
|
+
* `null` that is indistinguishable from a genuine one. The client must not settle that from its
|
|
15
|
+
* own metadata — that copy is wrong in the window right after a permission change, and will be
|
|
16
|
+
* wrong permanently once metadata is filtered for restricted users (issue #3485).
|
|
17
|
+
*
|
|
18
|
+
* The list names READABLE fields rather than denied ones, deliberately: naming denied fields
|
|
19
|
+
* would hand back precisely what metadata filtering exists to withhold.
|
|
20
|
+
*/
|
|
21
|
+
class TransportProbe extends ResolverBase {
|
|
22
|
+
public Build(entityInfo: EntityInfo, denied: Set<string> | null | undefined): string[] | null {
|
|
23
|
+
return this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
24
|
+
}
|
|
25
|
+
public MapOne(entityName: string, row: unknown, provider: IMetadataProvider, user?: UserInfo, denied?: Set<string>) {
|
|
26
|
+
return this.MapFieldNamesToCodeNames(entityName, row, user, provider, denied);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENTITY_NAME = 'Employees';
|
|
31
|
+
|
|
32
|
+
const FIELDS = [
|
|
33
|
+
{ Name: 'ID', CodeName: 'ID' },
|
|
34
|
+
{ Name: 'Name', CodeName: 'Name' },
|
|
35
|
+
{ Name: 'Salary', CodeName: 'Salary' },
|
|
36
|
+
{ Name: '__mj_CreatedAt', CodeName: '__mj_CreatedAt' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function fakeEntityInfo(): EntityInfo {
|
|
40
|
+
return { Name: ENTITY_NAME, Fields: FIELDS, EncryptedFields: [] } as unknown as EntityInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fakeProvider(): IMetadataProvider {
|
|
44
|
+
return {
|
|
45
|
+
EntityByName: (name: string) => (name === ENTITY_NAME ? fakeEntityInfo() : undefined),
|
|
46
|
+
} as unknown as IMetadataProvider;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function row(): Record<string, unknown> {
|
|
50
|
+
return { ID: 'e-1', Name: 'Ada', Salary: 120000, __mj_CreatedAt: 'T0' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('BuildReadableFieldsTransportValue', () => {
|
|
54
|
+
it('returns null when the caller is denied nothing — nothing to state', () => {
|
|
55
|
+
const probe = new TransportProbe();
|
|
56
|
+
expect(probe.Build(fakeEntityInfo(), null)).toBeNull();
|
|
57
|
+
expect(probe.Build(fakeEntityInfo(), undefined)).toBeNull();
|
|
58
|
+
expect(probe.Build(fakeEntityInfo(), new Set())).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('lists the readable fields and never the denied one', () => {
|
|
62
|
+
const readable = new TransportProbe().Build(fakeEntityInfo(), new Set(['salary']));
|
|
63
|
+
|
|
64
|
+
expect(readable).toEqual(['ID', 'Name', '__mj_CreatedAt']);
|
|
65
|
+
// The whole point of the readable-list direction: a denied field's NAME is not disclosed.
|
|
66
|
+
expect(readable).not.toContain('Salary');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('carries entity field NAMES, not the _mj__ transport shape', () => {
|
|
70
|
+
// The client consumes this AFTER reversing the transport rename and matches against
|
|
71
|
+
// EntityFieldInfo.Name — the same key the denied set is built from. Sending the shape
|
|
72
|
+
// both sides already agree on avoids a second mapping that could drift.
|
|
73
|
+
const readable = new TransportProbe().Build(fakeEntityInfo(), new Set(['salary']));
|
|
74
|
+
expect(readable).toContain('__mj_CreatedAt');
|
|
75
|
+
expect(readable).not.toContain('_mj__CreatedAt');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('MapFieldNamesToCodeNames attaches the transport key', () => {
|
|
80
|
+
it('strips the denied value AND says what remained readable', async () => {
|
|
81
|
+
const mapped = (await new TransportProbe().MapOne(
|
|
82
|
+
ENTITY_NAME, row(), fakeProvider(), undefined, new Set(['salary']),
|
|
83
|
+
)) as Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
expect(mapped.Salary).toBeUndefined();
|
|
86
|
+
expect(mapped[ReadableFieldsTransportKey]).toEqual(['ID', 'Name', '__mj_CreatedAt']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('omits the key entirely for an unrestricted caller', async () => {
|
|
90
|
+
// Nearly every request. A null here keeps the addition free where it is not needed.
|
|
91
|
+
const mapped = (await new TransportProbe().MapOne(
|
|
92
|
+
ENTITY_NAME, row(), fakeProvider(), undefined, new Set(),
|
|
93
|
+
)) as Record<string, unknown>;
|
|
94
|
+
|
|
95
|
+
expect(mapped[ReadableFieldsTransportKey]).toBeUndefined();
|
|
96
|
+
expect(mapped.Salary).toBe(120000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not write the key onto the caller\'s row', async () => {
|
|
100
|
+
const source = row();
|
|
101
|
+
await new TransportProbe().MapOne(ENTITY_NAME, source, fakeProvider(), undefined, new Set(['salary']));
|
|
102
|
+
|
|
103
|
+
expect(source[ReadableFieldsTransportKey]).toBeUndefined();
|
|
104
|
+
expect(source.Salary).toBe(120000);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserViewResolver.updateWhereClause.test.ts — the `UpdateWhereClause` query.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint exists so a dev can FORCE a view's WhereClause to be regenerated. Three things
|
|
5
|
+
* have to hold for it to actually do that:
|
|
6
|
+
* 1. it must use the READ-WRITE provider (it calls Save(); on a read-replica deployment the
|
|
7
|
+
* read-only provider would route the write to the replica);
|
|
8
|
+
* 2. it must AWAIT the forced `UpdateWhereClause(true)` before saving — the un-awaited call it
|
|
9
|
+
* used to make raced `Save()`, and `Save()` itself will not trigger a second pass because no
|
|
10
|
+
* filter field is dirty;
|
|
11
|
+
* 3. it must fail clearly when the view cannot be loaded instead of saving a blank record.
|
|
12
|
+
*/
|
|
13
|
+
import 'reflect-metadata';
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
15
|
+
import type { UserInfo } from '@memberjunction/core';
|
|
16
|
+
import type { DatabaseProviderBase } from '@memberjunction/core';
|
|
17
|
+
|
|
18
|
+
// The resolver's import chain reaches the 88k-line generated GraphQL schema (which loads server
|
|
19
|
+
// config at import time) and the type-graphql decorators. None of that is under test here — the
|
|
20
|
+
// provider selection (`../util.js`) and the resolver body are — so neutralize the chain.
|
|
21
|
+
vi.mock('@memberjunction/server', () => {
|
|
22
|
+
const noopDecorator = () => () => undefined;
|
|
23
|
+
return { Arg: noopDecorator, Ctx: noopDecorator, Query: noopDecorator, Resolver: noopDecorator, Int: {} };
|
|
24
|
+
});
|
|
25
|
+
vi.mock('../generated/generated.js', () => ({
|
|
26
|
+
MJUserView_: class {},
|
|
27
|
+
// Only the one ResolverBase member the query uses.
|
|
28
|
+
MJUserViewResolverBase: class {
|
|
29
|
+
protected GetUserFromPayload(userPayload: { userRecord?: unknown } | undefined) {
|
|
30
|
+
return userPayload?.userRecord;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../resolvers/UserResolver.js', () => ({ UserResolver: class {} }));
|
|
35
|
+
|
|
36
|
+
import { UserViewResolver } from '../resolvers/UserViewResolver.js';
|
|
37
|
+
import type { AppContext, ProviderInfo, UserPayload } from '../types.js';
|
|
38
|
+
|
|
39
|
+
const VIEW_ID = '0F8FAD5B-D9CB-469F-A165-70867728950E';
|
|
40
|
+
const USER = { ID: 'user-1', Email: 'dev@example.com' } as unknown as UserInfo;
|
|
41
|
+
|
|
42
|
+
interface FakeView {
|
|
43
|
+
Load: ReturnType<typeof vi.fn>;
|
|
44
|
+
UpdateWhereClause: ReturnType<typeof vi.fn>;
|
|
45
|
+
Save: ReturnType<typeof vi.fn>;
|
|
46
|
+
GetAll: ReturnType<typeof vi.fn>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeView(opts: { loadOk: boolean; saveOk?: boolean }): { view: FakeView; order: string[] } {
|
|
50
|
+
const order: string[] = [];
|
|
51
|
+
const view: FakeView = {
|
|
52
|
+
Load: vi.fn(async () => { order.push('load'); return opts.loadOk; }),
|
|
53
|
+
// Resolve on a later tick so an un-awaited call would let Save() run first.
|
|
54
|
+
UpdateWhereClause: vi.fn(() => new Promise<void>(resolve => setTimeout(() => { order.push('update'); resolve(); }, 5))),
|
|
55
|
+
Save: vi.fn(async () => { order.push('save'); return opts.saveOk ?? true; }),
|
|
56
|
+
GetAll: vi.fn(() => ({ ID: VIEW_ID, WhereClause: '[IsActive] = 1' })),
|
|
57
|
+
};
|
|
58
|
+
return { view, order };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeProvider(view: FakeView): DatabaseProviderBase & { GetEntityObject: ReturnType<typeof vi.fn> } {
|
|
62
|
+
return { GetEntityObject: vi.fn(async () => view) } as unknown as DatabaseProviderBase & { GetEntityObject: ReturnType<typeof vi.fn> };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeContext(providers: ProviderInfo[]): AppContext {
|
|
66
|
+
return {
|
|
67
|
+
providers,
|
|
68
|
+
userPayload: { userRecord: USER } as unknown as UserPayload,
|
|
69
|
+
} as unknown as AppContext;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('UserViewResolver.UpdateWhereClause', () => {
|
|
73
|
+
it('uses the READ-WRITE provider, awaits the forced regeneration, then saves and returns the record', async () => {
|
|
74
|
+
const { view, order } = makeView({ loadOk: true });
|
|
75
|
+
const readOnly = makeProvider(makeView({ loadOk: true }).view);
|
|
76
|
+
const readWrite = makeProvider(view);
|
|
77
|
+
const ctx = makeContext([
|
|
78
|
+
{ provider: readOnly, type: 'Read-Only' },
|
|
79
|
+
{ provider: readWrite, type: 'Read-Write' },
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const result = await new UserViewResolver().UpdateWhereClause(VIEW_ID, ctx);
|
|
83
|
+
|
|
84
|
+
expect(readWrite.GetEntityObject).toHaveBeenCalledWith('MJ: User Views', USER);
|
|
85
|
+
expect(readOnly.GetEntityObject).not.toHaveBeenCalled();
|
|
86
|
+
expect(view.Load).toHaveBeenCalledWith(VIEW_ID);
|
|
87
|
+
expect(view.UpdateWhereClause).toHaveBeenCalledWith(true);
|
|
88
|
+
// The regeneration must complete BEFORE Save() runs.
|
|
89
|
+
expect(order).toEqual(['load', 'update', 'save']);
|
|
90
|
+
expect(result).toEqual({ ID: VIEW_ID, WhereClause: '[IsActive] = 1' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to the only provider available when no read-write provider is registered', async () => {
|
|
94
|
+
const { view } = makeView({ loadOk: true });
|
|
95
|
+
const only = makeProvider(view);
|
|
96
|
+
await new UserViewResolver().UpdateWhereClause(VIEW_ID, makeContext([{ provider: only, type: 'Read-Only' }]));
|
|
97
|
+
expect(only.GetEntityObject).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(view.Save).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws and never regenerates or saves when the view cannot be loaded', async () => {
|
|
102
|
+
const { view } = makeView({ loadOk: false });
|
|
103
|
+
const ctx = makeContext([{ provider: makeProvider(view), type: 'Read-Write' }]);
|
|
104
|
+
await expect(new UserViewResolver().UpdateWhereClause(VIEW_ID, ctx)).rejects.toThrow(/not found or access denied/);
|
|
105
|
+
expect(view.UpdateWhereClause).not.toHaveBeenCalled();
|
|
106
|
+
expect(view.Save).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws when Save() reports failure', async () => {
|
|
110
|
+
const { view } = makeView({ loadOk: true, saveOk: false });
|
|
111
|
+
const ctx = makeContext([{ provider: makeProvider(view), type: 'Read-Write' }]);
|
|
112
|
+
await expect(new UserViewResolver().UpdateWhereClause(VIEW_ID, ctx)).rejects.toThrow(/Failed to update where clause/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -10,7 +10,10 @@ vi.mock('../config.js', () => ({
|
|
|
10
10
|
}));
|
|
11
11
|
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
12
12
|
UserCache: {
|
|
13
|
-
|
|
13
|
+
// The subject reads exactly these two through `ResolveConfiguredPrincipal`. It no longer
|
|
14
|
+
// calls `UserByName`/`GetSystemUser`, and stubbing members nobody calls hides the day a
|
|
15
|
+
// test does reach `resolveLookupUser` and trips the resolver's missing-system-id guard.
|
|
16
|
+
Instance: { Users: [], SYSTEM_USER_ID: 'ECAFCCEC-6A37-EF11-86D4-000D3A4E707E' },
|
|
14
17
|
Users: [],
|
|
15
18
|
},
|
|
16
19
|
}));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
|
|
6
|
+
describe('Generated GraphQL Schema Structural Rules', () => {
|
|
7
|
+
it('requires every entity ObjectType declaring _mj__CreatedAt to also declare ReadableFields___', () => {
|
|
8
|
+
const schemaPath = fileURLToPath(new URL('../generated/graphql-schemas/__mj.ts', import.meta.url));
|
|
9
|
+
expect(existsSync(schemaPath), `Schema file must exist at ${schemaPath}`).toBe(true);
|
|
10
|
+
|
|
11
|
+
const sourceCode = readFileSync(schemaPath, 'utf8');
|
|
12
|
+
const sourceFile = ts.createSourceFile('__mj.ts', sourceCode, ts.ScriptTarget.Latest, true);
|
|
13
|
+
|
|
14
|
+
const violations: string[] = [];
|
|
15
|
+
let checkedEntitiesCount = 0;
|
|
16
|
+
|
|
17
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
18
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
19
|
+
const className = node.name.text;
|
|
20
|
+
const memberNames = new Set<string>();
|
|
21
|
+
|
|
22
|
+
for (const member of node.members) {
|
|
23
|
+
if (member.name) {
|
|
24
|
+
if (ts.isIdentifier(member.name)) {
|
|
25
|
+
memberNames.add(member.name.text);
|
|
26
|
+
} else {
|
|
27
|
+
memberNames.add(member.name.getText(sourceFile));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (memberNames.has('_mj__CreatedAt')) {
|
|
33
|
+
checkedEntitiesCount++;
|
|
34
|
+
if (!memberNames.has('ReadableFields___')) {
|
|
35
|
+
violations.push(className);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Sanity guard: make sure we actually inspected entity classes in the file
|
|
42
|
+
expect(checkedEntitiesCount).toBeGreaterThan(0);
|
|
43
|
+
// Per-class rule: no entity ObjectType declaring _mj__CreatedAt may omit ReadableFields___
|
|
44
|
+
expect(violations).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-contract regression test for the shipped `userHandling.newUserRoles` default (issue #4260).
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT. `DEFAULT_SERVER_CONFIG` shipped `newUserRoles: ['UI', 'Developer']`. On the baseline
|
|
5
|
+
* seed the `Developer` role holds `CanUpdate` on 439 of the database's 446 entities with no row-level filter —
|
|
6
|
+
* `MJ: Users` among them, where `AllowUpdateAPI` is true and both `Type` and `Name` are
|
|
7
|
+
* `AllowUpdateAPI` fields. Every gate in the chain passes:
|
|
8
|
+
*
|
|
9
|
+
* config default -> newUsers.ts assigns the role -> EntityPermission grants Update
|
|
10
|
+
* -> Entity/Field AllowUpdateAPI allow it -> BaseEntity.CheckPermissions passes
|
|
11
|
+
* -> GRANT EXECUTE ON __mj.spUpdateUser TO cdp_Developer
|
|
12
|
+
*
|
|
13
|
+
* So anyone who obtained an account through JWT auto-provisioning or a magic-link redeem could
|
|
14
|
+
* write any row of `MJ: Users` — including setting their own `Type` to `'Owner'`, the column MJ's
|
|
15
|
+
* superuser checks read (`SqlLoggingConfigResolver`, `canIssueInvites`, the backup-system-user
|
|
16
|
+
* fallback). That is privilege escalation from "can obtain a token" to "platform Owner".
|
|
17
|
+
*
|
|
18
|
+
* WHY THE DEFAULT IS THE FIX. The value is not merely the fallback for a host with no config file.
|
|
19
|
+
* `loadConfig()` deep-merges through `mergeConfigs`, so a host writing a PARTIAL `userHandling`
|
|
20
|
+
* block (`{ autoCreateNewUsers: true }`, say) inherits this array — the Zod `.default([])` on the
|
|
21
|
+
* schema never fires, because the key is never absent after the merge. The shipped default is
|
|
22
|
+
* therefore the effective posture of every deployment that does not name `newUserRoles` explicitly.
|
|
23
|
+
*
|
|
24
|
+
* These assertions pin the CONTRACT, not just the literal: the shipped default must grant an
|
|
25
|
+
* auto-provisioned user no more than the end-user surface.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
28
|
+
|
|
29
|
+
// config.ts evaluates `configInfo = loadConfig()` at module load, and `loadConfig` rejects a
|
|
30
|
+
// configuration carrying no database credentials. Stubbing the cosmiconfig search — and ONLY that —
|
|
31
|
+
// supplies them so the module imports, while leaving `DEFAULT_SERVER_CONFIG` itself completely
|
|
32
|
+
// untouched. These assertions therefore run against the constant the server actually ships rather
|
|
33
|
+
// than against a copy of it, which is the whole point of a shipped-default contract test.
|
|
34
|
+
vi.mock('cosmiconfig', () => ({
|
|
35
|
+
cosmiconfigSync: () => ({
|
|
36
|
+
search: () => ({
|
|
37
|
+
filepath: '/test/mj.config.cjs',
|
|
38
|
+
isEmpty: false,
|
|
39
|
+
config: { dbUsername: 'test-user', dbPassword: 'test-password', dbDatabase: 'test-db' },
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
import { DEFAULT_SERVER_CONFIG } from '../config.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Seeded roles that carry platform-wide write on the baseline database, and so must never appear
|
|
48
|
+
* in a default handed to a party the host has not vetted. `Integration`'s own seed Description
|
|
49
|
+
* says it "shouldn't be applied to end-users"; `Developer` is the role this issue was filed about.
|
|
50
|
+
*/
|
|
51
|
+
const PLATFORM_WRITE_ROLES = ['Developer', 'Integration'];
|
|
52
|
+
|
|
53
|
+
describe('DEFAULT_SERVER_CONFIG.userHandling.newUserRoles (issue #4260)', () => {
|
|
54
|
+
const newUserRoles = DEFAULT_SERVER_CONFIG.userHandling?.newUserRoles;
|
|
55
|
+
|
|
56
|
+
it('grants auto-provisioned users the end-user role only', () => {
|
|
57
|
+
expect(newUserRoles).toEqual(['UI']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each(PLATFORM_WRITE_ROLES)('does not include the platform-write role %j', role => {
|
|
61
|
+
// The regression itself. Before the fix, 'Developer' was present here and every
|
|
62
|
+
// auto-provisioned user could UPDATE MJ: Users, Type and Name included.
|
|
63
|
+
expect(newUserRoles).not.toContain(role);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is not empty — an auto-provisioned user must still be able to use the product', () => {
|
|
67
|
+
// The opposite failure mode: locking the default down to [] would provision users who
|
|
68
|
+
// cannot read their own conversations, views or dashboards. 'UI' is the seeded role that
|
|
69
|
+
// carries exactly that end-user surface (48 update grants, none of them on MJ: Users).
|
|
70
|
+
expect(newUserRoles?.length).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('stays minimal because auto-provisioning is ON by default with no domain restriction', () => {
|
|
74
|
+
// This is the linkage that makes the role list security-relevant rather than a convenience
|
|
75
|
+
// setting. A stock server auto-creates an account for ANY identity its configured IdP will
|
|
76
|
+
// issue a token for. If either of these defaults is ever tightened, the role list may be
|
|
77
|
+
// reconsidered — but not before, and never in the other order.
|
|
78
|
+
expect(DEFAULT_SERVER_CONFIG.userHandling?.autoCreateNewUsers).toBe(true);
|
|
79
|
+
expect(DEFAULT_SERVER_CONFIG.userHandling?.newUserLimitedToAuthorizedDomains).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|