refactor(desktop): route every REST status error through httpStatusError

fetchJsonViaOauthSession and finalizeGatewayDownload still hand-rolled the
`Error("<status>: <body>")` + statusCode shape the helper was introduced to
consolidate, so the "shared by all three paths" claim was false on landing.
The download-transport source test now asserts the helper call.
This commit is contained in:
kshitijk4poor
2026-09-11 11:30:24 +05:30
committed by kshitij
parent 8d22781b1c
commit 0dd6bee93a
3 changed files with 3 additions and 293 deletions
@@ -1,285 +0,0 @@
/**
* E2E regression for issue #95701: a remote OAuth gateway whose stored
* session has been invalidated server-side must boot into ONE stable
* recovery overlay with a clickable Sign in action — not alternate between
* the connecting state and the overlay while the renderer's transient-boot
* retry loop re-drives a rejection that can never self-heal.
*
* The shape under test is the RFC 8252 native-bearer flow: the desktop holds
* a locally-unexpired access token, but the gateway rejects it (restarted
* with new signing state), and the refresh token is dead too. The gateway
* never rotates a native bearer server-side (see
* hermes_cli/dashboard_auth/middleware.py), so the desktop must try ONE
* rotation via /auth/native/refresh and then treat the rejection as
* confirmed: latch reauth, mark the boot non-retryable, hold the overlay.
*
* Contract asserted here, end to end through the real Electron main process
* and renderer:
*
* 1. The recovery overlay appears and then stays continuously visible —
* no sample over a window longer than the renderer's boot-retry backoff
* sees it hidden.
* 2. The gateway saw exactly one ticket mint and exactly one refresh
* attempt: the rejection was confirmed once, not hammered.
* 3. The boot-progress snapshot the renderer reads is the non-retryable,
* structured-401 verdict the reauth latch depends on.
* 4. The overlay offers the remote sign-in action (dead tokens were dropped,
* so Settings no longer reports the session as connected).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import * as fs from 'node:fs'
import * as http from 'node:http'
import type { AddressInfo } from 'node:net'
import * as path from 'node:path'
import { buildAppEnv, createSandbox, launchDesktop, type Sandbox, waitForBootFailure } from './fixtures'
import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test'
// Longer than the renderer's first boot-retry delay (2s base, full jitter,
// #82679) by a wide margin, so a retry loop that hides the overlay cannot
// slip between two samples.
const STABILITY_WINDOW_MS = 15_000
const SAMPLE_INTERVAL_MS = 100
interface RecordedRequest {
method: string
path: string
bearer: boolean
}
interface ExpiredSessionGateway {
url: string
requests: RecordedRequest[]
count: (method: string, pathname: string) => number
close: () => Promise<void>
}
/**
* A gated gateway whose every authenticated session is dead. Public
* discovery works (status advertises the native flow, providers are
* OAuth-style so the desktop keeps the strict guard), but the ticket mint
* and the native refresh both answer the middleware's structured
* `session_expired` 401. Every request is recorded so the test can prove the
* rejection was confirmed exactly once.
*/
async function startExpiredSessionGateway(): Promise<ExpiredSessionGateway> {
const requests: RecordedRequest[] = []
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const authorization = req.headers.authorization
requests.push({
method: req.method ?? 'GET',
path: url.pathname,
bearer: typeof authorization === 'string' && authorization.startsWith('Bearer '),
})
const json = (status: number, body: unknown): void => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
// Drain the body before answering so a POST never sees an early close.
req.resume()
req.on('end', () => {
if (url.pathname === '/api/status') {
json(200, { auth_required: true, auth_flows: ['native_pkce'], ok: true, version: '0.0.0-e2e-fake' })
return
}
if (url.pathname === '/api/auth/providers') {
json(200, { providers: [{ name: 'portal', supports_password: false }] })
return
}
if (url.pathname === '/api/auth/ws-ticket' || url.pathname === '/auth/native/refresh') {
json(401, {
error: 'session_expired',
detail: 'Unauthorized',
reason: 'invalid_or_expired_session',
login_url: '/login',
})
return
}
if (url.pathname.startsWith('/api/')) {
json(401, { error: 'unauthenticated', detail: 'Unauthorized', reason: 'no_cookie', login_url: '/login' })
return
}
json(404, { detail: 'not found' })
})
})
// No WebSocket can ever be authenticated here; refuse the upgrade fast.
server.on('upgrade', (_req, socket) => {
socket.destroy()
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address() as AddressInfo
return {
close: () =>
new Promise<void>(resolve => {
server.closeAllConnections?.()
server.close(() => resolve())
}),
count: (method, pathname) => requests.filter(r => r.method === method && r.path === pathname).length,
requests,
url: `http://127.0.0.1:${port}`,
}
}
/**
* Seed the sandbox the way a previously signed-in desktop leaves it: a saved
* remote OAuth connection and a stored native token set whose access token
* has NOT expired locally (so the desktop presents it as-is). The blob uses
* the plain encoding the desktop's secret reader accepts verbatim — an OS
* keyring is not available on the CI runner and is irrelevant to the contract.
*/
function seedExpiredNativeSession(sandbox: Sandbox, gatewayUrl: string): void {
fs.writeFileSync(
path.join(sandbox.userDataDir, 'connection.json'),
JSON.stringify({ mode: 'remote', remote: { url: gatewayUrl, authMode: 'oauth' }, profiles: {} }, null, 2),
{ encoding: 'utf8', mode: 0o600 },
)
const tokens = {
accessToken: 'e2e-stale-access-token',
refreshToken: 'e2e-stale-refresh-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
provider: 'portal',
userId: 'e2e-user',
}
fs.writeFileSync(
path.join(sandbox.userDataDir, 'native-oauth-tokens.json'),
JSON.stringify({ [gatewayUrl]: { encoding: 'plain', value: JSON.stringify(tokens) } }, null, 2),
{ encoding: 'utf8', mode: 0o600 },
)
}
/**
* Sample the recovery overlay inside the page at a fixed cadence and report
* every sample in which it was NOT showing. The overlay is identified by its
* recovery actions, the same signal waitForBootFailure keys on; while the
* renderer re-drives a boot the overlay is unmounted (`running: true`) and
* the connecting surface paints instead.
*/
async function sampleOverlayStability(
page: Page,
windowMs: number,
intervalMs: number,
): Promise<{ samples: number; hidden: number; flips: number }> {
return page.evaluate(
async ({ windowMs: w, intervalMs: i }) => {
const overlayShowing = (): boolean => {
const text = document.body.textContent ?? ''
return (
text.includes('Sign out & sign in') ||
text.includes('Gateway settings') ||
text.includes('Use local gateway') ||
text.includes('Repair install')
)
}
const started = Date.now()
let samples = 0
let hidden = 0
let flips = 0
let last = overlayShowing()
while (Date.now() - started < w) {
await new Promise(resolve => setTimeout(resolve, i))
const showing = overlayShowing()
samples += 1
if (!showing) {
hidden += 1
}
if (showing !== last) {
flips += 1
last = showing
}
}
return { samples, hidden, flips }
},
{ windowMs, intervalMs },
)
}
let gateway: ExpiredSessionGateway | null = null
let sandbox: Sandbox | null = null
let app: ElectronApplication | null = null
test.afterAll(async () => {
await app?.close().catch(() => undefined)
await gateway?.close()
sandbox?.cleanup()
app = null
gateway = null
sandbox = null
})
test.describe('remote OAuth session rejected at cold boot (#95701)', () => {
test.beforeEach(() => {
// The boot deliberately fails; the "Desktop boot failed" toast is expected.
allowErrorBanners()
})
test('the recovery overlay latches once and its Sign in action stays put', async () => {
gateway = await startExpiredSessionGateway()
sandbox = createSandbox('reauth-latch')
seedExpiredNativeSession(sandbox, gateway.url)
const launched = await launchDesktop(buildAppEnv(sandbox))
app = launched.app
const page = launched.page
await waitForBootFailure(page, 60_000)
// 1. Stability: once the overlay is up it never blinks out again. A
// transient-boot retry (`running: true` re-emitted over the failure)
// unmounts it, which is exactly the flicker the issue describes.
const stability = await sampleOverlayStability(page, STABILITY_WINDOW_MS, SAMPLE_INTERVAL_MS)
expect(stability.samples).toBeGreaterThan(50)
expect(stability, 'recovery overlay must stay visible for the whole window').toMatchObject({ hidden: 0, flips: 0 })
// 2. The rejection was confirmed exactly once: one bearer ticket mint,
// one forced rotation attempt, and no further boot re-entry hammering
// the dead session for the rest of the window.
const ticketMints = gateway.requests.filter(r => r.method === 'POST' && r.path === '/api/auth/ws-ticket')
expect(ticketMints, 'exactly one ws-ticket mint').toHaveLength(1)
expect(ticketMints[0]?.bearer, 'the mint presented the stored native bearer').toBe(true)
expect(gateway.count('POST', '/auth/native/refresh'), 'exactly one native refresh attempt').toBe(1)
// 3. The renderer-visible verdict is the structured, non-retryable one.
const snapshot = await page.evaluate(() =>
(window as unknown as { hermesDesktop: { getBootProgress: () => Promise<Record<string, unknown>> } })
.hermesDesktop.getBootProgress(),
)
expect(snapshot).toMatchObject({ running: false, retryable: false, statusCode: 401 })
// A session that DID exist reads as expired, not as never signed in — the
// dead tokens are dropped on the way out, so this copy must be chosen
// from the pre-mint state.
expect(String(snapshot.error)).toMatch(/session has expired/i)
// 4. The overlay offers remote sign-in — the dead tokens were dropped, so
// the connection no longer reads as "connected" and the reauth branch
// renders instead of the local Retry/Repair buttons.
await expect(page.getByRole('button', { name: /sign out & sign in/i })).toBeVisible()
await expect(page.getByRole('button', { name: /sign out & sign in/i })).toBeEnabled()
})
})
@@ -56,7 +56,7 @@ test('finalizeGatewayDownload prompts a save dialog then streams the response',
assert.match(fn, /fsPumpDeps\(\)/)
assert.doesNotMatch(fn, /fs\.createWriteStream/)
// HTTP errors carry their status so a 404 can trigger the fallback.
assert.match(fn, /error\.statusCode = statusCode/)
assert.match(fn, /throw httpStatusError\(statusCode, /)
})
test('data-URL fallback writes through the same failure-atomic primitive, never writeFile in place', () => {
+2 -7
View File
@@ -7836,9 +7836,7 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
const statusCode = res.statusCode || 500
if (statusCode >= 400) {
const err = new Error(`${statusCode}: ${text || ''}`) as any
err.statusCode = statusCode
reject(err)
reject(httpStatusError(statusCode, text))
return
}
@@ -8116,10 +8114,7 @@ function downloadViaOauthSessionToFile(url, ctx, options: any = {}) {
// can trigger the 404-only compatibility fallback.
async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {}) {
if (statusCode >= 400) {
const message = await readGatewayErrorText(res)
const error: any = new Error(`${statusCode}: ${message}`)
error.statusCode = statusCode
throw error
throw httpStatusError(statusCode, await readGatewayErrorText(res))
}
const disposition = headers['content-disposition'] || headers['Content-Disposition']