@memberjunction/server 5.50.0 → 6.1.0-edge.0

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 (116) hide show
  1. package/LICENSE +7 -0
  2. package/dist/agentSessions/SessionManager.d.ts +22 -1
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +18 -3
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/actingContextResolver.d.ts +44 -0
  7. package/dist/auth/actingContextResolver.d.ts.map +1 -0
  8. package/dist/auth/actingContextResolver.js +18 -0
  9. package/dist/auth/actingContextResolver.js.map +1 -0
  10. package/dist/auth/index.d.ts +1 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +1 -0
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/sessionUserClone.d.ts +32 -0
  15. package/dist/auth/sessionUserClone.d.ts.map +1 -0
  16. package/dist/auth/sessionUserClone.js +50 -0
  17. package/dist/auth/sessionUserClone.js.map +1 -0
  18. package/dist/context.d.ts +1 -1
  19. package/dist/context.d.ts.map +1 -1
  20. package/dist/context.js +43 -6
  21. package/dist/context.js.map +1 -1
  22. package/dist/generated/generated.d.ts +71 -0
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +383 -3
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  27. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  28. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  29. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  30. package/dist/generic/PushStatusResolver.d.ts +58 -0
  31. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  32. package/dist/generic/PushStatusResolver.js +42 -3
  33. package/dist/generic/PushStatusResolver.js.map +1 -1
  34. package/dist/generic/ResolverBase.d.ts +9 -0
  35. package/dist/generic/ResolverBase.d.ts.map +1 -1
  36. package/dist/generic/ResolverBase.js +31 -13
  37. package/dist/generic/ResolverBase.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/multiTenancy/index.d.ts +19 -0
  43. package/dist/multiTenancy/index.d.ts.map +1 -1
  44. package/dist/multiTenancy/index.js +82 -5
  45. package/dist/multiTenancy/index.js.map +1 -1
  46. package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
  47. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  48. package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
  49. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  50. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  51. package/dist/resolvers/ActionResolver.js +12 -5
  52. package/dist/resolvers/ActionResolver.js.map +1 -1
  53. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  54. package/dist/resolvers/MCPResolver.js +13 -16
  55. package/dist/resolvers/MCPResolver.js.map +1 -1
  56. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +22 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  58. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -30
  59. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  60. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/RemoteBrowserActionResolver.js +19 -26
  62. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  63. package/dist/resolvers/ReportResolver.d.ts.map +1 -1
  64. package/dist/resolvers/ReportResolver.js +11 -3
  65. package/dist/resolvers/ReportResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  70. package/dist/resolvers/RunTestResolver.js +90 -112
  71. package/dist/resolvers/RunTestResolver.js.map +1 -1
  72. package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
  73. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  74. package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
  75. package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
  76. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  77. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  78. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  79. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  80. package/dist/services/TaskOrchestrator.js +17 -15
  81. package/dist/services/TaskOrchestrator.js.map +1 -1
  82. package/package.json +100 -100
  83. package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
  84. package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
  85. package/src/__tests__/SessionManager.test.ts +106 -0
  86. package/src/__tests__/multiTenancy.security.test.ts +254 -22
  87. package/src/__tests__/multiTenancy.test.ts +22 -0
  88. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  89. package/src/__tests__/resolverBase.rls.test.ts +4 -0
  90. package/src/__tests__/search-knowledge-tags.test.ts +74 -6
  91. package/src/__tests__/sessionUserClone.test.ts +161 -0
  92. package/src/__tests__/widgetGuestElevation.test.ts +70 -2
  93. package/src/agentSessions/SessionManager.ts +32 -3
  94. package/src/auth/actingContextResolver.ts +56 -0
  95. package/src/auth/index.ts +1 -0
  96. package/src/auth/sessionUserClone.ts +50 -0
  97. package/src/context.ts +48 -6
  98. package/src/generated/generated.ts +276 -3
  99. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  100. package/src/generic/PushStatusResolver.ts +79 -4
  101. package/src/generic/ResolverBase.ts +33 -13
  102. package/src/index.ts +4 -2
  103. package/src/multiTenancy/index.ts +92 -5
  104. package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
  105. package/src/resolvers/ActionResolver.ts +15 -7
  106. package/src/resolvers/MCPResolver.ts +13 -15
  107. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -29
  108. package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
  109. package/src/resolvers/ReportResolver.ts +11 -3
  110. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  111. package/src/resolvers/RunTestResolver.ts +90 -112
  112. package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
  113. package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
  114. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  115. package/src/resolvers/__tests__/ReportResolver.test.ts +279 -0
  116. package/src/services/TaskOrchestrator.ts +17 -17
@@ -6,9 +6,16 @@
6
6
  * in multiTenancy.test.ts.
7
7
  */
8
8
  import { describe, it, expect, vi } from 'vitest';
9
- import { createTenantPreRunViewHook, createTenantPreSaveHook, createTenantMiddleware } from '../multiTenancy/index.js';
9
+ import {
10
+ createTenantPreRunViewHook,
11
+ createTenantPreSaveHook,
12
+ createTenantMiddleware,
13
+ IsValidTenantId,
14
+ attachTenantContext,
15
+ } from '../multiTenancy/index.js';
10
16
  import type { MultiTenancyConfig } from '../config.js';
11
- import type { RunViewParams } from '@memberjunction/core';
17
+ import { Metadata } from '@memberjunction/core';
18
+ import type { RunViewParams, UserInfo, TenantContext } from '@memberjunction/core';
12
19
 
13
20
  // Mock @memberjunction/core Metadata for entity schema lookup.
14
21
  vi.mock('@memberjunction/core', async (importOriginal) => {
@@ -20,6 +27,31 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
20
27
  { Name: 'Orders', SchemaName: 'dbo' },
21
28
  { Name: 'Users', SchemaName: '__mj' },
22
29
  ];
30
+
31
+ // The hook validates that the tenant column resolves to a real, stored
32
+ // (non-virtual) field before interpolating it as an identifier.
33
+ EntityByName(name: string) {
34
+ const entity = this.Entities.find(
35
+ e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
36
+ );
37
+ if (!entity) return undefined;
38
+ return {
39
+ ...entity,
40
+ Fields: [
41
+ { Name: 'OrganizationID', IsVirtual: false },
42
+ { Name: 'TenantID', IsVirtual: false },
43
+ // A view-only column: must be REJECTED as a tenant column target
44
+ { Name: 'OrganizationName', IsVirtual: true },
45
+ ],
46
+ };
47
+ }
48
+
49
+ // QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
50
+ // server (Metadata.Provider is a DatabaseProviderBase instance). Tests that need
51
+ // to exercise the no-provider-available fail-closed path override this locally.
52
+ static Provider: { QuoteIdentifier: (name: string) => string } | undefined = {
53
+ QuoteIdentifier: (name: string) => `[${name}]`,
54
+ };
23
55
  }
24
56
 
25
57
  return {
@@ -58,41 +90,164 @@ function makeUser(tenantId?: string, roles: string[] = []): MockUser {
58
90
 
59
91
  describe('Multi-Tenancy Security Edge Cases', () => {
60
92
  describe('SQL injection in tenant ID', () => {
61
- it('should treat SQL injection attempt as literal tenant ID', () => {
93
+ it('should escape quotes so an injection attempt stays INSIDE the string literal', () => {
62
94
  const hook = createTenantPreRunViewHook(makeConfig());
63
95
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
64
96
  const user = makeUser("' OR 1=1 --");
65
97
 
66
98
  const result = hook(params, user) as RunViewParams;
67
99
 
68
- // The tenant ID is inserted as-is the SQL provider should use
69
- // parameterized queries to prevent actual injection.
70
- // The hook correctly generates a filter with the raw value.
71
- expect(result.ExtraFilter).toContain("' OR 1=1 --");
72
- expect(result.ExtraFilter).toBe("[OrganizationID] = '' OR 1=1 --'");
100
+ // The single quote is doubled at predicate construction, so the payload
101
+ // cannot terminate the literal and rewrite the WHERE clause. (The header
102
+ // boundary rejects this value outright via IsValidTenantId; this is the
103
+ // defense-in-depth layer for TenantContext attached from other sources.)
104
+ expect(result.ExtraFilter).toBe("[OrganizationID] = ''' OR 1=1 --'");
73
105
  });
74
106
 
75
- it('should handle tenant ID with SQL UNION attack', () => {
107
+ it('should escape quotes in a UNION attack payload', () => {
76
108
  const hook = createTenantPreRunViewHook(makeConfig());
77
109
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
78
110
  const user = makeUser("' UNION SELECT * FROM Users --");
79
111
 
80
112
  const result = hook(params, user) as RunViewParams;
81
- expect(result.ExtraFilter).toContain("UNION SELECT");
82
- // Note: This is a known limitation the filter is string-concatenated.
83
- // Defense-in-depth: the tenant ID should be validated upstream before
84
- // it reaches TenantContext. Middle-layer middleware (e.g., BCSaaS)
85
- // should validate that the tenant ID is a valid UUID or org ID.
113
+ // Leading quote doubled → the UNION text is inert literal content
114
+ expect(result.ExtraFilter).toBe("[OrganizationID] = ''' UNION SELECT * FROM Users --'");
86
115
  });
87
116
 
88
- it('should handle tenant ID with semicolon (statement terminator)', () => {
117
+ it('should escape quotes in a stacked-statement payload', () => {
89
118
  const hook = createTenantPreRunViewHook(makeConfig());
90
119
  const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
91
120
  const user = makeUser("abc'; DROP TABLE Customers; --");
92
121
 
93
122
  const result = hook(params, user) as RunViewParams;
94
- expect(result.ExtraFilter).toContain("DROP TABLE");
95
- // Same caveat: parameterized queries at the DB layer prevent execution
123
+ expect(result.ExtraFilter).toBe("[OrganizationID] = 'abc''; DROP TABLE Customers; --'");
124
+ });
125
+ });
126
+
127
+ // ─── Boundary validation: IsValidTenantId ───────────────────────────────
128
+
129
+ describe('IsValidTenantId', () => {
130
+ it('rejects a quote-based SQL injection payload', () => {
131
+ expect(IsValidTenantId("x' OR '1'='1")).toBe(false);
132
+ });
133
+
134
+ it('rejects the empty string', () => {
135
+ expect(IsValidTenantId('')).toBe(false);
136
+ });
137
+
138
+ it('rejects a 129-character string (max is 128)', () => {
139
+ expect(IsValidTenantId('a'.repeat(129))).toBe(false);
140
+ });
141
+
142
+ it('accepts a 128-character string (boundary)', () => {
143
+ expect(IsValidTenantId('a'.repeat(128))).toBe(true);
144
+ });
145
+
146
+ it('rejects whitespace, semicolons, and comparison operators', () => {
147
+ expect(IsValidTenantId('tenant 1')).toBe(false);
148
+ expect(IsValidTenantId('a;b')).toBe(false);
149
+ expect(IsValidTenantId("1'='1")).toBe(false);
150
+ expect(IsValidTenantId('a=b')).toBe(false);
151
+ });
152
+
153
+ it('accepts a standard GUID', () => {
154
+ expect(IsValidTenantId('550e8400-e29b-41d4-a716-446655440000')).toBe(true);
155
+ });
156
+
157
+ it('accepts conservative identifiers with underscore, dot, and hyphen', () => {
158
+ expect(IsValidTenantId('tenant_1.a-b')).toBe(true);
159
+ });
160
+
161
+ it('KNOWN GAP: accepts consecutive hyphens (SQL line-comment token) because GUIDs need hyphens', () => {
162
+ // 'a--b' passes the character allowlist. This is safe today only because
163
+ // every consumer interpolates the value inside a quote-escaped string
164
+ // literal, where '--' is inert. If a future consumer places the tenant id
165
+ // outside a quoted literal, this becomes a comment-injection vector —
166
+ // flagged here so a tightening of the pattern flips this assertion.
167
+ expect(IsValidTenantId('a--b')).toBe(true);
168
+ });
169
+ });
170
+
171
+ // ─── attachTenantContext validation ─────────────────────────────────────
172
+
173
+ describe('attachTenantContext', () => {
174
+ function makePlainUser(): UserInfo {
175
+ return { ID: 'user-1' } as unknown as UserInfo;
176
+ }
177
+
178
+ it('throws on an invalid tenant id instead of attaching it', () => {
179
+ const user = makePlainUser();
180
+ expect(() => attachTenantContext(user, "x' OR '1'='1", 'header')).toThrow(/Invalid tenant identifier/);
181
+ expect(user.TenantContext).toBeUndefined();
182
+ });
183
+
184
+ it('throws on empty and oversized tenant ids', () => {
185
+ const user = makePlainUser();
186
+ expect(() => attachTenantContext(user, '', 'custom')).toThrow(/Invalid tenant identifier/);
187
+ expect(() => attachTenantContext(user, 'a'.repeat(129), 'custom')).toThrow(/Invalid tenant identifier/);
188
+ });
189
+
190
+ it('attaches a valid tenant id with the given source', () => {
191
+ const user = makePlainUser();
192
+ attachTenantContext(user, 'tenant_1.a-b', 'linkedEntity');
193
+ expect(user.TenantContext).toEqual<TenantContext>({ TenantID: 'tenant_1.a-b', Source: 'linkedEntity' });
194
+ });
195
+ });
196
+
197
+ // ─── Tenant column resolution (identifier position) ─────────────────────
198
+
199
+ describe('tenant column must resolve to a stored field', () => {
200
+ it('produces an escaped-value predicate with a quoted identifier for a valid setup', () => {
201
+ const hook = createTenantPreRunViewHook(makeConfig());
202
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
203
+ const user = makeUser("t'1"); // legal only via non-header sources; must still be escaped
204
+
205
+ const result = hook(params, user) as RunViewParams;
206
+ expect(result.ExtraFilter).toBe("[OrganizationID] = 't''1'");
207
+ });
208
+
209
+ it('throws when the configured tenant column does not exist on the entity', () => {
210
+ const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'NoSuchColumn' }));
211
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
212
+ const user = makeUser('tenant-1');
213
+
214
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
215
+ });
216
+
217
+ it('throws when the tenant column resolves only to a VIRTUAL field', () => {
218
+ const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'OrganizationName' }));
219
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
220
+ const user = makeUser('tenant-1');
221
+
222
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
223
+ });
224
+
225
+ it('throws when an entityColumnMappings override points at a nonexistent column', () => {
226
+ const hook = createTenantPreRunViewHook(makeConfig({
227
+ entityColumnMappings: { 'Customers': "Bad]Column" },
228
+ }));
229
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
230
+ const user = makeUser('tenant-1');
231
+
232
+ expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
233
+ });
234
+
235
+ it('throws (fails closed) rather than falling back to hardcoded bracket quoting when no provider is available', () => {
236
+ const savedProvider = Metadata.Provider;
237
+ // @ts-expect-error — simulating the no-provider-available case
238
+ Metadata.Provider = undefined;
239
+ try {
240
+ const hook = createTenantPreRunViewHook(makeConfig());
241
+ const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
242
+ const user = makeUser('tenant-1');
243
+
244
+ // A silent bracket-quoting fallback would produce invalid SQL on PostgreSQL
245
+ // without ever surfacing an error — refusing to build the query at all is the
246
+ // correct fail-closed behavior for a security-relevant identifier quote.
247
+ expect(() => hook(params, user)).toThrow(/no database provider available to quote/);
248
+ } finally {
249
+ Metadata.Provider = savedProvider;
250
+ }
96
251
  });
97
252
  });
98
253
 
@@ -232,12 +387,13 @@ describe('Multi-Tenancy Security Edge Cases', () => {
232
387
  expect(next).toHaveBeenCalledTimes(1);
233
388
  });
234
389
 
235
- it('should attach TenantContext when userPayload and header are present', () => {
390
+ it('should attach TenantContext to a CLONED userRecord when userPayload and header are present', () => {
236
391
  const middleware = createTenantMiddleware(makeConfig());
237
392
  const userRecord = { ID: 'u1' } as Record<string, unknown>;
393
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
238
394
  const req = {
239
395
  headers: { 'x-tenant-id': 'tenant-abc' },
240
- userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
396
+ userPayload,
241
397
  } as unknown as Parameters<typeof middleware>[0];
242
398
  const res = {} as Parameters<typeof middleware>[1];
243
399
  const next = vi.fn();
@@ -245,26 +401,56 @@ describe('Multi-Tenancy Security Edge Cases', () => {
245
401
  middleware(req, res, next);
246
402
 
247
403
  expect(next).toHaveBeenCalledTimes(1);
248
- expect(userRecord['TenantContext']).toEqual({
404
+ // Clone-before-stamp: the SHARED cached record must never be mutated —
405
+ // stamping it in place leaks one session's tenant to concurrent sessions.
406
+ expect(userRecord['TenantContext']).toBeUndefined();
407
+ const stamped = userPayload.userRecord as unknown as UserInfo;
408
+ expect(stamped).not.toBe(userRecord);
409
+ expect(stamped.ID).toBe('u1');
410
+ expect(stamped.TenantContext).toEqual({
249
411
  TenantID: 'tenant-abc',
250
412
  Source: 'header',
251
413
  });
252
414
  });
253
415
 
416
+ it('should reject an invalid tenant header with 400 and never attach or continue', () => {
417
+ const middleware = createTenantMiddleware(makeConfig());
418
+ const userRecord = { ID: 'u1' } as Record<string, unknown>;
419
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
420
+ const req = {
421
+ headers: { 'x-tenant-id': "x' OR '1'='1" },
422
+ userPayload,
423
+ } as unknown as Parameters<typeof middleware>[0];
424
+ const json = vi.fn();
425
+ const status = vi.fn().mockReturnValue({ json });
426
+ const res = { status } as unknown as Parameters<typeof middleware>[1];
427
+ const next = vi.fn();
428
+
429
+ middleware(req, res, next);
430
+
431
+ // Reject the REQUEST — never degrade to an unscoped (fail-open) session
432
+ expect(status).toHaveBeenCalledWith(400);
433
+ expect(json).toHaveBeenCalledWith({ error: 'Invalid tenant identifier' });
434
+ expect(next).not.toHaveBeenCalled();
435
+ expect(userPayload.userRecord).toBe(userRecord);
436
+ expect(userRecord['TenantContext']).toBeUndefined();
437
+ });
438
+
254
439
  it('should handle case-insensitive header name', () => {
255
440
  const middleware = createTenantMiddleware(makeConfig({ tenantHeader: 'X-Tenant-ID' }));
256
441
  const userRecord = { ID: 'u1' } as Record<string, unknown>;
442
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
257
443
  const req = {
258
444
  // Express normalizes headers to lowercase
259
445
  headers: { 'x-tenant-id': 'tenant-xyz' },
260
- userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
446
+ userPayload,
261
447
  } as unknown as Parameters<typeof middleware>[0];
262
448
  const res = {} as Parameters<typeof middleware>[1];
263
449
  const next = vi.fn();
264
450
 
265
451
  middleware(req, res, next);
266
452
 
267
- expect(userRecord['TenantContext']).toEqual({
453
+ expect((userPayload.userRecord as unknown as UserInfo).TenantContext).toEqual({
268
454
  TenantID: 'tenant-xyz',
269
455
  Source: 'header',
270
456
  });
@@ -284,6 +470,52 @@ describe('Multi-Tenancy Security Edge Cases', () => {
284
470
 
285
471
  expect(userRecord['TenantContext']).toBeUndefined();
286
472
  });
473
+
474
+ it('should reject a repeated header (array value) with 400 rather than guessing which value applies', () => {
475
+ const middleware = createTenantMiddleware(makeConfig());
476
+ const userRecord = { ID: 'u1' } as Record<string, unknown>;
477
+ const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
478
+ const req = {
479
+ // Express delivers a repeated header as string[]
480
+ headers: { 'x-tenant-id': ['tenant-A', 'tenant-B'] },
481
+ userPayload,
482
+ } as unknown as Parameters<typeof middleware>[0];
483
+ const json = vi.fn();
484
+ const status = vi.fn().mockReturnValue({ json });
485
+ const res = { status } as unknown as Parameters<typeof middleware>[1];
486
+ const next = vi.fn();
487
+
488
+ middleware(req, res, next);
489
+
490
+ expect(status).toHaveBeenCalledWith(400);
491
+ expect(next).not.toHaveBeenCalled();
492
+ expect(userPayload.userRecord).toBe(userRecord);
493
+ expect(userRecord['TenantContext']).toBeUndefined();
494
+ });
495
+
496
+ it('two concurrent requests for the same shared user with different headers do not cross-contaminate', () => {
497
+ const middleware = createTenantMiddleware(makeConfig());
498
+ const sharedUserRecord = { ID: 'u1', UserRoles: [] } as unknown as Record<string, unknown>;
499
+
500
+ const reqA = {
501
+ headers: { 'x-tenant-id': 'tenant-A' },
502
+ userPayload: { userRecord: sharedUserRecord, email: 'a@test.com', sessionId: 'sA' },
503
+ } as unknown as Parameters<typeof middleware>[0];
504
+ const reqB = {
505
+ headers: { 'x-tenant-id': 'tenant-B' },
506
+ userPayload: { userRecord: sharedUserRecord, email: 'b@test.com', sessionId: 'sB' },
507
+ } as unknown as Parameters<typeof middleware>[0];
508
+ const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Parameters<typeof middleware>[1];
509
+
510
+ middleware(reqA, res, vi.fn());
511
+ middleware(reqB, res, vi.fn());
512
+
513
+ const userA = (reqA as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
514
+ const userB = (reqB as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
515
+ expect(userA.TenantContext?.TenantID).toBe('tenant-A');
516
+ expect(userB.TenantContext?.TenantID).toBe('tenant-B');
517
+ expect((sharedUserRecord as unknown as UserInfo).TenantContext).toBeUndefined();
518
+ });
287
519
  });
288
520
 
289
521
  // ─── PreSave security edge cases ──────────────────────────────────────
@@ -15,6 +15,28 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
15
15
  { Name: 'AI Models', SchemaName: '__mj' },
16
16
  { Name: 'Users', SchemaName: '__mj' },
17
17
  ];
18
+
19
+ // The hook validates that the tenant column resolves to a real, stored
20
+ // (non-virtual) field on the entity before interpolating it as an identifier.
21
+ EntityByName(name: string) {
22
+ const entity = this.Entities.find(
23
+ e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
24
+ );
25
+ if (!entity) return undefined;
26
+ return {
27
+ ...entity,
28
+ Fields: [
29
+ { Name: 'OrganizationID', IsVirtual: false },
30
+ { Name: 'TenantID', IsVirtual: false },
31
+ ],
32
+ };
33
+ }
34
+
35
+ // QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
36
+ // server (Metadata.Provider is a DatabaseProviderBase instance).
37
+ static Provider = {
38
+ QuoteIdentifier: (name: string) => `[${name}]`,
39
+ };
18
40
  }
19
41
 
20
42
  return {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * pushStatusResolver.filter.test.ts — locks the security-critical subscription filter for B49.
3
+ *
4
+ * `statusUpdatesFilter` is the gate that fixes the `statusUpdates` session-hijack: a push is
5
+ * delivered only when the push's `sessionId` matches the subscriber's requested session AND the
6
+ * push's `ownerUserId` matches the subscriber CONNECTION's server-authenticated identity. Knowing
7
+ * a victim's client-chosen `sessionId` must no longer be sufficient to receive their pushes.
8
+ *
9
+ * The filter is a pure function (no DB / config / transport), so these are plain unit tests.
10
+ */
11
+ import 'reflect-metadata';
12
+ import { describe, it, expect } from 'vitest';
13
+ import { statusUpdatesFilter, type PushStatusNotificationPayload, type StatusUpdatesFilterContext } from '../generic/PushStatusResolver';
14
+ import type { UserPayload } from '../types';
15
+
16
+ const OWNER = 'AA11BB22-0000-4000-8000-000000000001';
17
+ const ATTACKER = 'CC33DD44-0000-4000-8000-000000000002';
18
+ const SESSION = 'session-abc-123';
19
+
20
+ function ctxFor(userId: string | undefined): StatusUpdatesFilterContext {
21
+ // userRecord is `any` in UserPayload; a minimal shape with ID is all the filter reads.
22
+ return { userPayload: (userId ? { userRecord: { ID: userId } } : {}) as unknown as UserPayload };
23
+ }
24
+
25
+ function push(overrides: Partial<PushStatusNotificationPayload> = {}): PushStatusNotificationPayload {
26
+ return { sessionId: SESSION, ownerUserId: OWNER, message: 'progress', ...overrides };
27
+ }
28
+
29
+ describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
30
+ it('delivers when sessionId matches AND the connection identity owns the push', () => {
31
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
32
+ });
33
+
34
+ it('THE FIX: rejects a matching sessionId when the connection identity is a DIFFERENT user (hijack)', () => {
35
+ // Attacker lifted the victim's sessionId and subscribes with it — but their authenticated
36
+ // connection identity is their own, so delivery must be denied.
37
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
38
+ });
39
+
40
+ it('rejects when the requested sessionId does not match the push', () => {
41
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: 'some-other-session' }, context: ctxFor(OWNER) })).toBe(false);
42
+ });
43
+
44
+ it('fails closed when the push carries no ownerUserId', () => {
45
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
46
+ });
47
+
48
+ it('fails closed when the connection has no authenticated identity', () => {
49
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(undefined) })).toBe(false);
50
+ expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: undefined })).toBe(false);
51
+ });
52
+
53
+ it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
54
+ expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
55
+ });
56
+ });
@@ -136,7 +136,11 @@ function makeEntity(name: string, opts: {
136
136
  return {
137
137
  Name: name,
138
138
  UserExemptFromRowLevelSecurity: () => opts.exempt,
139
+ // ResolverBase now delegates to GetEffectiveRowFilterWhereClause (role RLS
140
+ // + API-key row filters); the deprecated role-only method is kept in the
141
+ // mock shape for completeness but is no longer what the resolver calls.
139
142
  GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
143
+ GetEffectiveRowFilterWhereClause: () => opts.exempt ? '' : opts.rlsClause,
140
144
  } as unknown as EntityInfo;
141
145
  }
142
146
 
@@ -10,19 +10,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
10
 
11
11
  // ── Mocks ──────────────────────────────────────────────────────────────
12
12
 
13
- const { mockSearch, mockPreviewSearch } = vi.hoisted(() => ({
14
- mockSearch: vi.fn(),
15
- mockPreviewSearch: vi.fn(),
16
- }));
17
-
13
+ const { mockSearch, mockPreviewSearch, mockResolveEffectivePermission, mockLogForbiddenSearch } =
14
+ vi.hoisted(() => ({
15
+ mockSearch: vi.fn(),
16
+ mockPreviewSearch: vi.fn(),
17
+ mockResolveEffectivePermission: vi.fn(),
18
+ mockLogForbiddenSearch: vi.fn(),
19
+ }));
20
+
21
+ // `SearchKnowledgeResolver` obtains its permission resolver through the pluggable seam —
22
+ // `GetSearchScopePermissionResolver()` — so this mock has to provide that accessor even though
23
+ // no test below currently reaches it: every case here passes `undefined` for scope IDs, and
24
+ // `rejectForbiddenScopes` is guarded by `if (scopeIDs && scopeIDs.length)`.
25
+ //
26
+ // It is here because of how this omission fails. The missing export is simply `undefined`,
27
+ // calling it throws a TypeError, and the resolver's catch-all reports a result code unrelated
28
+ // to the thing under test — the same trap that made 11 failures in the CoreActions suite read
29
+ // like a permissions regression. The first person to add a test case WITH scope IDs would pay
30
+ // that diagnosis cost again, for no reason.
31
+ //
32
+ // The default is permissive (applied in beforeEach, so it survives a reset as well as a clear)
33
+ // so such a test exercises its own subject rather than tripping over an unconfigured double.
34
+ // Override `mockResolveEffectivePermission` in the test to drive the denial paths.
18
35
  vi.mock('@memberjunction/search-engine', () => ({
19
36
  SearchEngine: {
20
37
  Instance: {
21
38
  Config: vi.fn().mockResolvedValue(undefined),
22
39
  Search: (...args: unknown[]) => mockSearch(...args),
23
40
  PreviewSearch: (...args: unknown[]) => mockPreviewSearch(...args),
41
+ // Called only on the denial branch, to log a Status='Forbidden' execution row.
42
+ LogForbiddenSearch: (...args: unknown[]) => mockLogForbiddenSearch(...args),
24
43
  }
25
- }
44
+ },
45
+ GetSearchScopePermissionResolver: () => ({
46
+ ResolveEffectivePermission: (...args: unknown[]) => mockResolveEffectivePermission(...args),
47
+ }),
26
48
  }));
27
49
 
28
50
  vi.mock('@memberjunction/global', async (importOriginal) => {
@@ -120,6 +142,52 @@ const fakeContext = { userPayload: { email: 'test@test.com' } };
120
142
  describe('SearchKnowledgeResolver', () => {
121
143
  beforeEach(() => {
122
144
  vi.clearAllMocks();
145
+ mockResolveEffectivePermission.mockResolvedValue({
146
+ Allowed: true, Level: 'Search', Source: 'DirectGrant',
147
+ Reason: 'test double', toSqlPredicate: () => '1=1',
148
+ });
149
+ mockLogForbiddenSearch.mockResolvedValue(undefined);
150
+ });
151
+
152
+ // ── Scope authorization ────────────────────────────────────────────
153
+ //
154
+ // This is the branch `rejectForbiddenScopes` guards, and it had no coverage at all — every
155
+ // other case here passes `undefined` for scope IDs, so the whole authorization path was
156
+ // unexercised. These two tests are also what make the resolver entry in the module mock
157
+ // above load-bearing rather than decorative: remove it and both fail.
158
+ describe('scope authorization', () => {
159
+ it('runs the search when every requested scope resolves to Allowed', async () => {
160
+ const resolver = createResolver();
161
+ mockSearch.mockResolvedValue(createMockSearchResult());
162
+
163
+ const result = await resolver.SearchKnowledge(
164
+ 'test', 20, undefined, undefined, ['scope-a', 'scope-b'], undefined, undefined,
165
+ fakeContext as never);
166
+
167
+ expect(result.Success).toBe(true);
168
+ expect(mockSearch).toHaveBeenCalled();
169
+ // One resolution per requested scope — the check is per-scope, not per-call.
170
+ expect(mockResolveEffectivePermission).toHaveBeenCalledTimes(2);
171
+ });
172
+
173
+ it('REFUSES the whole call on the first denied scope, and never reaches the search', async () => {
174
+ const resolver = createResolver();
175
+ mockSearch.mockResolvedValue(createMockSearchResult());
176
+ mockResolveEffectivePermission.mockResolvedValue({
177
+ Allowed: false, Level: 'None', Source: 'NoGrant',
178
+ Reason: 'no grant for this scope', toSqlPredicate: () => '1=0',
179
+ });
180
+
181
+ const result = await resolver.SearchKnowledge(
182
+ 'test', 20, undefined, undefined, ['scope-a'], undefined, undefined,
183
+ fakeContext as never);
184
+
185
+ expect(result.Success).toBe(false);
186
+ // The point of the branch: a forbidden scope aborts rather than being silently
187
+ // dropped, because dropping it masks bugs in scope authoring.
188
+ expect(mockSearch).not.toHaveBeenCalled();
189
+ expect(mockLogForbiddenSearch).toHaveBeenCalled();
190
+ });
123
191
  });
124
192
 
125
193
  describe('SearchKnowledge mutation', () => {