The reconnect and boot paths resolved the WS URL with `(await getGatewayWsUrl().catch(() => null)) || conn.wsUrl`. For OAuth gateways the cached conn.wsUrl carries a single-use, ~30s-TTL ticket; the desktop connection is memoized for the process lifetime, so on reconnect that ticket is both expired and already consumed. A failed fresh mint therefore fell back to a guaranteed-dead ticket and surfaced as an opaque "connection closed", masking the gateway's actionable "session expired, sign in again" message. Extract resolveGatewayWsUrl() (with unit tests): in OAuth mode a mint failure throws a tagged GatewayReauthRequiredError instead of falling back; token/local modes keep the long-lived-token fallback. Thread that error through the reconnect path so requestGateway surfaces the reauth message rather than the generic transport error that triggered the retry. Co-authored-by: Kenmege <205099287+Kenmege@users.noreply.github.com>
79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
|
|
|
|
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
|
|
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
|
|
|
|
describe('resolveGatewayWsUrl', () => {
|
|
describe('oauth mode', () => {
|
|
it('uses the freshly minted URL', async () => {
|
|
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh')
|
|
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
|
|
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('throws a reauth error instead of falling back to the stale cached ticket', async () => {
|
|
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired'))
|
|
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
|
|
GatewayReauthRequiredError
|
|
)
|
|
})
|
|
|
|
it('preserves the underlying mint failure as the cause', async () => {
|
|
const cause = new Error('401 cookie expired')
|
|
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
|
|
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
|
|
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
|
|
expect((error as GatewayReauthRequiredError).cause).toBe(cause)
|
|
})
|
|
|
|
it('throws a reauth error when the preload cannot mint (no method)', async () => {
|
|
await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError)
|
|
})
|
|
|
|
it('never returns the stale cached ticket on failure', async () => {
|
|
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom'))
|
|
const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw')
|
|
expect(result).toBe('threw')
|
|
expect(result).not.toBe(oauthConn.wsUrl)
|
|
})
|
|
})
|
|
|
|
describe('token / local mode', () => {
|
|
it('uses the minted URL when available', async () => {
|
|
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh')
|
|
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
|
|
})
|
|
|
|
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
|
|
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
|
|
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
|
|
})
|
|
|
|
it('falls back to the cached URL when the preload method is absent', async () => {
|
|
await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl)
|
|
})
|
|
|
|
it('treats a missing authMode as non-oauth (falls back safely)', async () => {
|
|
await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('isGatewayReauthRequired', () => {
|
|
it('detects the dedicated error class', () => {
|
|
expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true)
|
|
})
|
|
|
|
it('detects plain objects tagged with needsOauthLogin (from the main process)', () => {
|
|
expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true)
|
|
})
|
|
|
|
it('rejects generic errors', () => {
|
|
expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false)
|
|
expect(isGatewayReauthRequired(null)).toBe(false)
|
|
expect(isGatewayReauthRequired('string')).toBe(false)
|
|
})
|
|
})
|