@antasphere/chassis-cli 0.0.0-stage → 1.0.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.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- package/README.md +0 -3
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { HUB_TOOL, saveConfig as saveCoreConfig } from '@antasphere/cli-core';
|
|
3
|
+
import { routedHarness, tempConfigEnv, type Route } from '@antasphere/chassis-cli/testing';
|
|
4
|
+
import { cli, run } from '@chassis-cli-test/host';
|
|
5
|
+
import {
|
|
6
|
+
cloudInstanceRoute,
|
|
7
|
+
exchangeRoutes,
|
|
8
|
+
HUB,
|
|
9
|
+
HUB_KEY,
|
|
10
|
+
key,
|
|
11
|
+
meBody,
|
|
12
|
+
ORG,
|
|
13
|
+
seedHubLogin,
|
|
14
|
+
TOOL_ACCESS_DENIED,
|
|
15
|
+
wsRow
|
|
16
|
+
} from './signin-fixtures.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A stranded cached key recovers ONCE (PRDCT-2947): a 401 answered to a key
|
|
20
|
+
* the connect cache served evicts it, runs the exchange again, and replays
|
|
21
|
+
* the same request with the new key. A second 401 stands; a direct key
|
|
22
|
+
* never recovers; a hub refusal on the way is its own sentence, exit 3.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { loadConfig, saveConfig } = cli;
|
|
26
|
+
const { bin, envPrefix: P, displayName } = cli.identity;
|
|
27
|
+
|
|
28
|
+
const OLD = key('old');
|
|
29
|
+
const NEW = key('new');
|
|
30
|
+
|
|
31
|
+
/** GET /files: 200 for the keys in `good`, else 401. */
|
|
32
|
+
function filesFor(good: string[]): Route {
|
|
33
|
+
return {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
path: /\/api\/v1\/files$/,
|
|
36
|
+
reply: ({ headers }) =>
|
|
37
|
+
good.includes(headers.get('authorization')?.replace(/^Bearer\s+/, '') ?? '')
|
|
38
|
+
? { body: { files: [], nextCursor: null } }
|
|
39
|
+
: { status: 401, body: { error: { code: 'invalid_api_key', message: 'API key not recognized' } } }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function cachedEnv(): Promise<Record<string, string>> {
|
|
44
|
+
const env = await tempConfigEnv();
|
|
45
|
+
seedHubLogin(env);
|
|
46
|
+
saveConfig(env, {
|
|
47
|
+
activeProfile: 'work',
|
|
48
|
+
profiles: { work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: OLD } } } }
|
|
49
|
+
});
|
|
50
|
+
return env;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trace = (h: ReturnType<typeof routedHarness>) =>
|
|
54
|
+
h.wire.map((c) => `${c.method} ${c.origin}${c.path} ${c.auth ?? '-'}`);
|
|
55
|
+
|
|
56
|
+
describe('the stranded cached key', () => {
|
|
57
|
+
it('a 401 on a cached key: evict, exchange again, replay the same request once with the new key', async () => {
|
|
58
|
+
const env = await cachedEnv();
|
|
59
|
+
const h = routedHarness([filesFor([NEW]), cloudInstanceRoute, ...exchangeRoutes([NEW])], env);
|
|
60
|
+
expect(await run(['files', 'list'], h.io)).toBe(0);
|
|
61
|
+
expect(trace(h)).toEqual([
|
|
62
|
+
`GET http://tool/api/v1/files Bearer ${OLD}`,
|
|
63
|
+
'GET http://tool/api/v1/instance -',
|
|
64
|
+
`POST ${HUB}/api/v1/sso/tool-token Bearer ${HUB_KEY}`,
|
|
65
|
+
'POST http://tool/api/v1/sso/cli-connect -',
|
|
66
|
+
`GET http://tool/api/v1/files Bearer ${NEW}`
|
|
67
|
+
]);
|
|
68
|
+
expect(h.err()).toContain('The cached key was refused; signed in again through Antasphere.\n');
|
|
69
|
+
expect(h.out()).toContain('No files.');
|
|
70
|
+
const work = loadConfig(env).profiles.work;
|
|
71
|
+
expect(work?.connectKeys?.default?.apiKey).toBe(NEW);
|
|
72
|
+
expect(JSON.stringify(loadConfig(env))).not.toContain(OLD);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a second 401 stands: the plain error, one recovery only', async () => {
|
|
76
|
+
const env = await cachedEnv();
|
|
77
|
+
const h = routedHarness([filesFor([]), cloudInstanceRoute, ...exchangeRoutes([NEW])], env);
|
|
78
|
+
expect(await run(['files', 'list'], h.io)).toBe(1);
|
|
79
|
+
expect(h.wire.filter((c) => c.path === '/api/v1/files').map((c) => c.auth)).toEqual([
|
|
80
|
+
`Bearer ${OLD}`,
|
|
81
|
+
`Bearer ${NEW}`
|
|
82
|
+
]);
|
|
83
|
+
expect(h.wire.filter((c) => c.path.endsWith('/sso/tool-token'))).toHaveLength(1);
|
|
84
|
+
expect(h.err()).toContain(`Error: API key not recognized (check the key: \`${bin} verify\`)\n`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([
|
|
88
|
+
['the profile key', { apiKey: OLD, baseUrl: 'http://tool' }, [], {}],
|
|
89
|
+
['the --api-key flag', { baseUrl: 'http://tool' }, ['--api-key', OLD], {}],
|
|
90
|
+
['the key variable', { baseUrl: 'http://tool' }, [], { [`${P}_API_KEY`]: OLD }]
|
|
91
|
+
] as const)('%s never recovers', async (_label, profile, args, extraEnv) => {
|
|
92
|
+
const env = await tempConfigEnv();
|
|
93
|
+
seedHubLogin(env);
|
|
94
|
+
saveConfig(env, { activeProfile: 'work', profiles: { work: profile } });
|
|
95
|
+
const h = routedHarness([filesFor([NEW]), cloudInstanceRoute, ...exchangeRoutes([NEW])], {
|
|
96
|
+
...env,
|
|
97
|
+
...extraEnv
|
|
98
|
+
});
|
|
99
|
+
expect(await run(['files', 'list', ...args], h.io)).toBe(1);
|
|
100
|
+
expect(trace(h)).toEqual([`GET http://tool/api/v1/files Bearer ${OLD}`]);
|
|
101
|
+
expect(h.err()).not.toContain('signed in again');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a hub refusal during the recovery: its own sentence, exit 3', async () => {
|
|
105
|
+
const env = await cachedEnv();
|
|
106
|
+
const h = routedHarness(
|
|
107
|
+
[
|
|
108
|
+
filesFor([NEW]),
|
|
109
|
+
cloudInstanceRoute,
|
|
110
|
+
...exchangeRoutes([NEW], { toolToken: () => TOOL_ACCESS_DENIED })
|
|
111
|
+
],
|
|
112
|
+
env
|
|
113
|
+
);
|
|
114
|
+
expect(await run(['files', 'list'], h.io)).toBe(3);
|
|
115
|
+
expect(h.err()).toBe(`Error: This account has no access to ${displayName} in any of its organizations\n`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('a hub refusal on a first connect: its own sentence, no suffix, exit 3', async () => {
|
|
119
|
+
const env = await tempConfigEnv();
|
|
120
|
+
seedHubLogin(env);
|
|
121
|
+
const h = routedHarness(
|
|
122
|
+
[
|
|
123
|
+
filesFor([NEW]),
|
|
124
|
+
cloudInstanceRoute,
|
|
125
|
+
...exchangeRoutes([NEW], { toolToken: () => TOOL_ACCESS_DENIED })
|
|
126
|
+
],
|
|
127
|
+
env
|
|
128
|
+
);
|
|
129
|
+
expect(await run(['files', 'list', '--api-url', 'http://tool'], h.io)).toBe(3);
|
|
130
|
+
expect(h.err()).toBe(`Error: This account has no access to ${displayName} in any of its organizations\n`);
|
|
131
|
+
expect(loadConfig(env).profiles.tool).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('a refused selection is not a dead key (verifier round 1, F2)', () => {
|
|
136
|
+
const NOT_MINE = '99999999-9999-4999-8999-999999999999';
|
|
137
|
+
const UNAUTHORIZED = {
|
|
138
|
+
status: 401,
|
|
139
|
+
body: { error: { code: 'invalid_api_key', message: 'API key not recognized' } }
|
|
140
|
+
};
|
|
141
|
+
const bearer = (headers: Headers) => headers.get('authorization')?.replace(/^Bearer\s+/, '') ?? '';
|
|
142
|
+
const traceWs = (h: ReturnType<typeof routedHarness>) =>
|
|
143
|
+
h.wire.map((c) => `${c.method} ${c.origin}${c.path} ${c.auth ?? '-'} ${c.workspace ?? '-'}`);
|
|
144
|
+
|
|
145
|
+
it('--org <not mine> on a live cached key: bare /me answers, nothing evicted, exchanged or replayed', async () => {
|
|
146
|
+
const env = await cachedEnv();
|
|
147
|
+
const before = loadConfig(env).profiles.work?.connectKeys;
|
|
148
|
+
const h = routedHarness(
|
|
149
|
+
[
|
|
150
|
+
{
|
|
151
|
+
method: 'GET',
|
|
152
|
+
path: /\/api\/v1\/files$/,
|
|
153
|
+
// The server answers a workspace the person is not in like an unknown key.
|
|
154
|
+
reply: ({ headers }) =>
|
|
155
|
+
headers.get('x-workspace-id') ? UNAUTHORIZED : { body: { files: [], nextCursor: null } }
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
method: 'GET',
|
|
159
|
+
path: /\/api\/v1\/me$/,
|
|
160
|
+
reply: ({ headers }) =>
|
|
161
|
+
bearer(headers) === OLD && !headers.get('x-workspace-id') ? { body: meBody() } : UNAUTHORIZED
|
|
162
|
+
},
|
|
163
|
+
cloudInstanceRoute,
|
|
164
|
+
...exchangeRoutes([NEW])
|
|
165
|
+
],
|
|
166
|
+
env
|
|
167
|
+
);
|
|
168
|
+
expect(await run(['files', 'list', '--org', NOT_MINE], h.io)).toBe(1);
|
|
169
|
+
expect(traceWs(h)).toEqual([
|
|
170
|
+
`GET http://tool/api/v1/files Bearer ${OLD} ${NOT_MINE}`,
|
|
171
|
+
`GET http://tool/api/v1/me Bearer ${OLD} -`,
|
|
172
|
+
`GET http://tool/api/v1/me Bearer ${OLD} -`
|
|
173
|
+
]);
|
|
174
|
+
expect(h.wire.some((c) => c.path.endsWith('/sso/tool-token'))).toBe(false);
|
|
175
|
+
expect(h.wire.some((c) => c.path.endsWith('/sso/cli-connect'))).toBe(false);
|
|
176
|
+
expect(loadConfig(env).profiles.work?.connectKeys).toEqual(before);
|
|
177
|
+
expect(loadConfig(env).profiles.work?.connectKeys?.default?.apiKey).toBe(OLD);
|
|
178
|
+
expect(h.err()).toContain(
|
|
179
|
+
`The organization "${NOT_MINE}" (selected by the --org flag) is not one of yours on http://tool; ` +
|
|
180
|
+
'the API key itself works.'
|
|
181
|
+
);
|
|
182
|
+
expect(h.err()).not.toContain('The cached key was refused');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('--org with a dead cached key: bare /me refuses too, so the recovery runs and replays once', async () => {
|
|
186
|
+
const env = await cachedEnv();
|
|
187
|
+
const h = routedHarness(
|
|
188
|
+
[
|
|
189
|
+
filesFor([NEW]),
|
|
190
|
+
{
|
|
191
|
+
method: 'GET',
|
|
192
|
+
path: /\/api\/v1\/me$/,
|
|
193
|
+
reply: ({ headers }) => (bearer(headers) === NEW ? { body: meBody() } : UNAUTHORIZED)
|
|
194
|
+
},
|
|
195
|
+
cloudInstanceRoute,
|
|
196
|
+
...exchangeRoutes([NEW])
|
|
197
|
+
],
|
|
198
|
+
env
|
|
199
|
+
);
|
|
200
|
+
expect(await run(['files', 'list', '--org', NOT_MINE], h.io)).toBe(0);
|
|
201
|
+
expect(traceWs(h)).toEqual([
|
|
202
|
+
`GET http://tool/api/v1/files Bearer ${OLD} ${NOT_MINE}`,
|
|
203
|
+
`GET http://tool/api/v1/me Bearer ${OLD} -`,
|
|
204
|
+
'GET http://tool/api/v1/instance - -',
|
|
205
|
+
`POST ${HUB}/api/v1/sso/tool-token Bearer ${HUB_KEY} -`,
|
|
206
|
+
'POST http://tool/api/v1/sso/cli-connect - -',
|
|
207
|
+
`GET http://tool/api/v1/files Bearer ${NEW} ${NOT_MINE}`
|
|
208
|
+
]);
|
|
209
|
+
expect(h.err()).toContain('The cached key was refused; signed in again through Antasphere.\n');
|
|
210
|
+
expect(loadConfig(env).profiles.work?.connectKeys?.default?.apiKey).toBe(NEW);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('F10: a bare /me probe with no answer keeps the key: no eviction, no exchange, the 401 stands', async () => {
|
|
214
|
+
const env = await cachedEnv();
|
|
215
|
+
const h = routedHarness(
|
|
216
|
+
[
|
|
217
|
+
filesFor([]),
|
|
218
|
+
{
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: /\/api\/v1\/me$/,
|
|
221
|
+
reply: () => {
|
|
222
|
+
throw new TypeError('fetch failed');
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
cloudInstanceRoute,
|
|
226
|
+
...exchangeRoutes([NEW])
|
|
227
|
+
],
|
|
228
|
+
env
|
|
229
|
+
);
|
|
230
|
+
expect(await run(['files', 'list', '--org', NOT_MINE], h.io)).toBe(1);
|
|
231
|
+
expect(h.wire.some((c) => c.path.endsWith('/sso/tool-token'))).toBe(false);
|
|
232
|
+
expect(h.wire.some((c) => c.path.endsWith('/sso/cli-connect'))).toBe(false);
|
|
233
|
+
expect(h.wire.filter((c) => c.path.endsWith('/api/v1/files'))).toHaveLength(1);
|
|
234
|
+
expect(loadConfig(env).profiles.work?.connectKeys?.default?.apiKey).toBe(OLD);
|
|
235
|
+
expect(h.err()).not.toContain('The cached key was refused');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('without a selection there is no probe: the recovery runs on the first 401', async () => {
|
|
239
|
+
const env = await cachedEnv();
|
|
240
|
+
const h = routedHarness([filesFor([NEW]), cloudInstanceRoute, ...exchangeRoutes([NEW])], env);
|
|
241
|
+
expect(await run(['files', 'list'], h.io)).toBe(0);
|
|
242
|
+
expect(h.wire.some((c) => c.path === '/api/v1/me')).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('a cached key of another Antasphere account (PRDCT-3032)', () => {
|
|
247
|
+
const BOB_HUB_KEY = 'ant_bobkey12_secretsecretsecret1234';
|
|
248
|
+
const bearer = (headers: Headers) => headers.get('authorization')?.replace(/^Bearer\s+/, '') ?? '';
|
|
249
|
+
|
|
250
|
+
/** The hub login switched to bob since the key was cached for ada. */
|
|
251
|
+
async function switchedEnv(cached: { apiKey: string; email?: string }, hubEmail: string | undefined) {
|
|
252
|
+
const env = await tempConfigEnv();
|
|
253
|
+
saveCoreConfig(env, HUB_TOOL, {
|
|
254
|
+
activeProfile: 'default',
|
|
255
|
+
profiles: {
|
|
256
|
+
default: { apiKey: BOB_HUB_KEY, baseUrl: HUB, ...(hubEmail ? { email: hubEmail } : {}) }
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
saveConfig(env, {
|
|
260
|
+
activeProfile: 'work',
|
|
261
|
+
profiles: { work: { baseUrl: 'http://tool', connectKeys: { default: cached } } }
|
|
262
|
+
});
|
|
263
|
+
return env;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `/me` for the given keys only, else 401; the cli-connect answer naming bob. */
|
|
267
|
+
function routesFor(good: string[]): Route[] {
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
method: 'GET',
|
|
271
|
+
path: /\/api\/v1\/me$/,
|
|
272
|
+
reply: ({ headers }) =>
|
|
273
|
+
good.includes(bearer(headers))
|
|
274
|
+
? {
|
|
275
|
+
body: meBody({
|
|
276
|
+
workspaces: [wsRow('w1', 'Acme', { default: true, hubOrigin: true, centralAccountId: ORG })]
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
: { status: 401, body: { error: { code: 'invalid_api_key', message: 'API key not recognized' } } }
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
method: 'DELETE',
|
|
283
|
+
path: /\/api\/v1\/cli\/auth\/key$/,
|
|
284
|
+
reply: () => ({ body: { revoked: true, id: 'k' } })
|
|
285
|
+
},
|
|
286
|
+
cloudInstanceRoute,
|
|
287
|
+
...exchangeRoutes([NEW]).map((r) => ({
|
|
288
|
+
...r,
|
|
289
|
+
reply: (call: Parameters<Route['reply']>[0]) => {
|
|
290
|
+
const answer = r.reply(call);
|
|
291
|
+
const body = answer.body as Record<string, unknown>;
|
|
292
|
+
return 'user' in body
|
|
293
|
+
? { ...answer, body: { ...body, user: { ...(body.user as object), email: 'bob@x.co' } } }
|
|
294
|
+
: answer;
|
|
295
|
+
}
|
|
296
|
+
}))
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it('is never sent: retired on stderr, revoked on the instance, the exchange runs for the new login, the command answers with the new key', async () => {
|
|
301
|
+
const env = await switchedEnv({ apiKey: OLD, email: 'ada@x.co' }, 'bob@x.co');
|
|
302
|
+
const h = routedHarness(routesFor([OLD, NEW]), env);
|
|
303
|
+
expect(await run(['whoami'], h.io)).toBe(0);
|
|
304
|
+
expect(h.err()).toContain(
|
|
305
|
+
`The ${displayName} key cached for ada@x.co is retired: the Antasphere login is now bob@x.co.\n`
|
|
306
|
+
);
|
|
307
|
+
expect(trace(h)).toEqual([
|
|
308
|
+
`DELETE http://tool/api/v1/cli/auth/key Bearer ${OLD}`,
|
|
309
|
+
'GET http://tool/api/v1/instance -',
|
|
310
|
+
`POST ${HUB}/api/v1/sso/tool-token Bearer ${BOB_HUB_KEY}`,
|
|
311
|
+
'POST http://tool/api/v1/sso/cli-connect -',
|
|
312
|
+
`GET http://tool/api/v1/me Bearer ${NEW}`
|
|
313
|
+
]);
|
|
314
|
+
expect(h.wire.filter((c) => c.auth === `Bearer ${OLD}`).map((c) => c.method)).toEqual(['DELETE']);
|
|
315
|
+
expect(h.out()).toContain('Ada <ada@x.co>');
|
|
316
|
+
const work = loadConfig(env).profiles.work;
|
|
317
|
+
expect(Object.keys(work?.connectKeys ?? {})).toEqual(['default']);
|
|
318
|
+
expect(work?.connectKeys?.default).toMatchObject({ apiKey: NEW, email: 'bob@x.co' });
|
|
319
|
+
expect(JSON.stringify(loadConfig(env))).not.toContain(OLD);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('a cache entry with no email, or a hub login with none, is trusted as before', async () => {
|
|
323
|
+
for (const [cached, hubEmail] of [
|
|
324
|
+
[{ apiKey: OLD }, 'bob@x.co'],
|
|
325
|
+
[{ apiKey: OLD, email: 'ada@x.co' }, undefined]
|
|
326
|
+
] as const) {
|
|
327
|
+
const env = await switchedEnv(cached, hubEmail);
|
|
328
|
+
const h = routedHarness(routesFor([OLD, NEW]), env);
|
|
329
|
+
expect(await run(['whoami'], h.io)).toBe(0);
|
|
330
|
+
expect(trace(h)).toEqual([`GET http://tool/api/v1/me Bearer ${OLD}`]);
|
|
331
|
+
expect(h.err()).not.toContain('is retired');
|
|
332
|
+
expect(loadConfig(env).profiles.work?.connectKeys?.default?.apiKey).toBe(OLD);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('the same account in another case is the same account: the cached key is served (verifier M3)', async () => {
|
|
337
|
+
const env = await switchedEnv({ apiKey: OLD, email: 'ada@x.co' }, 'ADA@X.CO');
|
|
338
|
+
const h = routedHarness(routesFor([OLD, NEW]), env);
|
|
339
|
+
expect(await run(['whoami'], h.io)).toBe(0);
|
|
340
|
+
// No DELETE, no exchange: the one call on the wire is the cached key's.
|
|
341
|
+
expect(trace(h)).toEqual([`GET http://tool/api/v1/me Bearer ${OLD}`]);
|
|
342
|
+
expect(h.err()).toBe('');
|
|
343
|
+
expect(loadConfig(env).profiles.work?.connectKeys?.default).toMatchObject({
|
|
344
|
+
apiKey: OLD,
|
|
345
|
+
email: 'ada@x.co'
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { safeDownloadName, sanitizeForTty } from '@antasphere/chassis-cli';
|
|
7
|
+
import { routedHarness, type Route } from '@antasphere/chassis-cli/testing';
|
|
8
|
+
import { cli, run } from '@chassis-cli-test/host';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PRDCT-1353 — the CLI hardening, the generic half: `files download` cannot
|
|
12
|
+
* be steered by the stored name, terminal control sequences never reach the
|
|
13
|
+
* terminal (and `--json` stays byte-exact), and the API key can stay out of
|
|
14
|
+
* argv. The deck half (pull, the ignore matcher, the scan, annotations, the
|
|
15
|
+
* CSV guard, the share password) is the tool's own security.test.ts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const sha = (b: Buffer | string) => createHash('sha256').update(b).digest('hex');
|
|
19
|
+
|
|
20
|
+
// The literal that spells the tool comes from the host (its identity).
|
|
21
|
+
const K = cli.identity.keyPrefix;
|
|
22
|
+
|
|
23
|
+
const KEY = ['--url', 'http://x', '--api-key', `${K}_k_s`];
|
|
24
|
+
|
|
25
|
+
async function emptyDir(prefix: string): Promise<string> {
|
|
26
|
+
return mkdtemp(join(tmpdir(), prefix));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── CLI-3: `files download` cannot be steered by the stored name ─────────────
|
|
30
|
+
|
|
31
|
+
describe('files download', () => {
|
|
32
|
+
const FILE_ID = '44444444-4444-4444-4444-444444444444';
|
|
33
|
+
|
|
34
|
+
function fileRoutes(originalName: string): Route[] {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
method: 'GET',
|
|
38
|
+
path: new RegExp(`/api/v1/files/${FILE_ID}$`),
|
|
39
|
+
reply: () => ({
|
|
40
|
+
body: {
|
|
41
|
+
id: FILE_ID,
|
|
42
|
+
sha256: sha('payload'),
|
|
43
|
+
sizeBytes: 7,
|
|
44
|
+
contentType: 'text/plain',
|
|
45
|
+
originalName,
|
|
46
|
+
createdBy: 'u1',
|
|
47
|
+
createdAt: '2026-01-01T00:00:00.000Z'
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: 'GET',
|
|
53
|
+
path: new RegExp(`/api/v1/files/${FILE_ID}/content$`),
|
|
54
|
+
reply: () => ({ raw: new Response(Buffer.from('payload'), { status: 200 }) })
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('writes the BASENAME of a traversing stored name inside --dir', async () => {
|
|
60
|
+
const root = await emptyDir('cli-sec-');
|
|
61
|
+
const dir = join(root, 'downloads');
|
|
62
|
+
const h = routedHarness(fileRoutes('../../pwned.txt'));
|
|
63
|
+
const code = await run(['files', 'download', FILE_ID, '--dir', dir, ...KEY], h.io);
|
|
64
|
+
expect(h.err()).toBe('');
|
|
65
|
+
expect(code).toBe(0);
|
|
66
|
+
expect(await readFile(join(dir, 'pwned.txt'), 'utf8')).toBe('payload');
|
|
67
|
+
await expect(stat(join(root, 'pwned.txt'))).rejects.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses an absolute stored name rather than writing to it', async () => {
|
|
71
|
+
const dir = await emptyDir('cli-sec-');
|
|
72
|
+
const h = routedHarness(fileRoutes('/etc/cron.d/backdoor'));
|
|
73
|
+
const code = await run(['files', 'download', FILE_ID, '--dir', dir, ...KEY], h.io);
|
|
74
|
+
// The basename survives; it lands inside the chosen directory.
|
|
75
|
+
expect(code).toBe(0);
|
|
76
|
+
expect(await readFile(join(dir, 'backdoor'), 'utf8')).toBe('payload');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('refuses a dotfile stored name and points at --out', async () => {
|
|
80
|
+
const dir = await emptyDir('cli-sec-');
|
|
81
|
+
const h = routedHarness(fileRoutes('.bashrc'));
|
|
82
|
+
const code = await run(['files', 'download', FILE_ID, '--dir', dir, ...KEY], h.io);
|
|
83
|
+
expect(code).toBe(1);
|
|
84
|
+
expect(h.err()).toMatch(/--out/);
|
|
85
|
+
await expect(stat(join(dir, '.bashrc'))).rejects.toThrow();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses to write through a symlink planted at the derived name', async () => {
|
|
89
|
+
const root = await emptyDir('cli-sec-');
|
|
90
|
+
const dir = join(root, 'downloads');
|
|
91
|
+
await mkdir(dir, { recursive: true });
|
|
92
|
+
const victim = join(root, 'victim.txt');
|
|
93
|
+
await writeFile(victim, 'original');
|
|
94
|
+
await symlink(victim, join(dir, 'report.txt'));
|
|
95
|
+
const h = routedHarness(fileRoutes('report.txt'));
|
|
96
|
+
const code = await run(['files', 'download', FILE_ID, '--dir', dir, ...KEY], h.io);
|
|
97
|
+
expect(code).toBe(1);
|
|
98
|
+
expect(await readFile(victim, 'utf8')).toBe('original');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('safeDownloadName strips both separator flavours', () => {
|
|
102
|
+
expect(safeDownloadName('a/b/c.txt')).toBe('c.txt');
|
|
103
|
+
expect(safeDownloadName('..\\..\\windows.txt')).toBe('windows.txt');
|
|
104
|
+
expect(safeDownloadName('plain.txt')).toBe('plain.txt');
|
|
105
|
+
expect(() => safeDownloadName('..')).toThrow(/--out/);
|
|
106
|
+
expect(() => safeDownloadName('.npmrc')).toThrow(/--out/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── CLI-5: terminal control sequences never reach the terminal ───────────────
|
|
111
|
+
|
|
112
|
+
describe('terminal-control sanitation', () => {
|
|
113
|
+
it('strips CSI, OSC and C1 while keeping tabs, newlines and CR', () => {
|
|
114
|
+
const ESC = '\u001b';
|
|
115
|
+
expect(sanitizeForTty(`${ESC}[31mred${ESC}[0m`)).toBe('red');
|
|
116
|
+
expect(sanitizeForTty(`${ESC}]8;;https://evil.example${ESC}\\click${ESC}]8;;${ESC}\\`)).toBe('click');
|
|
117
|
+
expect(sanitizeForTty(`${ESC}]52;c;cGF5bG9hZA==\u0007`)).toBe('');
|
|
118
|
+
// 8-bit C1: 0x9b IS a CSI introducer on its own.
|
|
119
|
+
expect(sanitizeForTty('a\u009b31mBb')).toBe('a31mBb');
|
|
120
|
+
expect(sanitizeForTty('a\tb\r\nc\n')).toBe('a\tb\r\nc\n');
|
|
121
|
+
expect(sanitizeForTty('plain')).toBe('plain');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// PRDCT-2530: the one invariant of this block that was only proven through a
|
|
125
|
+
// deck command (annotation bodies). `files list` is the generic command that
|
|
126
|
+
// prints somebody else's text: the stored name.
|
|
127
|
+
it('sanitizes a control-character stored name on human output and never on --json', async () => {
|
|
128
|
+
const ESC = '\u001b';
|
|
129
|
+
// ESC sequences AND a raw 8-bit C1 introducer: JSON.stringify escapes the
|
|
130
|
+
// former and keeps the latter as is, so the C1 byte is what a sanitizer
|
|
131
|
+
// wrongly applied to the JSON text would eat.
|
|
132
|
+
const originalName = `${ESC}[2K${ESC}[1Areport\u009b31m${ESC}]8;;https://evil.example${ESC}\\.txt`;
|
|
133
|
+
const file = { id: 'f-a', sizeBytes: 10, originalName };
|
|
134
|
+
const routes: Route[] = [
|
|
135
|
+
{
|
|
136
|
+
method: 'GET',
|
|
137
|
+
path: /\/api\/v1\/files$/,
|
|
138
|
+
reply: () => ({ body: { files: [file], nextCursor: null } })
|
|
139
|
+
}
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const human = routedHarness(routes);
|
|
143
|
+
expect(await run(['files', 'list', ...KEY], human.io)).toBe(0);
|
|
144
|
+
expect(human.out()).toContain('report31m.txt\n');
|
|
145
|
+
expect(human.out()).not.toContain(ESC);
|
|
146
|
+
expect(human.out()).not.toContain('\u009b');
|
|
147
|
+
expect(human.out()).not.toContain('evil.example');
|
|
148
|
+
|
|
149
|
+
const json = routedHarness(routes);
|
|
150
|
+
expect(await run(['files', 'list', '--json', ...KEY], json.io)).toBe(0);
|
|
151
|
+
expect(json.out()).toContain('\u009b');
|
|
152
|
+
expect(JSON.parse(json.out())).toEqual({ files: [file], nextCursor: null });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// The STDERR half of the same invariant (PRDCT-2530, verifier F-1): the
|
|
156
|
+
// runner prints `Error: <the server's message>`, and that message is
|
|
157
|
+
// somebody else's text as much as a stored name is. A 500 carries no hint,
|
|
158
|
+
// so the whole line is asserted.
|
|
159
|
+
it('sanitizes a control-character server error message on stderr, with and without --json', async () => {
|
|
160
|
+
const ESC = '\u001b';
|
|
161
|
+
const BEL = '\u0007';
|
|
162
|
+
// An OSC-8 hyperlink around "click", then a CSI line-erase.
|
|
163
|
+
const message = `deck ${ESC}]8;;http://evil.example${BEL}click${ESC}]8;;${BEL} ${ESC}[2K gone`;
|
|
164
|
+
const routes: Route[] = [
|
|
165
|
+
{
|
|
166
|
+
method: 'GET',
|
|
167
|
+
path: /\/api\/v1\/files$/,
|
|
168
|
+
reply: () => ({ status: 500, body: { error: { code: 'internal', message } } })
|
|
169
|
+
}
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const human = routedHarness(routes);
|
|
173
|
+
expect(await run(['files', 'list', ...KEY], human.io)).toBe(1);
|
|
174
|
+
expect(human.out()).toBe('');
|
|
175
|
+
expect(human.err()).toBe('Error: deck click gone\n');
|
|
176
|
+
|
|
177
|
+
// `--json` shapes what a command PRINTS; a failure is reported the same way
|
|
178
|
+
// under it: nothing on stdout, and the same inert human line on stderr.
|
|
179
|
+
const json = routedHarness(routes);
|
|
180
|
+
expect(await run(['files', 'list', '--json', ...KEY], json.io)).toBe(1);
|
|
181
|
+
expect(json.out()).toBe('');
|
|
182
|
+
expect(json.err()).toBe('Error: deck click gone\n');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── CLI-12: secrets no longer have to travel in argv ─────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('secrets off the command line', () => {
|
|
189
|
+
it('--api-key-stdin authenticates without the key ever entering argv', async () => {
|
|
190
|
+
const h = routedHarness([
|
|
191
|
+
{ method: 'GET', path: /\/api\/v1\/files$/, reply: () => ({ body: { files: [], nextCursor: null } }) }
|
|
192
|
+
]);
|
|
193
|
+
h.io.readStdin = async () => `${K}_from_stdin\n`;
|
|
194
|
+
const code = await run(['files', 'list', '--url', 'http://x', '--api-key-stdin'], h.io);
|
|
195
|
+
expect(h.err()).toBe('');
|
|
196
|
+
expect(code).toBe(0);
|
|
197
|
+
expect(h.wire[0]!.auth).toBe(`Bearer ${K}_from_stdin`);
|
|
198
|
+
});
|
|
199
|
+
});
|