@memberjunction/server 5.8.0 → 5.9.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.
- package/README.md +1 -0
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +207 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1112 -76
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +42 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +736 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +47 -0
- package/src/generic/ResolverBase.ts +53 -0
- package/src/hooks.ts +77 -0
- package/src/index.ts +198 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/types.ts +10 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security edge-case tests for multi-tenancy hooks.
|
|
3
|
+
*
|
|
4
|
+
* Tests SQL injection attempts in tenant IDs, boundary conditions,
|
|
5
|
+
* and scoping strategy edge cases that go beyond the happy-path tests
|
|
6
|
+
* in multiTenancy.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
9
|
+
import { createTenantPreRunViewHook, createTenantPreSaveHook, createTenantMiddleware } from '../multiTenancy/index.js';
|
|
10
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
11
|
+
import type { RunViewParams } from '@memberjunction/core';
|
|
12
|
+
|
|
13
|
+
// Mock @memberjunction/core Metadata for entity schema lookup.
|
|
14
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
16
|
+
|
|
17
|
+
class MockMetadata {
|
|
18
|
+
Entities = [
|
|
19
|
+
{ Name: 'Customers', SchemaName: 'dbo' },
|
|
20
|
+
{ Name: 'Orders', SchemaName: 'dbo' },
|
|
21
|
+
{ Name: 'Users', SchemaName: '__mj' },
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
Metadata: MockMetadata,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeConfig(overrides: Partial<MultiTenancyConfig> = {}): MultiTenancyConfig {
|
|
32
|
+
return {
|
|
33
|
+
enabled: true,
|
|
34
|
+
contextSource: 'header',
|
|
35
|
+
tenantHeader: 'x-tenant-id',
|
|
36
|
+
scopingStrategy: 'denylist',
|
|
37
|
+
scopedEntities: [],
|
|
38
|
+
autoExcludeCoreEntities: true,
|
|
39
|
+
defaultTenantColumn: 'OrganizationID',
|
|
40
|
+
entityColumnMappings: {},
|
|
41
|
+
adminRoles: ['Admin'],
|
|
42
|
+
writeProtection: 'strict',
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type MockUser = Parameters<ReturnType<typeof createTenantPreRunViewHook>>[1];
|
|
48
|
+
|
|
49
|
+
function makeUser(tenantId?: string, roles: string[] = []): MockUser {
|
|
50
|
+
return {
|
|
51
|
+
ID: 'user-1',
|
|
52
|
+
TenantContext: tenantId ? { TenantID: tenantId, Source: 'header' as const } : undefined,
|
|
53
|
+
UserRoles: roles.map(r => ({ Role: r, RoleID: `role-${r}`, UserID: 'user-1' })),
|
|
54
|
+
} as MockUser;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── SQL Injection in Tenant IDs ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('Multi-Tenancy Security Edge Cases', () => {
|
|
60
|
+
describe('SQL injection in tenant ID', () => {
|
|
61
|
+
it('should treat SQL injection attempt as literal tenant ID', () => {
|
|
62
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
63
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
64
|
+
const user = makeUser("' OR 1=1 --");
|
|
65
|
+
|
|
66
|
+
const result = hook(params, user) as RunViewParams;
|
|
67
|
+
|
|
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 --'");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle tenant ID with SQL UNION attack', () => {
|
|
76
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
77
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
78
|
+
const user = makeUser("' UNION SELECT * FROM Users --");
|
|
79
|
+
|
|
80
|
+
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.
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle tenant ID with semicolon (statement terminator)', () => {
|
|
89
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
90
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
91
|
+
const user = makeUser("abc'; DROP TABLE Customers; --");
|
|
92
|
+
|
|
93
|
+
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
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─── Tenant ID boundary values ──────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe('Tenant ID boundary values', () => {
|
|
102
|
+
it('should not filter when tenant ID is empty string (falsy)', () => {
|
|
103
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
104
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
105
|
+
// Empty string is falsy → makeUser sets TenantContext = undefined
|
|
106
|
+
// This matches createTenantMiddleware behavior: `if (tenantId)` skips empty strings
|
|
107
|
+
const user = makeUser('');
|
|
108
|
+
|
|
109
|
+
const result = hook(params, user) as RunViewParams;
|
|
110
|
+
expect(result.ExtraFilter).toBe(''); // no filter applied
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle very long tenant ID', () => {
|
|
114
|
+
const longId = 'a'.repeat(1000);
|
|
115
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
116
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
117
|
+
const user = makeUser(longId);
|
|
118
|
+
|
|
119
|
+
const result = hook(params, user) as RunViewParams;
|
|
120
|
+
expect(result.ExtraFilter).toContain(longId);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should handle UUID tenant ID (standard format)', () => {
|
|
124
|
+
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
|
125
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
126
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
127
|
+
const user = makeUser(uuid);
|
|
128
|
+
|
|
129
|
+
const result = hook(params, user) as RunViewParams;
|
|
130
|
+
expect(result.ExtraFilter).toBe(`[OrganizationID] = '${uuid}'`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should handle tenant ID with special characters', () => {
|
|
134
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
135
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
136
|
+
const user = makeUser('tenant<script>alert(1)</script>');
|
|
137
|
+
|
|
138
|
+
const result = hook(params, user) as RunViewParams;
|
|
139
|
+
expect(result.ExtraFilter).toContain('<script>');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ─── Admin role matching edge cases ────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe('Admin role matching', () => {
|
|
146
|
+
it('should be case-insensitive for admin role matching', () => {
|
|
147
|
+
const hook = createTenantPreRunViewHook(makeConfig({ adminRoles: ['Admin'] }));
|
|
148
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
149
|
+
|
|
150
|
+
// User role is 'admin' (lowercase), config has 'Admin'
|
|
151
|
+
const user = makeUser('tenant-1', ['admin']);
|
|
152
|
+
const result = hook(params, user) as RunViewParams;
|
|
153
|
+
expect(result.ExtraFilter).toBe(''); // bypassed
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should handle whitespace in role names', () => {
|
|
157
|
+
const hook = createTenantPreRunViewHook(makeConfig({ adminRoles: [' Admin '] }));
|
|
158
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
159
|
+
|
|
160
|
+
const user = makeUser('tenant-1', ['Admin']);
|
|
161
|
+
const result = hook(params, user) as RunViewParams;
|
|
162
|
+
expect(result.ExtraFilter).toBe(''); // bypassed due to trim
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should not bypass for non-admin roles', () => {
|
|
166
|
+
const hook = createTenantPreRunViewHook(makeConfig({ adminRoles: ['Admin'] }));
|
|
167
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
168
|
+
|
|
169
|
+
const user = makeUser('tenant-1', ['User', 'Editor']);
|
|
170
|
+
const result = hook(params, user) as RunViewParams;
|
|
171
|
+
expect(result.ExtraFilter).toContain('OrganizationID');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle user with empty roles array', () => {
|
|
175
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
176
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
177
|
+
|
|
178
|
+
const user = makeUser('tenant-1', []);
|
|
179
|
+
const result = hook(params, user) as RunViewParams;
|
|
180
|
+
expect(result.ExtraFilter).toContain('OrganizationID'); // not an admin
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ─── Entity name matching edge cases ──────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe('Entity name matching', () => {
|
|
187
|
+
it('should be case-insensitive for entity names in allowlist', () => {
|
|
188
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
189
|
+
scopingStrategy: 'allowlist',
|
|
190
|
+
scopedEntities: ['customers'],
|
|
191
|
+
}));
|
|
192
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
193
|
+
const user = makeUser('tenant-1');
|
|
194
|
+
|
|
195
|
+
const result = hook(params, user) as RunViewParams;
|
|
196
|
+
expect(result.ExtraFilter).toContain('OrganizationID');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should handle whitespace in entity names', () => {
|
|
200
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
201
|
+
scopingStrategy: 'allowlist',
|
|
202
|
+
scopedEntities: [' Customers '],
|
|
203
|
+
}));
|
|
204
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
205
|
+
const user = makeUser('tenant-1');
|
|
206
|
+
|
|
207
|
+
const result = hook(params, user) as RunViewParams;
|
|
208
|
+
expect(result.ExtraFilter).toContain('OrganizationID');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should not filter when EntityName is missing from params', () => {
|
|
212
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
213
|
+
const params = { ExtraFilter: '' } as RunViewParams;
|
|
214
|
+
const user = makeUser('tenant-1');
|
|
215
|
+
|
|
216
|
+
const result = hook(params, user) as RunViewParams;
|
|
217
|
+
expect(result.ExtraFilter).toBe('');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── Tenant middleware edge cases ─────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe('createTenantMiddleware', () => {
|
|
224
|
+
it('should skip tenant resolution when no userPayload on request', () => {
|
|
225
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
226
|
+
const req = { headers: { 'x-tenant-id': 'tenant-1' } } as unknown as Parameters<typeof middleware>[0];
|
|
227
|
+
const res = {} as Parameters<typeof middleware>[1];
|
|
228
|
+
const next = vi.fn();
|
|
229
|
+
|
|
230
|
+
middleware(req, res, next);
|
|
231
|
+
|
|
232
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should attach TenantContext when userPayload and header are present', () => {
|
|
236
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
237
|
+
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
238
|
+
const req = {
|
|
239
|
+
headers: { 'x-tenant-id': 'tenant-abc' },
|
|
240
|
+
userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
|
|
241
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
242
|
+
const res = {} as Parameters<typeof middleware>[1];
|
|
243
|
+
const next = vi.fn();
|
|
244
|
+
|
|
245
|
+
middleware(req, res, next);
|
|
246
|
+
|
|
247
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
248
|
+
expect(userRecord['TenantContext']).toEqual({
|
|
249
|
+
TenantID: 'tenant-abc',
|
|
250
|
+
Source: 'header',
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should handle case-insensitive header name', () => {
|
|
255
|
+
const middleware = createTenantMiddleware(makeConfig({ tenantHeader: 'X-Tenant-ID' }));
|
|
256
|
+
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
257
|
+
const req = {
|
|
258
|
+
// Express normalizes headers to lowercase
|
|
259
|
+
headers: { 'x-tenant-id': 'tenant-xyz' },
|
|
260
|
+
userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
|
|
261
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
262
|
+
const res = {} as Parameters<typeof middleware>[1];
|
|
263
|
+
const next = vi.fn();
|
|
264
|
+
|
|
265
|
+
middleware(req, res, next);
|
|
266
|
+
|
|
267
|
+
expect(userRecord['TenantContext']).toEqual({
|
|
268
|
+
TenantID: 'tenant-xyz',
|
|
269
|
+
Source: 'header',
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should not set TenantContext when header is missing', () => {
|
|
274
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
275
|
+
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
276
|
+
const req = {
|
|
277
|
+
headers: {},
|
|
278
|
+
userPayload: { userRecord, email: 'test@test.com', sessionId: 's1' },
|
|
279
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
280
|
+
const res = {} as Parameters<typeof middleware>[1];
|
|
281
|
+
const next = vi.fn();
|
|
282
|
+
|
|
283
|
+
middleware(req, res, next);
|
|
284
|
+
|
|
285
|
+
expect(userRecord['TenantContext']).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─── PreSave security edge cases ──────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
describe('PreSave security', () => {
|
|
292
|
+
function makeEntity(entityName: string, tenantValue: string | null, isSaved: boolean) {
|
|
293
|
+
return {
|
|
294
|
+
EntityInfo: { Name: entityName },
|
|
295
|
+
IsSaved: isSaved,
|
|
296
|
+
Get: vi.fn((col: string) => col === 'OrganizationID' ? tenantValue : null),
|
|
297
|
+
Set: vi.fn(),
|
|
298
|
+
} as unknown as Parameters<ReturnType<typeof createTenantPreSaveHook>>[0];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it('should reject save with SQL injection in entity tenant column', () => {
|
|
302
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
303
|
+
const entity = makeEntity('Customers', "' OR 1=1 --", true);
|
|
304
|
+
const user = makeUser('tenant-abc');
|
|
305
|
+
|
|
306
|
+
const result = hook(entity, user);
|
|
307
|
+
expect(typeof result).toBe('string');
|
|
308
|
+
expect(result).toContain('Save rejected');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should auto-assign tenant for new record even when entity has no EntityInfo', () => {
|
|
312
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
313
|
+
const entity = {
|
|
314
|
+
EntityInfo: undefined,
|
|
315
|
+
IsSaved: false,
|
|
316
|
+
Get: vi.fn(() => null),
|
|
317
|
+
Set: vi.fn(),
|
|
318
|
+
} as unknown as Parameters<ReturnType<typeof createTenantPreSaveHook>>[0];
|
|
319
|
+
const user = makeUser('tenant-abc');
|
|
320
|
+
|
|
321
|
+
// No EntityInfo → no entity name → not scoped → allow
|
|
322
|
+
const result = hook(entity, user);
|
|
323
|
+
expect(result).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should handle null contextUser gracefully', () => {
|
|
327
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
328
|
+
const entity = makeEntity('Customers', 'tenant-abc', true);
|
|
329
|
+
|
|
330
|
+
const result = hook(entity, undefined);
|
|
331
|
+
expect(result).toBe(true); // no context = no validation
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createTenantPreRunViewHook, createTenantPreSaveHook } from '../multiTenancy/index.js';
|
|
3
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
4
|
+
import type { RunViewParams } from '@memberjunction/core';
|
|
5
|
+
|
|
6
|
+
// Mock @memberjunction/core Metadata for entity schema lookup.
|
|
7
|
+
// Use a class-based mock so `new Metadata().Entities` works reliably.
|
|
8
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
10
|
+
|
|
11
|
+
class MockMetadata {
|
|
12
|
+
Entities = [
|
|
13
|
+
{ Name: 'Customers', SchemaName: 'dbo' },
|
|
14
|
+
{ Name: 'Orders', SchemaName: 'dbo' },
|
|
15
|
+
{ Name: 'AI Models', SchemaName: '__mj' },
|
|
16
|
+
{ Name: 'Users', SchemaName: '__mj' },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
Metadata: MockMetadata,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function makeConfig(overrides: Partial<MultiTenancyConfig> = {}): MultiTenancyConfig {
|
|
27
|
+
return {
|
|
28
|
+
enabled: true,
|
|
29
|
+
contextSource: 'header',
|
|
30
|
+
tenantHeader: 'X-Tenant-ID',
|
|
31
|
+
scopingStrategy: 'denylist',
|
|
32
|
+
scopedEntities: [],
|
|
33
|
+
autoExcludeCoreEntities: true,
|
|
34
|
+
defaultTenantColumn: 'OrganizationID',
|
|
35
|
+
entityColumnMappings: {},
|
|
36
|
+
adminRoles: ['Admin', 'System'],
|
|
37
|
+
writeProtection: 'strict',
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeUser(tenantId?: string, roles: string[] = []) {
|
|
43
|
+
return {
|
|
44
|
+
ID: 'user-1',
|
|
45
|
+
TenantContext: tenantId ? { TenantID: tenantId, Source: 'header' as const } : undefined,
|
|
46
|
+
UserRoles: roles.map(r => ({ Role: r, RoleID: `role-${r}`, UserID: 'user-1' })),
|
|
47
|
+
} as Parameters<ReturnType<typeof createTenantPreRunViewHook>>[1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('Multi-Tenancy Hooks', () => {
|
|
51
|
+
describe('createTenantPreRunViewHook', () => {
|
|
52
|
+
it('should inject tenant filter for scoped entity', () => {
|
|
53
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
54
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
55
|
+
const user = makeUser('tenant-abc');
|
|
56
|
+
|
|
57
|
+
const result = hook(params, user);
|
|
58
|
+
expect((result as RunViewParams).ExtraFilter).toBe("[OrganizationID] = 'tenant-abc'");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should AND with existing filter', () => {
|
|
62
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
63
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Status = 'Active'" } as RunViewParams;
|
|
64
|
+
const user = makeUser('tenant-abc');
|
|
65
|
+
|
|
66
|
+
const result = hook(params, user);
|
|
67
|
+
expect((result as RunViewParams).ExtraFilter).toBe(
|
|
68
|
+
"(Status = 'Active') AND [OrganizationID] = 'tenant-abc'"
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should skip core __mj entities when autoExcludeCoreEntities is true', () => {
|
|
73
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
74
|
+
const params = { EntityName: 'AI Models', ExtraFilter: '' } as RunViewParams;
|
|
75
|
+
const user = makeUser('tenant-abc');
|
|
76
|
+
|
|
77
|
+
const result = hook(params, user);
|
|
78
|
+
expect((result as RunViewParams).ExtraFilter).toBe('');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should not filter when user has no TenantContext', () => {
|
|
82
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
83
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
84
|
+
const user = makeUser(undefined);
|
|
85
|
+
|
|
86
|
+
const result = hook(params, user);
|
|
87
|
+
expect((result as RunViewParams).ExtraFilter).toBe('');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should bypass filtering for admin users', () => {
|
|
91
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
92
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
93
|
+
const user = makeUser('tenant-abc', ['Admin']);
|
|
94
|
+
|
|
95
|
+
const result = hook(params, user);
|
|
96
|
+
expect((result as RunViewParams).ExtraFilter).toBe('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should use entity column mapping override', () => {
|
|
100
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
101
|
+
entityColumnMappings: { 'Orders': 'TenantID' },
|
|
102
|
+
}));
|
|
103
|
+
const params = { EntityName: 'Orders', ExtraFilter: '' } as RunViewParams;
|
|
104
|
+
const user = makeUser('tenant-xyz');
|
|
105
|
+
|
|
106
|
+
const result = hook(params, user);
|
|
107
|
+
expect((result as RunViewParams).ExtraFilter).toBe("[TenantID] = 'tenant-xyz'");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should respect allowlist scoping strategy', () => {
|
|
111
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
112
|
+
scopingStrategy: 'allowlist',
|
|
113
|
+
scopedEntities: ['Customers'],
|
|
114
|
+
}));
|
|
115
|
+
const user = makeUser('tenant-abc');
|
|
116
|
+
|
|
117
|
+
// Customers is in the allowlist → should be filtered
|
|
118
|
+
const params1 = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
119
|
+
const result1 = hook(params1, user);
|
|
120
|
+
expect((result1 as RunViewParams).ExtraFilter).toContain('OrganizationID');
|
|
121
|
+
|
|
122
|
+
// Orders is NOT in the allowlist → should NOT be filtered
|
|
123
|
+
const params2 = { EntityName: 'Orders', ExtraFilter: '' } as RunViewParams;
|
|
124
|
+
const result2 = hook(params2, user);
|
|
125
|
+
expect((result2 as RunViewParams).ExtraFilter).toBe('');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should respect denylist scoping strategy', () => {
|
|
129
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
130
|
+
scopingStrategy: 'denylist',
|
|
131
|
+
scopedEntities: ['Orders'],
|
|
132
|
+
}));
|
|
133
|
+
const user = makeUser('tenant-abc');
|
|
134
|
+
|
|
135
|
+
// Orders is in the denylist → should NOT be filtered
|
|
136
|
+
const params1 = { EntityName: 'Orders', ExtraFilter: '' } as RunViewParams;
|
|
137
|
+
const result1 = hook(params1, user);
|
|
138
|
+
expect((result1 as RunViewParams).ExtraFilter).toBe('');
|
|
139
|
+
|
|
140
|
+
// Customers is NOT in the denylist → should be filtered
|
|
141
|
+
const params2 = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
142
|
+
const result2 = hook(params2, user);
|
|
143
|
+
expect((result2 as RunViewParams).ExtraFilter).toContain('OrganizationID');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('createTenantPreSaveHook', () => {
|
|
148
|
+
function makeEntity(entityName: string, tenantValue: string | null, isSaved: boolean) {
|
|
149
|
+
return {
|
|
150
|
+
EntityInfo: { Name: entityName },
|
|
151
|
+
IsSaved: isSaved,
|
|
152
|
+
Get: vi.fn((col: string) => col === 'OrganizationID' ? tenantValue : null),
|
|
153
|
+
Set: vi.fn(),
|
|
154
|
+
} as unknown as Parameters<ReturnType<typeof createTenantPreSaveHook>>[0];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it('should allow save when tenant matches', () => {
|
|
158
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
159
|
+
const entity = makeEntity('Customers', 'tenant-abc', true);
|
|
160
|
+
const user = makeUser('tenant-abc');
|
|
161
|
+
|
|
162
|
+
const result = hook(entity, user);
|
|
163
|
+
expect(result).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should reject save in strict mode when tenant mismatches', () => {
|
|
167
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
168
|
+
const entity = makeEntity('Customers', 'tenant-other', true);
|
|
169
|
+
const user = makeUser('tenant-abc');
|
|
170
|
+
|
|
171
|
+
const result = hook(entity, user);
|
|
172
|
+
expect(typeof result).toBe('string');
|
|
173
|
+
expect(result).toContain('Save rejected');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should warn but allow in log mode when tenant mismatches', () => {
|
|
177
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
178
|
+
const hook = createTenantPreSaveHook(makeConfig({ writeProtection: 'log' }));
|
|
179
|
+
const entity = makeEntity('Customers', 'tenant-other', true);
|
|
180
|
+
const user = makeUser('tenant-abc');
|
|
181
|
+
|
|
182
|
+
const result = hook(entity, user);
|
|
183
|
+
expect(result).toBe(true);
|
|
184
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[MultiTenancy]'));
|
|
185
|
+
warnSpy.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should allow when writeProtection is off', () => {
|
|
189
|
+
const hook = createTenantPreSaveHook(makeConfig({ writeProtection: 'off' }));
|
|
190
|
+
const entity = makeEntity('Customers', 'tenant-other', true);
|
|
191
|
+
const user = makeUser('tenant-abc');
|
|
192
|
+
|
|
193
|
+
const result = hook(entity, user);
|
|
194
|
+
expect(result).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should auto-assign tenant for new records without tenant value', () => {
|
|
198
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
199
|
+
const entity = makeEntity('Customers', null, false);
|
|
200
|
+
const user = makeUser('tenant-abc');
|
|
201
|
+
|
|
202
|
+
const result = hook(entity, user);
|
|
203
|
+
expect(result).toBe(true);
|
|
204
|
+
expect(entity.Set).toHaveBeenCalledWith('OrganizationID', 'tenant-abc');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should bypass validation for admin users', () => {
|
|
208
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
209
|
+
const entity = makeEntity('Customers', 'tenant-other', true);
|
|
210
|
+
const user = makeUser('tenant-abc', ['Admin']);
|
|
211
|
+
|
|
212
|
+
const result = hook(entity, user);
|
|
213
|
+
expect(result).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should skip core __mj entities', () => {
|
|
217
|
+
const hook = createTenantPreSaveHook(makeConfig());
|
|
218
|
+
const entity = makeEntity('AI Models', 'any-tenant', true);
|
|
219
|
+
const user = makeUser('tenant-abc');
|
|
220
|
+
|
|
221
|
+
const result = hook(entity, user);
|
|
222
|
+
expect(result).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|