@memberjunction/server 5.9.0 → 5.10.1

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 (41) hide show
  1. package/dist/agents/skip-agent.d.ts.map +1 -1
  2. package/dist/agents/skip-agent.js +1 -0
  3. package/dist/agents/skip-agent.js.map +1 -1
  4. package/dist/agents/skip-sdk.d.ts +6 -0
  5. package/dist/agents/skip-sdk.d.ts.map +1 -1
  6. package/dist/agents/skip-sdk.js +5 -3
  7. package/dist/agents/skip-sdk.js.map +1 -1
  8. package/dist/generated/generated.d.ts +3 -0
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +13 -0
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/generic/PubSubManager.d.ts.map +1 -1
  13. package/dist/generic/PubSubManager.js +0 -1
  14. package/dist/generic/PubSubManager.js.map +1 -1
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +16 -4
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
  23. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
  24. package/dist/resolvers/CurrentUserContextResolver.js +54 -0
  25. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
  26. package/dist/test-dynamic-plugin.d.ts +6 -0
  27. package/dist/test-dynamic-plugin.d.ts.map +1 -0
  28. package/dist/test-dynamic-plugin.js +18 -0
  29. package/dist/test-dynamic-plugin.js.map +1 -0
  30. package/package.json +59 -59
  31. package/src/__tests__/bcsaas-integration.test.ts +455 -0
  32. package/src/__tests__/middleware-integration.test.ts +877 -0
  33. package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
  34. package/src/agents/skip-agent.ts +1 -0
  35. package/src/agents/skip-sdk.ts +13 -3
  36. package/src/generated/generated.ts +10 -0
  37. package/src/generic/PubSubManager.ts +0 -1
  38. package/src/generic/ResolverBase.ts +17 -4
  39. package/src/index.ts +1 -0
  40. package/src/resolvers/CurrentUserContextResolver.ts +39 -0
  41. package/src/test-dynamic-plugin.ts +36 -0
@@ -0,0 +1,877 @@
1
+ /**
2
+ * Middleware Integration Tests — HTTP-level smoke tests.
3
+ *
4
+ * These tests verify the unified auth middleware, CORS handling, and
5
+ * multi-tenancy headers against a RUNNING MJAPI instance.
6
+ *
7
+ * Usage:
8
+ * 1. Start MJAPI: cd packages/MJAPI && npm run start
9
+ * 2. Run tests: MJAPI_URL=http://localhost:4001 npm run test:integration
10
+ *
11
+ * Environment variables:
12
+ * MJAPI_URL Base URL of the running MJAPI instance (required)
13
+ * MJAPI_SYSTEM_API_KEY System API key (x-mj-api-key header)
14
+ * MJAPI_BEARER_TOKEN Valid JWT bearer token
15
+ * MJAPI_USER_API_KEY User API key (x-api-key header, mj_sk_* format)
16
+ * MJAPI_GRAPHQL_PATH GraphQL endpoint path (default: /)
17
+ * MJAPI_TENANT_HEADER Tenant header name (default: x-tenant-id)
18
+ *
19
+ * Tests are organized into tiers:
20
+ * Tier 1 — No auth needed (CORS, 401 behavior, rejection edge cases)
21
+ * Tier 2 — System API key auth (authenticates as system user)
22
+ * Tier 3 — JWT bearer token auth (authenticates as specific user)
23
+ * Tier 4 — User API key auth (authenticates as API key owner)
24
+ * Tier 5 — Multi-tenancy data isolation
25
+ */
26
+ import { describe, it, expect, beforeAll } from 'vitest';
27
+
28
+ // ─── Configuration ─────────────────────────────────────────────────────────
29
+
30
+ const BASE_URL = process.env.MJAPI_URL ?? '';
31
+ const SYSTEM_API_KEY = process.env.MJAPI_SYSTEM_API_KEY ?? '';
32
+ const BEARER_TOKEN = process.env.MJAPI_BEARER_TOKEN ?? '';
33
+ const USER_API_KEY = process.env.MJAPI_USER_API_KEY ?? '';
34
+ const GRAPHQL_PATH = process.env.MJAPI_GRAPHQL_PATH ?? '/';
35
+ const TENANT_HEADER = process.env.MJAPI_TENANT_HEADER ?? 'x-tenant-id';
36
+
37
+ const GRAPHQL_URL = `${BASE_URL}${GRAPHQL_PATH}`;
38
+ const HAS_SERVER = !!BASE_URL;
39
+ const HAS_SYSTEM_KEY = !!SYSTEM_API_KEY;
40
+ const HAS_BEARER = !!BEARER_TOKEN;
41
+ const HAS_USER_KEY = !!USER_API_KEY;
42
+ // True if ANY auth method is available
43
+ const HAS_AUTH = HAS_SYSTEM_KEY || HAS_BEARER || HAS_USER_KEY;
44
+
45
+ // ─── Helpers ────────────────────────────────────────────────────────────────
46
+
47
+ /** Simple introspection query for testing GraphQL connectivity */
48
+ const INTROSPECTION_QUERY = JSON.stringify({
49
+ query: '{ __typename }',
50
+ });
51
+
52
+ /** RunDynamicView query to verify data access by EntityName.
53
+ * Note: Results is a complex type [RunViewResultRow] requiring subfield selection. */
54
+ function makeRunViewQuery(entityName: string, maxRows = 1): string {
55
+ return JSON.stringify({
56
+ query: `query RunDynamicView($input: RunDynamicViewInput!) {
57
+ RunDynamicView(input: $input) {
58
+ Results {
59
+ PrimaryKey { FieldName Value }
60
+ EntityID
61
+ Data
62
+ }
63
+ UserViewRunID
64
+ RowCount
65
+ TotalRowCount
66
+ ExecutionTime
67
+ ErrorMessage
68
+ Success
69
+ }
70
+ }`,
71
+ variables: {
72
+ input: {
73
+ EntityName: entityName,
74
+ ExtraFilter: '',
75
+ OrderBy: '',
76
+ MaxRows: maxRows,
77
+ ResultType: 'simple',
78
+ },
79
+ },
80
+ operationName: 'RunDynamicView',
81
+ });
82
+ }
83
+
84
+ /** Build auth headers depending on which method is available */
85
+ function getAuthHeaders(): Record<string, string> {
86
+ if (HAS_SYSTEM_KEY) {
87
+ return { 'Content-Type': 'application/json', 'x-mj-api-key': SYSTEM_API_KEY };
88
+ }
89
+ if (HAS_BEARER) {
90
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${BEARER_TOKEN}` };
91
+ }
92
+ if (HAS_USER_KEY) {
93
+ return { 'Content-Type': 'application/json', 'x-api-key': USER_API_KEY };
94
+ }
95
+ return { 'Content-Type': 'application/json' };
96
+ }
97
+
98
+ async function fetchJson(
99
+ url: string,
100
+ options: RequestInit = {}
101
+ ): Promise<{ status: number; headers: Headers; body: Record<string, unknown> }> {
102
+ const response = await fetch(url, options);
103
+ let body: Record<string, unknown> = {};
104
+ try {
105
+ body = (await response.json()) as Record<string, unknown>;
106
+ } catch {
107
+ // Non-JSON response — body stays empty
108
+ }
109
+ return { status: response.status, headers: response.headers, body };
110
+ }
111
+
112
+ /** Encode a string to base64url (JWT-compatible) */
113
+ function base64url(str: string): string {
114
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
115
+ }
116
+
117
+ /**
118
+ * Asserts that auth succeeded (status 200) and optionally checks RunView execution.
119
+ * RunView can fail at the GraphQL level (e.g., system user not in UserCache) even though
120
+ * auth succeeded. The test differentiates:
121
+ * - Auth failure: status !== 200 → hard fail
122
+ * - GraphQL error (auth OK, resolver error): status 200, data null → skip data assertions
123
+ * - Full success: status 200, data present, Success=true
124
+ */
125
+ function assertRunViewResult(
126
+ status: number,
127
+ body: Record<string, unknown>,
128
+ entityName: string
129
+ ): void {
130
+ // Auth must have succeeded — RunView should never return 401
131
+ expect(status, `Expected 200 for ${entityName} query (auth OK)`).toBe(200);
132
+
133
+ const errors = body.errors as Array<{ message: string }> | undefined;
134
+ const data = body.data as Record<string, Record<string, unknown>> | null | undefined;
135
+
136
+ if (errors?.length && data === null) {
137
+ // GraphQL-level error (not auth-related). This can happen when the system user
138
+ // isn't in UserCache or lacks entity permissions. Auth is verified, so we skip
139
+ // the data assertions and log a diagnostic message.
140
+ console.warn(
141
+ `[DIAGNOSTIC] RunView for '${entityName}' returned GraphQL error (auth OK): ${errors[0].message}`
142
+ );
143
+ return;
144
+ }
145
+
146
+ // If we got data back, verify RunView succeeded
147
+ expect(data?.RunDynamicView?.Success).toBe(true);
148
+ const rowCount = data?.RunDynamicView?.RowCount as number | undefined;
149
+ if (rowCount !== undefined) {
150
+ expect(rowCount).toBeGreaterThan(0);
151
+ }
152
+ }
153
+
154
+ // ─── Tier 1: No Auth Needed ────────────────────────────────────────────────
155
+
156
+ describe.skipIf(!HAS_SERVER)('Tier 1: Unauthenticated Middleware Behavior', () => {
157
+ beforeAll(async () => {
158
+ // Verify server is reachable
159
+ try {
160
+ await fetch(BASE_URL, { method: 'OPTIONS' });
161
+ } catch {
162
+ throw new Error(
163
+ `Cannot reach MJAPI at ${BASE_URL}. Start the server first:\n` +
164
+ ` cd packages/MJAPI && npm run start`
165
+ );
166
+ }
167
+ });
168
+
169
+ describe('CORS Preflight (OPTIONS)', () => {
170
+ it('should respond to OPTIONS with 2xx and CORS headers', async () => {
171
+ const response = await fetch(GRAPHQL_URL, {
172
+ method: 'OPTIONS',
173
+ headers: {
174
+ Origin: 'http://localhost:4200',
175
+ 'Access-Control-Request-Method': 'POST',
176
+ 'Access-Control-Request-Headers': 'content-type,authorization',
177
+ },
178
+ });
179
+
180
+ // Preflight should succeed (200 or 204)
181
+ expect(response.status).toBeLessThan(300);
182
+
183
+ // CORS headers must be present
184
+ const allowOrigin = response.headers.get('access-control-allow-origin');
185
+ expect(allowOrigin).toBeTruthy();
186
+ });
187
+
188
+ it('should include authorization in allowed headers', async () => {
189
+ const response = await fetch(GRAPHQL_URL, {
190
+ method: 'OPTIONS',
191
+ headers: {
192
+ Origin: 'http://localhost:4200',
193
+ 'Access-Control-Request-Method': 'POST',
194
+ 'Access-Control-Request-Headers': 'authorization,content-type',
195
+ },
196
+ });
197
+
198
+ const allowHeaders = response.headers.get('access-control-allow-headers');
199
+ expect(allowHeaders).toBeTruthy();
200
+ });
201
+
202
+ it('should allow POST method in preflight response', async () => {
203
+ const response = await fetch(GRAPHQL_URL, {
204
+ method: 'OPTIONS',
205
+ headers: {
206
+ Origin: 'http://localhost:4200',
207
+ 'Access-Control-Request-Method': 'POST',
208
+ },
209
+ });
210
+
211
+ const allowMethods = response.headers.get('access-control-allow-methods');
212
+ expect(allowMethods).toBeTruthy();
213
+ });
214
+ });
215
+
216
+ describe('Unauthenticated Requests', () => {
217
+ it('should return 401 for POST without authorization', async () => {
218
+ const { status, headers } = await fetchJson(GRAPHQL_URL, {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json', Origin: 'http://localhost:4200' },
221
+ body: INTROSPECTION_QUERY,
222
+ });
223
+
224
+ expect(status).toBe(401);
225
+
226
+ // Critical: 401 response MUST include CORS headers so the browser
227
+ // can read the response body (needed for MSAL token refresh).
228
+ const allowOrigin = headers.get('access-control-allow-origin');
229
+ expect(allowOrigin).toBeTruthy();
230
+ });
231
+
232
+ it('should return error body with 401', async () => {
233
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json' },
236
+ body: INTROSPECTION_QUERY,
237
+ });
238
+
239
+ expect(status).toBe(401);
240
+ expect(body).toBeDefined();
241
+ expect(body.error).toBeDefined();
242
+ });
243
+
244
+ it('should return JWT_EXPIRED code for expired token', async () => {
245
+ // Create a JWT with an expired exp claim.
246
+ // jwt.decode() expects base64url encoding (no padding, - and _ instead of + and /).
247
+ // getUserPayload checks exp BEFORE calling verifyAsync, so no valid signature needed.
248
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
249
+ const payload = base64url(JSON.stringify({
250
+ iss: 'https://fake-issuer.example.com',
251
+ sub: 'test-user',
252
+ exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
253
+ aud: 'test',
254
+ }));
255
+ const expiredToken = `${header}.${payload}.fake-signature`;
256
+
257
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'Content-Type': 'application/json',
261
+ Authorization: `Bearer ${expiredToken}`,
262
+ },
263
+ body: INTROSPECTION_QUERY,
264
+ });
265
+
266
+ expect(status).toBe(401);
267
+ expect(body.errors).toBeDefined();
268
+ expect(body.errors[0].message).toBe('Token expired');
269
+ expect(body.errors[0].extensions.code).toBe('JWT_EXPIRED');
270
+ });
271
+
272
+ it('should return 401 for invalid bearer token format', async () => {
273
+ const { status } = await fetchJson(GRAPHQL_URL, {
274
+ method: 'POST',
275
+ headers: {
276
+ 'Content-Type': 'application/json',
277
+ Authorization: 'Bearer not-a-real-jwt',
278
+ },
279
+ body: INTROSPECTION_QUERY,
280
+ });
281
+
282
+ expect(status).toBe(401);
283
+ });
284
+
285
+ it('should return 401 for invalid system API key', async () => {
286
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/json',
290
+ 'x-mj-api-key': 'wrong-api-key-12345',
291
+ },
292
+ body: INTROSPECTION_QUERY,
293
+ });
294
+
295
+ expect(status).toBe(401);
296
+ expect(body.error).toBeDefined();
297
+ });
298
+
299
+ it('should return 401 for invalid user API key', async () => {
300
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
301
+ method: 'POST',
302
+ headers: {
303
+ 'Content-Type': 'application/json',
304
+ 'x-api-key': 'mj_sk_invalid_key_12345',
305
+ },
306
+ body: INTROSPECTION_QUERY,
307
+ });
308
+
309
+ expect(status).toBe(401);
310
+ expect(body.error).toBeDefined();
311
+ });
312
+
313
+ it('should return 401 for GET without authorization', async () => {
314
+ const { status } = await fetchJson(GRAPHQL_URL, {
315
+ method: 'GET',
316
+ headers: { Origin: 'http://localhost:4200' },
317
+ });
318
+
319
+ expect(status).toBe(401);
320
+ });
321
+ });
322
+ });
323
+
324
+ // ─── Tier 2: System API Key Auth ───────────────────────────────────────────
325
+
326
+ describe.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
327
+ 'Tier 2: System API Key Authentication (x-mj-api-key)',
328
+ () => {
329
+ const systemKeyHeaders = {
330
+ 'Content-Type': 'application/json',
331
+ 'x-mj-api-key': SYSTEM_API_KEY,
332
+ };
333
+
334
+ describe('Basic connectivity', () => {
335
+ it('should return 200 for introspection with system API key', async () => {
336
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
337
+ method: 'POST',
338
+ headers: systemKeyHeaders,
339
+ body: INTROSPECTION_QUERY,
340
+ });
341
+
342
+ expect(status).toBe(200);
343
+ expect(body.data).toBeDefined();
344
+ });
345
+
346
+ it('should include CORS headers on successful response', async () => {
347
+ const { status, headers } = await fetchJson(GRAPHQL_URL, {
348
+ method: 'POST',
349
+ headers: { ...systemKeyHeaders, Origin: 'http://localhost:4200' },
350
+ body: INTROSPECTION_QUERY,
351
+ });
352
+
353
+ expect(status).toBe(200);
354
+ const allowOrigin = headers.get('access-control-allow-origin');
355
+ expect(allowOrigin).toBeTruthy();
356
+ });
357
+ });
358
+
359
+ describe('Data access', () => {
360
+ it('should authenticate and query Entities via RunView', async () => {
361
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
362
+ method: 'POST',
363
+ headers: systemKeyHeaders,
364
+ body: makeRunViewQuery('Entities', 3),
365
+ });
366
+
367
+ assertRunViewResult(status, body, 'Entities');
368
+ });
369
+
370
+ it('should authenticate and query Roles via RunView', async () => {
371
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
372
+ method: 'POST',
373
+ headers: systemKeyHeaders,
374
+ body: makeRunViewQuery('Roles', 3),
375
+ });
376
+
377
+ assertRunViewResult(status, body, 'Roles');
378
+ });
379
+
380
+ it('should authenticate and query Applications via RunView', async () => {
381
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
382
+ method: 'POST',
383
+ headers: systemKeyHeaders,
384
+ body: makeRunViewQuery('Applications', 3),
385
+ });
386
+
387
+ assertRunViewResult(status, body, 'Applications');
388
+ });
389
+ });
390
+
391
+ describe('System API key should NOT work with wrong header', () => {
392
+ it('should reject system API key sent in Authorization header', async () => {
393
+ const { status } = await fetchJson(GRAPHQL_URL, {
394
+ method: 'POST',
395
+ headers: {
396
+ 'Content-Type': 'application/json',
397
+ Authorization: `Bearer ${SYSTEM_API_KEY}`,
398
+ },
399
+ body: INTROSPECTION_QUERY,
400
+ });
401
+
402
+ // System key in Authorization header is treated as a JWT → fails
403
+ expect(status).toBe(401);
404
+ });
405
+
406
+ it('should reject system API key sent in x-api-key header', async () => {
407
+ const { status } = await fetchJson(GRAPHQL_URL, {
408
+ method: 'POST',
409
+ headers: {
410
+ 'Content-Type': 'application/json',
411
+ 'x-api-key': SYSTEM_API_KEY,
412
+ },
413
+ body: INTROSPECTION_QUERY,
414
+ });
415
+
416
+ // System key in x-api-key header is treated as a user API key → fails
417
+ expect(status).toBe(401);
418
+ });
419
+ });
420
+
421
+ describe('Session and tenant headers', () => {
422
+ it('should handle x-session-id header with system key', async () => {
423
+ const { status } = await fetchJson(GRAPHQL_URL, {
424
+ method: 'POST',
425
+ headers: {
426
+ ...systemKeyHeaders,
427
+ 'x-session-id': 'integration-test-session',
428
+ },
429
+ body: INTROSPECTION_QUERY,
430
+ });
431
+
432
+ expect(status).toBe(200);
433
+ });
434
+
435
+ it('should handle tenant header with system key', async () => {
436
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
437
+ method: 'POST',
438
+ headers: {
439
+ ...systemKeyHeaders,
440
+ [TENANT_HEADER]: 'test-tenant-from-system-key',
441
+ },
442
+ body: INTROSPECTION_QUERY,
443
+ });
444
+
445
+ // Tenant header should not break system key auth
446
+ expect(status).toBe(200);
447
+ expect(body.data).toBeDefined();
448
+ });
449
+ });
450
+ }
451
+ );
452
+
453
+ // ─── Tier 3: JWT Bearer Token Auth ─────────────────────────────────────────
454
+
455
+ describe.skipIf(!HAS_SERVER || !HAS_BEARER)(
456
+ 'Tier 3: JWT Bearer Token Authentication',
457
+ () => {
458
+ const bearerHeaders = {
459
+ 'Content-Type': 'application/json',
460
+ Authorization: `Bearer ${BEARER_TOKEN}`,
461
+ };
462
+
463
+ describe('Basic connectivity', () => {
464
+ it('should return 200 for introspection with bearer token', async () => {
465
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
466
+ method: 'POST',
467
+ headers: bearerHeaders,
468
+ body: INTROSPECTION_QUERY,
469
+ });
470
+
471
+ expect(status).toBe(200);
472
+ expect(body.data).toBeDefined();
473
+ });
474
+ });
475
+
476
+ describe('Data access', () => {
477
+ it('should authenticate and query Entities via RunView', async () => {
478
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
479
+ method: 'POST',
480
+ headers: bearerHeaders,
481
+ body: makeRunViewQuery('Entities', 3),
482
+ });
483
+
484
+ assertRunViewResult(status, body, 'Entities');
485
+ });
486
+
487
+ it('should authenticate and query Roles via RunView', async () => {
488
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
489
+ method: 'POST',
490
+ headers: bearerHeaders,
491
+ body: makeRunViewQuery('Roles', 3),
492
+ });
493
+
494
+ assertRunViewResult(status, body, 'Roles');
495
+ });
496
+ });
497
+
498
+ describe('Tenant context header handling', () => {
499
+ it('should process tenant header without error', async () => {
500
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
501
+ method: 'POST',
502
+ headers: {
503
+ ...bearerHeaders,
504
+ [TENANT_HEADER]: 'test-tenant-001',
505
+ },
506
+ body: INTROSPECTION_QUERY,
507
+ });
508
+
509
+ expect(status).toBe(200);
510
+ expect(body.data).toBeDefined();
511
+ });
512
+
513
+ it('should work without tenant header', async () => {
514
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
515
+ method: 'POST',
516
+ headers: bearerHeaders,
517
+ body: INTROSPECTION_QUERY,
518
+ });
519
+
520
+ expect(status).toBe(200);
521
+ expect(body.data).toBeDefined();
522
+ });
523
+ });
524
+
525
+ describe('Backward compatibility', () => {
526
+ it('should handle x-session-id header', async () => {
527
+ const { status } = await fetchJson(GRAPHQL_URL, {
528
+ method: 'POST',
529
+ headers: {
530
+ ...bearerHeaders,
531
+ 'x-session-id': 'test-session-123',
532
+ },
533
+ body: INTROSPECTION_QUERY,
534
+ });
535
+
536
+ expect(status).toBe(200);
537
+ });
538
+ });
539
+ }
540
+ );
541
+
542
+ // ─── Tier 4: User API Key Auth ─────────────────────────────────────────────
543
+
544
+ describe.skipIf(!HAS_SERVER || !HAS_USER_KEY)(
545
+ 'Tier 4: User API Key Authentication (x-api-key)',
546
+ () => {
547
+ const userKeyHeaders = {
548
+ 'Content-Type': 'application/json',
549
+ 'x-api-key': USER_API_KEY,
550
+ };
551
+
552
+ describe('Basic connectivity', () => {
553
+ it('should return 200 for introspection with user API key', async () => {
554
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
555
+ method: 'POST',
556
+ headers: userKeyHeaders,
557
+ body: INTROSPECTION_QUERY,
558
+ });
559
+
560
+ expect(status).toBe(200);
561
+ expect(body.data).toBeDefined();
562
+ });
563
+ });
564
+
565
+ describe('Data access', () => {
566
+ it('should authenticate and query Entities via RunView', async () => {
567
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
568
+ method: 'POST',
569
+ headers: userKeyHeaders,
570
+ body: makeRunViewQuery('Entities', 3),
571
+ });
572
+
573
+ assertRunViewResult(status, body, 'Entities');
574
+ });
575
+ });
576
+
577
+ describe('User API key should NOT work with wrong header', () => {
578
+ it('should reject user API key in Authorization header', async () => {
579
+ const { status } = await fetchJson(GRAPHQL_URL, {
580
+ method: 'POST',
581
+ headers: {
582
+ 'Content-Type': 'application/json',
583
+ Authorization: `Bearer ${USER_API_KEY}`,
584
+ },
585
+ body: INTROSPECTION_QUERY,
586
+ });
587
+
588
+ // User key in Authorization header is treated as a JWT → fails
589
+ expect(status).toBe(401);
590
+ });
591
+
592
+ it('should reject user API key in x-mj-api-key header', async () => {
593
+ const { status } = await fetchJson(GRAPHQL_URL, {
594
+ method: 'POST',
595
+ headers: {
596
+ 'Content-Type': 'application/json',
597
+ 'x-mj-api-key': USER_API_KEY,
598
+ },
599
+ body: INTROSPECTION_QUERY,
600
+ });
601
+
602
+ // User key in system key header → fails (doesn't match system API key)
603
+ expect(status).toBe(401);
604
+ });
605
+ });
606
+ }
607
+ );
608
+
609
+ // ─── Tier 5: Multi-Tenancy Data Isolation ──────────────────────────────────
610
+
611
+ describe.skipIf(!HAS_SERVER || !HAS_AUTH)(
612
+ 'Tier 5: Multi-Tenancy Data Isolation',
613
+ () => {
614
+ it('should succeed with tenant header set (regardless of MT config)', async () => {
615
+ // With a tenant header, RunView results should be scoped if MT is enabled.
616
+ // Without MT config, results are unaffected.
617
+ // Either way, the request should succeed (200) — not 401.
618
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
619
+ method: 'POST',
620
+ headers: {
621
+ ...getAuthHeaders(),
622
+ [TENANT_HEADER]: 'nonexistent-tenant-for-testing',
623
+ },
624
+ body: makeRunViewQuery('Entities', 5),
625
+ });
626
+
627
+ assertRunViewResult(status, body, 'Entities (with tenant header)');
628
+ });
629
+
630
+ it('should return same results with and without tenant header when MT disabled', async () => {
631
+ const headers = getAuthHeaders();
632
+
633
+ const [withTenant, withoutTenant] = await Promise.all([
634
+ fetchJson(GRAPHQL_URL, {
635
+ method: 'POST',
636
+ headers: { ...headers, [TENANT_HEADER]: 'some-tenant-id' },
637
+ body: makeRunViewQuery('Entities', 3),
638
+ }),
639
+ fetchJson(GRAPHQL_URL, {
640
+ method: 'POST',
641
+ headers,
642
+ body: makeRunViewQuery('Entities', 3),
643
+ }),
644
+ ]);
645
+
646
+ // Both requests should authenticate successfully (200)
647
+ expect(withTenant.status).toBe(200);
648
+ expect(withoutTenant.status).toBe(200);
649
+
650
+ // If both have GraphQL errors (e.g., system user can't run views), that's OK —
651
+ // the important thing is that the tenant header didn't change the outcome
652
+ const withData = withTenant.body.data as Record<string, Record<string, unknown>> | null | undefined;
653
+ const withoutData = withoutTenant.body.data as Record<string, Record<string, unknown>> | null | undefined;
654
+
655
+ // When both return data, verify row counts match (MT not configured → same data)
656
+ if (withData?.RunDynamicView && withoutData?.RunDynamicView) {
657
+ expect(withData.RunDynamicView.RowCount).toBe(withoutData.RunDynamicView.RowCount);
658
+ }
659
+ // When both return null (GraphQL error), the tenant header had no effect — also passing
660
+ });
661
+ }
662
+ );
663
+
664
+ // ─── Tier 6: Multi-Tenancy Middleware Pipeline (Phase 1) ─────────────────
665
+ // These tests verify the multi-tenancy middleware integrates correctly with
666
+ // the auth pipeline when MT is enabled in mj.config.cjs. They validate
667
+ // that the tenant middleware doesn't break auth, runs in the correct order,
668
+ // and handles edge cases at the HTTP level.
669
+
670
+ describe.skipIf(!HAS_SERVER || !HAS_AUTH)(
671
+ 'Tier 6: Multi-Tenancy Middleware Pipeline',
672
+ () => {
673
+ describe('Tenant header does not interfere with auth', () => {
674
+ it('should authenticate with system key + tenant header on scoped entity', async () => {
675
+ if (!HAS_SYSTEM_KEY) return;
676
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
677
+ method: 'POST',
678
+ headers: {
679
+ 'Content-Type': 'application/json',
680
+ 'x-mj-api-key': SYSTEM_API_KEY,
681
+ [TENANT_HEADER]: 'test-tenant-for-employees',
682
+ },
683
+ body: makeRunViewQuery('Employees', 3),
684
+ });
685
+
686
+ // Auth must succeed (200). RunView may fail at resolver level (pre-existing).
687
+ assertRunViewResult(status, body, 'Employees (scoped, with tenant header)');
688
+ });
689
+
690
+ it('should authenticate with system key + tenant header on non-scoped entity', async () => {
691
+ if (!HAS_SYSTEM_KEY) return;
692
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
693
+ method: 'POST',
694
+ headers: {
695
+ 'Content-Type': 'application/json',
696
+ 'x-mj-api-key': SYSTEM_API_KEY,
697
+ [TENANT_HEADER]: 'test-tenant-for-roles',
698
+ },
699
+ body: makeRunViewQuery('Roles', 3),
700
+ });
701
+
702
+ // Non-scoped entity: tenant header should have no filtering effect
703
+ assertRunViewResult(status, body, 'Roles (non-scoped, with tenant header)');
704
+ });
705
+
706
+ it('should authenticate without tenant header on scoped entity', async () => {
707
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
708
+ method: 'POST',
709
+ headers: getAuthHeaders(),
710
+ body: makeRunViewQuery('Employees', 3),
711
+ });
712
+
713
+ // No tenant header → no TenantContext → no filtering
714
+ assertRunViewResult(status, body, 'Employees (scoped, no tenant header)');
715
+ });
716
+ });
717
+
718
+ describe('Tenant header edge cases at HTTP level', () => {
719
+ it('should handle empty tenant header value gracefully', async () => {
720
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
721
+ method: 'POST',
722
+ headers: {
723
+ ...getAuthHeaders(),
724
+ [TENANT_HEADER]: '',
725
+ },
726
+ body: INTROSPECTION_QUERY,
727
+ });
728
+
729
+ // Empty header → falsy → no TenantContext set → request proceeds normally
730
+ expect(status).toBe(200);
731
+ expect(body.data).toBeDefined();
732
+ });
733
+
734
+ it('should handle SQL injection attempt in tenant header', async () => {
735
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
736
+ method: 'POST',
737
+ headers: {
738
+ ...getAuthHeaders(),
739
+ [TENANT_HEADER]: "' OR 1=1 --",
740
+ },
741
+ body: INTROSPECTION_QUERY,
742
+ });
743
+
744
+ // The tenant header is processed but introspection queries don't trigger
745
+ // RunView hooks, so this should just succeed at the auth level.
746
+ expect(status).toBe(200);
747
+ expect(body.data).toBeDefined();
748
+ });
749
+
750
+ it('should handle UUID tenant header value', async () => {
751
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
752
+ method: 'POST',
753
+ headers: {
754
+ ...getAuthHeaders(),
755
+ [TENANT_HEADER]: '550e8400-e29b-41d4-a716-446655440000',
756
+ },
757
+ body: INTROSPECTION_QUERY,
758
+ });
759
+
760
+ expect(status).toBe(200);
761
+ expect(body.data).toBeDefined();
762
+ });
763
+
764
+ it('should handle very long tenant header value', async () => {
765
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
766
+ method: 'POST',
767
+ headers: {
768
+ ...getAuthHeaders(),
769
+ [TENANT_HEADER]: 'a'.repeat(500),
770
+ },
771
+ body: INTROSPECTION_QUERY,
772
+ });
773
+
774
+ // Should not crash the server — just process normally
775
+ expect(status).toBe(200);
776
+ expect(body.data).toBeDefined();
777
+ });
778
+ });
779
+
780
+ describe('Core entity exclusion (autoExcludeCoreEntities)', () => {
781
+ it('should not filter core __mj entity (Entities) even with tenant header', async () => {
782
+ // When autoExcludeCoreEntities is true, __mj entities are never filtered.
783
+ // Querying Entities with a tenant header should return the same as without.
784
+ const headers = getAuthHeaders();
785
+
786
+ const [withTenant, withoutTenant] = await Promise.all([
787
+ fetchJson(GRAPHQL_URL, {
788
+ method: 'POST',
789
+ headers: { ...headers, [TENANT_HEADER]: 'any-tenant-id' },
790
+ body: makeRunViewQuery('Entities', 5),
791
+ }),
792
+ fetchJson(GRAPHQL_URL, {
793
+ method: 'POST',
794
+ headers,
795
+ body: makeRunViewQuery('Entities', 5),
796
+ }),
797
+ ]);
798
+
799
+ expect(withTenant.status).toBe(200);
800
+ expect(withoutTenant.status).toBe(200);
801
+
802
+ const withData = withTenant.body.data as Record<string, Record<string, unknown>> | null | undefined;
803
+ const withoutData = withoutTenant.body.data as Record<string, Record<string, unknown>> | null | undefined;
804
+
805
+ // If RunView works, row counts should match (core entity not filtered)
806
+ if (withData?.RunDynamicView && withoutData?.RunDynamicView) {
807
+ expect(withData.RunDynamicView.RowCount).toBe(withoutData.RunDynamicView.RowCount);
808
+ }
809
+ });
810
+ });
811
+
812
+ describe('CORS with tenant header', () => {
813
+ it('should include CORS headers on response with tenant header', async () => {
814
+ const { status, headers } = await fetchJson(GRAPHQL_URL, {
815
+ method: 'POST',
816
+ headers: {
817
+ ...getAuthHeaders(),
818
+ Origin: 'http://localhost:4200',
819
+ [TENANT_HEADER]: 'cors-test-tenant',
820
+ },
821
+ body: INTROSPECTION_QUERY,
822
+ });
823
+
824
+ expect(status).toBe(200);
825
+ expect(headers.get('access-control-allow-origin')).toBeTruthy();
826
+ });
827
+
828
+ it('should expose tenant header in CORS preflight', async () => {
829
+ const response = await fetch(GRAPHQL_URL, {
830
+ method: 'OPTIONS',
831
+ headers: {
832
+ Origin: 'http://localhost:4200',
833
+ 'Access-Control-Request-Method': 'POST',
834
+ 'Access-Control-Request-Headers': `content-type,authorization,${TENANT_HEADER}`,
835
+ },
836
+ });
837
+
838
+ // Tenant header should be allowed in CORS preflight
839
+ const allowHeaders = response.headers.get('access-control-allow-headers') ?? '';
840
+ expect(response.status).toBeGreaterThanOrEqual(200);
841
+ expect(response.status).toBeLessThan(300);
842
+ // CORS should allow the tenant header (either explicitly or via wildcard)
843
+ const headerAllowed =
844
+ allowHeaders === '*' ||
845
+ allowHeaders.toLowerCase().includes(TENANT_HEADER.toLowerCase());
846
+ expect(headerAllowed).toBe(true);
847
+ });
848
+ });
849
+
850
+ describe('Concurrent requests with different tenant contexts', () => {
851
+ it('should handle multiple concurrent requests with different tenant IDs', async () => {
852
+ const headers = getAuthHeaders();
853
+ const tenantIds = ['tenant-a', 'tenant-b', 'tenant-c', 'no-tenant'];
854
+
855
+ const requests = tenantIds.map(tenantId => {
856
+ const reqHeaders = tenantId === 'no-tenant'
857
+ ? headers
858
+ : { ...headers, [TENANT_HEADER]: tenantId };
859
+
860
+ return fetchJson(GRAPHQL_URL, {
861
+ method: 'POST',
862
+ headers: reqHeaders,
863
+ body: INTROSPECTION_QUERY,
864
+ });
865
+ });
866
+
867
+ const results = await Promise.all(requests);
868
+
869
+ // All requests should authenticate successfully (200)
870
+ for (const result of results) {
871
+ expect(result.status).toBe(200);
872
+ expect(result.body.data).toBeDefined();
873
+ }
874
+ });
875
+ });
876
+ }
877
+ );