Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/features/auth/__tests__/authService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ describe('authService', () => {
await expect(sessionLogin('alice', 'wrong')).rejects.toEqual(errorData)
})

it('sessionLogin reports the status when a filter rejects the request without a body', async () => {
// The resource-server filter answers a stale bearer token with a bodyless
// 401, so the client reports no error payload at all.
clientMocks.sessionLogin.mockResolvedValue({
data: undefined,
error: undefined,
response: new Response(null, { status: 401, statusText: 'Unauthorized' }),
})

await expect(sessionLogin('alice', 'correct-password')).rejects.toMatchObject({
status: 401,
detail: 'The server rejected the request with status 401.',
})
})

it('confirmEmail throws on client error', async () => {
const errorData = { title: 'Gone', status: 410 }
clientMocks.confirmEmail.mockResolvedValue(fail(errorData))
Expand Down
33 changes: 29 additions & 4 deletions src/features/auth/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type RegisterData = RegisterUserRequest
interface ClientResult<T> {
data: T | undefined
error: unknown | undefined
response?: Response
}

const messageResponseSchema = z.record(z.string(), z.string()).transform((data) => ({
Expand Down Expand Up @@ -68,10 +69,34 @@ function apiOptions(): {
}
}

// A request rejected by a servlet filter carries no ProblemDetail body, so the
// status is the only thing the user can be told. Reporting it beats the blanket
// "Login failed" that hid a 401 from the resource-server filter.
function problemFromStatus(response: Response): unknown {
return {
type: 'about:blank',
title: response.statusText || 'Request failed',
status: response.status,
detail: `The server rejected the request with status ${response.status}.`,
}
}

function rejectionOf(result: ClientResult<unknown>): unknown {
const { error, response } = result
const hasProblemBody = typeof error === 'object' && error !== null && 'status' in error
if (hasProblemBody) return error
if (response !== undefined && !response.ok) return problemFromStatus(response)
return error ?? new Error('Missing response body')
}

function isRejected(result: ClientResult<unknown>): boolean {
return result.error !== undefined || (result.response !== undefined && !result.response.ok)
}

async function unwrap<T>(result: Promise<ClientResult<T>>): Promise<NonNullable<T>> {
const response = await result
if (response.error !== undefined) {
throw response.error
if (isRejected(response)) {
throw rejectionOf(response)
}
if (response.data == null) {
throw new Error('Missing response body')
Expand All @@ -81,8 +106,8 @@ async function unwrap<T>(result: Promise<ClientResult<T>>): Promise<NonNullable<

async function unwrapVoid(result: Promise<ClientResult<unknown>>): Promise<void> {
const response = await result
if (response.error !== undefined) {
throw response.error
if (isRejected(response)) {
throw rejectionOf(response)
}
}

Expand Down
Loading