8d22781b1c
Cold-launching against a gated remote gateway whose stored session has been
invalidated server-side alternated between the connecting state and the
recovery overlay; the Sign in button was only intermittently clickable.
Root cause: fetchJson() built a bare Error("401: ...") for HTTP failures on
the native-bearer path, dropping res.statusCode. Every downstream classifier
is shape-based (isGatewayAuthRejection, isServerSideHttpError, the
ensureNativeAccessToken 401 check), so the confirmed rejection looked like a
transport blip: withTransientRetries hammered it, gatewayTicketFailure used
the transport copy, startHermes tagged the boot retryable, and the renderer's
bounded boot-retry loop re-emitted running:true over the overlay on every
attempt. Separately, gatewayTicketFailure only ever set needsOauthLogin,
which isReauthRequiredError ignores, so even a structured 401 from the cookie
path never latched.
- api-transport: httpStatusError() is the one HTTP-error shape; fetchJson and
fetchPublicJson use it, matching fetchJsonViaOauthSession.
- mintGatewayWsTicket: the gateway never rotates a native bearer server-side
(dashboard_auth/middleware.py), so a bearer 401 gets ONE forced
/auth/native/refresh; a live refresh token retries the mint once, a dead one
drops the stored tokens and the rejection is confirmed.
- gatewayTicketFailure: a confirmed 401/403 is tagged isReauthRequired so
startHermes latches it and marks the boot non-retryable.
- startHermes: latches are set before the first await in the failure path;
updateBootProgress holds every update that is not a re-emit of the latched
failure until a recovery path clears the latch.
Tests: composition + main.ts source pins (remote-reauth-latch.test.ts), unit
coverage for the new helpers, and a Playwright e2e that boots the real app
against a fake gateway with a dead session and proves the overlay latches
once (one mint, one refresh, retryable:false, Sign in stays clickable).
205 lines
8.8 KiB
TypeScript
205 lines
8.8 KiB
TypeScript
/**
|
|
* Regression tests for electron/native-auth-decisions.ts — the pure decision
|
|
* seams behind the RFC 8252 native-app auth flow, each of which was a real
|
|
* runtime bug that the mocked flow tests could not catch.
|
|
*
|
|
* Run via the vitest `electron` project (electron/**\/*.test.ts).
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import {
|
|
normalizeAdvertisedAuthProviders,
|
|
oauthGuardMayHardFail,
|
|
oauthSessionIsLive,
|
|
oauthTicketFailureAuthMessage,
|
|
resolveGatedDownloadAuth,
|
|
resolveJsonBody,
|
|
resolveOauthRestAuth,
|
|
resolveReadinessProbeAuth,
|
|
shouldRotateNativeTokenAfterRejection
|
|
} from './native-auth-decisions'
|
|
|
|
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
|
|
|
|
test('resolveJsonBody returns the object unchanged (no pre-stringify)', () => {
|
|
const body = { code: 'abc', code_verifier: 'xyz' }
|
|
const out = resolveJsonBody(body)
|
|
|
|
// Must be the SAME object reference / shape — NOT a JSON string. Pre-
|
|
// stringifying here is what produced the gateway 422 "Input should be a
|
|
// valid dictionary" at /auth/native/token.
|
|
assert.equal(typeof out, 'object')
|
|
assert.deepEqual(out, body)
|
|
})
|
|
|
|
test('resolveJsonBody does not stringify — a string stays a string, an object stays an object', () => {
|
|
assert.equal(typeof resolveJsonBody({ a: 1 }), 'object')
|
|
// If a caller ever passes an already-encoded string (the bug), we return it
|
|
// as-is rather than re-wrapping — the contract is "fetchJson owns encoding".
|
|
assert.equal(typeof resolveJsonBody('{"a":1}'), 'string')
|
|
})
|
|
|
|
// --- 2. oauth liveness (guards the needsOauthLogin loop) ---
|
|
|
|
test('oauthSessionIsLive is true when a native bearer token exists, even with no cookie', () => {
|
|
// The exact bug: native login stores a bearer, sets no cookie. Gating on the
|
|
// cookie alone looped the UI into "not signed in".
|
|
assert.equal(oauthSessionIsLive(true, false), true)
|
|
})
|
|
|
|
test('oauthSessionIsLive is true when a live cookie exists with no native token', () => {
|
|
assert.equal(oauthSessionIsLive(false, true), true)
|
|
})
|
|
|
|
test('oauthSessionIsLive is true when both are present', () => {
|
|
assert.equal(oauthSessionIsLive(true, true), true)
|
|
})
|
|
|
|
test('oauthSessionIsLive is false only when neither is present', () => {
|
|
assert.equal(oauthSessionIsLive(false, false), false)
|
|
})
|
|
|
|
// --- 3. REST auth selection (guards the 401 no_cookie) ---
|
|
|
|
test('resolveOauthRestAuth prefers the native bearer when a token is present', () => {
|
|
const auth = resolveOauthRestAuth('bearer-token-123')
|
|
|
|
assert.deepEqual(auth, { kind: 'bearer', token: 'bearer-token-123' })
|
|
})
|
|
|
|
test('resolveOauthRestAuth falls back to cookie when there is no native token', () => {
|
|
assert.deepEqual(resolveOauthRestAuth(null), { kind: 'cookie' })
|
|
assert.deepEqual(resolveOauthRestAuth(undefined), { kind: 'cookie' })
|
|
// Empty string is not a usable bearer — must fall back, not send "Bearer ".
|
|
assert.deepEqual(resolveOauthRestAuth(''), { kind: 'cookie' })
|
|
})
|
|
|
|
// --- 4. readiness-probe auth (guards the credential-free 401 boot loop) ---
|
|
|
|
test('resolveReadinessProbeAuth reuses the oauth bearer-vs-cookie choice', () => {
|
|
assert.deepEqual(resolveReadinessProbeAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' })
|
|
assert.deepEqual(resolveReadinessProbeAuth('oauth', null), { kind: 'cookie' })
|
|
assert.deepEqual(resolveReadinessProbeAuth('oauth', ''), { kind: 'cookie' })
|
|
})
|
|
|
|
test('resolveReadinessProbeAuth sends the session token for a token gateway', () => {
|
|
assert.deepEqual(resolveReadinessProbeAuth('token', null, 'session-token'), {
|
|
kind: 'token',
|
|
token: 'session-token'
|
|
})
|
|
assert.deepEqual(resolveReadinessProbeAuth('token', null, null), { kind: 'token', token: null })
|
|
})
|
|
|
|
test('resolveReadinessProbeAuth stays public for local and unknown modes', () => {
|
|
// A loopback backend has no gate; sending credentials it never issued is
|
|
// meaningless, and an unknown mode must not invent a credential.
|
|
assert.deepEqual(resolveReadinessProbeAuth('local', 'native-at', 'session-token'), { kind: 'public' })
|
|
assert.deepEqual(resolveReadinessProbeAuth(undefined, 'native-at', 'session-token'), { kind: 'public' })
|
|
assert.deepEqual(resolveReadinessProbeAuth('something-new', null, null), { kind: 'public' })
|
|
})
|
|
|
|
// --- 5. oauth guard vs password gateways (guards the false "not signed in") ---
|
|
|
|
test('oauthGuardMayHardFail is false only when EVERY provider is password-based', () => {
|
|
assert.equal(oauthGuardMayHardFail([{ name: 'basic', supportsPassword: true }]), false)
|
|
assert.equal(
|
|
oauthGuardMayHardFail([
|
|
{ name: 'basic', supportsPassword: true },
|
|
{ name: 'ldap', supportsPassword: true }
|
|
]),
|
|
false
|
|
)
|
|
})
|
|
|
|
test('oauthGuardMayHardFail keeps the strict guard for oauth and mixed deployments', () => {
|
|
assert.equal(oauthGuardMayHardFail([{ name: 'nous', supportsPassword: false }]), true)
|
|
assert.equal(
|
|
oauthGuardMayHardFail([
|
|
{ name: 'nous', supportsPassword: false },
|
|
{ name: 'basic', supportsPassword: true }
|
|
]),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', () => {
|
|
// Backends predating /api/auth/providers, or an unreachable probe, must not
|
|
// silently weaken the guard.
|
|
assert.equal(oauthGuardMayHardFail([]), true)
|
|
assert.equal(oauthGuardMayHardFail(null), true)
|
|
assert.equal(oauthGuardMayHardFail(undefined), true)
|
|
assert.equal(oauthGuardMayHardFail('nonsense' as any), true)
|
|
assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true)
|
|
})
|
|
|
|
test('oauthGuardMayHardFail treats status-shaped string basic as password-only', () => {
|
|
assert.equal(oauthGuardMayHardFail(['basic'] as any), false)
|
|
assert.equal(oauthGuardMayHardFail([' basic '] as any), false)
|
|
})
|
|
|
|
test('oauthGuardMayHardFail keeps the strict guard for string oauth providers', () => {
|
|
assert.equal(oauthGuardMayHardFail(['nous'] as any), true)
|
|
assert.equal(oauthGuardMayHardFail(['nous', 'basic'] as any), true)
|
|
})
|
|
|
|
test('normalizeAdvertisedAuthProviders maps snake_case supports_password', () => {
|
|
assert.deepEqual(normalizeAdvertisedAuthProviders([{ name: 'basic', supports_password: true }]), [
|
|
{ name: 'basic', supportsPassword: true }
|
|
])
|
|
})
|
|
|
|
test('oauthTicketFailureAuthMessage is expired only with a decryptable native session', () => {
|
|
assert.match(oauthTicketFailureAuthMessage(true), /session has expired/)
|
|
assert.match(oauthTicketFailureAuthMessage(false), /not signed in/)
|
|
})
|
|
|
|
// --- 6. gated download auth (guards the Files-panel 401 on cookieless native) ---
|
|
|
|
test('resolveGatedDownloadAuth matches oauth REST: bearer first, then cookie', () => {
|
|
assert.deepEqual(resolveGatedDownloadAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' })
|
|
assert.deepEqual(resolveGatedDownloadAuth('oauth', null), { kind: 'cookie' })
|
|
assert.deepEqual(resolveGatedDownloadAuth('oauth', ''), { kind: 'cookie' })
|
|
})
|
|
|
|
test('resolveGatedDownloadAuth uses the session token for token and local modes', () => {
|
|
assert.deepEqual(resolveGatedDownloadAuth('token', 'native-at', 'session-token'), {
|
|
kind: 'token',
|
|
token: 'session-token'
|
|
})
|
|
assert.deepEqual(resolveGatedDownloadAuth('local', null, 'sess'), { kind: 'token', token: 'sess' })
|
|
assert.deepEqual(resolveGatedDownloadAuth(undefined, null, null), { kind: 'token', token: null })
|
|
})
|
|
|
|
// --- 7. forced native rotation after a bearer rejection (#95701) ---
|
|
|
|
test('shouldRotateNativeTokenAfterRejection: only a structured 401 earns the one forced refresh', () => {
|
|
// The gate never rotates a native bearer server-side, so a 401 on a
|
|
// locally-unexpired access token is ambiguous until /auth/native/refresh
|
|
// has run once.
|
|
assert.equal(
|
|
shouldRotateNativeTokenAfterRejection(Object.assign(new Error('401: expired'), { statusCode: 401 })),
|
|
true
|
|
)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection({ statusCode: 401 }), true)
|
|
})
|
|
|
|
test('shouldRotateNativeTokenAfterRejection: 403, 5xx, transport, and anonymous errors never rotate', () => {
|
|
// 403 is a policy refusal for an identity the gate recognized — a fresh
|
|
// bearer for the same identity cannot change it.
|
|
assert.equal(
|
|
shouldRotateNativeTokenAfterRejection(Object.assign(new Error('403: forbidden'), { statusCode: 403 })),
|
|
false
|
|
)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection(Object.assign(new Error('503: down'), { statusCode: 503 })), false)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection(Object.assign(new Error('reset'), { code: 'ECONNRESET' })), false)
|
|
// The pre-fix fetchJson shape: a "401: ..." message with no statusCode says
|
|
// nothing structured about the credential and must not trigger rotation.
|
|
assert.equal(shouldRotateNativeTokenAfterRejection(new Error('401: {"error":"session_expired"}')), false)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection(null), false)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection(undefined), false)
|
|
assert.equal(shouldRotateNativeTokenAfterRejection('401'), false)
|
|
})
|