fix(frontend): handle session 401 before reading error bodies

This commit is contained in:
2026-10-19 04:51:56 +08:00
parent 3b8d9a2e71
commit 6071994695
3 changed files with 102 additions and 2 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
import { boundedFetch } from './transport'
import { boundedFetch, rejectSession401 } from './transport'
let expired: () => void = () => {}
let authorizationVersion = 0
@@ -19,6 +19,7 @@ export const api = createClient<paths>({
() => authorizationVersion,
),
})
api.use({ onResponse: rejectSession401 })
// Only the bounded administrator ZIP endpoint uses the longer upload deadline.
// Ordinary login/submission requests keep their existing ten-second budget.
+13
View File
@@ -15,6 +15,19 @@ export class ApiError extends Error {
}
}
export function rejectSession401({
schemaPath,
response,
}: {
schemaPath: string
response: Response
}): void {
if (schemaPath !== '/api/v1/auth/me' || response.status !== 401) return
// Session identity follows the status even if its error body cannot be read.
void response.body?.cancel().catch(() => undefined)
throw new ApiError(401)
}
export function boundedFetch(
fetcher: typeof fetch,
unauthorized: () => void,
+87 -1
View File
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import createClient from 'openapi-fetch'
import type { paths } from '../src/api/generated/schema'
import { submissionIntent } from '../src/api/submissionIntent.ts'
import { boundedPoll } from '../src/api/polling.ts'
import { ApiError, boundedFetch, unwrap } from '../src/api/transport.ts'
import { ApiError, boundedFetch, rejectSession401, unwrap } from '../src/api/transport.ts'
function memory() {
const values = new Map<string, string>()
@@ -250,3 +252,87 @@ test('an old401 cannot expire a newly authenticated session', async () => {
await current
assert.equal(expired, 1)
})
test('session401 is classified before the OpenAPI client reads an unreadable error body', async () => {
let reads = 0,
cancelled = 0,
expired = 0
const body = new ReadableStream(
{
pull(controller) {
reads++
controller.error(new Error('synthetic error body read failure'))
},
cancel() {
cancelled++
},
},
{ highWaterMark: 0 },
)
const client = createClient<paths>({
baseUrl: 'http://localhost',
fetch: boundedFetch(
async () => new Response(body, { status: 401 }),
() => {
expired++
},
),
})
client.use({ onResponse: rejectSession401 })
await assert.rejects(
client.GET('/api/v1/auth/me'),
(e) => e instanceof ApiError && e.status === 401,
)
assert.equal(reads, 0)
assert.equal(cancelled, 1)
assert.equal(expired, 1)
})
test('session response classification preserves other endpoints and unavailable or successful sessions', async () => {
let response = new Response('{"detail":"denied"}', { status: 401 })
const client = createClient<paths>({ baseUrl: 'http://localhost', fetch: async () => response })
client.use({ onResponse: rejectSession401 })
const other = await client.GET('/api/v1/submissions/{submission_id}', {
params: { path: { submission_id: '00000000-0000-4000-8000-000000000001' } },
})
assert.equal(other.response.status, 401)
assert.deepEqual(other.error, { detail: 'denied' })
const failure = new Error('synthetic unavailable response body')
response = new Response(
new ReadableStream({
pull(controller) {
controller.error(failure)
},
}),
{ status: 503 },
)
await assert.rejects(client.GET('/api/v1/auth/me'), (e) => e === failure)
response = new Response('{"username":"synthetic"}', { status: 200 })
assert.deepEqual((await client.GET('/api/v1/auth/me')).data, { username: 'synthetic' })
})
test('session401 middleware retains stale-response authorization fencing', async () => {
let version = 0,
expired = 0
let complete: (response: Response) => void = () => {}
const client = createClient<paths>({
baseUrl: 'http://localhost',
fetch: boundedFetch(
async () => new Promise<Response>((resolve) => (complete = resolve)),
() => {
expired++
},
() => version,
),
})
client.use({ onResponse: rejectSession401 })
const old = client.GET('/api/v1/auth/me')
version++
complete(new Response('{}', { status: 401 }))
await assert.rejects(old, (e) => e instanceof ApiError && e.status === 401)
assert.equal(expired, 0)
const current = client.GET('/api/v1/auth/me')
complete(new Response('{}', { status: 401 }))
await assert.rejects(current, (e) => e instanceof ApiError && e.status === 401)
assert.equal(expired, 1)
})