@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.
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +1 -0
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts +6 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +5 -3
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +3 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +13 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/PubSubManager.d.ts.map +1 -1
- package/dist/generic/PubSubManager.js +0 -1
- package/dist/generic/PubSubManager.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +16 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
- package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
- package/dist/resolvers/CurrentUserContextResolver.js +54 -0
- package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
- package/dist/test-dynamic-plugin.d.ts +6 -0
- package/dist/test-dynamic-plugin.d.ts.map +1 -0
- package/dist/test-dynamic-plugin.js +18 -0
- package/dist/test-dynamic-plugin.js.map +1 -0
- package/package.json +59 -59
- package/src/__tests__/bcsaas-integration.test.ts +455 -0
- package/src/__tests__/middleware-integration.test.ts +877 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
- package/src/agents/skip-agent.ts +1 -0
- package/src/agents/skip-sdk.ts +13 -3
- package/src/generated/generated.ts +10 -0
- package/src/generic/PubSubManager.ts +0 -1
- package/src/generic/ResolverBase.ts +17 -4
- package/src/index.ts +1 -0
- package/src/resolvers/CurrentUserContextResolver.ts +39 -0
- 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
|
+
);
|