The desktop OAuth remote-gateway path gated connectivity on hasOauthSessionCookie(), which checks only the access-token cookie (hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend() and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing a full IDP re-login every ~15 min — even though a valid 24h refresh-token cookie (hermes_session_rt) was sitting in the same jar. The desktop OAuth code (2026-06-04) was written against the obsolete "contract v1 issues no refresh token" model, two days after #37247 re-introduced server-side transparent refresh: Portal now issues a 24h rotating, reuse-detected refresh token, and the gateway middleware (_attempt_refresh) rotates a fresh AT from the RT on the next authenticated request. So an expired-AT/live-RT session is fully connectable — the desktop just never let the request through. Fix: - connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession() (true when EITHER a live AT or RT cookie is present). Keep cookiesHaveSession() AT-only for callers that need that specific signal. - main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth branch now early-outs only when NEITHER cookie is present, otherwise uses the ws-ticket mint as the authoritative liveness probe (that POST carries the RT cookie and triggers the server-side AT rotation). A real 401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout report against the same AT-or-RT notion. - Remove the stale "contract v1 / NO refresh token" docstrings in cookies.py and the verify_session comments in the Nous provider that contradicted #37247. Tests: +57 lines in connection-config.test.cjs covering the RT-only "still connectable" case. node --test: 32/32. dashboard-auth + nous-provider Python suites: 223/223. Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/) are comment/docstring-only here, but this touches outside apps/desktop/ so it needs Teknium review.
267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
/**
|
|
* Tests for electron/connection-config.cjs.
|
|
*
|
|
* Run with: node --test electron/connection-config.test.cjs
|
|
* (Wire into npm test:desktop:platforms in package.json.)
|
|
*
|
|
* These are the pure helpers behind the remote-gateway connection settings:
|
|
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
|
|
* classification from /api/status, the coerce-time auth-mode resolution rules,
|
|
* and the OAuth session-cookie detector.
|
|
*/
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const {
|
|
AT_COOKIE_VARIANTS,
|
|
RT_COOKIE_VARIANTS,
|
|
authModeFromStatus,
|
|
buildGatewayWsUrl,
|
|
buildGatewayWsUrlWithTicket,
|
|
cookiesHaveSession,
|
|
cookiesHaveLiveSession,
|
|
normalizeRemoteBaseUrl,
|
|
resolveAuthMode,
|
|
resolveTestWsUrl,
|
|
tokenPreview
|
|
} = require('./connection-config.cjs')
|
|
|
|
// --- normalizeRemoteBaseUrl ---
|
|
|
|
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
|
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
|
|
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
|
|
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
|
|
})
|
|
|
|
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
|
|
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
|
|
})
|
|
|
|
test('normalizeRemoteBaseUrl rejects empty input', () => {
|
|
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
|
|
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
|
|
})
|
|
|
|
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
|
|
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
|
|
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
|
|
})
|
|
|
|
test('normalizeRemoteBaseUrl rejects garbage', () => {
|
|
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
|
|
})
|
|
|
|
// --- buildGatewayWsUrl (token) ---
|
|
|
|
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
|
|
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
|
|
})
|
|
|
|
test('buildGatewayWsUrl uses ws for http', () => {
|
|
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
|
|
})
|
|
|
|
test('buildGatewayWsUrl honors a path prefix', () => {
|
|
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
|
|
})
|
|
|
|
test('buildGatewayWsUrl url-encodes the token', () => {
|
|
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
|
|
})
|
|
|
|
// --- buildGatewayWsUrlWithTicket (oauth) ---
|
|
|
|
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
|
|
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
|
|
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
|
|
assert.ok(!url.includes('token='))
|
|
})
|
|
|
|
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
|
|
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
|
|
})
|
|
|
|
// --- authModeFromStatus ---
|
|
|
|
test('authModeFromStatus returns oauth when auth_required is true', () => {
|
|
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
|
|
})
|
|
|
|
test('authModeFromStatus returns token when auth_required is false/missing', () => {
|
|
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
|
|
assert.equal(authModeFromStatus({}), 'token')
|
|
assert.equal(authModeFromStatus(null), 'token')
|
|
assert.equal(authModeFromStatus(undefined), 'token')
|
|
})
|
|
|
|
// --- resolveAuthMode ---
|
|
|
|
test('resolveAuthMode: explicit input wins over existing', () => {
|
|
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
|
|
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
|
|
})
|
|
|
|
test('resolveAuthMode: falls back to existing when input absent', () => {
|
|
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
|
|
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
|
|
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
|
|
})
|
|
|
|
test('resolveAuthMode: defaults to token when nothing is set', () => {
|
|
assert.equal(resolveAuthMode(undefined, undefined), 'token')
|
|
assert.equal(resolveAuthMode(null, null), 'token')
|
|
})
|
|
|
|
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
|
|
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
|
|
})
|
|
|
|
// --- cookiesHaveSession ---
|
|
|
|
test('cookiesHaveSession detects the bare access-token cookie', () => {
|
|
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
|
})
|
|
|
|
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
|
|
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
|
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
|
})
|
|
|
|
test('cookiesHaveSession is false for an empty value', () => {
|
|
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
|
})
|
|
|
|
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
|
|
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
|
|
// is NOT an access token, so this returns false. Connectivity callers must
|
|
// use cookiesHaveLiveSession instead (see below).
|
|
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
|
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
|
})
|
|
|
|
test('cookiesHaveSession handles non-arrays', () => {
|
|
assert.equal(cookiesHaveSession(null), false)
|
|
assert.equal(cookiesHaveSession(undefined), false)
|
|
assert.equal(cookiesHaveSession([]), false)
|
|
})
|
|
|
|
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
|
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
|
|
})
|
|
|
|
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
|
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
|
|
})
|
|
|
|
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
|
|
|
|
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
|
|
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
|
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
|
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
|
})
|
|
|
|
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
|
|
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
|
|
// but the 24h RT cookie is still alive. The session is still connectable —
|
|
// the gateway rotates a fresh AT from the RT on the next request.
|
|
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
|
|
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
|
|
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
|
|
})
|
|
|
|
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
|
|
assert.equal(
|
|
cookiesHaveLiveSession([
|
|
{ name: 'hermes_session_at', value: 'a' },
|
|
{ name: 'hermes_session_rt', value: 'r' }
|
|
]),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('cookiesHaveLiveSession is false for empty values', () => {
|
|
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
|
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
|
|
assert.equal(
|
|
cookiesHaveLiveSession([
|
|
{ name: 'hermes_session_at', value: '' },
|
|
{ name: 'hermes_session_rt', value: '' }
|
|
]),
|
|
false
|
|
)
|
|
})
|
|
|
|
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
|
|
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
|
|
assert.equal(cookiesHaveLiveSession(null), false)
|
|
assert.equal(cookiesHaveLiveSession(undefined), false)
|
|
assert.equal(cookiesHaveLiveSession([]), false)
|
|
})
|
|
|
|
// --- tokenPreview ---
|
|
|
|
test('tokenPreview returns null for empty', () => {
|
|
assert.equal(tokenPreview(''), null)
|
|
assert.equal(tokenPreview(null), null)
|
|
})
|
|
|
|
test('tokenPreview returns set for short tokens', () => {
|
|
assert.equal(tokenPreview('12345678'), 'set')
|
|
})
|
|
|
|
test('tokenPreview returns a masked suffix for long tokens', () => {
|
|
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
|
|
})
|
|
|
|
// --- resolveTestWsUrl ---
|
|
//
|
|
// The "Test remote" button must exercise the same WS transport the app uses,
|
|
// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that
|
|
// is the exact false-positive PR #39098 set out to eliminate.
|
|
|
|
test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => {
|
|
const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123')
|
|
assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123')
|
|
})
|
|
|
|
test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => {
|
|
assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null)
|
|
})
|
|
|
|
test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
|
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
|
mintTicket: async () => 'tkt-9'
|
|
})
|
|
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
|
})
|
|
|
|
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
|
mintTicket: async () => {
|
|
throw new Error('401 ticket mint failed')
|
|
}
|
|
}),
|
|
err => {
|
|
// Actionable, points the user at re-auth, and preserves the cause + flag
|
|
// the boot overlay uses to offer a sign-in prompt.
|
|
assert.match(err.message, /WebSocket ticket/i)
|
|
assert.match(err.message, /sign in again/i)
|
|
assert.equal(err.needsOauthLogin, true)
|
|
assert.ok(err.cause instanceof Error)
|
|
return true
|
|
}
|
|
)
|
|
})
|
|
|
|
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
|
await assert.rejects(
|
|
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
|
|
/mintTicket function is required/
|
|
)
|
|
})
|