Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/auth/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getValidToken } from './oauth'
import { AutoDevError } from '../errors'
import type { ClientType } from '../core/types'

export async function resolveAuth(options?: { apiKey?: string; clientType?: ClientType }): Promise<string> {
Expand All @@ -8,7 +9,7 @@ export async function resolveAuth(options?: { apiKey?: string; clientType?: Clie
await getValidToken(options?.clientType ?? 'cli')

if (!apiKey) {
throw new Error('No API key found. Set AUTODEV_API_KEY or run: auto login')
throw new AutoDevError(401, 'UNAUTHORIZED', 'No API key found', 'Set AUTODEV_API_KEY or run: auto login')
}

return apiKey as string
Expand Down
3 changes: 2 additions & 1 deletion src/cli/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export function makeCommand(def: CommandDef): Command {
}
process.exit(1)
}
throw err
console.error(formatError(err instanceof Error ? err.message : String(err)))
process.exit(1)
}
})

Expand Down
9 changes: 7 additions & 2 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,15 @@ export class AutoDevClient {

if (!response.ok) {
const body = await response.json().catch(() => ({ error: response.statusText }))
const errorBody = body as { error?: string; suggestion?: string; upgradeLink?: string }
// Most endpoints return { error: string }; the auth middleware returns { error: { message } }
const errorBody = body as { error?: string | { message?: string }; suggestion?: string; upgradeLink?: string }
const message =
typeof errorBody.error === 'string'
? errorBody.error
: (errorBody.error?.message ?? response.statusText)
const code = this.statusToCode(response.status)
const suggestion = this.getSuggestion(response.status, definition.tier, errorBody.upgradeLink)
throw new AutoDevError(response.status, code, errorBody.error ?? response.statusText, suggestion)
throw new AutoDevError(response.status, code, message, suggestion)
}

const rawData = (await response.json()) as T
Expand Down
6 changes: 6 additions & 0 deletions test/auth/auth-origin-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ describe('getValidToken delegates to upstream refresh with clientType', () => {

describe('clearCredentials accepts clientType', () => {
it('accepts cli clientType without throwing', async () => {
// HOME is sandboxed by test/setup.ts, so this exercises the real code path
// without touching (or revoking) the developer's stored credentials.
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }))
await expect(clearCredentials('cli')).resolves.toBeUndefined()
fetchSpy.mockRestore()
})
})
31 changes: 31 additions & 0 deletions test/auth/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// Stub the stored-credential lookup so tests never read a real ~/.id.org.ai token
let storedToken: string | null = null

vi.mock('../../src/auth/oauth', () => ({
getValidToken: async () => storedToken,
}))

describe('resolveAuth', () => {
let originalApiKey: string | undefined

Expand Down Expand Up @@ -28,4 +35,28 @@ describe('resolveAuth', () => {
const key = await resolveAuth({})
expect(key).toBe('sk_env')
})

it('falls back to the stored login token', async () => {
delete process.env.AUTODEV_API_KEY
storedToken = 'stored_oauth_token'
const { resolveAuth } = await import('../../src/auth/resolve')
const key = await resolveAuth({})
expect(key).toBe('stored_oauth_token')
storedToken = null
})

it('throws a typed AutoDevError when no credentials exist anywhere', async () => {
delete process.env.AUTODEV_API_KEY
storedToken = null
const { resolveAuth } = await import('../../src/auth/resolve')
const { AutoDevError } = await import('../../src/errors')
const err = await resolveAuth({}).catch((e: unknown) => e)
expect(err).toBeInstanceOf(AutoDevError)
expect(err).toMatchObject({
status: 401,
code: 'UNAUTHORIZED',
message: 'No API key found',
suggestion: 'Set AUTODEV_API_KEY or run: auto login',
})
})
})
25 changes: 25 additions & 0 deletions test/cli/factory-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const ctorCalls: Array<Record<string, unknown>> = []
let requestImpl: (() => Promise<unknown>) | null = null

vi.mock('../../src/core/client', () => {
class MockAutoDevClient {
constructor(opts: Record<string, unknown>) {
ctorCalls.push(opts)
}
async request() {
if (requestImpl) return requestImpl()
return { data: {}, meta: { requestId: '', tier: 'starter' } }
}
}
Expand Down Expand Up @@ -39,4 +41,27 @@ describe('CLI makeCommand wiring', () => {
logSpy.mockRestore()
errSpy.mockRestore()
})

it('formats unexpected errors instead of rethrowing a raw stack trace', async () => {
requestImpl = async () => {
throw new Error('boom from below')
}
const cmd = makeCommand({ name: 'decode', description: 'Decode VIN' })
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('__exit__')
}) as never)

await expect(cmd.parseAsync(['node', 'decode', '1HGCM82633A004352', '--json'])).rejects.toThrow('__exit__')

expect(exitSpy).toHaveBeenCalledWith(1)
const printed = errSpy.mock.calls.flat().join('\n')
expect(printed).toContain('boom from below')

logSpy.mockRestore()
errSpy.mockRestore()
exitSpy.mockRestore()
requestImpl = null
})
})
60 changes: 60 additions & 0 deletions test/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,66 @@ describe('AutoDevClient', () => {
fetchSpy.mockRestore()
})

it('surfaces flat string error bodies as the error message', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Growth plan required' }), {
status: 403,
headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_flat' },
}),
)

await expect(client.request('specs', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({
name: 'AutoDevError',
status: 403,
message: 'Growth plan required',
})

fetchSpy.mockRestore()
})

it('surfaces nested auth-middleware error bodies instead of [object Object]', async () => {
// The auth middleware returns { status, error: { message } } — not the flat { error: string } shape
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({
status: 401,
error: { message: 'Authentication required. Get your API key at https://auto.dev/dashboard/api-keys' },
}),
{
status: 401,
headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_nested' },
},
),
)

await expect(client.request('decode', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({
name: 'AutoDevError',
status: 401,
code: 'UNAUTHORIZED',
message: 'Authentication required. Get your API key at https://auto.dev/dashboard/api-keys',
})

fetchSpy.mockRestore()
})

it('falls back to statusText when the error body has no message', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ status: 401, error: {} }), {
status: 401,
statusText: 'Unauthorized',
headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_empty' },
}),
)

await expect(client.request('decode', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({
name: 'AutoDevError',
status: 401,
message: 'Unauthorized',
})

fetchSpy.mockRestore()
})

it('builds plate URL with state and number path params', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ vin: '1HGCM82633A004352' }), {
Expand Down
10 changes: 10 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { mkdtempSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'

// Sandbox HOME so no test can ever read, revoke, or delete the developer's real
// stored credentials (~/.id.org.ai/token, ~/.oauth.do/token). clearCredentials()
// and the oauth module's default storage both resolve paths from os.homedir().
const sandboxHome = mkdtempSync(join(tmpdir(), 'auto-dev-sdk-test-home-'))
process.env.HOME = sandboxHome
process.env.USERPROFILE = sandboxHome
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.ts'],
},
resolve: {
alias: {
Expand Down
Loading