Files
orca/src/main/cloud-api/cloud-api-client.test.ts
T

332 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { CloudApiError, classifyCloudApiResponse, createCloudApiClient } from './cloud-api-client'
const KEY = {
id: 'key_1',
name: 'CI',
prefix: 'orca_sk_Ab3x',
scopes: ['workspaces:read', 'future:scope'],
createdBy: 'u1',
createdAt: 1,
lastUsedAt: null,
expiresAt: null
}
function respond(status: number, body: unknown, headers: Record<string, string> = {}) {
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...headers }
})
}
function client(fetchImpl: typeof fetch, getAccessToken = async () => 'tok_secret') {
return createCloudApiClient({
controlPlaneUrl: 'https://cp.example.dev/',
getAccessToken,
fetchImpl
})
}
async function cloudApiErrorOf(promise: Promise<unknown>): Promise<CloudApiError> {
const caught = await promise.then(
() => null,
(error: unknown) => error
)
if (!(caught instanceof CloudApiError)) {
throw new Error(`expected a CloudApiError, got ${String(caught)}`)
}
return caught
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise
} catch (error) {
if (error instanceof CloudApiError) {
return error.code
}
throw error
}
return 'ok'
}
describe('createCloudApiClient', () => {
it('sends the bearer token, JSON body and query, and validates the answer', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
respond(200, { keys: [KEY], availableScopes: ['audit:read'] })
)
const keys = await client(fetchImpl).listApiKeys()
expect(keys.keys[0]?.prefix).toBe('orca_sk_Ab3x')
// Unknown scopes from a newer server are dropped, not fatal.
expect(keys.keys[0]?.scopes).toEqual(['workspaces:read'])
const [url, init] = fetchImpl.mock.calls[0] ?? []
expect(url).toBe('https://cp.example.dev/v1/api-keys')
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer tok_secret')
expect(init?.redirect).toBe('error')
const created = vi.fn<typeof fetch>(async () =>
respond(201, { key: KEY, secret: `orca_sk_${'a'.repeat(32)}` })
)
await client(created).createApiKey({
name: 'CI',
scopes: ['workspaces:read']
})
const [, createInit] = created.mock.calls[0] ?? []
expect(createInit?.method).toBe('POST')
expect(JSON.parse(String(createInit?.body))).toEqual({
name: 'CI',
scopes: ['workspaces:read']
})
const runs = vi.fn<typeof fetch>(async () => respond(200, { runs: [], nextBefore: null }))
await client(runs).listRoutineRuns('rt/1', { limit: 20, before: 99 })
expect(runs.mock.calls[0]?.[0]).toBe(
'https://cp.example.dev/v1/routines/rt%2F1/runs?limit=20&before=99'
)
})
it('rejects a malformed answer as invalid-response', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => respond(200, { keys: 'nope' }))
expect(await codeOf(client(fetchImpl).listApiKeys())).toBe('invalid-response')
const badSecret = vi.fn<typeof fetch>(async () =>
respond(201, { key: KEY, secret: 'not-a-key' })
)
expect(
await codeOf(
client(badSecret).createApiKey({
name: 'x',
scopes: ['audit:read']
})
)
).toBe('invalid-response')
})
it('maps network and token failures without leaking the token', async () => {
const offline = vi.fn<typeof fetch>(async () => {
throw new TypeError('fetch failed tok_secret')
})
const error = await cloudApiErrorOf(client(offline).me())
expect(error.code).toBe('network')
expect(error.message).not.toContain('tok_secret')
const noToken = client(vi.fn<typeof fetch>(), async () => {
throw new Error('signed out')
})
expect(await codeOf(noToken.me())).toBe('signed-out')
})
it('treats a missing routines route as not-available but a missing routine as not-found', async () => {
const missing = vi.fn<typeof fetch>(async () => respond(404, { error: 'not_found' }))
const c = client(missing)
expect(await codeOf(c.listRoutines())).toBe('not-available')
expect(await codeOf(c.getRoutine('rt_1'))).toBe('not-found')
})
it('carries retry-after on rate limits and accepts empty DELETE bodies', async () => {
const limited = vi.fn<typeof fetch>(async () =>
respond(429, { error: 'rate_limited' }, { 'retry-after': '7' })
)
const error = await cloudApiErrorOf(client(limited).listApiKeys())
expect(error.code).toBe('rate-limited')
expect(error.retryAfterSeconds).toBe(7)
const deleted = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }))
await expect(client(deleted).revokeApiKey('key_1')).resolves.toBeUndefined()
})
})
describe('teammate names', () => {
it('accepts optional names and degrades a bad one to null', async () => {
const named = { ...KEY, createdByName: 'Ada Lovelace' }
const bad = { ...KEY, id: 'key_2', createdByName: 42 }
const legacy = { ...KEY, id: 'key_3' }
const fetchImpl = vi.fn<typeof fetch>(async () =>
respond(200, { keys: [named, bad, legacy], availableScopes: [] })
)
const { keys } = await client(fetchImpl).listApiKeys()
expect(keys.map((key) => key.createdByName)).toEqual(['Ada Lovelace', null, undefined])
})
it('reads the team audit log: filters go in the query, unknown kinds and categories still parse', async () => {
const event = {
id: 'aud_1',
at: 5,
category: 'something_new',
action: 'secret.updated',
actor: { kind: 'robot', userId: 'u_ada', name: 'Ada Lovelace', via: 'carrier_pigeon' },
target: { type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' },
detail: { scope: 'org' }
}
const fetchImpl = vi.fn<typeof fetch>(async () =>
respond(200, {
events: [event],
nextCursor: '5.aud_1',
actors: [event.actor],
categories: ['secrets']
})
)
const page = await client(fetchImpl).listAuditLog({
category: 'secrets',
actor: 'u_ada',
limit: 50
})
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&actor=u_ada&limit=50'
)
expect(page.nextCursor).toBe('5.aud_1')
expect(page.events[0]).toMatchObject({
category: 'other',
actor: { kind: 'person', name: 'Ada Lovelace', via: null },
target: { label: 'STRIPE_KEY' }
})
})
it('tells a plan without the audit log apart from a missing role and an older control plane', async () => {
const answer = (status: number, body: unknown) =>
codeOf(client(vi.fn<typeof fetch>(async () => respond(status, body))).listAuditLog())
expect(await answer(403, { error: 'entitlement_denied', reason: 'audit_log' })).toBe(
'plan-required'
)
expect(await answer(403, { error: 'forbidden', reason: 'admin_required' })).toBe(
'admin-required'
)
expect(await answer(403, { error: 'insufficient_scope', reason: 'audit:read' })).toBe(
'forbidden'
)
expect(await answer(404, { error: 'not_found' })).toBe('not-available')
expect(await answer(503, { error: 'billing_unavailable' })).toBe('server')
const refused = await cloudApiErrorOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).listAuditLog()
)
expect(refused.detail).toBe('audit_log')
})
it('exports the audit log as CSV text and says when the export was cut short', async () => {
const csv = 'time,category,action\r\n2026-11-12T00:00:00.000Z,secrets,secret.updated\r\n'
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(csv, {
status: 200,
headers: { 'content-type': 'text/csv', 'x-orca-audit-truncated': 'true' }
})
)
const exported = await client(fetchImpl).exportAuditLogCsv({ category: 'secrets' })
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&format=csv'
)
expect(exported).toEqual({ csv, truncated: true })
expect(
await codeOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).exportAuditLogCsv()
)
).toBe('plan-required')
})
})
describe('response hardening', () => {
it('keeps a freshly minted secret even if the key metadata drifted', async () => {
const secret = `orca_sk_${'b'.repeat(32)}`
const fetchImpl = vi.fn<typeof fetch>(async () => respond(201, { key: { id: 7 }, secret }))
const created = await client(fetchImpl).createApiKey({
name: 'x',
scopes: ['workspaces:read']
})
expect(created).toEqual({ key: null, secret })
})
it('drops non-https links from the server', async () => {
const run = {
id: 'run_1',
routineId: 'rt_1',
trigger: 'manual',
triggerDetail: null,
status: 'succeeded',
workspaceId: null,
result: { prUrl: 'javascript:alert(1)' },
error: null,
createdAt: 1,
startedAt: 1,
finishedAt: 2,
durationMs: 1
}
const fetchImpl = vi.fn<typeof fetch>(async () => respond(202, { run }))
// A bad link is dropped, never opened, and doesn't fail the whole response.
const { run: parsed } = await client(fetchImpl).runRoutine('rt_1')
expect(parsed.result?.prUrl).toBeUndefined()
expect(parsed.status).toBe('succeeded')
})
it('accepts the run the control plane answers Run now with (object trigger detail)', async () => {
// Exactly what POST /v1/routines/:id/run returns (routines/store.ts runView, private view).
const queued = {
id: 'run_9',
routineId: 'rt_1',
trigger: 'manual',
triggerDetail: { requestedBy: 'usr_1' },
status: 'queued',
workspaceId: null,
result: null,
error: null,
createdAt: 5,
startedAt: null,
finishedAt: null,
durationMs: null
}
const fetchImpl = vi.fn<typeof fetch>(async () => respond(202, { run: queued }))
const { run } = await client(fetchImpl).runRoutine('rt_1')
expect(run).toMatchObject({ id: 'run_9', status: 'queued', triggerDetail: null })
const github = {
...queued,
trigger: 'github',
triggerDetail: {
event: 'pull_request.labeled',
delivery: 'd1',
target: { repo: 'acme/web', number: 482, kind: 'pull_request' },
context: { anything: true }
}
}
const page = vi.fn<typeof fetch>(async () =>
respond(200, {
runs: [github, { ...queued, triggerDetail: 'POST from ci' }],
nextBefore: null
})
)
const { runs } = await client(page).listRoutineRuns('rt_1')
expect(runs.map((r) => r.triggerDetail)).toEqual([
'acme/web#482 pull_request.labeled',
'POST from ci'
])
})
})
describe('classifyCloudApiResponse', () => {
it.each([
[401, { error: 'unauthorized' }, 'signed-out'],
[403, { error: 'forbidden', reason: 'admin_required' }, 'admin-required'],
[403, { error: 'forbidden', reason: 'admin_scope:audit:read' }, 'admin-required'],
[403, { error: 'insufficient_scope', reason: 'routines:write' }, 'forbidden'],
[400, { error: 'invalid_request', reason: 'name: Too small' }, 'invalid-request'],
[503, { error: 'not_configured' }, 'not-available'],
[500, null, 'server'],
[502, { error: 'workspace_unavailable' }, 'server']
])('%i %j → %s', (status, body, code) => {
expect(classifyCloudApiResponse(status, body).code).toBe(code)
})
it('keeps the server validation reason as detail, truncated', () => {
expect(classifyCloudApiResponse(400, { reason: 'name: Too small' }).detail).toBe(
'name: Too small'
)
expect(classifyCloudApiResponse(400, { reason: 'x'.repeat(1000) }).detail).toHaveLength(200)
})
})