Files
Matthieu Sieben 52834aba18 Lex SDK error handling improvements (#4688)
* Lex SDK error handling improvements

* Allow `WWWAuthenticate` to have multiple challenges for the same scheme

* tidy

* add tests

* tests

* review comments

* tidy

* tidy

* tidy

* tidy
2026-03-02 15:37:00 +01:00

263 lines
9.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { XrpcInternalError, XrpcResponseError } from '@atproto/lex-client'
import { LexError } from '@atproto/lex-data'
import { IssueInvalidType, LexValidationError, l } from '@atproto/lex-schema'
import { LexServerAuthError, LexServerError } from './errors.js'
// Minimal method fixtures for XrpcError subclasses
const testQuery = l.query(
'io.example.test',
l.params(),
l.payload('application/json', l.object({ value: l.string() })),
)
describe(LexServerError, () => {
it('stores status, body, and headers', () => {
const error = new LexServerError(
400,
{ error: 'InvalidRequest', message: 'Bad input' },
{ 'X-Custom': 'header' },
)
expect(error.status).toBe(400)
expect(error.body).toEqual({
error: 'InvalidRequest',
message: 'Bad input',
})
expect(error.headers?.get('x-custom')).toBe('header')
expect(error.error).toBe('InvalidRequest')
expect(error.message).toBe('Bad input')
})
it('has undefined headers when none provided', () => {
const error = new LexServerError(500, { error: 'InternalError' })
expect(error.headers).toBeUndefined()
})
it('toJSON returns the body', () => {
const body = { error: 'TestError' as const, message: 'test' }
const error = new LexServerError(400, body)
expect(error.toJSON()).toEqual(body)
})
it('toResponse creates a Response with correct status and body', async () => {
const error = new LexServerError(
422,
{ error: 'ValidationError', message: 'Invalid data' },
{ 'X-Test': 'yes' },
)
const response = error.toResponse()
expect(response.status).toBe(422)
expect(response.headers.get('X-Test')).toBe('yes')
expect(await response.json()).toEqual({
error: 'ValidationError',
message: 'Invalid data',
})
})
describe('from()', () => {
it('returns existing LexServerError as-is', () => {
const original = new LexServerError(400, { error: 'Test' })
expect(LexServerError.from(original)).toBe(original)
})
it('returns a LexServerAuthError as-is (since it extends LexServerError)', () => {
const original = new LexServerAuthError('AuthenticationRequired', 'test')
expect(LexServerError.from(original)).toBe(original)
})
it('converts XrpcError to downstream LexServerError', () => {
const xrpcError = new XrpcInternalError(testQuery, 'Something broke')
const serverError = LexServerError.from(xrpcError)
expect(serverError).toBeInstanceOf(LexServerError)
expect(serverError.status).toBe(500)
expect(serverError.body.error).toBe('InternalServerError')
expect(serverError.cause).toBe(xrpcError)
})
it('converts XrpcResponseError with 500 to 502', () => {
const response = new Response(null, { status: 500 })
const xrpcError = new XrpcResponseError(testQuery, response, {
encoding: 'application/json',
body: { error: 'Boom', message: 'Try again later' },
})
const serverError = LexServerError.from(xrpcError)
expect(serverError.status).toBe(502)
expect(serverError.body.error).toBe('Boom')
})
it('preserves the status of non-500 5xx errors', () => {
const response = new Response(null, { status: 502 })
const xrpcError = new XrpcResponseError(testQuery, response, {
encoding: 'application/json',
body: { error: 'FooBar', message: 'Try again later' },
})
const serverError = LexServerError.from(xrpcError)
expect(serverError.status).toBe(502)
expect(serverError.body.error).toBe('FooBar')
})
it('converts XrpcResponseError with 4xx preserving status', () => {
const response = new Response(null, { status: 404 })
const xrpcError = new XrpcResponseError(testQuery, response, {
encoding: 'application/json',
body: { error: 'NotFound', message: 'Record not found' },
})
const serverError = LexServerError.from(xrpcError)
expect(serverError.status).toBe(404)
expect(serverError.body.error).toBe('NotFound')
})
it('converts LexValidationError to 400', () => {
const validationError = new LexValidationError([
new IssueInvalidType([], 'hello', ['number']),
])
const serverError = LexServerError.from(validationError)
expect(serverError.status).toBe(400)
expect(serverError.body.error).toBe('InvalidRequest')
expect(serverError.cause).toBe(validationError)
})
it('converts plain LexError to 500', () => {
const lexError = new LexError('CustomError', 'Something happened')
const serverError = LexServerError.from(lexError)
expect(serverError.status).toBe(500)
expect(serverError.body.error).toBe('CustomError')
expect(serverError.cause).toBe(lexError)
})
it('converts unknown errors to 500 InternalServerError', () => {
const serverError = LexServerError.from(new TypeError('oops'))
expect(serverError.status).toBe(500)
expect(serverError.body.error).toBe('InternalServerError')
expect(serverError.body.message).toBe('An internal error occurred')
})
it('converts non-Error values to 500 InternalServerError', () => {
const serverError = LexServerError.from('string error')
expect(serverError.status).toBe(500)
expect(serverError.body.error).toBe('InternalServerError')
})
})
})
describe(LexServerAuthError, () => {
it('always has status 401', () => {
const error = new LexServerAuthError(
'AuthenticationRequired',
'Token expired',
)
expect(error.status).toBe(401)
})
it('sets WWW-Authenticate header', () => {
const error = new LexServerAuthError(
'AuthenticationRequired',
'Token required',
{ Bearer: { realm: 'api.example.com', error: 'InvalidToken' } },
)
const header = error.headers?.get('WWW-Authenticate')
expect(header).toContain('Bearer')
expect(header).toContain('realm="api.example.com"')
expect(header).toContain('error="InvalidToken"')
})
it('sets Access-Control-Expose-Headers for CORS', () => {
const error = new LexServerAuthError(
'AuthenticationRequired',
'Token required',
{
Bearer: { realm: 'api.example.com', error: 'InvalidToken' },
},
)
expect(error.headers?.get('Access-Control-Expose-Headers')).toBe(
'WWW-Authenticate',
)
expect(error.headers?.get('WWW-Authenticate')).toBe(
'Bearer realm="api.example.com", error="InvalidToken"',
)
})
it('does not set WWW-Authenticate header if wwwAuthenticate is empty', () => {
const error = new LexServerAuthError('AuthenticationRequired', 'No token')
expect(error.headers).toBeUndefined()
})
it('toResponse returns 401 with proper headers', async () => {
const error = new LexServerAuthError(
'AuthenticationRequired',
'Missing token',
{ Bearer: { error: 'MissingToken' } },
)
const response = error.toResponse()
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer error="MissingToken"',
)
const body = await response.json()
expect(body.error).toBe('AuthenticationRequired')
expect(body.message).toBe('Missing token')
})
describe('from()', () => {
it('returns existing LexServerAuthError as-is', () => {
const original = new LexServerAuthError('AuthenticationRequired', 'test')
expect(LexServerAuthError.from(original)).toBe(original)
})
it('wraps a LexServerError using its error code and message', () => {
const serverError = new LexServerError(403, {
error: 'Forbidden',
message: 'Access denied',
})
const authError = LexServerAuthError.from(serverError, {
Bearer: { error: 'InsufficientScope' },
})
expect(authError).toBeInstanceOf(LexServerAuthError)
expect(authError.error).toBe('Forbidden')
expect(authError.message).toBe('Access denied')
expect(authError.cause).toBe(serverError)
expect(authError.headers?.get('WWW-Authenticate')).toBe(
'Bearer error="InsufficientScope"',
)
})
it('wraps a LexError preserving error code and message', () => {
const lexError = new LexError('ExpiredToken', 'Token has expired')
const authError = LexServerAuthError.from(lexError, {
Bearer: { error: 'ExpiredToken' },
})
expect(authError).toBeInstanceOf(LexServerAuthError)
expect(authError.error).toBe('ExpiredToken')
expect(authError.message).toBe('Token has expired')
expect(authError.cause).toBe(lexError)
})
it('wraps unknown errors with default error code', () => {
const authError = LexServerAuthError.from(new Error('something'))
expect(authError.error).toBe('AuthenticationRequired')
expect(authError.message).toBe('Authentication failed')
})
it('wraps non-Error values with default error code', () => {
const authError = LexServerAuthError.from(null)
expect(authError.error).toBe('AuthenticationRequired')
expect(authError.message).toBe('Authentication failed')
})
})
})