52834aba18
* 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
263 lines
9.0 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|