Files
hermes-agent/apps/desktop/electron/native-access-token.test.ts
brooklyn! 22751c8fd9 fix(desktop): preserve remote auth through refresh failures and login races
Salvage native refresh coordination and cookie fallback without losing forced bearer rotation or replaying REST mutations.

Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>

Co-authored-by: Zeus-Deus <github.commits@widow.cc>
2026-09-11 07:32:34 -05:00

225 lines
7.6 KiB
TypeScript

import { expect, test } from 'vitest'
import { normalizeRemoteBaseUrl } from './connection-config'
import { createNativeAccessTokenCoordinator } from './native-access-token'
import { type NativeTokenSet, tokenNeedsRefresh } from './native-oauth'
const tokenSet = (name: string, expiresAt = 2_000): NativeTokenSet => ({
accessToken: name,
refreshToken: `${name}-rt`,
expiresAt,
provider: 'nous',
userId: 'user'
})
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
// Covers forced rotation too: a locally live bearer can be rejected server-side.
test('normalised hosts share a rotation, and late bearer rejections reuse its winner', async () => {
const store = new Map([['https://gw.test', tokenSet('old')]])
const pending = deferred<NativeTokenSet>()
let rotations = 0
const coordinator = createNativeAccessTokenCoordinator({
normalizeBaseUrl: normalizeRemoteBaseUrl,
nowSeconds: () => 1_000,
tokenNeedsRefresh,
loadTokens: host => store.get(host) ?? null,
storeTokens: (host, tokens) => {
store.set(host, tokens)
},
clearTokens: host => {
store.delete(host)
},
isRefreshAuthRejection: error => (error as { statusCode?: number })?.statusCode === 401,
refreshTokens: async () => {
rotations++
return pending.promise
}
})
const first = coordinator.ensure('https://GW.test/', { forceRefresh: true, rejectedAccessToken: 'old' })
const second = coordinator.ensure('https://gw.test', { forceRefresh: true, rejectedAccessToken: 'old' })
const ordinary = coordinator.ensure('https://gw.test')
expect(rotations).toBe(1)
pending.resolve(tokenSet('winner'))
expect(await Promise.all([first, second, ordinary])).toEqual(['winner', 'winner', 'winner'])
expect(await coordinator.ensure('https://gw.test', { forceRefresh: true, rejectedAccessToken: 'old' })).toBe('winner')
expect(rotations).toBe(1)
})
test('a pending or abandoned login preserves the existing refresh flight and its rotation', async () => {
const host = 'https://gw.test'
const store = new Map([[host, tokenSet('old', 1_000)]])
const pending = deferred<NativeTokenSet>()
const refreshTokens: string[] = []
const coordinator = createNativeAccessTokenCoordinator({
normalizeBaseUrl: normalizeRemoteBaseUrl,
nowSeconds: () => 1_000,
tokenNeedsRefresh,
loadTokens: key => store.get(key) ?? null,
storeTokens: (key, tokens) => {
store.set(key, tokens)
},
clearTokens: key => {
store.delete(key)
},
isRefreshAuthRejection: () => false,
refreshTokens: async (_key, tokens) => {
refreshTokens.push(tokens.refreshToken!)
return pending.promise
}
})
const first = coordinator.ensure(host)
const loginIsCurrent = coordinator.beginLogin('https://GW.test/')
const parallel = coordinator.ensure(host, { forceRefresh: true, rejectedAccessToken: 'old' })
const results = Promise.allSettled([first, parallel])
pending.resolve(tokenSet('rotated'))
expect(await results).toEqual([
{ status: 'fulfilled', value: 'rotated' },
{ status: 'fulfilled', value: 'rotated' }
])
expect(loginIsCurrent()).toBe(true)
// No login completion/store: closing the browser must leave the rotation intact.
expect(store.get(host)?.refreshToken).toBe('rotated-rt')
expect(await coordinator.ensure(host, { forceRefresh: true, rejectedAccessToken: 'old' })).toBe('rotated')
expect(refreshTokens).toEqual(['old-rt'])
})
test('explicit token mutations fence stale success and rejection without affecting other hosts', async () => {
for (const rejection of [false, true]) {
const host = 'https://gw.test'
const sibling = 'https://other.test'
const store = new Map([
[host, tokenSet('old', 1_000)],
[sibling, tokenSet('other', 1_000)]
])
const pending = deferred<NativeTokenSet>()
const other = deferred<NativeTokenSet>()
const coordinator = createNativeAccessTokenCoordinator({
normalizeBaseUrl: normalizeRemoteBaseUrl,
nowSeconds: () => 1_000,
tokenNeedsRefresh,
loadTokens: key => store.get(key) ?? null,
storeTokens: (key, value) => {
store.set(key, value)
},
clearTokens: key => {
store.delete(key)
},
isRefreshAuthRejection: error => (error as { statusCode?: number })?.statusCode === 401,
refreshTokens: key => (key === host ? pending.promise : other.promise)
})
const old = coordinator.ensure(host)
const stale = expect(old).rejects.toThrow('Authentication changed')
const otherFlight = coordinator.ensure(sibling)
const login = coordinator.beginLogin('https://GW.test/')
coordinator.clearTokens(host) // logout supersedes the pending login before cookie I/O
expect(login()).toBe(false)
expect(await coordinator.ensure(host)).toBeNull()
if (rejection) {
pending.reject({ statusCode: 401 })
} else {
pending.resolve(tokenSet('stale'))
}
other.resolve(tokenSet('other-winner'))
await stale
expect(await otherFlight).toBe('other-winner')
expect(store.has(host)).toBe(false)
expect(await coordinator.ensure(host)).toBeNull()
}
})
test('newer login intent wins out-of-order completions and fences an older refresh only on store', async () => {
for (const rejection of [false, true]) {
const host = 'https://gw.test'
const store = new Map([[host, tokenSet('old', 1_000)]])
const pending = deferred<NativeTokenSet>()
const coordinator = createNativeAccessTokenCoordinator({
normalizeBaseUrl: normalizeRemoteBaseUrl,
nowSeconds: () => 1_000,
tokenNeedsRefresh,
loadTokens: key => store.get(key) ?? null,
storeTokens: (key, tokens) => {
store.set(key, tokens)
},
clearTokens: key => {
store.delete(key)
},
isRefreshAuthRejection: () => true,
refreshTokens: () => pending.promise
})
const completeLogin = async (rawHost: string, completion: Promise<NativeTokenSet>) => {
const isCurrent = coordinator.beginLogin(rawHost)
const tokens = await completion
if (!isCurrent()) {
return false
}
coordinator.storeTokens(rawHost, tokens)
return true
}
const refresh = coordinator.ensure(host)
const stale = expect(refresh).rejects.toThrow('Authentication changed')
const oldCompletion = deferred<NativeTokenSet>()
const newerCompletion = deferred<NativeTokenSet>()
const oldLogin = completeLogin('https://GW.test/', oldCompletion.promise)
const newerLogin = completeLogin(host, newerCompletion.promise)
if (!rejection) {
oldCompletion.resolve(tokenSet('stale-login'))
expect(await oldLogin).toBe(false) // newer intent wins even before it completes
}
newerCompletion.resolve(tokenSet('new-login'))
expect(await newerLogin).toBe(true)
expect(await coordinator.ensure(host)).toBe('new-login')
if (rejection) {
oldCompletion.resolve(tokenSet('stale-login'))
expect(await oldLogin).toBe(false)
}
if (rejection) {
pending.reject({ statusCode: 401 })
} else {
pending.resolve(tokenSet('stale-refresh'))
}
await stale
expect(store.get(host)?.accessToken).toBe('new-login')
const logoutCompletion = deferred<NativeTokenSet>()
const loggedOutLogin = completeLogin(host, logoutCompletion.promise)
coordinator.clearTokens('https://GW.test/')
logoutCompletion.resolve(tokenSet('after-logout'))
expect(await loggedOutLogin).toBe(false)
expect(await coordinator.ensure(host)).toBeNull()
}
})