@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.
Files changed (100) hide show
  1. package/README.md +6 -2
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.js +2 -2
  4. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  5. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  6. package/dist/auth/exampleNewUserSubClass.js +9 -4
  7. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  9. package/dist/auth/magicLink/MagicLinkService.js +2 -8
  10. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  11. package/dist/auth/newUsers.d.ts.map +1 -1
  12. package/dist/auth/newUsers.js +4 -12
  13. package/dist/auth/newUsers.js.map +1 -1
  14. package/dist/auth/principals.d.ts +92 -0
  15. package/dist/auth/principals.d.ts.map +1 -0
  16. package/dist/auth/principals.js +193 -0
  17. package/dist/auth/principals.js.map +1 -0
  18. package/dist/config.d.ts +70 -12
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +32 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/generated/graphql-schemas/__mj.d.ts +5347 -5291
  23. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  24. package/dist/generated/graphql-schemas/__mj.js +21098 -20793
  25. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +35 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +128 -2
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +16 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  35. package/dist/realtimeWidget/WidgetSessionService.js +2 -12
  36. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  37. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  38. package/dist/realtimeWidget/visitorIdentity.js +20 -5
  39. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  40. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  41. package/dist/resolvers/FileCategoryResolver.js +1 -2
  42. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  43. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  44. package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
  45. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  46. package/dist/resolvers/QuerySystemUserResolver.js +1 -1
  47. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  48. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
  49. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  50. package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
  51. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  52. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  53. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  54. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  55. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  56. package/dist/resolvers/UserViewResolver.js +8 -4
  57. package/dist/resolvers/UserViewResolver.js.map +1 -1
  58. package/dist/rest/EntityCRUDHandler.d.ts +2 -1
  59. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  60. package/dist/rest/EntityCRUDHandler.js +3 -18
  61. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  62. package/dist/rest/RESTEndpointHandler.d.ts +2 -1
  63. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  64. package/dist/rest/RESTEndpointHandler.js +3 -16
  65. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  66. package/dist/telephony/TeamsMeetingsRouter.js +18 -2
  67. package/dist/telephony/TeamsMeetingsRouter.js.map +1 -1
  68. package/package.json +92 -91
  69. package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
  70. package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
  71. package/src/__tests__/ResolverBase.filterEscaping.test.ts +127 -3
  72. package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
  73. package/src/__tests__/WidgetSessionService.test.ts +4 -1
  74. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  75. package/src/__tests__/newUsers.test.ts +36 -14
  76. package/src/__tests__/principals.callSites.test.ts +112 -0
  77. package/src/__tests__/principals.logging.test.ts +160 -0
  78. package/src/__tests__/principals.test.ts +305 -0
  79. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  80. package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
  81. package/src/agentSessions/SessionJanitor.ts +2 -2
  82. package/src/auth/exampleNewUserSubClass.ts +9 -6
  83. package/src/auth/magicLink/MagicLinkService.ts +2 -8
  84. package/src/auth/newUsers.ts +7 -15
  85. package/src/auth/principals.ts +257 -0
  86. package/src/config.ts +32 -4
  87. package/src/generated/graphql-schemas/__mj.ts +13937 -13726
  88. package/src/generic/ResolverBase.ts +157 -2
  89. package/src/index.ts +17 -2
  90. package/src/realtimeWidget/WidgetSessionService.ts +2 -14
  91. package/src/realtimeWidget/visitorIdentity.ts +21 -6
  92. package/src/resolvers/FileCategoryResolver.ts +2 -3
  93. package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
  94. package/src/resolvers/QuerySystemUserResolver.ts +1 -1
  95. package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
  96. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  97. package/src/resolvers/UserViewResolver.ts +8 -4
  98. package/src/rest/EntityCRUDHandler.ts +3 -19
  99. package/src/rest/RESTEndpointHandler.ts +3 -17
  100. 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 do (succeed / throw a capability error). ---
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
- Instance: { Users: [], UserByName: () => undefined, GetSystemUser: () => undefined },
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
- mockUserByName,
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
- mockUserByName: vi.fn(),
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
- const CONTEXT_USER = { ID: 'sys-1', Name: 'system@test.com', Email: 'system@test.com', Type: 'Owner' } as unknown as UserInfo;
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
- mockUserByName.mockReturnValue(undefined);
303
- const owner = { ID: 'owner-9', Name: 'Fallback Owner', Email: 'owner@x.com', Type: ' OWNER ' };
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
- mockUserByName.mockReturnValue(undefined);
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
- mockUserByName.mockImplementation(() => {
326
- throw new Error('cache exploded');
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
- expect(mockCacheUsers).toHaveLength(0);
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