@antasphere/chassis-cli 1.0.2 → 1.1.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.
@@ -0,0 +1,588 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import {
4
+ configPath as coreConfigPath,
5
+ HUB_TOOL,
6
+ loadConfig as loadCoreConfig,
7
+ saveConfig as saveCoreConfig
8
+ } from '@antasphere/cli-core';
9
+ import { routedHarness, tempConfigEnv, type Route } from '@antasphere/chassis-cli/testing';
10
+ import { cli, run } from '@chassis-cli-test/host';
11
+ import {
12
+ cloudInstanceRoute,
13
+ exchangeRoutes,
14
+ HUB,
15
+ HUB_KEY,
16
+ key,
17
+ meBody,
18
+ meRoute,
19
+ ORG,
20
+ otpRoutes
21
+ } from './signin-fixtures.js';
22
+
23
+ /**
24
+ * The hub profile of a run is the one `ANTASPHERE_URL` names (PRDCT-3392 and
25
+ * PRDCT-3046 on the chassis, cli-core 0.6.0 `selectHubProfile`): the
26
+ * exchange runs at that hub with that hub's key, its key is cached under
27
+ * that profile's slot, and the cache is read from the same slot. The active
28
+ * hub profile's key never travels when the variable names another hub. A
29
+ * hub profile with no `baseUrl` of its own is at the default hub (the
30
+ * chassis passes `hubCloudUrl`, verifier round 1, F4).
31
+ */
32
+
33
+ const { loadConfig, saveConfig } = cli;
34
+ const { bin, displayName } = cli.identity;
35
+ const DEFAULT_HUB_URL = 'https://account.antasphere.com';
36
+ const CLOUD_HUB_KEY = 'ant_cloudk12_secretsecretsecret1234';
37
+
38
+ /** Two hub logins on this machine: `cloud` (active) at the default hub, `team` at `HUB`. */
39
+ function seedTwoHubs(env: Record<string, string>): void {
40
+ saveCoreConfig(env, HUB_TOOL, {
41
+ activeProfile: 'cloud',
42
+ profiles: {
43
+ cloud: { apiKey: CLOUD_HUB_KEY, baseUrl: DEFAULT_HUB_URL, email: 'ada@x.co' },
44
+ team: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' }
45
+ }
46
+ });
47
+ }
48
+
49
+ const trace = (h: ReturnType<typeof routedHarness>) =>
50
+ h.wire.map((c) => `${c.method} ${c.origin}${c.path} ${c.auth ?? '-'}`);
51
+
52
+ /** The same routes, every answer that names a user naming `email` instead. */
53
+ function answeringAs(routes: Route[], email: string, name = 'Ada'): Route[] {
54
+ return routes.map((r) => ({
55
+ ...r,
56
+ reply: (call: Parameters<Route['reply']>[0]) => {
57
+ const answer = r.reply(call);
58
+ const body = answer.body as Record<string, unknown> | undefined;
59
+ return body && 'user' in body
60
+ ? { ...answer, body: { ...body, user: { ...(body.user as object), email, name } } }
61
+ : answer;
62
+ }
63
+ }));
64
+ }
65
+
66
+ const revokeRoute: Route = {
67
+ method: 'DELETE',
68
+ path: /\/api\/v1\/cli\/auth\/key$/,
69
+ reply: () => ({ body: { revoked: true, id: 'k' } })
70
+ };
71
+
72
+ describe('the hub profile ANTASPHERE_URL names (PRDCT-3392)', () => {
73
+ it.each(['http://hub', 'http://hub/', 'HTTP://HUB'])(
74
+ 'ANTASPHERE_URL=%s: login exchanges at that hub with its key into its slot, and the next command is served from that slot',
75
+ async (spelling) => {
76
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: spelling };
77
+ seedTwoHubs(env);
78
+ const minted = key('team');
79
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
80
+ const asked: string[] = [];
81
+ h.io.prompt = async (q) => {
82
+ asked.push(q);
83
+ return '';
84
+ };
85
+ expect(await run(['login', '--api-url', 'http://tool'], h.io)).toBe(0);
86
+ expect(asked).toEqual([]);
87
+ expect(h.wire.some((c) => c.path.startsWith('/api/v1/cli/auth/'))).toBe(false);
88
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/tool-token'))).toEqual([
89
+ expect.objectContaining({ origin: HUB, auth: `Bearer ${HUB_KEY}` })
90
+ ]);
91
+ expect(h.wire.some((c) => c.auth === `Bearer ${CLOUD_HUB_KEY}`)).toBe(false);
92
+ const connectKeys = loadConfig(env).profiles.tool?.connectKeys ?? {};
93
+ expect(Object.keys(connectKeys)).toEqual(['team']);
94
+ expect(connectKeys.team).toMatchObject({ apiKey: minted, email: 'ada@x.co' });
95
+ // Nothing of the hub's file moved: `cloud` stays the active hub login.
96
+ expect(loadCoreConfig(env, HUB_TOOL).activeProfile).toBe('cloud');
97
+
98
+ const next = routedHarness([meRoute()], env);
99
+ expect(await run(['whoami', '--json'], next.io)).toBe(0);
100
+ expect(trace(next)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
101
+ expect(JSON.parse(next.out())).toMatchObject({
102
+ credentialSource: 'hub-cache',
103
+ profile: 'tool',
104
+ hubLogin: { profile: 'team', email: 'ada@x.co' }
105
+ });
106
+ }
107
+ );
108
+
109
+ it('ANTASPHERE_URL naming a hub with no profile on disk: login signs in inline on THAT hub, writes the profile named for its host, then exchanges there', async () => {
110
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: HUB };
111
+ saveCoreConfig(env, HUB_TOOL, {
112
+ activeProfile: 'cloud',
113
+ profiles: { cloud: { apiKey: CLOUD_HUB_KEY, baseUrl: DEFAULT_HUB_URL, email: 'ada@x.co' } }
114
+ });
115
+ const minted = key('hub');
116
+ const h = routedHarness(
117
+ [cloudInstanceRoute, ...otpRoutes(HUB_KEY, ORG), ...exchangeRoutes([minted]), meRoute()],
118
+ env
119
+ );
120
+ h.io.prompt = async () => '123456';
121
+ expect(await run(['login', '--api-url', 'http://tool', '--email', 'ada@x.co'], h.io)).toBe(0);
122
+ expect(trace(h)).toEqual([
123
+ 'GET http://tool/api/v1/instance -',
124
+ `POST ${HUB}/api/v1/cli/auth/request -`,
125
+ `POST ${HUB}/api/v1/cli/auth/complete -`,
126
+ 'GET http://tool/api/v1/instance -',
127
+ `POST ${HUB}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
128
+ 'POST http://tool/api/v1/sso/cli-connect -',
129
+ `GET http://tool/api/v1/me Bearer ${minted}`
130
+ ]);
131
+ const hubConfig = loadCoreConfig(env, HUB_TOOL);
132
+ expect(hubConfig.activeProfile).toBe('hub');
133
+ expect(hubConfig.profiles.hub).toMatchObject({ apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' });
134
+ expect(hubConfig.profiles.cloud).toEqual({
135
+ apiKey: CLOUD_HUB_KEY,
136
+ baseUrl: DEFAULT_HUB_URL,
137
+ email: 'ada@x.co'
138
+ });
139
+ expect(Object.keys(loadConfig(env).profiles.tool?.connectKeys ?? {})).toEqual(['hub']);
140
+ });
141
+ });
142
+
143
+ describe('a hub profile with no baseUrl is at the default hub (verifier round 1, F4)', () => {
144
+ /** A pre-`baseUrl` hub login, active, and ANTASPHERE_URL naming the default hub. */
145
+ async function legacyEnv(): Promise<Record<string, string>> {
146
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: DEFAULT_HUB_URL };
147
+ saveCoreConfig(env, HUB_TOOL, {
148
+ activeProfile: 'default',
149
+ profiles: { default: { apiKey: HUB_KEY, email: 'ada@x.co' } }
150
+ });
151
+ return env;
152
+ }
153
+
154
+ it('a key cached under that profile is served from the cache: zero hub calls', async () => {
155
+ const env = await legacyEnv();
156
+ const cached = key('cached');
157
+ saveConfig(env, {
158
+ activeProfile: 'work',
159
+ profiles: {
160
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: cached, email: 'ada@x.co' } } }
161
+ }
162
+ });
163
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([key('new')]), meRoute()], env);
164
+ expect(await run(['whoami', '--json'], h.io)).toBe(0);
165
+ expect(trace(h)).toEqual([`GET http://tool/api/v1/me Bearer ${cached}`]);
166
+ expect(JSON.parse(h.out())).toMatchObject({
167
+ credentialSource: 'hub-cache',
168
+ hubLogin: { profile: 'default', email: 'ada@x.co' }
169
+ });
170
+ });
171
+
172
+ it('a 401 on that cached key: evicted from the slot it was read from, a fresh exchange at the default hub, the replay with the new key under the same slot', async () => {
173
+ const env = await legacyEnv();
174
+ const cached = key('cached');
175
+ const minted = key('new');
176
+ saveConfig(env, {
177
+ activeProfile: 'work',
178
+ profiles: {
179
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: cached, email: 'ada@x.co' } } }
180
+ }
181
+ });
182
+ const me: Route = {
183
+ method: 'GET',
184
+ path: /\/api\/v1\/me$/,
185
+ reply: ({ headers }) =>
186
+ headers.get('authorization') === `Bearer ${minted}`
187
+ ? { body: meBody() }
188
+ : { status: 401, body: { error: { code: 'invalid_api_key', message: 'API key not recognized' } } }
189
+ };
190
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), me], env);
191
+ expect(await run(['whoami'], h.io)).toBe(0);
192
+ expect(trace(h)).toEqual([
193
+ `GET http://tool/api/v1/me Bearer ${cached}`,
194
+ 'GET http://tool/api/v1/instance -',
195
+ `POST ${new URL(DEFAULT_HUB_URL).origin}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
196
+ 'POST http://tool/api/v1/sso/cli-connect -',
197
+ `GET http://tool/api/v1/me Bearer ${minted}`
198
+ ]);
199
+ const connectKeys = loadConfig(env).profiles.work?.connectKeys ?? {};
200
+ expect(Object.keys(connectKeys)).toEqual(['default']);
201
+ expect(connectKeys.default).toMatchObject({ apiKey: minted, email: 'ada@x.co' });
202
+ expect(JSON.stringify(loadConfig(env))).not.toContain(cached);
203
+ });
204
+
205
+ it('without a cached key it exchanges at the default hub with its own key, and the connect key lands under its slot', async () => {
206
+ const env = await legacyEnv();
207
+ const minted = key('new');
208
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
209
+ expect(await run(['whoami', '--api-url', 'http://tool'], h.io)).toBe(0);
210
+ expect(trace(h)).toEqual([
211
+ 'GET http://tool/api/v1/instance -',
212
+ `POST ${new URL(DEFAULT_HUB_URL).origin}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
213
+ 'POST http://tool/api/v1/sso/cli-connect -',
214
+ `GET http://tool/api/v1/me Bearer ${minted}`
215
+ ]);
216
+ const connectKeys = loadConfig(env).profiles.tool?.connectKeys ?? {};
217
+ expect(Object.keys(connectKeys)).toEqual(['default']);
218
+ expect(connectKeys.default).toMatchObject({ apiKey: minted, email: 'ada@x.co' });
219
+ // The profile carried its address: nothing of the hub's file moves.
220
+ expect(loadCoreConfig(env, HUB_TOOL)).toEqual({
221
+ activeProfile: 'default',
222
+ profiles: { default: { apiKey: HUB_KEY, email: 'ada@x.co' } }
223
+ });
224
+ });
225
+
226
+ it('login finds that stored login: no email code is asked, the exchange runs at the default hub with its key', async () => {
227
+ const env = await legacyEnv();
228
+ const minted = key('new');
229
+ const h = routedHarness(
230
+ [cloudInstanceRoute, ...otpRoutes(key('otp')), ...exchangeRoutes([minted]), meRoute()],
231
+ env
232
+ );
233
+ const asked: string[] = [];
234
+ h.io.prompt = async (q) => {
235
+ asked.push(q);
236
+ return '';
237
+ };
238
+ expect(await run(['login', '--api-url', 'http://tool'], h.io)).toBe(0);
239
+ expect(asked).toEqual([]);
240
+ expect(h.wire.some((c) => c.path === '/api/v1/cli/auth/request')).toBe(false);
241
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/tool-token'))).toEqual([
242
+ expect.objectContaining({ origin: new URL(DEFAULT_HUB_URL).origin, auth: `Bearer ${HUB_KEY}` })
243
+ ]);
244
+ expect(Object.keys(loadConfig(env).profiles.tool?.connectKeys ?? {})).toEqual(['default']);
245
+ });
246
+
247
+ it('logout --all revokes that login at the default hub and forgets it', async () => {
248
+ const env = await legacyEnv();
249
+ const toolKey = key('cached');
250
+ saveConfig(env, {
251
+ activeProfile: 'work',
252
+ profiles: {
253
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: toolKey, email: 'ada@x.co' } } }
254
+ }
255
+ });
256
+ const h = routedHarness([revokeRoute], env);
257
+ expect(await run(['logout', '--all'], h.io)).toBe(0);
258
+ expect(trace(h)).toEqual([
259
+ `DELETE http://tool/api/v1/cli/auth/key Bearer ${toolKey}`,
260
+ `DELETE ${new URL(DEFAULT_HUB_URL).origin}/api/v1/cli/auth/key Bearer ${HUB_KEY}`
261
+ ]);
262
+ expect(h.out()).toContain('The Antasphere login ada@x.co is revoked and forgotten.');
263
+ expect(loadCoreConfig(env, HUB_TOOL).profiles).toEqual({});
264
+ });
265
+
266
+ it('plain logout with no profile of the tool names the two commands that end that login, nothing sent', async () => {
267
+ const env = await legacyEnv();
268
+ const h = routedHarness([revokeRoute], env);
269
+ expect(await run(['logout'], h.io)).toBe(1);
270
+ expect(h.err()).toBe(
271
+ `Error: Nothing of ${displayName}'s to sign out of on this machine. The Antasphere login ada@x.co ` +
272
+ `stays for the whole tool family: \`${bin} logout --all\` or \`antasphere logout\` ends it.\n`
273
+ );
274
+ expect(h.wire).toEqual([]);
275
+ });
276
+ });
277
+
278
+ describe('logout --all ends the hub login ANTASPHERE_URL names', () => {
279
+ it('under ANTASPHERE_URL=http://hub: the `team` login is revoked on its hub and forgotten, `cloud` and its key stay', async () => {
280
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: HUB };
281
+ seedTwoHubs(env);
282
+ const toolKey = key('team');
283
+ saveConfig(env, {
284
+ activeProfile: 'tool',
285
+ profiles: {
286
+ tool: { baseUrl: 'http://tool', connectKeys: { team: { apiKey: toolKey, email: 'ada@x.co' } } }
287
+ }
288
+ });
289
+ const h = routedHarness([revokeRoute], env);
290
+ expect(await run(['logout', '--all'], h.io)).toBe(0);
291
+ expect(trace(h)).toEqual([
292
+ `DELETE http://tool/api/v1/cli/auth/key Bearer ${toolKey}`,
293
+ `DELETE ${HUB}/api/v1/cli/auth/key Bearer ${HUB_KEY}`
294
+ ]);
295
+ expect(h.wire.some((c) => c.auth === `Bearer ${CLOUD_HUB_KEY}`)).toBe(false);
296
+ expect(h.out()).toContain('The Antasphere login ada@x.co is revoked and forgotten.');
297
+ expect(loadCoreConfig(env, HUB_TOOL)).toEqual({
298
+ activeProfile: 'cloud',
299
+ profiles: { cloud: { apiKey: CLOUD_HUB_KEY, baseUrl: DEFAULT_HUB_URL, email: 'ada@x.co' } }
300
+ });
301
+ expect(loadConfig(env).profiles.tool?.connectKeys ?? {}).toEqual({});
302
+ });
303
+ });
304
+
305
+ describe('logout --all revokes the hub key at the hub', () => {
306
+ it('a hub profile with no baseUrl: the hub key is revoked at the default hub, and no request carrying it reaches the tool', async () => {
307
+ const env = await tempConfigEnv();
308
+ saveCoreConfig(env, HUB_TOOL, {
309
+ activeProfile: 'default',
310
+ profiles: { default: { apiKey: HUB_KEY, email: 'ada@x.co' } }
311
+ });
312
+ const toolKey = key('cached');
313
+ saveConfig(env, {
314
+ activeProfile: 'work',
315
+ profiles: {
316
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: toolKey, email: 'ada@x.co' } } }
317
+ }
318
+ });
319
+ const h = routedHarness([revokeRoute], env);
320
+ expect(await run(['logout', '--all'], h.io)).toBe(0);
321
+ expect(trace(h)).toEqual([
322
+ `DELETE http://tool/api/v1/cli/auth/key Bearer ${toolKey}`,
323
+ `DELETE ${new URL(DEFAULT_HUB_URL).origin}/api/v1/cli/auth/key Bearer ${HUB_KEY}`
324
+ ]);
325
+ expect(h.wire.filter((c) => c.auth === `Bearer ${HUB_KEY}`).map((c) => c.origin)).toEqual([
326
+ new URL(DEFAULT_HUB_URL).origin
327
+ ]);
328
+ expect(h.wire.some((c) => c.origin !== 'http://tool' && c.auth === `Bearer ${toolKey}`)).toBe(false);
329
+ expect(h.wire.some((c) => c.origin === new URL(cli.identity.cloudUrl).origin)).toBe(false);
330
+ expect(loadCoreConfig(env, HUB_TOOL).profiles).toEqual({});
331
+ });
332
+ });
333
+
334
+ describe('a hub profile without its address is healed by the first fresh exchange', () => {
335
+ it('login on a hub profile with no email: one exchange, the address the hub answered written onto that profile only, the next command served from the cache', async () => {
336
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: HUB };
337
+ saveCoreConfig(env, HUB_TOOL, {
338
+ activeProfile: 'cloud',
339
+ profiles: {
340
+ cloud: { apiKey: CLOUD_HUB_KEY, baseUrl: DEFAULT_HUB_URL },
341
+ team: { apiKey: HUB_KEY, baseUrl: HUB }
342
+ }
343
+ });
344
+ const minted = key('team');
345
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
346
+ expect(await run(['login', '--api-url', 'http://tool'], h.io)).toBe(0);
347
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/cli-connect'))).toHaveLength(1);
348
+ // An absent address is filled in silently.
349
+ expect(h.err()).not.toContain("The Antasphere login's address is now");
350
+ expect(loadCoreConfig(env, HUB_TOOL)).toEqual({
351
+ activeProfile: 'cloud',
352
+ profiles: {
353
+ cloud: { apiKey: CLOUD_HUB_KEY, baseUrl: DEFAULT_HUB_URL },
354
+ team: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' }
355
+ }
356
+ });
357
+
358
+ const next = routedHarness([cloudInstanceRoute, ...exchangeRoutes([key('again')]), meRoute()], env);
359
+ expect(await run(['whoami'], next.io)).toBe(0);
360
+ expect(trace(next)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
361
+ expect(next.err()).toBe('');
362
+ });
363
+
364
+ it("a stale address (the account changed it at the hub): one mint, the profile at the hub's answer, said on stderr, the next run served from the cache with no DELETE", async () => {
365
+ const env = await tempConfigEnv();
366
+ saveCoreConfig(env, HUB_TOOL, {
367
+ activeProfile: 'default',
368
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' } }
369
+ });
370
+ const minted = key('new');
371
+ const routes = answeringAs([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], 'ada@new.co');
372
+ const h = routedHarness(routes, env);
373
+ expect(await run(['whoami', '--api-url', 'http://tool'], h.io)).toBe(0);
374
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/cli-connect'))).toHaveLength(1);
375
+ expect(h.err()).toContain(
376
+ "The Antasphere login's address is now ada@new.co, replaced from ada@x.co (the hub's own answer).\n"
377
+ );
378
+ expect(loadCoreConfig(env, HUB_TOOL)).toEqual({
379
+ activeProfile: 'default',
380
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@new.co' } }
381
+ });
382
+ expect(loadConfig(env).profiles.tool?.connectKeys?.default).toMatchObject({
383
+ apiKey: minted,
384
+ email: 'ada@new.co'
385
+ });
386
+
387
+ for (let i = 0; i < 2; i++) {
388
+ const next = routedHarness(
389
+ answeringAs([cloudInstanceRoute, ...exchangeRoutes([key('again')]), meRoute()], 'ada@new.co'),
390
+ env
391
+ );
392
+ expect(await run(['whoami'], next.io)).toBe(0);
393
+ expect(trace(next)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
394
+ expect(next.err()).toBe('');
395
+ }
396
+ });
397
+
398
+ it('a blank address is healed like an absent one, silently, and no sentence carries the blank', async () => {
399
+ const env = await tempConfigEnv();
400
+ saveCoreConfig(env, HUB_TOOL, {
401
+ activeProfile: 'default',
402
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: ' ' } }
403
+ });
404
+ const old = key('old');
405
+ saveConfig(env, {
406
+ activeProfile: 'work',
407
+ profiles: {
408
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: old, email: 'ada@x.co' } } }
409
+ }
410
+ });
411
+ const minted = key('new');
412
+ const h = routedHarness([revokeRoute, cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
413
+ expect(await run(['whoami'], h.io)).toBe(0);
414
+ expect(trace(h)).toEqual([
415
+ `DELETE http://tool/api/v1/cli/auth/key Bearer ${old}`,
416
+ 'GET http://tool/api/v1/instance -',
417
+ `POST ${HUB}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
418
+ 'POST http://tool/api/v1/sso/cli-connect -',
419
+ `GET http://tool/api/v1/me Bearer ${minted}`
420
+ ]);
421
+ expect(h.err()).toContain(
422
+ `The ${displayName} key cached for ada@x.co is retired: the Antasphere login carries no address to match it with.\n`
423
+ );
424
+ expect(h.err()).not.toMatch(/ {2}/);
425
+ expect(h.err()).not.toContain("The Antasphere login's address is now");
426
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default).toEqual({
427
+ apiKey: HUB_KEY,
428
+ baseUrl: HUB,
429
+ email: 'ada@x.co'
430
+ });
431
+
432
+ const next = routedHarness([cloudInstanceRoute, ...exchangeRoutes([key('again')]), meRoute()], env);
433
+ expect(await run(['whoami'], next.io)).toBe(0);
434
+ expect(trace(next)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
435
+ });
436
+ it('the address changed at the hub under a live cached key: the recovery mints once and heals, the next run is served from the cache with no DELETE (verifier round 2, F1)', async () => {
437
+ const env = await tempConfigEnv();
438
+ saveCoreConfig(env, HUB_TOOL, {
439
+ activeProfile: 'default',
440
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' } }
441
+ });
442
+ const live = key('live');
443
+ saveConfig(env, {
444
+ activeProfile: 'work',
445
+ profiles: {
446
+ work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: live, email: 'ada@x.co' } } }
447
+ }
448
+ });
449
+ const minted = key('new');
450
+ const me: Route = {
451
+ method: 'GET',
452
+ path: /\/api\/v1\/me$/,
453
+ reply: ({ headers }) =>
454
+ headers.get('authorization') === `Bearer ${minted}`
455
+ ? { body: meBody() }
456
+ : { status: 401, body: { error: { code: 'invalid_api_key', message: 'API key not recognized' } } }
457
+ };
458
+ const routes = [
459
+ revokeRoute,
460
+ me,
461
+ ...answeringAs([cloudInstanceRoute, ...exchangeRoutes([minted])], 'ada@new.co')
462
+ ];
463
+
464
+ const a = routedHarness(routes, env);
465
+ expect(await run(['whoami'], a.io)).toBe(0);
466
+ expect(trace(a)).toEqual([
467
+ `GET http://tool/api/v1/me Bearer ${live}`,
468
+ 'GET http://tool/api/v1/instance -',
469
+ `POST ${HUB}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
470
+ 'POST http://tool/api/v1/sso/cli-connect -',
471
+ `GET http://tool/api/v1/me Bearer ${minted}`
472
+ ]);
473
+ expect(a.err()).toContain('The cached key was refused; signed in again through Antasphere.\n');
474
+ expect(a.err()).toContain(
475
+ "The Antasphere login's address is now ada@new.co, replaced from ada@x.co (the hub's own answer).\n"
476
+ );
477
+ expect(a.err()).not.toContain('is retired');
478
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default?.email).toBe('ada@new.co');
479
+
480
+ const b = routedHarness(routes, env);
481
+ expect(await run(['whoami'], b.io)).toBe(0);
482
+ expect(trace(b)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
483
+ expect(b.wire.some((c) => c.method === 'DELETE')).toBe(false);
484
+ expect(b.err()).toBe('');
485
+ });
486
+
487
+ it('a stored address in another case is the same address: one fresh exchange writes nothing, says nothing, and the next run is served (verifier round 2, F2)', async () => {
488
+ const env = await tempConfigEnv();
489
+ saveCoreConfig(env, HUB_TOOL, {
490
+ activeProfile: 'default',
491
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ADA@X.CO' } }
492
+ });
493
+ const hubFile = coreConfigPath(env, HUB_TOOL)!;
494
+ const before = readFileSync(hubFile, 'utf8');
495
+ const minted = key('new');
496
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([minted]), meRoute()], env);
497
+ expect(await run(['whoami', '--api-url', 'http://tool'], h.io)).toBe(0);
498
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/cli-connect'))).toHaveLength(1);
499
+ expect(readFileSync(hubFile, 'utf8')).toBe(before);
500
+ expect(h.err()).not.toContain("The Antasphere login's address is now");
501
+
502
+ const next = routedHarness([cloudInstanceRoute, ...exchangeRoutes([key('again')]), meRoute()], env);
503
+ expect(await run(['whoami'], next.io)).toBe(0);
504
+ expect(trace(next)).toEqual([`GET http://tool/api/v1/me Bearer ${minted}`]);
505
+ expect(next.err()).toBe('');
506
+ });
507
+
508
+ it('a blank answered address is never written: the hub profile unchanged, no line, the entry stored as cli-core stores it (verifier round 2, F3)', async () => {
509
+ const env = await tempConfigEnv();
510
+ saveCoreConfig(env, HUB_TOOL, {
511
+ activeProfile: 'default',
512
+ profiles: { default: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' } }
513
+ });
514
+ const hubFile = coreConfigPath(env, HUB_TOOL)!;
515
+ const before = readFileSync(hubFile, 'utf8');
516
+ const minted = key('new');
517
+ const h = routedHarness(
518
+ [meRoute(), ...answeringAs([cloudInstanceRoute, ...exchangeRoutes([minted])], '')],
519
+ env
520
+ );
521
+ expect(await run(['whoami', '--api-url', 'http://tool'], h.io)).toBe(0);
522
+ expect(readFileSync(hubFile, 'utf8')).toBe(before);
523
+ expect(h.err()).not.toContain("The Antasphere login's address is now");
524
+ expect(loadConfig(env).profiles.tool?.connectKeys?.default).toMatchObject({ apiKey: minted, email: '' });
525
+ });
526
+ });
527
+
528
+ describe('login --email on a stored login with no address is a switch (verifier round 1, F2)', () => {
529
+ it.each([
530
+ ['absent', {}],
531
+ ['blank', { email: ' ' }]
532
+ ] as const)(
533
+ "a hub key with its address %s and --email bob: bob signs in afresh, the exchange runs with bob's key, the profile and the output name bob",
534
+ async (_label, address) => {
535
+ const ADA_KEY = 'ant_adakey12_secretsecretsecret1234';
536
+ const BOB_KEY = 'ant_bobkey12_secretsecretsecret1234';
537
+ const env = await tempConfigEnv();
538
+ saveCoreConfig(env, HUB_TOOL, {
539
+ activeProfile: 'default',
540
+ profiles: { default: { apiKey: ADA_KEY, baseUrl: HUB, ...address } }
541
+ });
542
+ const minted = key('bob');
543
+ const h = routedHarness(
544
+ answeringAs(
545
+ [cloudInstanceRoute, ...otpRoutes(BOB_KEY, ORG), ...exchangeRoutes([minted]), meRoute()],
546
+ 'bob@x.co',
547
+ 'Bob'
548
+ ),
549
+ env
550
+ );
551
+ h.io.prompt = async () => '123456';
552
+ expect(await run(['login', '--api-url', 'http://tool', '--email', 'bob@x.co'], h.io)).toBe(0);
553
+ expect(h.wire.filter((c) => c.path === '/api/v1/cli/auth/request')).toEqual([
554
+ expect.objectContaining({ origin: HUB, body: { email: 'bob@x.co' } })
555
+ ]);
556
+ expect(h.wire.filter((c) => c.path.endsWith('/sso/tool-token'))).toEqual([
557
+ expect.objectContaining({ origin: HUB, auth: `Bearer ${BOB_KEY}` })
558
+ ]);
559
+ expect(h.wire.some((c) => c.auth === `Bearer ${ADA_KEY}`)).toBe(false);
560
+ expect(h.err()).toContain('Signing in as bob@x.co: the stored Antasphere login carries no address.\n');
561
+ expect(h.err()).not.toMatch(/ {2}/);
562
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default).toMatchObject({
563
+ apiKey: BOB_KEY,
564
+ baseUrl: HUB,
565
+ email: 'bob@x.co'
566
+ });
567
+ expect(h.out()).toContain('Signed in as Bob <bob@x.co>');
568
+ expect(h.out()).not.toContain('ada@x.co');
569
+ }
570
+ );
571
+ });
572
+
573
+ describe('profiles names why another hub login is not the one in use (verifier round 1, F6)', () => {
574
+ it('under ANTASPHERE_URL a slot of another hub login reads "not the one ANTASPHERE_URL names"', async () => {
575
+ const env: Record<string, string> = { ...(await tempConfigEnv()), ANTASPHERE_URL: HUB };
576
+ seedTwoHubs(env);
577
+ saveConfig(env, {
578
+ activeProfile: 'tool',
579
+ profiles: {
580
+ tool: { baseUrl: 'http://tool', connectKeys: { cloud: { apiKey: key('cloud'), email: 'ada@x.co' } } }
581
+ }
582
+ });
583
+ const h = routedHarness([], env);
584
+ expect(await run(['profiles'], h.io)).toBe(0);
585
+ expect(h.out()).toContain('via Antasphere (login "cloud", not the one ANTASPHERE_URL names)');
586
+ expect(h.out()).not.toContain('not the active one');
587
+ });
588
+ });
@@ -489,7 +489,9 @@ describe('login on the cloud: the Antasphere sign-in, then the exchange', () =>
489
489
  const minted = key('tool');
490
490
  saveConfig(env, {
491
491
  activeProfile: 'tool',
492
- profiles: { tool: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: live } } } }
492
+ profiles: {
493
+ tool: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: live, email: 'ada@x.co' } } }
494
+ }
493
495
  });
494
496
  const h = routedHarness(
495
497
  [
@@ -220,7 +220,12 @@ describe('which profile a command runs on', () => {
220
220
  seedHubLogin(env);
221
221
  saveConfig(env, {
222
222
  activeProfile: 'work',
223
- profiles: { work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: key('cached') } } } }
223
+ profiles: {
224
+ work: {
225
+ baseUrl: 'http://tool',
226
+ connectKeys: { default: { apiKey: key('cached'), email: 'ada@x.co' } }
227
+ }
228
+ }
224
229
  });
225
230
  const h = routedHarness([ossInstanceRoute, filesRoute], env);
226
231
  expect(await run(['files', 'list', '--profile', 'work', '--api-url', 'http://other'], h.io)).toBe(1);