mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
The control plane sends a run's triggerDetail as an object ({requestedBy}, {event, target}, {scheduledFor}); the desktop schema wanted a string, so POST /run and run history failed to parse although the run had started, inviting a duplicate run. Accept the object and show one short label.
264 lines
9.2 KiB
TypeScript
264 lines
9.2 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 audit actor names', async () => {
|
|
const entry = {
|
|
id: 'a1',
|
|
actor: { type: 'user', id: 'u_ada', userId: 'u_ada', userName: 'Ada Lovelace' },
|
|
action: 'api_key.create',
|
|
target: null,
|
|
createdAt: 1
|
|
}
|
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
respond(200, { entries: [entry], nextBefore: null })
|
|
)
|
|
const page = await client(fetchImpl).listAudit()
|
|
expect(page.entries[0]?.actor.userName).toBe('Ada Lovelace')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|