@memberjunction/server 5.8.0 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/skip-agent.d.ts.map +1 -1
  3. package/dist/agents/skip-agent.js +1 -0
  4. package/dist/agents/skip-agent.js.map +1 -1
  5. package/dist/agents/skip-sdk.d.ts +6 -0
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +5 -3
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/apolloServer/index.d.ts +10 -2
  10. package/dist/apolloServer/index.d.ts.map +1 -1
  11. package/dist/apolloServer/index.js +22 -8
  12. package/dist/apolloServer/index.js.map +1 -1
  13. package/dist/config.d.ts +125 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +23 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts +17 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +144 -62
  20. package/dist/context.js.map +1 -1
  21. package/dist/generated/generated.d.ts +210 -0
  22. package/dist/generated/generated.d.ts.map +1 -1
  23. package/dist/generated/generated.js +1049 -0
  24. package/dist/generated/generated.js.map +1 -1
  25. package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
  26. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
  27. package/dist/generic/CacheInvalidationResolver.js +80 -0
  28. package/dist/generic/CacheInvalidationResolver.js.map +1 -0
  29. package/dist/generic/PubSubManager.d.ts +27 -0
  30. package/dist/generic/PubSubManager.d.ts.map +1 -0
  31. package/dist/generic/PubSubManager.js +41 -0
  32. package/dist/generic/PubSubManager.js.map +1 -0
  33. package/dist/generic/ResolverBase.d.ts +14 -0
  34. package/dist/generic/ResolverBase.d.ts.map +1 -1
  35. package/dist/generic/ResolverBase.js +66 -4
  36. package/dist/generic/ResolverBase.js.map +1 -1
  37. package/dist/hooks.d.ts +65 -0
  38. package/dist/hooks.d.ts.map +1 -0
  39. package/dist/hooks.js +14 -0
  40. package/dist/hooks.js.map +1 -0
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +173 -45
  44. package/dist/index.js.map +1 -1
  45. package/dist/multiTenancy/index.d.ts +47 -0
  46. package/dist/multiTenancy/index.d.ts.map +1 -0
  47. package/dist/multiTenancy/index.js +152 -0
  48. package/dist/multiTenancy/index.js.map +1 -0
  49. package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
  50. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
  51. package/dist/resolvers/CurrentUserContextResolver.js +54 -0
  52. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
  53. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
  55. package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
  57. package/dist/rest/RESTEndpointHandler.d.ts +3 -1
  58. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  59. package/dist/rest/RESTEndpointHandler.js +14 -33
  60. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  61. package/dist/test-dynamic-plugin.d.ts +6 -0
  62. package/dist/test-dynamic-plugin.d.ts.map +1 -0
  63. package/dist/test-dynamic-plugin.js +18 -0
  64. package/dist/test-dynamic-plugin.js.map +1 -0
  65. package/dist/types.d.ts +9 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +61 -57
  69. package/src/__tests__/bcsaas-integration.test.ts +455 -0
  70. package/src/__tests__/middleware-integration.test.ts +877 -0
  71. package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
  72. package/src/__tests__/multiTenancy.security.test.ts +334 -0
  73. package/src/__tests__/multiTenancy.test.ts +225 -0
  74. package/src/__tests__/unifiedAuth.test.ts +416 -0
  75. package/src/agents/skip-agent.ts +1 -0
  76. package/src/agents/skip-sdk.ts +13 -3
  77. package/src/apolloServer/index.ts +32 -16
  78. package/src/config.ts +25 -0
  79. package/src/context.ts +205 -98
  80. package/src/generated/generated.ts +746 -1
  81. package/src/generic/CacheInvalidationResolver.ts +66 -0
  82. package/src/generic/PubSubManager.ts +46 -0
  83. package/src/generic/ResolverBase.ts +70 -4
  84. package/src/hooks.ts +77 -0
  85. package/src/index.ts +199 -49
  86. package/src/multiTenancy/index.ts +183 -0
  87. package/src/resolvers/CurrentUserContextResolver.ts +39 -0
  88. package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
  89. package/src/rest/RESTEndpointHandler.ts +23 -42
  90. package/src/test-dynamic-plugin.ts +36 -0
  91. package/src/types.ts +10 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * BCSaaS Integration Tests — Phase 4, 5, 6
3
+ *
4
+ * Tests the BCSaaS middle-layer plugin architecture against a RUNNING MJAPI instance
5
+ * that has BCSaaS loaded via DynamicPackageLoader and a database with BCSaaS entities.
6
+ *
7
+ * Test data setup (mj_test database):
8
+ * - Organizations: Acme Corp (11111111-...), Beta Inc (22222222-...), Acme West Division (33333333-...)
9
+ * - Contacts: System User (AAAAAAAA-...) linked to MJ System user, Other User (BBBBBBBB-...)
10
+ * - OrgContacts: System→Acme (Owner), System→Beta (Member), Other→Beta (Admin)
11
+ * - ContactRoles: Owner, Admin, Member, Viewer
12
+ *
13
+ * Environment variables:
14
+ * MJAPI_URL Base URL of the running MJAPI instance (required)
15
+ * MJAPI_SYSTEM_API_KEY System API key (x-mj-api-key header)
16
+ */
17
+ import { describe, it, expect, beforeAll } from 'vitest';
18
+
19
+ // ─── Configuration ─────────────────────────────────────────────────────────
20
+
21
+ const BASE_URL = process.env.MJAPI_URL ?? '';
22
+ const SYSTEM_API_KEY = process.env.MJAPI_SYSTEM_API_KEY ?? '';
23
+ const GRAPHQL_PATH = process.env.MJAPI_GRAPHQL_PATH ?? '/';
24
+
25
+ const GRAPHQL_URL = `${BASE_URL}${GRAPHQL_PATH}`;
26
+ const HAS_SERVER = !!BASE_URL;
27
+ const HAS_SYSTEM_KEY = !!SYSTEM_API_KEY;
28
+
29
+ // Timeout for HTTP requests (some first-request queries are slow due to connection pool warmup)
30
+ const REQUEST_TIMEOUT = 60_000;
31
+
32
+ // ─── Test Data IDs ─────────────────────────────────────────────────────────
33
+
34
+ const ACME_ORG_ID = '11111111-1111-1111-1111-111111111111';
35
+ const BETA_ORG_ID = '22222222-2222-2222-2222-222222222222';
36
+ const SYSTEM_CONTACT_ID = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
37
+ const NONEXISTENT_ORG_ID = '99999999-9999-9999-9999-999999999999';
38
+
39
+ // ─── Helpers ────────────────────────────────────────────────────────────────
40
+
41
+ interface FetchResult {
42
+ status: number;
43
+ headers: Headers;
44
+ body: Record<string, unknown>;
45
+ }
46
+
47
+ async function fetchJson(url: string, options: RequestInit = {}): Promise<FetchResult> {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
50
+ try {
51
+ const response = await fetch(url, { ...options, signal: controller.signal });
52
+ let body: Record<string, unknown> = {};
53
+ try {
54
+ body = (await response.json()) as Record<string, unknown>;
55
+ } catch {
56
+ // Non-JSON response — body stays empty
57
+ }
58
+ return { status: response.status, headers: response.headers, body };
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ function systemKeyHeaders(extra: Record<string, string> = {}): Record<string, string> {
65
+ return {
66
+ 'Content-Type': 'application/json',
67
+ 'x-mj-api-key': SYSTEM_API_KEY,
68
+ ...extra,
69
+ };
70
+ }
71
+
72
+ function makeRunViewQuery(entityName: string, extraFilter = '', maxRows = 100): string {
73
+ return JSON.stringify({
74
+ query: `query RunDynamicView($input: RunDynamicViewInput!) {
75
+ RunDynamicView(input: $input) {
76
+ Results { PrimaryKey { FieldName Value } EntityID Data }
77
+ RowCount TotalRowCount ExecutionTime ErrorMessage
78
+ }
79
+ }`,
80
+ variables: {
81
+ input: {
82
+ EntityName: entityName,
83
+ ExtraFilter: extraFilter,
84
+ OrderBy: '',
85
+ MaxRows: maxRows,
86
+ ResultType: 'simple',
87
+ },
88
+ },
89
+ operationName: 'RunDynamicView',
90
+ });
91
+ }
92
+
93
+ interface RunViewData {
94
+ Results: Array<{ Data: string; PrimaryKey: Array<{ FieldName: string; Value: string }> }>;
95
+ RowCount: number;
96
+ TotalRowCount: number;
97
+ ErrorMessage: string | null;
98
+ }
99
+
100
+ function extractRunViewData(body: Record<string, unknown>): RunViewData | null {
101
+ const data = body.data as Record<string, RunViewData> | null;
102
+ if (!data?.RunDynamicView) return null;
103
+ return data.RunDynamicView;
104
+ }
105
+
106
+ function parseResults(rvData: RunViewData): Record<string, unknown>[] {
107
+ return rvData.Results.map(r => JSON.parse(r.Data) as Record<string, unknown>);
108
+ }
109
+
110
+ // ─── Phase 4: BCSaaS as Middle-Layer Plugin ─────────────────────────────────
111
+
112
+ describe('Phase 4: BCSaaS as Middle-Layer Plugin', () => {
113
+ beforeAll(() => {
114
+ if (!HAS_SERVER) {
115
+ console.warn('MJAPI_URL not set — skipping BCSaaS integration tests');
116
+ }
117
+ });
118
+
119
+ describe('4.4 BCTenantContext resolution', () => {
120
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
121
+ 'should resolve tenant context for authenticated system user',
122
+ async () => {
123
+ // The system user (not.set@nowhere.com) has a Contact linked via LinkedUserID.
124
+ // bcTenantContextMiddleware finds the Contact, loads org memberships.
125
+ // Verify by querying BC: Contacts (non-scoped) — success means middleware ran.
126
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
127
+ method: 'POST',
128
+ headers: systemKeyHeaders(),
129
+ body: makeRunViewQuery('BC: Contacts'),
130
+ });
131
+
132
+ expect(status).toBe(200);
133
+ const rvData = extractRunViewData(body);
134
+ if (rvData) {
135
+ expect(rvData.ErrorMessage).toBeNull();
136
+ // Should see both contacts (BC: Contacts has no OrganizationID — unfiltered)
137
+ expect(rvData.RowCount).toBe(2);
138
+ }
139
+ },
140
+ REQUEST_TIMEOUT
141
+ );
142
+ });
143
+
144
+ describe('4.6 Multi-org user resolution', () => {
145
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
146
+ 'should access both orgs the user belongs to',
147
+ async () => {
148
+ // System user belongs to Acme Corp (Owner) and Beta Inc (Member).
149
+ // Verify multi-org by switching org context and confirming both work.
150
+ // Query with Acme context:
151
+ const acmeResult = await fetchJson(GRAPHQL_URL, {
152
+ method: 'POST',
153
+ headers: systemKeyHeaders({ 'x-organization-id': ACME_ORG_ID }),
154
+ body: makeRunViewQuery('BC: Organization Contacts'),
155
+ });
156
+ expect(acmeResult.status).toBe(200);
157
+
158
+ // Query with Beta context:
159
+ const betaResult = await fetchJson(GRAPHQL_URL, {
160
+ method: 'POST',
161
+ headers: systemKeyHeaders({ 'x-organization-id': BETA_ORG_ID }),
162
+ body: makeRunViewQuery('BC: Organization Contacts'),
163
+ });
164
+ expect(betaResult.status).toBe(200);
165
+
166
+ const acmeData = extractRunViewData(acmeResult.body);
167
+ const betaData = extractRunViewData(betaResult.body);
168
+
169
+ if (acmeData && betaData) {
170
+ // Acme has 1 org contact (System user as Owner)
171
+ const acmeContacts = parseResults(acmeData);
172
+ expect(acmeContacts.length).toBe(1);
173
+ expect(acmeContacts[0].OrganizationID).toBe(ACME_ORG_ID);
174
+
175
+ // Beta has 2 org contacts (System as Member + Other as Admin)
176
+ const betaContacts = parseResults(betaData);
177
+ expect(betaContacts.length).toBe(2);
178
+ for (const c of betaContacts) {
179
+ expect(c.OrganizationID).toBe(BETA_ORG_ID);
180
+ }
181
+ }
182
+ },
183
+ REQUEST_TIMEOUT
184
+ );
185
+ });
186
+
187
+ describe('4.7 Org selector via X-Organization-ID header', () => {
188
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
189
+ 'should switch active org via X-Organization-ID header',
190
+ async () => {
191
+ // When X-Organization-ID is set to Beta Inc, the request should succeed.
192
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
193
+ method: 'POST',
194
+ headers: systemKeyHeaders({ 'x-organization-id': BETA_ORG_ID }),
195
+ body: JSON.stringify({ query: '{ __typename }' }),
196
+ });
197
+
198
+ expect(status).toBe(200);
199
+ const data = body.data as Record<string, string> | null;
200
+ expect(data?.__typename).toBe('Query');
201
+ },
202
+ REQUEST_TIMEOUT
203
+ );
204
+
205
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
206
+ 'should reject X-Organization-ID for org user has no access to',
207
+ async () => {
208
+ // System user does NOT belong to org 99999999-...
209
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
210
+ method: 'POST',
211
+ headers: systemKeyHeaders({ 'x-organization-id': NONEXISTENT_ORG_ID }),
212
+ body: JSON.stringify({ query: '{ __typename }' }),
213
+ });
214
+
215
+ expect(status).toBe(403);
216
+ const error = body as { error?: string; message?: string };
217
+ expect(error.error).toBe('Forbidden');
218
+ }
219
+ );
220
+ });
221
+
222
+ describe('4.8 MJ TenantContext populated', () => {
223
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
224
+ 'should populate TenantContext with org data',
225
+ async () => {
226
+ // When context is set to Acme, org-scoped queries should be filtered.
227
+ // This proves TenantContext is populated and hooks can read it.
228
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
229
+ method: 'POST',
230
+ headers: systemKeyHeaders({ 'x-organization-id': ACME_ORG_ID }),
231
+ body: makeRunViewQuery('BC: Organization Contacts'),
232
+ });
233
+
234
+ expect(status).toBe(200);
235
+ const rvData = extractRunViewData(body);
236
+ if (rvData) {
237
+ expect(rvData.ErrorMessage).toBeNull();
238
+ // Hook used TenantContext.organizationID to filter — only Acme contacts
239
+ const results = parseResults(rvData);
240
+ expect(results.length).toBe(1);
241
+ expect(results[0].OrganizationID).toBe(ACME_ORG_ID);
242
+ }
243
+ },
244
+ REQUEST_TIMEOUT
245
+ );
246
+ });
247
+
248
+ describe('4.12 GraphQL context has tenant', () => {
249
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
250
+ 'should authenticate and execute GraphQL with tenant context',
251
+ async () => {
252
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
253
+ method: 'POST',
254
+ headers: systemKeyHeaders(),
255
+ body: JSON.stringify({ query: '{ __typename }' }),
256
+ });
257
+
258
+ expect(status).toBe(200);
259
+ const data = body.data as Record<string, string> | null;
260
+ expect(data?.__typename).toBe('Query');
261
+ }
262
+ );
263
+ });
264
+ });
265
+
266
+ // ─── Phase 5: BCSaaS Hook Integration ───────────────────────────────────────
267
+
268
+ describe('Phase 5: BCSaaS Hook Integration', () => {
269
+ describe('5.3 BCSaaS PreRunView filter uses org context', () => {
270
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
271
+ 'should filter org-scoped entities by OrganizationID from default context',
272
+ async () => {
273
+ // Default org for System user is Acme Corp (first membership).
274
+ // BC: Organization Contacts (org-scoped) should only return Acme contacts.
275
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
276
+ method: 'POST',
277
+ headers: systemKeyHeaders(),
278
+ body: makeRunViewQuery('BC: Organization Contacts'),
279
+ });
280
+
281
+ expect(status).toBe(200);
282
+ const rvData = extractRunViewData(body);
283
+ if (rvData && rvData.RowCount > 0) {
284
+ const results = parseResults(rvData);
285
+ for (const row of results) {
286
+ expect(row.OrganizationID).toBe(ACME_ORG_ID);
287
+ }
288
+ }
289
+ },
290
+ REQUEST_TIMEOUT
291
+ );
292
+
293
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
294
+ 'should filter by selected org when X-Organization-ID is set',
295
+ async () => {
296
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
297
+ method: 'POST',
298
+ headers: systemKeyHeaders({ 'x-organization-id': BETA_ORG_ID }),
299
+ body: makeRunViewQuery('BC: Organization Contacts'),
300
+ });
301
+
302
+ expect(status).toBe(200);
303
+ const rvData = extractRunViewData(body);
304
+ if (rvData && rvData.RowCount > 0) {
305
+ const results = parseResults(rvData);
306
+ for (const row of results) {
307
+ expect(row.OrganizationID).toBe(BETA_ORG_ID);
308
+ }
309
+ }
310
+ },
311
+ REQUEST_TIMEOUT
312
+ );
313
+ });
314
+
315
+ describe('5.3b Non-scoped entities pass through unfiltered', () => {
316
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
317
+ 'should NOT filter entities without OrganizationID field',
318
+ async () => {
319
+ // BC: Contacts does NOT have OrganizationID — should return all 2 contacts
320
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
321
+ method: 'POST',
322
+ headers: systemKeyHeaders(),
323
+ body: makeRunViewQuery('BC: Contacts'),
324
+ });
325
+
326
+ expect(status).toBe(200);
327
+ const rvData = extractRunViewData(body);
328
+ if (rvData) {
329
+ expect(rvData.ErrorMessage).toBeNull();
330
+ expect(rvData.RowCount).toBe(2);
331
+ }
332
+ },
333
+ REQUEST_TIMEOUT
334
+ );
335
+ });
336
+
337
+ describe('5.6 Hook priority ordering', () => {
338
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
339
+ 'should execute BCSaaS hook at priority 100 for org filtering',
340
+ async () => {
341
+ // BCSaaS hooks at priority 100 with namespace 'mj:tenantFilter'.
342
+ // Verify the hook runs by checking org filtering works.
343
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
344
+ method: 'POST',
345
+ headers: systemKeyHeaders({ 'x-organization-id': BETA_ORG_ID }),
346
+ body: makeRunViewQuery('BC: Organization Contacts'),
347
+ });
348
+
349
+ expect(status).toBe(200);
350
+ const rvData = extractRunViewData(body);
351
+ if (rvData && rvData.RowCount > 0) {
352
+ const results = parseResults(rvData);
353
+ for (const row of results) {
354
+ expect(row.OrganizationID).toBe(BETA_ORG_ID);
355
+ }
356
+ }
357
+ },
358
+ REQUEST_TIMEOUT
359
+ );
360
+ });
361
+
362
+ describe('5.7-5.8 MJ multi-tenancy disabled, BCSaaS handles all', () => {
363
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
364
+ 'should handle tenant filtering entirely through BCSaaS hooks',
365
+ async () => {
366
+ // MJ config-driven MT is disabled. BCSaaS hooks handle all filtering.
367
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
368
+ method: 'POST',
369
+ headers: systemKeyHeaders({ 'x-organization-id': ACME_ORG_ID }),
370
+ body: makeRunViewQuery('BC: Organization Contacts'),
371
+ });
372
+
373
+ expect(status).toBe(200);
374
+ const rvData = extractRunViewData(body);
375
+ if (rvData && rvData.RowCount > 0) {
376
+ const results = parseResults(rvData);
377
+ // Acme Corp has 1 org contact (System user)
378
+ expect(results.length).toBe(1);
379
+ expect(results[0].OrganizationID).toBe(ACME_ORG_ID);
380
+ }
381
+ },
382
+ REQUEST_TIMEOUT
383
+ );
384
+ });
385
+ });
386
+
387
+ // ─── Phase 6: Multi-Layer Stacking ──────────────────────────────────────────
388
+
389
+ describe('Phase 6: Multi-Layer Stacking', () => {
390
+ describe('6.5 BCSaaS hooks coexist with base MJ', () => {
391
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
392
+ 'should run BCSaaS middleware and hooks without conflicting with MJ base',
393
+ async () => {
394
+ // Full pipeline: MJ auth → BCSaaS tenant → hooks → GraphQL
395
+ // BC: Organizations has no OrganizationID field, so it's unfiltered — all 3 orgs.
396
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
397
+ method: 'POST',
398
+ headers: systemKeyHeaders(),
399
+ body: makeRunViewQuery('BC: Organizations'),
400
+ });
401
+
402
+ expect(status).toBe(200);
403
+ const rvData = extractRunViewData(body);
404
+ if (rvData) {
405
+ expect(rvData.ErrorMessage).toBeNull();
406
+ expect(rvData.RowCount).toBe(3);
407
+ }
408
+ },
409
+ REQUEST_TIMEOUT
410
+ );
411
+ });
412
+
413
+ describe('6.6 Middleware execution order', () => {
414
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
415
+ 'should execute: MJ Auth → BCSaaS Tenant → GraphQL in correct order',
416
+ async () => {
417
+ // Order verified by:
418
+ // 1. Auth succeeds (200, not 401)
419
+ // 2. Tenant context resolved (org filtering works on scoped entity)
420
+ // 3. GraphQL query returns filtered data
421
+ const { status, body } = await fetchJson(GRAPHQL_URL, {
422
+ method: 'POST',
423
+ headers: systemKeyHeaders({ 'x-organization-id': ACME_ORG_ID }),
424
+ body: makeRunViewQuery('BC: Organization Contacts'),
425
+ });
426
+
427
+ expect(status).toBe(200);
428
+ const rvData = extractRunViewData(body);
429
+ if (rvData) {
430
+ expect(rvData.ErrorMessage).toBeNull();
431
+ if (rvData.RowCount > 0) {
432
+ const results = parseResults(rvData);
433
+ for (const row of results) {
434
+ expect(row.OrganizationID).toBe(ACME_ORG_ID);
435
+ }
436
+ }
437
+ }
438
+ },
439
+ REQUEST_TIMEOUT
440
+ );
441
+
442
+ it.skipIf(!HAS_SERVER || !HAS_SYSTEM_KEY)(
443
+ 'should return 401 when no auth is provided (MJ auth blocks before BCSaaS)',
444
+ async () => {
445
+ const { status } = await fetchJson(GRAPHQL_URL, {
446
+ method: 'POST',
447
+ headers: { 'Content-Type': 'application/json' },
448
+ body: makeRunViewQuery('BC: Organizations'),
449
+ });
450
+
451
+ expect(status).toBe(401);
452
+ }
453
+ );
454
+ });
455
+ });