@antasphere/chassis-server 1.4.0 → 1.5.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.
@@ -0,0 +1,544 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import type { Db } from '@antasphere/chassis-db';
6
+ import type { GrantAccess } from '../../src/identity/hub-grant.js';
7
+ import type { Logger } from '../../src/logger.js';
8
+ import { OpenAPIHono } from '@hono/zod-openapi';
9
+ import type { Principal } from '@antasphere/chassis-contract';
10
+ import {
11
+ appsFromFile,
12
+ readDescriptor,
13
+ readOrganization,
14
+ safeHttpUrl,
15
+ selectHubApps
16
+ } from '../../src/apps/select.js';
17
+ import { registerAppsRoutes } from '../../src/apps/routes.js';
18
+ import { APPS_CACHE_TTL_MS, AppsService, type HubTokens } from '../../src/apps/service.js';
19
+
20
+ /**
21
+ * The Apps page's list (`src/apps/`, PRDCT-3493): the URL rule, the
22
+ * operator's file with the descriptor keys, the hub's one answer kept where
23
+ * the organization opens the tool, the current tool, and the service's
24
+ * fall-backs and cache, the suspended organization served as an empty list,
25
+ * the handler's own session-only refusal. The route through the real boot
26
+ * (the allowlist's 403 before the handler) is the integration suite's
27
+ * (`test/integration/apps-route.test.ts`).
28
+ */
29
+
30
+ const ORG = '0b9f1a52-6f8e-4b5e-9a35-1d2c3e4f5a6b';
31
+ const DESCRIPTOR = {
32
+ description: 'Where the company keeps its figures.',
33
+ icon: 'landmark',
34
+ drawing: 'ledger',
35
+ accent: '#B5542A'
36
+ };
37
+
38
+ describe('safeHttpUrl', () => {
39
+ it('keeps http and https, normalised', () => {
40
+ expect(safeHttpUrl('https://finance.example.com')).toBe('https://finance.example.com/');
41
+ expect(safeHttpUrl('http://localhost:4300/home')).toBe('http://localhost:4300/home');
42
+ });
43
+
44
+ it('refuses every other scheme, a relative path, credentials, a blank and a non-string', () => {
45
+ for (const bad of [
46
+ 'javascript:alert(1)',
47
+ 'data:text/html,hi',
48
+ 'file:///etc/passwd',
49
+ '/relative',
50
+ 'https://user:pw@finance.example.com',
51
+ '',
52
+ ' ',
53
+ 42,
54
+ null,
55
+ undefined,
56
+ 'https://' + 'a'.repeat(2001)
57
+ ]) {
58
+ expect(safeHttpUrl(bad)).toBeNull();
59
+ }
60
+ });
61
+ });
62
+
63
+ describe('readDescriptor', () => {
64
+ it('keeps a valid descriptor and drops an invalid or empty one, never refusing the entry', () => {
65
+ expect(readDescriptor(DESCRIPTOR)).toEqual(DESCRIPTOR);
66
+ expect(readDescriptor({ icon: 'mail' })).toEqual({ icon: 'mail' });
67
+ expect(readDescriptor({})).toBeUndefined();
68
+ expect(readDescriptor(null)).toBeUndefined();
69
+ expect(readDescriptor({ icon: 'skull' })).toBeUndefined();
70
+ expect(readDescriptor({ logo: 'https://x.example/logo.svg' })).toBeUndefined();
71
+ expect(readDescriptor({ accent: 'red' })).toBeUndefined();
72
+ expect(readDescriptor('landmark')).toBeUndefined();
73
+ expect(readDescriptor(['landmark'])).toBeUndefined();
74
+ });
75
+
76
+ it('drops the one key the contract refuses and keeps the rest: a newer hub’s icon or key never costs the sentence', () => {
77
+ expect(readDescriptor({ icon: 'mail', badge: 'new' })).toEqual({ icon: 'mail' });
78
+ expect(readDescriptor({ icon: 'anchor', drawing: 'ledger', accent: '#112233' })).toEqual({
79
+ drawing: 'ledger',
80
+ accent: '#112233'
81
+ });
82
+ expect(readDescriptor({ ...DESCRIPTOR, accent: 'red' })).toEqual({
83
+ description: DESCRIPTOR.description,
84
+ icon: DESCRIPTOR.icon,
85
+ drawing: DESCRIPTOR.drawing
86
+ });
87
+ expect(readDescriptor({ description: ' trimmed ', icon: 'skull' })).toEqual({ description: 'trimmed' });
88
+ });
89
+ });
90
+
91
+ describe('readOrganization', () => {
92
+ it('reads the organization and its status, null without one or on another shape', () => {
93
+ expect(readOrganization({ organization: { id: ORG, name: 'Acme', status: 'active' } })).toEqual({
94
+ name: 'Acme',
95
+ status: 'active'
96
+ });
97
+ expect(readOrganization({ organization: { id: ORG, name: 'Acme', status: 'suspended' } })).toEqual({
98
+ name: 'Acme',
99
+ status: 'suspended'
100
+ });
101
+ expect(readOrganization({ organization: { id: ORG, name: 'Acme' } })).toBeNull();
102
+ expect(readOrganization({ organization: { status: 'frozen' } })).toBeNull();
103
+ expect(readOrganization({ organization: 'Acme' })).toBeNull();
104
+ expect(readOrganization({})).toBeNull();
105
+ expect(readOrganization(null)).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe('appsFromFile', () => {
110
+ it('lists the valid entries in order, marks this tool current, reads the descriptor keys', () => {
111
+ const file = appsFromFile(
112
+ [
113
+ {
114
+ slug: 'finance',
115
+ name: 'Antasphere Finance',
116
+ launchUrl: 'https://finance.example.com',
117
+ ...DESCRIPTOR
118
+ },
119
+ { slug: 'yourtool', name: 'Your Tool', launchUrl: 'https://yourtool.example.com' },
120
+ {
121
+ slug: 'mail',
122
+ name: 'Antasphere Mail',
123
+ launchUrl: 'https://mail.example.com',
124
+ icon: 'mail',
125
+ accent: 'red'
126
+ }
127
+ ],
128
+ 'yourtool'
129
+ );
130
+ expect(file.rejected).toEqual([]);
131
+ expect(file.hub).toBeNull();
132
+ expect(file.apps.map((a) => [a.slug, a.current, a.descriptor])).toEqual([
133
+ ['finance', false, DESCRIPTOR],
134
+ ['yourtool', true, undefined],
135
+ // one bad key drops that key alone, the entry and its other keys stay
136
+ ['mail', false, { icon: 'mail' }]
137
+ ]);
138
+ expect(file.apps[0]).toMatchObject({ connected: false, firstUsedAt: null, lastUsedAt: null });
139
+ });
140
+
141
+ it('leaves out an entry without a slug, a name or an http(s) launchUrl, and a second of the same slug', () => {
142
+ const file = appsFromFile(
143
+ [
144
+ { name: 'No slug', launchUrl: 'https://a.example.com' },
145
+ { slug: 'Bad Slug', name: 'x', launchUrl: 'https://a.example.com' },
146
+ { slug: 'noname', launchUrl: 'https://a.example.com' },
147
+ { slug: 'crm', name: 'CRM', launchUrl: 'javascript:alert(1)' },
148
+ { slug: 'finance', name: 'Finance', launchUrl: 'https://f.example.com' },
149
+ { slug: 'finance', name: 'Finance again', launchUrl: 'https://f2.example.com' },
150
+ null
151
+ ],
152
+ 'yourtool'
153
+ );
154
+ expect(file.apps.map((a) => a.slug)).toEqual(['finance']);
155
+ expect(file.rejected.map((r) => r.index)).toEqual([0, 1, 2, 3, 5, 6]);
156
+ });
157
+
158
+ it('takes an entry named hub or account as the account’s card, not a tool', () => {
159
+ const file = appsFromFile(
160
+ [
161
+ { slug: 'account', name: 'Antasphere account', launchUrl: 'https://account.example.com' },
162
+ { slug: 'finance', name: 'Finance', launchUrl: 'https://f.example.com' }
163
+ ],
164
+ 'yourtool'
165
+ );
166
+ expect(file.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere account' });
167
+ expect(file.apps.map((a) => a.slug)).toEqual(['finance']);
168
+ });
169
+
170
+ it('a file that is not an array lists nothing', () => {
171
+ const file = appsFromFile({ slug: 'finance' }, 'yourtool');
172
+ expect(file.apps).toEqual([]);
173
+ expect(file.rejected[0]!.reason).toContain('not a JSON array');
174
+ });
175
+ });
176
+
177
+ describe('selectHubApps', () => {
178
+ const answer = {
179
+ organization: { id: ORG, name: 'Acme' },
180
+ tools: [
181
+ {
182
+ slug: 'finance',
183
+ name: 'Antasphere Finance',
184
+ launchUrl: 'https://finance.example.com',
185
+ connected: true,
186
+ connectedAt: '2026-09-01T00:00:00Z',
187
+ lastUsedAt: '2026-10-10T00:00:00Z',
188
+ descriptor: DESCRIPTOR,
189
+ open: true
190
+ },
191
+ {
192
+ slug: 'yourtool',
193
+ name: 'Your Tool',
194
+ launchUrl: 'https://yourtool.example.com',
195
+ connected: false,
196
+ open: true
197
+ },
198
+ {
199
+ slug: 'hiring',
200
+ name: 'Antasphere Hiring',
201
+ launchUrl: 'https://hiring.example.com',
202
+ connected: false,
203
+ open: false
204
+ },
205
+ {
206
+ slug: 'mail',
207
+ name: 'Antasphere Mail',
208
+ launchUrl: 'javascript:alert(1)',
209
+ connected: false,
210
+ open: true
211
+ },
212
+ { slug: 'Bad Slug', name: 'x', launchUrl: 'https://x.example.com', connected: false, open: true },
213
+ {
214
+ slug: 'finance',
215
+ name: 'Finance twice',
216
+ launchUrl: 'https://f2.example.com',
217
+ connected: false,
218
+ open: true
219
+ },
220
+ {
221
+ slug: 'sign',
222
+ name: 'Antasphere Sign',
223
+ launchUrl: 'https://sign.example.com',
224
+ connected: false,
225
+ open: true,
226
+ descriptor: { icon: 'skull' }
227
+ }
228
+ ]
229
+ };
230
+
231
+ it('keeps the tools the organization opens, in the hub’s order, with their facts and descriptor', () => {
232
+ const apps = selectHubApps(answer, 'yourtool')!;
233
+ expect(apps.map((a) => a.slug)).toEqual(['finance', 'yourtool', 'sign']);
234
+ expect(apps[0]).toEqual({
235
+ slug: 'finance',
236
+ name: 'Antasphere Finance',
237
+ launchUrl: 'https://finance.example.com/',
238
+ descriptor: DESCRIPTOR,
239
+ connected: true,
240
+ firstUsedAt: '2026-09-01T00:00:00Z',
241
+ lastUsedAt: '2026-10-10T00:00:00Z',
242
+ current: false
243
+ });
244
+ expect(apps[1]!.current).toBe(true);
245
+ // an invalid descriptor drops the descriptor, never the tool
246
+ expect(apps[2]!.descriptor).toBeUndefined();
247
+ });
248
+
249
+ it('a tool not open is left out, never listed greyed', () => {
250
+ expect(selectHubApps(answer, 'yourtool')!.some((a) => a.slug === 'hiring')).toBe(false);
251
+ });
252
+
253
+ it('is null when the body carries no list', () => {
254
+ expect(selectHubApps({}, 'x')).toBeNull();
255
+ expect(selectHubApps(null, 'x')).toBeNull();
256
+ expect(selectHubApps({ tools: 'nope' }, 'x')).toBeNull();
257
+ });
258
+ });
259
+
260
+ // ── The service ──────────────────────────────────────────────────────────────
261
+
262
+ const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } as unknown as Logger;
263
+
264
+ /** A db whose one query (the workspace's hub organization) answers `hubOrgId`. */
265
+ function fakeDb(hubOrgId: string | null): Db {
266
+ const chain = {
267
+ select: () => chain,
268
+ from: () => chain,
269
+ where: () => chain,
270
+ limit: () => Promise.resolve([{ hubOrgId }])
271
+ };
272
+ return chain as unknown as Db;
273
+ }
274
+
275
+ function fakeTokens(access: GrantAccess = { kind: 'ok', accessToken: 'tok-1' }) {
276
+ return {
277
+ accessToken: vi.fn<HubTokens['accessToken']>(() => Promise.resolve(access)),
278
+ refresh: vi.fn<HubTokens['refresh']>(() => Promise.resolve({ kind: 'ok', accessToken: 'tok-2' })),
279
+ invalidateAccess: vi.fn<HubTokens['invalidateAccess']>()
280
+ };
281
+ }
282
+
283
+ /** A hub that answers /me/apps for the given token, recording every call with its organization header. */
284
+ function fakeHub(
285
+ opts: { token?: string; status?: number; body?: unknown; orgStatus?: 'active' | 'suspended' } = {}
286
+ ) {
287
+ const calls: Array<{ path: string; org: string | null }> = [];
288
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
289
+ const url = new URL(String(input));
290
+ const headers = new Headers(init?.headers);
291
+ calls.push({ path: url.pathname, org: headers.get('x-workspace-id') });
292
+ if (headers.get('authorization') !== `Bearer ${opts.token ?? 'tok-1'}`) {
293
+ return Response.json({ error: {} }, { status: 401 });
294
+ }
295
+ if (opts.status) return Response.json(opts.body ?? {}, { status: opts.status });
296
+ return Response.json(
297
+ opts.body ?? {
298
+ organization: { id: ORG, name: 'Acme', status: opts.orgStatus ?? 'active' },
299
+ tools: [
300
+ {
301
+ slug: 'finance',
302
+ name: 'Antasphere Finance',
303
+ launchUrl: 'https://finance.example.com',
304
+ connected: true,
305
+ connectedAt: '2026-09-01T00:00:00Z',
306
+ lastUsedAt: null,
307
+ descriptor: DESCRIPTOR,
308
+ open: true
309
+ },
310
+ {
311
+ slug: 'yourtool',
312
+ name: 'Your Tool',
313
+ launchUrl: 'https://yourtool.example.com',
314
+ connected: true,
315
+ open: true
316
+ },
317
+ {
318
+ slug: 'hiring',
319
+ name: 'Antasphere Hiring',
320
+ launchUrl: 'https://hiring.example.com',
321
+ connected: false,
322
+ open: false
323
+ }
324
+ ]
325
+ }
326
+ );
327
+ });
328
+ return { impl: impl as unknown as typeof fetch, calls };
329
+ }
330
+
331
+ const principal = { userId: 'usr_1', workspaceId: 'ws_1' };
332
+
333
+ function cloud(over: {
334
+ db?: Db;
335
+ tokens?: HubTokens;
336
+ fetchImpl?: typeof fetch;
337
+ appsFile?: string;
338
+ now?: () => number;
339
+ }) {
340
+ return new AppsService({
341
+ db: over.db ?? fakeDb(ORG),
342
+ logger,
343
+ edition: 'cloud',
344
+ hubIssuerUrl: 'https://account.example.com/',
345
+ appsFile: over.appsFile,
346
+ selfSlug: 'yourtool',
347
+ tokens: over.tokens ?? fakeTokens(),
348
+ ...(over.fetchImpl ? { fetchImpl: over.fetchImpl } : {}),
349
+ ...(over.now ? { now: over.now } : {})
350
+ });
351
+ }
352
+
353
+ function writeFile(entries: unknown): string {
354
+ const dir = mkdtempSync(join(tmpdir(), 'apps-'));
355
+ const path = join(dir, 'apps.json');
356
+ writeFileSync(path, JSON.stringify(entries));
357
+ return path;
358
+ }
359
+
360
+ describe('AppsService on the cloud edition', () => {
361
+ it('asks the hub once as the person, for the workspace’s organization, and keeps what it opens', async () => {
362
+ const hub = fakeHub();
363
+ const list = await cloud({ fetchImpl: hub.impl }).list(principal);
364
+ expect(hub.calls).toEqual([{ path: '/api/v1/me/apps', org: ORG }]);
365
+ expect(list.source).toBe('hub');
366
+ expect(list.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere Account' });
367
+ expect(list.apps.map((a) => [a.slug, a.current, a.connected])).toEqual([
368
+ ['finance', false, true],
369
+ ['yourtool', true, true]
370
+ ]);
371
+ expect(list.apps[0]!.descriptor).toEqual(DESCRIPTOR);
372
+ expect(list.organization).toEqual({ name: 'Acme', status: 'active' });
373
+ });
374
+
375
+ it('serves a suspended organization as an empty list that says so, never as the tools, and holds that answer', async () => {
376
+ const hub = fakeHub({ orgStatus: 'suspended' });
377
+ const service = cloud({ fetchImpl: hub.impl });
378
+ const list = await service.list(principal);
379
+ expect(list).toEqual({
380
+ apps: [],
381
+ hub: { url: 'https://account.example.com/', name: 'Antasphere Account' },
382
+ source: 'hub',
383
+ organization: { name: 'Acme', status: 'suspended' }
384
+ });
385
+ await service.list(principal);
386
+ expect(hub.calls).toHaveLength(1);
387
+ });
388
+
389
+ it('a suspended organization empties the list even when the body carries no tools', async () => {
390
+ const body = { organization: { id: ORG, name: 'Acme', status: 'suspended' } };
391
+ const list = await cloud({ fetchImpl: fakeHub({ body }).impl }).list(principal);
392
+ expect(list.source).toBe('hub');
393
+ expect(list.apps).toEqual([]);
394
+ expect(list.organization).toEqual({ name: 'Acme', status: 'suspended' });
395
+ });
396
+
397
+ it('an older hub naming no organization status is read as active', async () => {
398
+ const body = {
399
+ organization: { id: ORG, name: 'Acme' },
400
+ tools: [
401
+ {
402
+ slug: 'finance',
403
+ name: 'Finance',
404
+ launchUrl: 'https://finance.example.com',
405
+ connected: false,
406
+ open: true
407
+ }
408
+ ]
409
+ };
410
+ const list = await cloud({ fetchImpl: fakeHub({ body }).impl }).list(principal);
411
+ expect(list.apps.map((a) => a.slug)).toEqual(['finance']);
412
+ expect(list.organization).toBeUndefined();
413
+ });
414
+
415
+ it('refreshes the grant once on a 401 and retries', async () => {
416
+ const hub = fakeHub({ token: 'tok-2' });
417
+ const tokens = fakeTokens();
418
+ const list = await cloud({ fetchImpl: hub.impl, tokens }).list(principal);
419
+ expect(tokens.invalidateAccess).toHaveBeenCalledWith('usr_1');
420
+ expect(tokens.refresh).toHaveBeenCalledTimes(1);
421
+ expect(hub.calls).toHaveLength(2);
422
+ expect(list.source).toBe('hub');
423
+ });
424
+
425
+ it('holds the answer five minutes per person and workspace', async () => {
426
+ const hub = fakeHub();
427
+ let now = 1_000_000;
428
+ const service = cloud({ fetchImpl: hub.impl, now: () => now });
429
+ await service.list(principal);
430
+ await service.list(principal);
431
+ expect(hub.calls).toHaveLength(1);
432
+ await service.list({ userId: 'usr_2', workspaceId: 'ws_1' });
433
+ expect(hub.calls).toHaveLength(2);
434
+ // the same person in another workspace is another organization: asked again
435
+ await service.list({ userId: 'usr_1', workspaceId: 'ws_2' });
436
+ expect(hub.calls).toHaveLength(3);
437
+ now += APPS_CACHE_TTL_MS;
438
+ await service.list(principal);
439
+ expect(hub.calls).toHaveLength(4);
440
+ });
441
+
442
+ it('falls back to the file when the grant is dead, the hub fails or answers another shape, or the workspace is no hub organization', async () => {
443
+ const file = writeFile([{ slug: 'finance', name: 'F', launchUrl: 'https://f.example.com' }]);
444
+ const dead = await cloud({ appsFile: file, tokens: fakeTokens({ kind: 'grant_dead' }) }).list(principal);
445
+ expect(dead.source).toBe('file');
446
+ const down = await cloud({ appsFile: file, fetchImpl: fakeHub({ status: 503 }).impl }).list(principal);
447
+ expect(down.source).toBe('file');
448
+ const odd = await cloud({ appsFile: file, fetchImpl: fakeHub({ body: { orgs: [] } }).impl }).list(
449
+ principal
450
+ );
451
+ expect(odd.source).toBe('file');
452
+ const local = await cloud({ appsFile: file, db: fakeDb(null), fetchImpl: fakeHub().impl }).list(
453
+ principal
454
+ );
455
+ expect(local.source).toBe('file');
456
+ const noFile = await cloud({ fetchImpl: fakeHub({ status: 503 }).impl }).list(principal);
457
+ expect(noFile.source).toBe('none');
458
+ expect(noFile.hub).toEqual({ url: 'https://account.example.com/', name: 'Antasphere Account' });
459
+ });
460
+
461
+ it('does not hold a failed answer', async () => {
462
+ const hub = fakeHub({ status: 503 });
463
+ const service = cloud({ fetchImpl: hub.impl });
464
+ await service.list(principal);
465
+ await service.list(principal);
466
+ expect(hub.calls).toHaveLength(2);
467
+ });
468
+ });
469
+
470
+ describe('AppsService on a self-hosted instance', () => {
471
+ const oss = (appsFile?: string) =>
472
+ new AppsService({
473
+ db: fakeDb(null),
474
+ logger,
475
+ edition: 'oss',
476
+ appsFile,
477
+ selfSlug: 'yourtool',
478
+ tokens: null
479
+ });
480
+
481
+ it('reads the file, the account from its hub entry, never the hub', async () => {
482
+ const file = writeFile([
483
+ { slug: 'hub', name: 'Our account', launchUrl: 'https://account.example.com' },
484
+ { slug: 'finance', name: 'Finance', launchUrl: 'https://f.example.com', icon: 'landmark' },
485
+ { slug: 'yourtool', name: 'Your Tool', launchUrl: 'https://y.example.com' }
486
+ ]);
487
+ const list = await oss(file).list(principal);
488
+ expect(list.source).toBe('file');
489
+ expect(list.hub).toEqual({ url: 'https://account.example.com/', name: 'Our account' });
490
+ expect(list.apps.map((a) => [a.slug, a.current])).toEqual([
491
+ ['finance', false],
492
+ ['yourtool', true]
493
+ ]);
494
+ expect(list.apps[0]!.descriptor).toEqual({ icon: 'landmark' });
495
+ });
496
+
497
+ it('lists nothing without a file, or with one that is not JSON', async () => {
498
+ expect(await oss().list(principal)).toEqual({ apps: [], hub: null, source: 'none' });
499
+ const dir = mkdtempSync(join(tmpdir(), 'apps-'));
500
+ const path = join(dir, 'apps.json');
501
+ writeFileSync(path, '{not json');
502
+ expect((await oss(path).list(principal)).source).toBe('none');
503
+ });
504
+ });
505
+
506
+ // ── The handler ──────────────────────────────────────────────────────────────
507
+
508
+ describe('the GET /apps handler', () => {
509
+ /** The route alone, behind a principal the test sets: the allowlist that refuses machines first is not mounted here. */
510
+ function routeAs(via: Principal['via']) {
511
+ const api = new OpenAPIHono();
512
+ api.use('*', async (c, next) => {
513
+ c.set('principal', {
514
+ ...principal,
515
+ email: 'p@x.test',
516
+ name: 'P',
517
+ role: 'member',
518
+ origin: 'local',
519
+ via
520
+ } as Principal);
521
+ await next();
522
+ });
523
+ const service = { list: vi.fn(async () => ({ apps: [], hub: null, source: 'none' as const })) };
524
+ registerAppsRoutes(api, service as unknown as AppsService);
525
+ return { api, service };
526
+ }
527
+
528
+ it('answers a browser session', async () => {
529
+ const { api, service } = routeAs('session');
530
+ const res = await api.request('/apps');
531
+ expect(res.status).toBe(200);
532
+ expect(service.list).toHaveBeenCalledWith(expect.objectContaining(principal));
533
+ });
534
+
535
+ it('refuses anything that is not a session itself, 403 session_required, whatever an allowlist would say', async () => {
536
+ for (const via of ['api_key', 'oauth'] as const) {
537
+ const { api, service } = routeAs(via);
538
+ const res = await api.request('/apps');
539
+ expect(res.status).toBe(403);
540
+ expect(((await res.json()) as { error: { code: string } }).error.code).toBe('session_required');
541
+ expect(service.list).not.toHaveBeenCalled();
542
+ }
543
+ });
544
+ });
@@ -0,0 +1,71 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { meAppsSchema } from '@antasphere/chassis-contract';
3
+ import { FakeHub, type HubUserFixture } from '../../src/testing/fake-hub.js';
4
+
5
+ /**
6
+ * The fake hub's `GET /me/apps` answers the hub's published shape and
7
+ * nothing else (PRDCT-3493): a conforming catalogue parses with
8
+ * `meAppsSchema`, and a catalogue the fake was handed off the contract is
9
+ * refused as a 500 naming the drift, never served as if the real hub would.
10
+ */
11
+
12
+ const ORG = '11111111-2222-4333-8444-555555555555';
13
+ const mira: HubUserFixture = {
14
+ sub: 'hub-user-mira',
15
+ email: 'mira@fake.test',
16
+ name: 'Mira Lund',
17
+ workspaceId: ORG,
18
+ role: 'member',
19
+ workspaceName: 'Acme'
20
+ };
21
+
22
+ describe('FakeHub GET /me/apps', () => {
23
+ let hub: FakeHub;
24
+ beforeAll(async () => {
25
+ hub = await FakeHub.start({ clientId: 'tool-test' });
26
+ // a signed token seeds no organization: the registry says who belongs where
27
+ hub.setUserOrg(mira.sub, ORG, { name: 'Acme', role: 'member' });
28
+ });
29
+ afterAll(async () => {
30
+ await hub?.stop();
31
+ });
32
+
33
+ async function read() {
34
+ const token = await hub.signAccessToken(mira, hub.apiResource);
35
+ return fetch(`${hub.issuer}/api/v1/me/apps`, {
36
+ headers: { authorization: `Bearer ${token}`, 'x-workspace-id': ORG }
37
+ });
38
+ }
39
+
40
+ it('answers the contract shape, the organization with its status', async () => {
41
+ hub.setTools([
42
+ {
43
+ slug: 'finance',
44
+ name: 'Finance',
45
+ launchUrl: 'https://finance.example.com',
46
+ descriptor: { icon: 'landmark' }
47
+ }
48
+ ]);
49
+ const res = await read();
50
+ expect(res.status).toBe(200);
51
+ const body = (await res.json()) as { organization: unknown };
52
+ expect(meAppsSchema.safeParse(body).success).toBe(true);
53
+ expect(body.organization).toEqual({ id: ORG, name: 'Acme', status: 'active' });
54
+ });
55
+
56
+ it('refuses its own drift as a 500 naming it, never as an answer', async () => {
57
+ hub.setTools([
58
+ {
59
+ slug: 'finance',
60
+ name: 'Finance',
61
+ launchUrl: 'https://finance.example.com',
62
+ descriptor: { icon: 'skull' }
63
+ }
64
+ ]);
65
+ const res = await read();
66
+ expect(res.status).toBe(500);
67
+ const body = (await res.json()) as { error: { code: string; message: string } };
68
+ expect(body.error.code).toBe('fake_hub_drift');
69
+ expect(body.error.message).toContain('off the hub contract');
70
+ });
71
+ });