@marcusrbrown/infra 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,1157 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
-
3
- import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
4
- import {
5
- checkHttpReachability,
6
- checkProviderAuth,
7
- checkUsageStats,
8
- checkVersion,
9
- cliproxyStatusAction,
10
- formatDurationMs,
11
- formatUsageSummaryLine,
12
- getCliproxyStatusSummary,
13
- levelLabel,
14
- stripTrailingSlash,
15
- toNumber,
16
- } from './status'
17
-
18
- const originalFetch = globalThis.fetch
19
-
20
- type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
21
-
22
- function createFetchImplementation(handler: FetchReplacement): typeof fetch {
23
- return Object.assign(
24
- (input: string | URL | Request, init?: RequestInit) => {
25
- if (typeof input !== 'string') {
26
- throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
27
- }
28
-
29
- return handler(input, init)
30
- },
31
- {preconnect: originalFetch.preconnect},
32
- )
33
- }
34
-
35
- describe('cliproxy status helpers', () => {
36
- beforeEach(() => {
37
- globalThis.fetch = createFetchImplementation(async () => {
38
- throw new Error('Unexpected fetch call')
39
- })
40
- })
41
-
42
- afterEach(() => {
43
- globalThis.fetch = originalFetch
44
- })
45
-
46
- describe('levelLabel', () => {
47
- it('returns OK for ok', () => {
48
- expect(levelLabel('ok')).toBe('OK')
49
- })
50
-
51
- it('returns WARN for warning', () => {
52
- expect(levelLabel('warning')).toBe('WARN')
53
- })
54
-
55
- it('returns ERROR for error', () => {
56
- expect(levelLabel('error')).toBe('ERROR')
57
- })
58
- })
59
-
60
- describe('formatDurationMs', () => {
61
- it('formats positive durations', () => {
62
- expect(formatDurationMs(150)).toBe('150ms')
63
- })
64
-
65
- it('formats zero duration', () => {
66
- expect(formatDurationMs(0)).toBe('0ms')
67
- })
68
-
69
- it('clamps negative durations to zero', () => {
70
- expect(formatDurationMs(-5)).toBe('0ms')
71
- })
72
- })
73
-
74
- describe('stripTrailingSlash', () => {
75
- it('removes a trailing slash', () => {
76
- expect(stripTrailingSlash('https://example.com/')).toBe('https://example.com')
77
- })
78
-
79
- it('does nothing when there is no trailing slash', () => {
80
- expect(stripTrailingSlash('https://example.com')).toBe('https://example.com')
81
- })
82
- })
83
-
84
- describe('toNumber', () => {
85
- it('returns finite numbers as-is', () => {
86
- expect(toNumber(42)).toBe(42)
87
- })
88
-
89
- it('returns null for non-numbers', () => {
90
- expect(toNumber('not a number')).toBeNull()
91
- })
92
-
93
- it('returns null for NaN', () => {
94
- expect(toNumber(Number.NaN)).toBeNull()
95
- })
96
-
97
- it('returns null for infinity', () => {
98
- expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull()
99
- })
100
- })
101
-
102
- describe('checkHttpReachability', () => {
103
- it('returns ok for HTTP 200', async () => {
104
- globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
105
-
106
- const result = await checkHttpReachability('https://cliproxy.example.com', false)
107
-
108
- expect(result.level).toBe('ok')
109
- expect(result.summary).toContain('GET https://cliproxy.example.com → 200')
110
- expect(result.summary).toContain('ms')
111
- })
112
-
113
- it('returns error for HTTP 500', async () => {
114
- globalThis.fetch = createFetchImplementation(async () => new Response('error', {status: 500}))
115
-
116
- const result = await checkHttpReachability('https://cliproxy.example.com', false)
117
-
118
- expect(result.level).toBe('error')
119
- expect(result.summary).toContain('GET https://cliproxy.example.com → 500')
120
- })
121
-
122
- it('returns error details for network failures', async () => {
123
- globalThis.fetch = createFetchImplementation(async () => {
124
- throw new Error('Network timeout')
125
- })
126
-
127
- const result = await checkHttpReachability('https://cliproxy.example.com', true)
128
-
129
- expect(result.level).toBe('error')
130
- expect(result.summary).toContain('Network timeout')
131
- expect(result.details).toEqual(['URL: https://cliproxy.example.com', 'Timeout: 10000ms'])
132
- })
133
- })
134
-
135
- describe('checkUsageStats', () => {
136
- it('returns ok for empty array (idle)', async () => {
137
- globalThis.fetch = createFetchImplementation(
138
- async () => new Response('[]', {status: 200, headers: {'content-type': 'application/json'}}),
139
- )
140
-
141
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
142
-
143
- expect(result.level).toBe('ok')
144
- expect(result.summary).toMatch(/idle|recent: 0/)
145
- })
146
-
147
- it('returns ok for all-success queue array', async () => {
148
- const queue = [{status: 200}, {status: 201}, {status: 200}]
149
- globalThis.fetch = createFetchImplementation(
150
- async () => new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}}),
151
- )
152
-
153
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
154
-
155
- expect(result.level).toBe('ok')
156
- expect(result.summary).toContain('recent: 3')
157
- })
158
-
159
- it('returns warning for queue with error-status records', async () => {
160
- const queue = [{status: 200}, {status: 500}, {status: 200}]
161
- globalThis.fetch = createFetchImplementation(
162
- async () => new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}}),
163
- )
164
-
165
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
166
-
167
- expect(result.level).toBe('warning')
168
- expect(result.summary).toContain('recent: 3')
169
- expect(result.summary).toContain('errors: 1')
170
- })
171
-
172
- it('returns warning when usage-queue returns a non-array object', async () => {
173
- globalThis.fetch = createFetchImplementation(
174
- async () =>
175
- new Response(JSON.stringify({unexpected: 'object'}), {
176
- status: 200,
177
- headers: {'content-type': 'application/json'},
178
- }),
179
- )
180
-
181
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
182
-
183
- expect(result.level).toBe('warning')
184
- })
185
-
186
- it('returns warning when rate limited', async () => {
187
- globalThis.fetch = createFetchImplementation(async () => new Response('rate limited', {status: 429}))
188
-
189
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
190
-
191
- expect(result.level).toBe('warning')
192
- expect(result.summary).toContain('Rate limited')
193
- })
194
-
195
- it('returns error for non-200 responses', async () => {
196
- globalThis.fetch = createFetchImplementation(async () => new Response('boom', {status: 500}))
197
-
198
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
199
-
200
- expect(result.level).toBe('error')
201
- expect(result.summary).toContain('HTTP 500')
202
- })
203
-
204
- it('returns error for network failures', async () => {
205
- globalThis.fetch = createFetchImplementation(async () => {
206
- throw new Error('socket hang up')
207
- })
208
-
209
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
210
-
211
- expect(result.level).toBe('error')
212
- expect(result.summary).toContain('Unable to read usage stats: socket hang up')
213
- })
214
- })
215
-
216
- describe('checkVersion', () => {
217
- it('returns ok with latest-version from response', async () => {
218
- globalThis.fetch = createFetchImplementation(
219
- async () =>
220
- new Response(JSON.stringify({'latest-version': 'v6.9.15'}), {
221
- status: 200,
222
- headers: {'content-type': 'application/json'},
223
- }),
224
- )
225
-
226
- const result = await checkVersion('https://cliproxy.example.com', 'secret')
227
-
228
- expect(result.level).toBe('ok')
229
- expect(result.summary).toBe('v6.9.15')
230
- })
231
-
232
- it('returns warning when latest-version key is missing', async () => {
233
- globalThis.fetch = createFetchImplementation(
234
- async () =>
235
- new Response(JSON.stringify({}), {
236
- status: 200,
237
- headers: {'content-type': 'application/json'},
238
- }),
239
- )
240
-
241
- const result = await checkVersion('https://cliproxy.example.com', 'secret')
242
-
243
- expect(result.level).toBe('warning')
244
- expect(result.summary).toContain('latest-version')
245
- })
246
-
247
- it('returns warning when rate limited', async () => {
248
- globalThis.fetch = createFetchImplementation(async () => new Response('rate limited', {status: 429}))
249
-
250
- const result = await checkVersion('https://cliproxy.example.com', 'secret')
251
-
252
- expect(result.level).toBe('warning')
253
- expect(result.summary).toContain('Rate limited')
254
- })
255
-
256
- it('returns error for non-200 responses', async () => {
257
- globalThis.fetch = createFetchImplementation(async () => new Response('boom', {status: 500}))
258
-
259
- const result = await checkVersion('https://cliproxy.example.com', 'secret')
260
-
261
- expect(result.level).toBe('error')
262
- expect(result.summary).toContain('HTTP 500')
263
- })
264
- })
265
-
266
- describe('formatUsageSummaryLine', () => {
267
- it('formats a recent-activity summary with no errors', () => {
268
- const result = formatUsageSummaryLine({
269
- title: 'Usage stats',
270
- level: 'ok',
271
- summary: 'recent: 10',
272
- })
273
-
274
- expect(result).toBe('Recent requests: 10')
275
- })
276
-
277
- it('formats a recent-activity summary with errors appended', () => {
278
- const result = formatUsageSummaryLine({
279
- title: 'Usage stats',
280
- level: 'warning',
281
- summary: 'recent: 10, errors: 3',
282
- })
283
-
284
- expect(result).toBe('Recent requests: 10, 3 errors')
285
- })
286
-
287
- it('formats an idle recent summary', () => {
288
- const result = formatUsageSummaryLine({
289
- title: 'Usage stats',
290
- level: 'ok',
291
- summary: 'recent: 0 (idle)',
292
- })
293
-
294
- expect(result).toBe('Recent requests: 0')
295
- })
296
-
297
- it('returns null when summary has no recent-activity field', () => {
298
- const result = formatUsageSummaryLine({
299
- title: 'Usage stats',
300
- level: 'warning',
301
- summary: 'Rate limited by management API (HTTP 429). Retry in a few moments.',
302
- })
303
-
304
- expect(result).toBeNull()
305
- })
306
-
307
- it('returns null for error summaries without a recent field', () => {
308
- const result = formatUsageSummaryLine({
309
- title: 'Usage stats',
310
- level: 'error',
311
- summary: 'Unable to read usage stats: socket hang up',
312
- })
313
-
314
- expect(result).toBeNull()
315
- })
316
- })
317
- })
318
-
319
- describe('cliproxyStatusAction (Tier-2 ctx capture, failure-path parity)', () => {
320
- afterEach(() => {
321
- globalThis.fetch = originalFetch
322
- })
323
-
324
- it('Tier-2: routes unexpected thrown error through ctx.console.error + ctx.process.exit(1)', async () => {
325
- // Trigger an unexpected error by having ctx.console.log throw on first call
326
- const {ctx, captured} = createCapturedCtx()
327
- let callCount = 0
328
- const originalLog = ctx.console.log
329
- ctx.console.log = (...args: unknown[]) => {
330
- callCount++
331
- if (callCount === 1) {
332
- throw new Error('Unexpected internal error during status output')
333
- }
334
-
335
- originalLog(...args)
336
- }
337
-
338
- globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
339
-
340
- await expect(cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
341
- name: 'MockProcessExit',
342
- code: 1,
343
- })
344
- expect(captured.stderr.join('')).toContain('Unexpected internal error')
345
- expect(captured.exit).toEqual({code: 1})
346
- })
347
- })
348
-
349
- describe('cliproxyStatusAction (Tier-2 ctx capture)', () => {
350
- afterEach(() => {
351
- globalThis.fetch = originalFetch
352
- })
353
-
354
- it('Mode A: captures CLIProxyAPI status header to ctx.stdout', async () => {
355
- globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
356
-
357
- const {ctx, captured} = createCapturedCtx()
358
- await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
359
-
360
- expect(expectCapturedToInclude(captured, 'CLIProxyAPI status')).toBe(true)
361
- expect(expectCapturedToInclude(captured, 'HTTP reachability')).toBe(true)
362
- expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
363
- })
364
-
365
- it('Mode A: calls ctx.process.exit(1) on HTTP error', async () => {
366
- globalThis.fetch = createFetchImplementation(async () => new Response('error', {status: 500}))
367
-
368
- const {ctx, captured} = createCapturedCtx()
369
- let threw: unknown
370
- try {
371
- await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
372
- } catch (error) {
373
- threw = error
374
- }
375
-
376
- expect(threw).toBeInstanceOf(MockProcessExit)
377
- expect(captured.exit?.code).toBe(1)
378
- expect(expectCapturedToInclude(captured, 'ERROR')).toBe(true)
379
- })
380
-
381
- it('Mode A: shows management key warning when no key provided', async () => {
382
- globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
383
-
384
- const savedKey = process.env.CLIPROXY_MANAGEMENT_KEY
385
- delete process.env.CLIPROXY_MANAGEMENT_KEY
386
- try {
387
- const {ctx, captured} = createCapturedCtx()
388
- await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
389
-
390
- expect(expectCapturedToInclude(captured, 'CLIPROXY_MANAGEMENT_KEY')).toBe(true)
391
- } finally {
392
- if (savedKey !== undefined) process.env.CLIPROXY_MANAGEMENT_KEY = savedKey
393
- }
394
- })
395
- })
396
-
397
- describe('usage-queue migration', () => {
398
- afterEach(() => {
399
- globalThis.fetch = originalFetch
400
- })
401
-
402
- it('populated usage-queue returns recent-activity summary with correct total and error count', async () => {
403
- const queue = [
404
- {status: 200, model: 'claude-3-5-sonnet'},
405
- {status: 500, model: 'claude-3-5-sonnet'},
406
- {status: 200, model: 'claude-3-5-sonnet'},
407
- ]
408
- globalThis.fetch = createFetchImplementation(async url => {
409
- if (url.includes('/v0/management/usage-queue')) {
410
- return new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}})
411
- }
412
-
413
- throw new Error(`Unexpected fetch: ${url}`)
414
- })
415
-
416
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
417
-
418
- expect(result.level).not.toBe('error')
419
- expect(result.summary).toContain('recent: 3')
420
- expect(result.summary).toContain('errors: 1')
421
- })
422
-
423
- it('empty usage-queue returns ok/idle result, not an error', async () => {
424
- globalThis.fetch = createFetchImplementation(async url => {
425
- if (url.includes('/v0/management/usage-queue')) {
426
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
427
- }
428
-
429
- throw new Error(`Unexpected fetch: ${url}`)
430
- })
431
-
432
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
433
-
434
- expect(result.level).toBe('ok')
435
- expect(result.summary).toMatch(/idle|recent: 0/)
436
- })
437
-
438
- it('malformed usage-queue response returns warning, not error, and does not throw', async () => {
439
- globalThis.fetch = createFetchImplementation(async url => {
440
- if (url.includes('/v0/management/usage-queue')) {
441
- return new Response('{"not":"an-array"}', {status: 200, headers: {'content-type': 'application/json'}})
442
- }
443
-
444
- throw new Error(`Unexpected fetch: ${url}`)
445
- })
446
-
447
- const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
448
-
449
- expect(result.level).toBe('warning')
450
- })
451
-
452
- it('formatUsageSummaryLine returns a human-friendly line for recent-window summary', () => {
453
- const result = formatUsageSummaryLine({
454
- title: 'Usage stats',
455
- level: 'ok',
456
- summary: 'recent: 5, errors: 1',
457
- })
458
-
459
- expect(result).not.toBeNull()
460
- expect(result).toContain('5')
461
- })
462
- })
463
-
464
- describe('management auth failure surfaces in unified summary (FIX 4)', () => {
465
- afterEach(() => {
466
- globalThis.fetch = originalFetch
467
- })
468
-
469
- it('getCliproxyStatusSummary with a bad key (401) shows auth failure in version and usageStats, not "— (no key)"', async () => {
470
- globalThis.fetch = createFetchImplementation(async (url, init) => {
471
- const hdrs = init?.headers
472
- const hasKey =
473
- hdrs instanceof Headers
474
- ? hdrs.has('x-management-key')
475
- : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
476
- if (url.includes('/v0/management/') && hasKey) {
477
- return new Response('Unauthorized', {status: 401})
478
- }
479
-
480
- return new Response('ok', {status: 200})
481
- })
482
-
483
- const summary = await getCliproxyStatusSummary('https://cliproxy.example.com', 'bad-key', false)
484
-
485
- // Must NOT show the no-key sentinel — a bad key is distinct from no key
486
- expect(summary.version).not.toBe('— (no key)')
487
- expect(summary.usageStats).not.toBe('— (no key)')
488
- // Must contain some error/auth indicator
489
- expect(summary.version.toLowerCase()).toMatch(/error|auth|401|management|unauthorized/i)
490
- expect(summary.usageStats.toLowerCase()).toMatch(/error|auth|401|management|unauthorized/i)
491
- })
492
- })
493
-
494
- describe('ban body word-boundary detection (FIX 5)', () => {
495
- afterEach(() => {
496
- globalThis.fetch = originalFetch
497
- })
498
-
499
- it('403 with body {detail:"bandwidth exceeded"} is NOT treated as a ban (generic 403)', async () => {
500
- globalThis.fetch = createFetchImplementation(async url => {
501
- if (url.includes('/v0/management/config')) {
502
- return new Response(JSON.stringify({detail: 'bandwidth exceeded'}), {
503
- status: 403,
504
- headers: {'content-type': 'application/json'},
505
- })
506
- }
507
-
508
- return new Response('ok', {status: 200})
509
- })
510
-
511
- const {ctx, captured} = createCapturedCtx()
512
- try {
513
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
514
- } catch (error) {
515
- if (!(error instanceof MockProcessExit)) throw error
516
- }
517
-
518
- const output = [...captured.stdout, ...captured.stderr].join('\n')
519
- expect(output.toLowerCase()).not.toMatch(/ip.?ban/)
520
- })
521
-
522
- it('403 with body {error:"IP banned"} IS treated as a ban', async () => {
523
- globalThis.fetch = createFetchImplementation(async url => {
524
- if (url.includes('/v0/management/config')) {
525
- return new Response(JSON.stringify({error: 'IP banned'}), {
526
- status: 403,
527
- headers: {'content-type': 'application/json'},
528
- })
529
- }
530
-
531
- return new Response('ok', {status: 200})
532
- })
533
-
534
- const {ctx, captured} = createCapturedCtx()
535
- try {
536
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
537
- } catch (error) {
538
- if (!(error instanceof MockProcessExit)) throw error
539
- }
540
-
541
- const output = [...captured.stdout, ...captured.stderr].join('\n')
542
- expect(output.toLowerCase()).toMatch(/ip.?ban/)
543
- })
544
- })
545
-
546
- describe('reachability probe targets /healthz liveness endpoint', () => {
547
- afterEach(() => {
548
- globalThis.fetch = originalFetch
549
- })
550
-
551
- it('cliproxyStatusAction reports HTTP reachable when /healthz returns 200 and bare base returns 404', async () => {
552
- const BASE = 'https://cliproxy.example.com'
553
- globalThis.fetch = createFetchImplementation(async url => {
554
- if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
555
- if (url === BASE) return new Response('Not Found', {status: 404})
556
- return new Response('ok', {status: 200})
557
- })
558
-
559
- const {ctx, captured} = createCapturedCtx()
560
- await cliproxyStatusAction({url: BASE}, ctx)
561
-
562
- const output = [...captured.stdout, ...captured.stderr].join('\n')
563
- expect(output).toContain('OK')
564
- expect(output).not.toMatch(/ERROR.*HTTP reachability|HTTP reachability.*ERROR/)
565
- })
566
-
567
- it('getCliproxyStatusSummary reports http ok when /healthz returns 200 and bare base returns 404', async () => {
568
- const BASE = 'https://cliproxy.example.com'
569
- globalThis.fetch = createFetchImplementation(async url => {
570
- if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
571
- if (url === BASE) return new Response('Not Found', {status: 404})
572
- return new Response('ok', {status: 200})
573
- })
574
-
575
- const summary = await getCliproxyStatusSummary(BASE, '', false)
576
-
577
- expect(summary.http).toMatch(/^OK/)
578
- })
579
- })
580
-
581
- describe('management auth probe (ban-awareness)', () => {
582
- afterEach(() => {
583
- globalThis.fetch = originalFetch
584
- })
585
-
586
- it('bad management key causes at most one management fetch and skips version+usage calls', async () => {
587
- const managementFetchUrls: string[] = []
588
-
589
- globalThis.fetch = createFetchImplementation(async (url, init) => {
590
- const hdrs = init?.headers
591
- const hasManagementKey =
592
- hdrs instanceof Headers
593
- ? hdrs.has('x-management-key')
594
- : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
595
- if (url.includes('/v0/management/') && hasManagementKey) {
596
- managementFetchUrls.push(url)
597
- return new Response('Unauthorized', {status: 401})
598
- }
599
-
600
- if (url.includes('/healthz') || !url.includes('/v0/management/')) {
601
- return new Response('ok', {status: 200})
602
- }
603
-
604
- throw new Error(`Unexpected fetch: ${url}`)
605
- })
606
-
607
- const {ctx} = createCapturedCtx()
608
- // Auth failure yields error-level result → exit(1) → MockProcessExit thrown
609
- try {
610
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'bad-key'}, ctx)
611
- } catch (error) {
612
- if (!(error instanceof MockProcessExit)) throw error
613
- }
614
-
615
- expect(managementFetchUrls.length).toBe(1)
616
- expect(managementFetchUrls[0]).toContain('/v0/management/config')
617
- })
618
-
619
- it('403 with ban body surfaces a distinct IP-banned message', async () => {
620
- globalThis.fetch = createFetchImplementation(async url => {
621
- if (url.includes('/v0/management/config')) {
622
- return new Response(JSON.stringify({error: 'IP banned'}), {
623
- status: 403,
624
- headers: {'content-type': 'application/json'},
625
- })
626
- }
627
-
628
- return new Response('ok', {status: 200})
629
- })
630
-
631
- const {ctx, captured} = createCapturedCtx()
632
- // 403+ban → error level → exit(1)
633
- try {
634
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
635
- } catch (error) {
636
- if (!(error instanceof MockProcessExit)) throw error
637
- }
638
-
639
- const output = [...captured.stdout, ...captured.stderr].join('\n')
640
- expect(output.toLowerCase()).toMatch(/ip.?ban/)
641
- })
642
-
643
- it('auth error message does not contain the management key value', async () => {
644
- const secretKey = 'super-secret-mgmt-key-12345'
645
-
646
- globalThis.fetch = createFetchImplementation(async url => {
647
- if (url.includes('/v0/management/config')) {
648
- return new Response('Unauthorized', {status: 401})
649
- }
650
-
651
- return new Response('ok', {status: 200})
652
- })
653
-
654
- const {ctx, captured} = createCapturedCtx()
655
- // 401 → error level → exit(1)
656
- try {
657
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: secretKey}, ctx)
658
- } catch (error) {
659
- if (!(error instanceof MockProcessExit)) throw error
660
- }
661
-
662
- const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
663
- expect(allOutput).not.toContain(secretKey)
664
- })
665
-
666
- it('successful probe allows version and usage checks to proceed in parallel', async () => {
667
- const fetchedUrls: string[] = []
668
-
669
- globalThis.fetch = createFetchImplementation(async url => {
670
- fetchedUrls.push(url)
671
- if (url.includes('/v0/management/config')) {
672
- return new Response(JSON.stringify({config: 'ok'}), {
673
- status: 200,
674
- headers: {'content-type': 'application/json'},
675
- })
676
- }
677
-
678
- if (url.includes('/v0/management/usage-queue')) {
679
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
680
- }
681
-
682
- if (url.includes('/v0/management/latest-version')) {
683
- return new Response(JSON.stringify({'latest-version': 'v7.1.31'}), {
684
- status: 200,
685
- headers: {'content-type': 'application/json'},
686
- })
687
- }
688
-
689
- return new Response('ok', {status: 200})
690
- })
691
-
692
- const {ctx} = createCapturedCtx()
693
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'valid-key'}, ctx)
694
-
695
- expect(fetchedUrls.some(u => u.includes('/v0/management/latest-version'))).toBe(true)
696
- expect(fetchedUrls.some(u => u.includes('/v0/management/usage-queue'))).toBe(true)
697
- })
698
-
699
- it('getCliproxyStatusSummary with bad key fires only one management fetch', async () => {
700
- const managementFetchUrls: string[] = []
701
-
702
- globalThis.fetch = createFetchImplementation(async (url, init) => {
703
- const hdrs = init?.headers
704
- const hasKey =
705
- hdrs instanceof Headers
706
- ? hdrs.has('x-management-key')
707
- : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
708
- if (url.includes('/v0/management/') && hasKey) {
709
- managementFetchUrls.push(url)
710
- return new Response('Unauthorized', {status: 401})
711
- }
712
-
713
- return new Response('ok', {status: 200})
714
- })
715
-
716
- await getCliproxyStatusSummary('https://cliproxy.example.com', 'bad-key', false)
717
-
718
- expect(managementFetchUrls.length).toBe(1)
719
- })
720
- })
721
-
722
- // ─── Ambient management key trusted-URL binding ───────────────────────────────
723
-
724
- describe('cliproxyStatusAction — ambient key does not follow agent-supplied URLs', () => {
725
- let originalEnv: Record<string, string | undefined>
726
-
727
- beforeEach(() => {
728
- originalEnv = {
729
- CLIPROXY_URL: process.env.CLIPROXY_URL,
730
- CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
731
- }
732
- process.env.CLIPROXY_MANAGEMENT_KEY = 'secret-ambient-key'
733
- delete process.env.CLIPROXY_URL
734
- })
735
-
736
- afterEach(() => {
737
- for (const [k, v] of Object.entries(originalEnv)) {
738
- if (v === undefined) {
739
- delete process.env[k]
740
- } else {
741
- process.env[k] = v
742
- }
743
- }
744
- globalThis.fetch = originalFetch
745
- })
746
-
747
- it('does NOT send the ambient management key to a non-trusted --url override', async () => {
748
- const capturedRequestsByHost: Record<string, string[]> = {}
749
-
750
- globalThis.fetch = createFetchImplementation(async (url, init) => {
751
- const {hostname} = new URL(url)
752
- const hdrs = init?.headers
753
- const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
754
-
755
- if (!capturedRequestsByHost[hostname]) capturedRequestsByHost[hostname] = []
756
- if (key) capturedRequestsByHost[hostname].push(key)
757
-
758
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
759
- if (url.includes('/v0/management/'))
760
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
761
- return new Response('ok', {status: 200})
762
- })
763
-
764
- const {ctx} = createCapturedCtx()
765
- try {
766
- await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
767
- } catch {
768
- // exit(1) expected or not — either way, we just care about key leakage
769
- }
770
-
771
- // The ambient secret key must NOT appear in any request to the evil host
772
- expect(capturedRequestsByHost['evil.example.com'] ?? []).not.toContain('secret-ambient-key')
773
- })
774
-
775
- it('still uses the ambient key when --url matches the default trusted URL', async () => {
776
- const capturedManagementKeys: string[] = []
777
-
778
- globalThis.fetch = createFetchImplementation(async (url, init) => {
779
- const hdrs = init?.headers
780
- const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
781
- if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
782
-
783
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
784
- if (url.includes('/v0/management/config'))
785
- return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
786
- if (url.includes('/v0/management/usage-queue'))
787
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
788
- if (url.includes('/v0/management/latest-version'))
789
- return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
790
- status: 200,
791
- headers: {'content-type': 'application/json'},
792
- })
793
- return new Response('ok', {status: 200})
794
- })
795
-
796
- const {ctx} = createCapturedCtx()
797
- // Default URL is https://cliproxy.fro.bot — pass it explicitly
798
- await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
799
-
800
- expect(capturedManagementKeys).toContain('secret-ambient-key')
801
- })
802
-
803
- it('uses an explicit --key even when --url is a non-trusted host', async () => {
804
- const capturedManagementKeys: string[] = []
805
-
806
- globalThis.fetch = createFetchImplementation(async (url, init) => {
807
- const hdrs = init?.headers
808
- const key = hdrs instanceof Headers ? hdrs.get('x-management-key') : null
809
- if (url.includes('/v0/management/') && key) capturedManagementKeys.push(key)
810
-
811
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
812
- if (url.includes('/v0/management/config'))
813
- return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
814
- if (url.includes('/v0/management/usage-queue'))
815
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
816
- if (url.includes('/v0/management/latest-version'))
817
- return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
818
- status: 200,
819
- headers: {'content-type': 'application/json'},
820
- })
821
- return new Response('ok', {status: 200})
822
- })
823
-
824
- const {ctx} = createCapturedCtx()
825
- await cliproxyStatusAction({url: 'https://other-cliproxy.example.com', key: 'explicit-key'}, ctx)
826
-
827
- expect(capturedManagementKeys).toContain('explicit-key')
828
- })
829
-
830
- it('falls through to the no-key warning path when URL is untrusted and no --key given', async () => {
831
- globalThis.fetch = createFetchImplementation(async url => {
832
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
833
- return new Response('[]', {status: 200})
834
- })
835
-
836
- const {ctx, captured} = createCapturedCtx()
837
- await cliproxyStatusAction({url: 'https://other-cliproxy.example.com'}, ctx)
838
-
839
- const output = [...captured.stdout, ...captured.stderr].join('\n')
840
- expect(output).toMatch(/CLIPROXY_MANAGEMENT_KEY|no key|skipping/i)
841
- })
842
- })
843
-
844
- // ─── checkProviderAuth ────────────────────────────────────────────────────────
845
-
846
- describe('checkProviderAuth', () => {
847
- afterEach(() => {
848
- globalThis.fetch = originalFetch
849
- })
850
-
851
- it('returns ok when POST /v1/chat/completions returns 200', async () => {
852
- globalThis.fetch = createFetchImplementation(
853
- async () =>
854
- new Response(JSON.stringify({choices: [{message: {content: 'pong'}}]}), {
855
- status: 200,
856
- headers: {'content-type': 'application/json'},
857
- }),
858
- )
859
-
860
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
861
-
862
- expect(result.level).toBe('ok')
863
- expect(result.title).toBe('Upstream provider auth (anthropic)')
864
- expect(result.summary).toContain('anthropic route OK')
865
- expect(result.summary).toContain('claude-sonnet-4-6')
866
- })
867
-
868
- it('returns error when POST returns 401', async () => {
869
- globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
870
-
871
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
872
-
873
- expect(result.level).toBe('error')
874
- expect(result.summary).toContain('401')
875
- expect(result.summary).toContain('cliproxy login claude')
876
- })
877
-
878
- it('returns error when POST returns 503 with auth_unavailable body', async () => {
879
- globalThis.fetch = createFetchImplementation(
880
- async () => new Response('auth_unavailable: no auth available (providers=claude)', {status: 503}),
881
- )
882
-
883
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
884
-
885
- expect(result.level).toBe('error')
886
- expect(result.summary).toContain('503')
887
- expect(result.summary).toContain('cliproxy login claude')
888
- })
889
-
890
- it('returns error when POST returns 503 with "no auth available" in body', async () => {
891
- globalThis.fetch = createFetchImplementation(async () => new Response('no auth available', {status: 503}))
892
-
893
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
894
-
895
- expect(result.level).toBe('error')
896
- expect(result.summary).toContain('cliproxy login claude')
897
- })
898
-
899
- it('returns error when POST returns 503 with "providers=claude" in body', async () => {
900
- globalThis.fetch = createFetchImplementation(
901
- async () => new Response('{"error":"providers=claude unavailable"}', {status: 503}),
902
- )
903
-
904
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
905
-
906
- expect(result.level).toBe('error')
907
- expect(result.summary).toContain('cliproxy login claude')
908
- })
909
-
910
- it('returns warning when POST returns 503 without auth-unavailable markers', async () => {
911
- globalThis.fetch = createFetchImplementation(async () => new Response('Service Unavailable', {status: 503}))
912
-
913
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
914
-
915
- expect(result.level).toBe('warning')
916
- expect(result.summary).toContain('503')
917
- expect(result.summary).not.toContain('cliproxy login claude')
918
- })
919
-
920
- it('returns warning when POST returns other non-2xx (e.g. 500)', async () => {
921
- globalThis.fetch = createFetchImplementation(async () => new Response('Internal Server Error', {status: 500}))
922
-
923
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
924
-
925
- expect(result.level).toBe('warning')
926
- expect(result.summary).toContain('500')
927
- })
928
-
929
- it('returns warning (not error) when fetch throws (network/timeout)', async () => {
930
- globalThis.fetch = createFetchImplementation(async () => {
931
- throw new Error('The operation was aborted due to timeout')
932
- })
933
-
934
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
935
-
936
- expect(result.level).toBe('warning')
937
- expect(result.summary).toContain('Anthropic probe failed')
938
- expect(result.summary).toContain('aborted')
939
- })
940
-
941
- it('never includes the apiKey in summary or details', async () => {
942
- const secretKey = 'super-secret-api-key-do-not-leak'
943
-
944
- globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
945
-
946
- const result = await checkProviderAuth('https://cliproxy.example.com', secretKey)
947
-
948
- expect(result.summary).not.toContain(secretKey)
949
- const detailsText = (result.details ?? []).join('\n')
950
- expect(detailsText).not.toContain(secretKey)
951
- })
952
-
953
- it('uses the default model claude-sonnet-4-6 when no model override given', async () => {
954
- let capturedBody = ''
955
-
956
- globalThis.fetch = createFetchImplementation(async (_url, init) => {
957
- capturedBody = typeof init?.body === 'string' ? init.body : ''
958
- return new Response(JSON.stringify({choices: []}), {
959
- status: 200,
960
- headers: {'content-type': 'application/json'},
961
- })
962
- })
963
-
964
- await checkProviderAuth('https://cliproxy.example.com', 'test-api-key')
965
-
966
- expect(capturedBody).toContain('claude-sonnet-4-6')
967
- })
968
-
969
- it('uses the model override when options.model is provided', async () => {
970
- let capturedBody = ''
971
-
972
- globalThis.fetch = createFetchImplementation(async (_url, init) => {
973
- capturedBody = typeof init?.body === 'string' ? init.body : ''
974
- return new Response(JSON.stringify({choices: []}), {
975
- status: 200,
976
- headers: {'content-type': 'application/json'},
977
- })
978
- })
979
-
980
- await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {model: 'claude-opus-4-5'})
981
-
982
- expect(capturedBody).toContain('claude-opus-4-5')
983
- expect(capturedBody).not.toContain('claude-sonnet-4-6')
984
- })
985
-
986
- it('includes verbose details (URL, model, status) when verbose=true', async () => {
987
- globalThis.fetch = createFetchImplementation(
988
- async () =>
989
- new Response(JSON.stringify({choices: []}), {
990
- status: 200,
991
- headers: {'content-type': 'application/json'},
992
- }),
993
- )
994
-
995
- const result = await checkProviderAuth('https://cliproxy.example.com', 'test-api-key', {verbose: true})
996
-
997
- expect(result.details).toBeDefined()
998
- const detailsText = (result.details ?? []).join('\n')
999
- expect(detailsText).toContain('https://cliproxy.example.com')
1000
- expect(detailsText).toContain('claude-sonnet-4-6')
1001
- })
1002
- })
1003
-
1004
- // ─── cliproxyStatusAction — provider auth wiring ─────────────────────────────
1005
-
1006
- describe('cliproxyStatusAction — provider auth probe wiring', () => {
1007
- let originalEnv: Record<string, string | undefined>
1008
-
1009
- beforeEach(() => {
1010
- originalEnv = {
1011
- CLIPROXY_URL: process.env.CLIPROXY_URL,
1012
- CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY,
1013
- CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
1014
- }
1015
- delete process.env.CLIPROXY_URL
1016
- delete process.env.CLIPROXY_MANAGEMENT_KEY
1017
- delete process.env.CLIPROXY_API_KEY
1018
- })
1019
-
1020
- afterEach(() => {
1021
- for (const [k, v] of Object.entries(originalEnv)) {
1022
- if (v === undefined) {
1023
- delete process.env[k]
1024
- } else {
1025
- process.env[k] = v
1026
- }
1027
- }
1028
- globalThis.fetch = originalFetch
1029
- })
1030
-
1031
- it('shows a warning (not error) when no provider key is available', async () => {
1032
- globalThis.fetch = createFetchImplementation(async url => {
1033
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
1034
- throw new Error(`Unexpected fetch: ${url}`)
1035
- })
1036
-
1037
- const {ctx, captured} = createCapturedCtx()
1038
- await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
1039
-
1040
- const output = [...captured.stdout, ...captured.stderr].join('\n')
1041
- expect(output).toMatch(/CLIPROXY_API_KEY|skipping upstream provider probe/i)
1042
- // Must be a warning, not an error — no exit(1) for missing key
1043
- expect(captured.exit).toBeNull()
1044
- })
1045
-
1046
- it('runs the provider auth probe when --api-key is provided', async () => {
1047
- const fetchedUrls: string[] = []
1048
-
1049
- globalThis.fetch = createFetchImplementation(async url => {
1050
- fetchedUrls.push(url)
1051
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
1052
- if (url.includes('/v1/chat/completions'))
1053
- return new Response(JSON.stringify({choices: []}), {
1054
- status: 200,
1055
- headers: {'content-type': 'application/json'},
1056
- })
1057
- throw new Error(`Unexpected fetch: ${url}`)
1058
- })
1059
-
1060
- const {ctx, captured} = createCapturedCtx()
1061
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1062
-
1063
- expect(fetchedUrls.some(u => u.includes('/v1/chat/completions'))).toBe(true)
1064
- const output = [...captured.stdout, ...captured.stderr].join('\n')
1065
- expect(output).toContain('Upstream provider auth')
1066
- })
1067
-
1068
- it('does NOT forward ambient CLIPROXY_API_KEY to an explicit --url override', async () => {
1069
- process.env.CLIPROXY_API_KEY = 'ambient-api-key-secret'
1070
-
1071
- const capturedAuthHeaders: string[] = []
1072
-
1073
- globalThis.fetch = createFetchImplementation(async (url, init) => {
1074
- const hdrs = init?.headers
1075
- let auth: string | null = null
1076
- if (hdrs instanceof Headers) {
1077
- auth = hdrs.get('authorization')
1078
- } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1079
- const hdrsObj = hdrs as Record<string, string>
1080
- auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1081
- }
1082
- if (auth) capturedAuthHeaders.push(auth)
1083
-
1084
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
1085
- if (url.includes('/v1/chat/completions')) return new Response(JSON.stringify({choices: []}), {status: 200})
1086
- return new Response('ok', {status: 200})
1087
- })
1088
-
1089
- const {ctx} = createCapturedCtx()
1090
- // Explicit --url override to a non-trusted host — ambient key must NOT follow
1091
- await cliproxyStatusAction({url: 'https://evil.example.com'}, ctx)
1092
-
1093
- // The ambient API key must not appear in any Authorization header sent to evil host
1094
- expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-secret'))).toBe(false)
1095
- })
1096
-
1097
- it('uses ambient CLIPROXY_API_KEY when URL is the trusted default', async () => {
1098
- process.env.CLIPROXY_API_KEY = 'ambient-api-key-trusted'
1099
-
1100
- const capturedAuthHeaders: string[] = []
1101
-
1102
- globalThis.fetch = createFetchImplementation(async (url, init) => {
1103
- const hdrs = init?.headers
1104
- let auth: string | null = null
1105
- if (hdrs instanceof Headers) {
1106
- auth = hdrs.get('authorization')
1107
- } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
1108
- const hdrsObj = hdrs as Record<string, string>
1109
- auth = hdrsObj.authorization ?? hdrsObj.Authorization ?? null
1110
- }
1111
- if (auth) capturedAuthHeaders.push(auth)
1112
-
1113
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
1114
- if (url.includes('/v1/chat/completions'))
1115
- return new Response(JSON.stringify({choices: []}), {
1116
- status: 200,
1117
- headers: {'content-type': 'application/json'},
1118
- })
1119
- // Management endpoints — return ok so management checks don't block
1120
- if (url.includes('/v0/management/config'))
1121
- return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}})
1122
- if (url.includes('/v0/management/usage-queue'))
1123
- return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
1124
- if (url.includes('/v0/management/latest-version'))
1125
- return new Response(JSON.stringify({'latest-version': 'v1.0.0'}), {
1126
- status: 200,
1127
- headers: {'content-type': 'application/json'},
1128
- })
1129
- return new Response('ok', {status: 200})
1130
- })
1131
-
1132
- const {ctx} = createCapturedCtx()
1133
- // Default trusted URL — ambient key SHOULD be used
1134
- await cliproxyStatusAction({url: 'https://cliproxy.fro.bot'}, ctx)
1135
-
1136
- expect(capturedAuthHeaders.some(h => h.includes('ambient-api-key-trusted'))).toBe(true)
1137
- })
1138
-
1139
- it('provider auth error (401) causes exit(1) — dead upstream is a real error', async () => {
1140
- globalThis.fetch = createFetchImplementation(async url => {
1141
- if (url.includes('/healthz')) return new Response('ok', {status: 200})
1142
- if (url.includes('/v1/chat/completions')) return new Response('Unauthorized', {status: 401})
1143
- throw new Error(`Unexpected fetch: ${url}`)
1144
- })
1145
-
1146
- const {ctx, captured} = createCapturedCtx()
1147
- let threw: unknown
1148
- try {
1149
- await cliproxyStatusAction({url: 'https://cliproxy.example.com', apiKey: 'my-api-key'}, ctx)
1150
- } catch (error) {
1151
- threw = error
1152
- }
1153
-
1154
- expect(threw).toBeInstanceOf(MockProcessExit)
1155
- expect(captured.exit?.code).toBe(1)
1156
- })
1157
- })