@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.
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +17 -0
- package/dist/api/create-api.js.map +1 -1
- package/dist/apps/index.d.ts +11 -0
- package/dist/apps/index.d.ts.map +1 -0
- package/dist/apps/index.js +11 -0
- package/dist/apps/index.js.map +1 -0
- package/dist/apps/routes.d.ts +14 -0
- package/dist/apps/routes.d.ts.map +1 -0
- package/dist/apps/routes.js +23 -0
- package/dist/apps/routes.js.map +1 -0
- package/dist/apps/select.d.ts +64 -0
- package/dist/apps/select.d.ts.map +1 -0
- package/dist/apps/select.js +168 -0
- package/dist/apps/select.js.map +1 -0
- package/dist/apps/service.d.ts +74 -0
- package/dist/apps/service.d.ts.map +1 -0
- package/dist/apps/service.js +165 -0
- package/dist/apps/service.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -1
- package/dist/testing/fake-hub.d.ts +20 -0
- package/dist/testing/fake-hub.d.ts.map +1 -1
- package/dist/testing/fake-hub.js +73 -1
- package/dist/testing/fake-hub.js.map +1 -1
- package/package.json +7 -3
- package/src/api/create-api.ts +21 -0
- package/src/apps/index.ts +17 -0
- package/src/apps/routes.ts +30 -0
- package/src/apps/select.ts +175 -0
- package/src/apps/service.ts +198 -0
- package/src/env.ts +2 -0
- package/src/testing/fake-hub.ts +83 -1
- package/test/integration/apps-route.test.ts +239 -0
- package/test/unit/apps.test.ts +544 -0
- package/test/unit/fake-hub-me-apps.test.ts +71 -0
|
@@ -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
|
+
});
|