@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.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 +6 -2
- 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 +70 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +32 -4
- package/dist/config.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +5347 -5291
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +21098 -20793
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +35 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +128 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- 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/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/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/telephony/TeamsMeetingsRouter.js +18 -2
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +127 -3
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- 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.refusalMessage.test.ts +88 -0
- 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 +32 -4
- package/src/generated/graphql-schemas/__mj.ts +13937 -13726
- package/src/generic/ResolverBase.ts +157 -2
- package/src/index.ts +17 -2
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- 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/telephony/TeamsMeetingsRouter.ts +21 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// polyfill is loaded first. MUST precede any import that pulls in the resolver file.
|
|
3
3
|
import 'reflect-metadata';
|
|
4
4
|
|
|
5
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { RemoteBrowserCapabilityNotSupportedError } from '@memberjunction/remote-browser-base';
|
|
7
7
|
|
|
8
8
|
// type-graphql's `@Field()`/`@ObjectType()`/`@Mutation()` decorators need `emitDecoratorMetadata`
|
|
@@ -32,14 +32,21 @@ vi.mock('type-graphql', () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
// --- Mock the live-session engine the resolver drives. A single mutable session object lets each
|
|
35
|
-
// test script what StartAudioStream/StopAudioStream
|
|
35
|
+
// test script what StartAudioStream/StopAudioStream (and, for the TTL-sweep tests below,
|
|
36
|
+
// StartScreencast/StopScreencast) do (succeed / throw a capability error). ---
|
|
36
37
|
interface FakeLiveSession {
|
|
37
38
|
StartAudioStream: ReturnType<typeof vi.fn>;
|
|
38
39
|
StopAudioStream: ReturnType<typeof vi.fn>;
|
|
40
|
+
StartScreencast: ReturnType<typeof vi.fn>;
|
|
41
|
+
StopScreencast: ReturnType<typeof vi.fn>;
|
|
42
|
+
GetCurrentUrl: ReturnType<typeof vi.fn>;
|
|
39
43
|
}
|
|
40
44
|
const liveSession: FakeLiveSession = {
|
|
41
45
|
StartAudioStream: vi.fn(async () => undefined),
|
|
42
46
|
StopAudioStream: vi.fn(async () => undefined),
|
|
47
|
+
StartScreencast: vi.fn(async () => undefined),
|
|
48
|
+
StopScreencast: vi.fn(async () => undefined),
|
|
49
|
+
GetCurrentUrl: vi.fn(() => 'https://example.com'),
|
|
43
50
|
};
|
|
44
51
|
const startSessionMock = vi.fn(async () => liveSession);
|
|
45
52
|
const getSessionMock = vi.fn(() => liveSession as FakeLiveSession | undefined);
|
|
@@ -116,6 +123,8 @@ describe('RemoteBrowserActionResolver — audio stream', () => {
|
|
|
116
123
|
resolver = new TestableResolver();
|
|
117
124
|
liveSession.StartAudioStream.mockReset().mockResolvedValue(undefined);
|
|
118
125
|
liveSession.StopAudioStream.mockReset().mockResolvedValue(undefined);
|
|
126
|
+
liveSession.StartScreencast.mockReset().mockResolvedValue(undefined);
|
|
127
|
+
liveSession.StopScreencast.mockReset().mockResolvedValue(undefined);
|
|
119
128
|
startSessionMock.mockReset().mockResolvedValue(liveSession);
|
|
120
129
|
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
121
130
|
});
|
|
@@ -194,3 +203,73 @@ describe('RemoteBrowserActionResolver — audio stream', () => {
|
|
|
194
203
|
await expect(resolver.StopRemoteBrowserAudioStream('sess-1', ctx)).resolves.toBe(true);
|
|
195
204
|
});
|
|
196
205
|
});
|
|
206
|
+
|
|
207
|
+
describe('RemoteBrowserActionResolver — stream idempotency-map TTL sweep', () => {
|
|
208
|
+
// Regression coverage for the Round 13 memory-leak-audit finding: `startedScreencasts` /
|
|
209
|
+
// `startedAudioStreams` are process-lifetime maps (the resolver is instantiated once per process by
|
|
210
|
+
// type-graphql's default container) that only ever shrink via an explicit Stop call. A session that
|
|
211
|
+
// crashes/disconnects/times out before calling Stop — a normal occurrence, not an edge case — used to
|
|
212
|
+
// leave its entry behind forever. These tests drive the maps past their internal TTL (see
|
|
213
|
+
// MAX_STREAM_ENTRY_AGE_MS in RemoteBrowserActionResolver.ts) WITHOUT ever calling Stop, and assert the
|
|
214
|
+
// swept entry no longer blocks a fresh Start — proving the leak is now bounded rather than permanent.
|
|
215
|
+
let resolver: TestableResolver;
|
|
216
|
+
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
resolver = new TestableResolver();
|
|
219
|
+
liveSession.StartAudioStream.mockReset().mockResolvedValue(undefined);
|
|
220
|
+
liveSession.StopAudioStream.mockReset().mockResolvedValue(undefined);
|
|
221
|
+
liveSession.StartScreencast.mockReset().mockResolvedValue(undefined);
|
|
222
|
+
liveSession.StopScreencast.mockReset().mockResolvedValue(undefined);
|
|
223
|
+
startSessionMock.mockReset().mockResolvedValue(liveSession);
|
|
224
|
+
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
225
|
+
vi.useFakeTimers();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
vi.useRealTimers();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('audio stream: a crashed session (no Stop call) is forgotten after the TTL, so a later Start re-invokes StartAudioStream', async () => {
|
|
233
|
+
const pubSub = makePubSub();
|
|
234
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
235
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
236
|
+
|
|
237
|
+
// Well under the TTL: still idempotent, exactly like a legitimately long-lived active stream.
|
|
238
|
+
vi.advanceTimersByTime(60 * 60 * 1000); // +1h
|
|
239
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
240
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
241
|
+
|
|
242
|
+
// Past the TTL, with Stop NEVER called (simulating a crash/disconnect): the stale entry is swept,
|
|
243
|
+
// and a fresh Start is treated as a real (re)start rather than trusting the dead flag forever.
|
|
244
|
+
vi.advanceTimersByTime(5 * 60 * 60 * 1000); // +5h more (6h total, past the 4h TTL)
|
|
245
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
246
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('screencast: a crashed session (no Stop call) is forgotten after the TTL, so a later Start re-invokes StartScreencast', async () => {
|
|
250
|
+
const pubSub = makePubSub();
|
|
251
|
+
await resolver.StartRemoteBrowserScreencast('sess-crash-2', ctx, pubSub as never);
|
|
252
|
+
expect(liveSession.StartScreencast).toHaveBeenCalledTimes(1);
|
|
253
|
+
|
|
254
|
+
vi.advanceTimersByTime(6 * 60 * 60 * 1000); // 6h, past the 4h TTL
|
|
255
|
+
await resolver.StartRemoteBrowserScreencast('sess-crash-2', ctx, pubSub as never);
|
|
256
|
+
expect(liveSession.StartScreencast).toHaveBeenCalledTimes(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('sweeping one stale surface does not evict an unrelated surface still inside the TTL', async () => {
|
|
260
|
+
const pubSub = makePubSub();
|
|
261
|
+
await resolver.StartRemoteBrowserAudioStream('sess-old', ctx, pubSub as never);
|
|
262
|
+
|
|
263
|
+
vi.advanceTimersByTime(3 * 60 * 60 * 1000); // +3h — sess-old is now 3h old, sess-new is fresh
|
|
264
|
+
await resolver.StartRemoteBrowserAudioStream('sess-new', ctx, pubSub as never);
|
|
265
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(2);
|
|
266
|
+
|
|
267
|
+
vi.advanceTimersByTime(2 * 60 * 60 * 1000); // +2h more — sess-old is 5h old (past TTL), sess-new is 2h old (within TTL)
|
|
268
|
+
// Triggering the sweep via a third session's Start must not evict sess-new's still-valid entry.
|
|
269
|
+
await resolver.StartRemoteBrowserAudioStream('sess-trigger', ctx, pubSub as never);
|
|
270
|
+
await resolver.StartRemoteBrowserAudioStream('sess-new', ctx, pubSub as never);
|
|
271
|
+
// sess-old, sess-new(first call), sess-trigger all real starts = 3 so far; the second sess-new
|
|
272
|
+
// call must still be idempotent (no 4th real start).
|
|
273
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(3);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -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,19 @@ 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
|
+
}
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
describe('ResolverBase.findBy — ExtraFilter escaping', () => {
|
|
@@ -127,6 +153,104 @@ describe('ResolverBase.findBy — ExtraFilter escaping', () => {
|
|
|
127
153
|
});
|
|
128
154
|
});
|
|
129
155
|
|
|
156
|
+
describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
|
|
157
|
+
// Keyword SELECT/EXISTS ban (#4253) broke first-party IN (SELECT … FROM base view).
|
|
158
|
+
// The replacement parses the fragment and allows only entity BaseViews.
|
|
159
|
+
|
|
160
|
+
const provider = () => fakeProvider({ params: null });
|
|
161
|
+
|
|
162
|
+
it('rejects an EXISTS subquery against a base table', () => {
|
|
163
|
+
expect(() =>
|
|
164
|
+
new Probe().ScreenClause(`EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`, 'ExtraFilter', provider())
|
|
165
|
+
).toThrow(/entity base view/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects a scalar SELECT subquery against a non-view', () => {
|
|
169
|
+
expect(() =>
|
|
170
|
+
new Probe().ScreenClause('(SELECT COUNT(*) FROM __mj.APIKey)', 'OrderBy', provider())
|
|
171
|
+
).toThrow(/entity base view/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('allows IN (SELECT …) against an entity BaseView', () => {
|
|
175
|
+
expect(() =>
|
|
176
|
+
new Probe().ScreenClause(
|
|
177
|
+
`ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
|
|
178
|
+
'ExtraFilter',
|
|
179
|
+
provider(),
|
|
180
|
+
)
|
|
181
|
+
).not.toThrow();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('allows the restored Committees ExtraFilter shapes (Command Center, workspace, tracker)', () => {
|
|
185
|
+
const p = new Probe();
|
|
186
|
+
const md = provider();
|
|
187
|
+
const clauses = [
|
|
188
|
+
`TaskID IN (SELECT ID FROM [__mj_BizAppsTasks].[vwTasks] WHERE Status IN ('Open', 'InProgress'))`,
|
|
189
|
+
`ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
|
|
190
|
+
`MeetingID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMeetings] WHERE CommitteeID='x')`,
|
|
191
|
+
`MotionID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMotions] WHERE MeetingID = 'x')`,
|
|
192
|
+
`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')`,
|
|
193
|
+
];
|
|
194
|
+
for (const c of clauses) {
|
|
195
|
+
expect(() => p.ScreenClause(c, 'ExtraFilter', md)).not.toThrow();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('allows an ordinary comparison filter', () => {
|
|
200
|
+
expect(() =>
|
|
201
|
+
new Probe().ScreenClause(`Email = 'a@b.com' AND IsActive = 1`, 'ExtraFilter', provider())
|
|
202
|
+
).not.toThrow();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not false-positive on SELECT/EXISTS inside string literals', () => {
|
|
206
|
+
expect(() =>
|
|
207
|
+
new Probe().ScreenClause(`Name LIKE '%select%' OR Name = 'exists'`, 'ExtraFilter', provider())
|
|
208
|
+
).not.toThrow();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('allows empty/undefined clauses', () => {
|
|
212
|
+
expect(() => new Probe().ScreenClause('', 'ExtraFilter', provider())).not.toThrow();
|
|
213
|
+
expect(() => new Probe().ScreenClause(undefined, 'OrderBy', provider())).not.toThrow();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('RunDynamicViewGeneric never reaches RunView when ExtraFilter hits a base table', async () => {
|
|
217
|
+
const captured: Captured = { params: null };
|
|
218
|
+
const input = {
|
|
219
|
+
EntityName: ENTITY_NAME,
|
|
220
|
+
ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`,
|
|
221
|
+
} as RunDynamicViewInput;
|
|
222
|
+
|
|
223
|
+
await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(
|
|
224
|
+
/entity base view/
|
|
225
|
+
);
|
|
226
|
+
expect(captured.params).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('RunDynamicViewGeneric passes a BaseView subquery through to RunView', async () => {
|
|
230
|
+
const captured: Captured = { params: null };
|
|
231
|
+
const input = {
|
|
232
|
+
EntityName: ENTITY_NAME,
|
|
233
|
+
ExtraFilter: `ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`,
|
|
234
|
+
} as RunDynamicViewInput;
|
|
235
|
+
|
|
236
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
237
|
+
|
|
238
|
+
expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
|
|
242
|
+
const captured: Captured = { params: null };
|
|
243
|
+
const input = {
|
|
244
|
+
EntityName: ENTITY_NAME,
|
|
245
|
+
ExtraFilter: `Email = 'a@b.com'`,
|
|
246
|
+
} as RunDynamicViewInput;
|
|
247
|
+
|
|
248
|
+
await new Probe().RunDynamic(input, fakeProvider(captured));
|
|
249
|
+
|
|
250
|
+
expect(captured.params?.ExtraFilter).toBe(`Email = 'a@b.com'`);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
130
254
|
describe('ResolverBase.RunViewByNameGeneric — view-name escaping', () => {
|
|
131
255
|
it('escapes the client-supplied view name', async () => {
|
|
132
256
|
const captured: Captured = { params: null };
|
|
@@ -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,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
|
+
});
|
|
@@ -21,7 +21,7 @@ import type { UserInfo } from '@memberjunction/core';
|
|
|
21
21
|
const {
|
|
22
22
|
mockConfig,
|
|
23
23
|
mockCacheUsers,
|
|
24
|
-
|
|
24
|
+
mockSystemUserId,
|
|
25
25
|
mockRefresh,
|
|
26
26
|
mockGetEntityObject,
|
|
27
27
|
mockRunViewFn,
|
|
@@ -39,6 +39,7 @@ const {
|
|
|
39
39
|
Name: string;
|
|
40
40
|
Email: string;
|
|
41
41
|
Type: string;
|
|
42
|
+
IsActive?: boolean;
|
|
42
43
|
}
|
|
43
44
|
return {
|
|
44
45
|
mockConfig: {
|
|
@@ -55,7 +56,11 @@ const {
|
|
|
55
56
|
},
|
|
56
57
|
},
|
|
57
58
|
mockCacheUsers: [] as HoistedCacheUser[],
|
|
58
|
-
|
|
59
|
+
// The system user's ID as the cache reports it. A well-formed UUID that matches NO fixture
|
|
60
|
+
// below, so the resolver's system rung stays inert unless a test opts into it. Well-formed
|
|
61
|
+
// matters: `UUIDsEqual` is a string compare with no shape check, so a malformed value here
|
|
62
|
+
// would read as real data while being unrepresentable in the column it stands for.
|
|
63
|
+
mockSystemUserId: { value: '00000000-0000-0000-0000-00000000d1f5' },
|
|
59
64
|
mockRefresh: vi.fn(),
|
|
60
65
|
mockGetEntityObject: vi.fn(),
|
|
61
66
|
mockRunViewFn: vi.fn(),
|
|
@@ -76,10 +81,12 @@ vi.mock('../config.js', () => ({ configInfo: mockConfig }));
|
|
|
76
81
|
|
|
77
82
|
vi.mock('@memberjunction/generic-database-provider', () => {
|
|
78
83
|
const instance = {
|
|
79
|
-
UserByName: mockUserByName,
|
|
80
84
|
get Users() {
|
|
81
85
|
return mockCacheUsers;
|
|
82
86
|
},
|
|
87
|
+
get SYSTEM_USER_ID() {
|
|
88
|
+
return mockSystemUserId.value;
|
|
89
|
+
},
|
|
83
90
|
Refresh: mockRefresh,
|
|
84
91
|
GetSystemUser: vi.fn(),
|
|
85
92
|
};
|
|
@@ -234,7 +241,9 @@ function entitiesOf(name: string): MockEntity[] {
|
|
|
234
241
|
return createdEntities.filter((e) => e.TestEntityName === name);
|
|
235
242
|
}
|
|
236
243
|
|
|
237
|
-
|
|
244
|
+
/** The cache row for the configured context user; `CONTEXT_USER` is the same object, typed. */
|
|
245
|
+
const CONTEXT_USER_ROW = { ID: 'sys-1', Name: 'system@test.com', Email: 'system@test.com', Type: 'Owner', IsActive: true };
|
|
246
|
+
const CONTEXT_USER = CONTEXT_USER_ROW as unknown as UserInfo;
|
|
238
247
|
|
|
239
248
|
function resetConfig(): void {
|
|
240
249
|
mockConfig.userHandling = {
|
|
@@ -261,14 +270,22 @@ beforeEach(() => {
|
|
|
261
270
|
getEntityObjectCalls = [];
|
|
262
271
|
saveBehavior = {};
|
|
263
272
|
entitySeq = 0;
|
|
273
|
+
// defineProperty rather than assignment: one test replaces this with a throwing getter.
|
|
274
|
+
Object.defineProperty(mockSystemUserId, 'value', {
|
|
275
|
+
value: '00000000-0000-0000-0000-00000000d1f5',
|
|
276
|
+
writable: true,
|
|
277
|
+
configurable: true,
|
|
278
|
+
});
|
|
279
|
+
// The configured context user must be IN the cache: resolution reads the cache directly rather
|
|
280
|
+
// than going through a UserByName stub that answered regardless of what the cache held.
|
|
264
281
|
mockCacheUsers.length = 0;
|
|
282
|
+
mockCacheUsers.push(CONTEXT_USER_ROW);
|
|
265
283
|
|
|
266
284
|
mockRolesArray.length = 0;
|
|
267
285
|
mockRolesArray.push({ ID: 'role-ui', Name: 'UI' }, { ID: 'role-dev', Name: 'Developer' });
|
|
268
286
|
mockApplicationsArray.length = 0;
|
|
269
287
|
mockApplicationsArray.push({ ID: 'app-crm', Name: ' CRM ' }, { ID: 'app-admin', Name: 'Admin' });
|
|
270
288
|
|
|
271
|
-
mockUserByName.mockImplementation((name: string) => (name === 'system@test.com' ? CONTEXT_USER : undefined));
|
|
272
289
|
mockGetEntityObject.mockImplementation(async (entityName: string, contextUser?: UserInfo) => {
|
|
273
290
|
const entity = makeMockEntity(entityName);
|
|
274
291
|
createdEntities.push(entity);
|
|
@@ -290,7 +307,6 @@ describe('NewUserBase.createNewUser', () => {
|
|
|
290
307
|
const user = await create();
|
|
291
308
|
|
|
292
309
|
expect(user).not.toBeNull();
|
|
293
|
-
expect(mockUserByName).toHaveBeenCalledWith('system@test.com');
|
|
294
310
|
expect(getEntityObjectCalls[0]).toEqual({ entityName: 'MJ: Users', contextUser: CONTEXT_USER });
|
|
295
311
|
// Role records are scoped to the same creation context user
|
|
296
312
|
for (const call of getEntityObjectCalls) {
|
|
@@ -299,9 +315,9 @@ describe('NewUserBase.createNewUser', () => {
|
|
|
299
315
|
});
|
|
300
316
|
|
|
301
317
|
it('falls back to an Owner-typed cache user (case-insensitive, trimmed) when the configured user is missing', async () => {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User' }, owner);
|
|
318
|
+
const owner = { ID: 'owner-9', Name: 'Fallback Owner', Email: 'owner@x.com', Type: ' OWNER ', IsActive: true };
|
|
319
|
+
mockCacheUsers.length = 0; // the configured user is absent from this deployment
|
|
320
|
+
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User', IsActive: true }, owner);
|
|
305
321
|
|
|
306
322
|
const user = await create();
|
|
307
323
|
|
|
@@ -311,8 +327,8 @@ describe('NewUserBase.createNewUser', () => {
|
|
|
311
327
|
});
|
|
312
328
|
|
|
313
329
|
it('returns null without opening a transaction when no context user can be resolved at all', async () => {
|
|
314
|
-
|
|
315
|
-
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User' });
|
|
330
|
+
mockCacheUsers.length = 0; // no configured user, no system user, no Owner
|
|
331
|
+
mockCacheUsers.push({ ID: 'u-1', Name: 'Plain', Email: 'p@x.com', Type: 'User', IsActive: true });
|
|
316
332
|
|
|
317
333
|
const user = await create();
|
|
318
334
|
|
|
@@ -322,8 +338,12 @@ describe('NewUserBase.createNewUser', () => {
|
|
|
322
338
|
});
|
|
323
339
|
|
|
324
340
|
it('returns undefined (outer catch) when context resolution throws', async () => {
|
|
325
|
-
|
|
326
|
-
|
|
341
|
+
// A cache that cannot report its own system user id fails resolution outright.
|
|
342
|
+
Object.defineProperty(mockSystemUserId, 'value', {
|
|
343
|
+
get() {
|
|
344
|
+
throw new Error('cache exploded');
|
|
345
|
+
},
|
|
346
|
+
configurable: true,
|
|
327
347
|
});
|
|
328
348
|
|
|
329
349
|
const user = await create();
|
|
@@ -604,7 +624,9 @@ describe('verifyUserRecord', () => {
|
|
|
604
624
|
const user = await verifyUserRecord('new@example.com', 'New', 'User');
|
|
605
625
|
|
|
606
626
|
expect(user).toBeUndefined();
|
|
607
|
-
|
|
627
|
+
// The cache is no longer empty at rest (it holds the configured context user), so
|
|
628
|
+
// assert the thing that actually matters: the failed user was not added to it.
|
|
629
|
+
expect(mockCacheUsers.some((u) => u.Email === 'new@example.com')).toBe(false);
|
|
608
630
|
});
|
|
609
631
|
});
|
|
610
632
|
|