341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import {
|
|
DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
|
|
isAuthRejectionError,
|
|
isGatedMissingHealthError,
|
|
isMissingHealthEndpointError,
|
|
isReauthRequiredError,
|
|
waitForHermesReady
|
|
} from './backend-health'
|
|
|
|
const GATE_401 = '401: {"error":"unauthenticated","detail":"Unauthorized","reason":"no_cookie","login_url":"/login"}'
|
|
|
|
test('uses lightweight /api/health for current backends', async () => {
|
|
const calls: string[][] = []
|
|
|
|
await waitForHermesReady('http://127.0.0.1:9000/', {
|
|
token: 'secret-token',
|
|
fetchPublicJson: async url => {
|
|
calls.push(['public', url])
|
|
|
|
return { ok: true }
|
|
},
|
|
fetchJson: async url => {
|
|
calls.push(['token', url])
|
|
throw new Error('status should not be called')
|
|
},
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
})
|
|
|
|
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
|
|
})
|
|
|
|
test('falls back to /api/status only for old backends without /api/health', async () => {
|
|
const calls: string[][] = []
|
|
|
|
await waitForHermesReady('http://127.0.0.1:9000', {
|
|
token: 'secret-token',
|
|
fetchPublicJson: async url => {
|
|
calls.push(['public', url])
|
|
|
|
throw new Error('404: {"detail":"Not Found"}')
|
|
},
|
|
fetchJson: async (url, token) => {
|
|
calls.push(['token', url, token ?? ''])
|
|
|
|
return { version: 'old' }
|
|
},
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
})
|
|
|
|
assert.deepEqual(calls, [
|
|
['public', 'http://127.0.0.1:9000/api/health'],
|
|
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
|
|
])
|
|
})
|
|
|
|
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
|
|
const calls: string[][] = []
|
|
let currentTime = 0
|
|
|
|
await assert.rejects(
|
|
waitForHermesReady('http://127.0.0.1:9000', {
|
|
fetchPublicJson: async url => {
|
|
calls.push(['public', url])
|
|
throw new Error('Timed out connecting to Hermes backend after 15000ms')
|
|
},
|
|
fetchJson: async url => {
|
|
calls.push(['token', url])
|
|
},
|
|
sleep: async () => {},
|
|
now: () => {
|
|
currentTime += 20
|
|
|
|
return currentTime
|
|
},
|
|
timeoutMs: 50,
|
|
pollMs: 1
|
|
}),
|
|
/Timed out connecting/
|
|
)
|
|
|
|
assert.ok(calls.length > 0)
|
|
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
|
})
|
|
|
|
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
|
|
const timeouts: (number | undefined)[] = []
|
|
|
|
await waitForHermesReady('http://127.0.0.1:9000', {
|
|
fetchPublicJson: async (_url, options) => {
|
|
timeouts.push(options?.timeoutMs)
|
|
|
|
throw new Error('404: {"detail":"Not Found"}')
|
|
},
|
|
fetchJson: async (_url, _token, options) => {
|
|
timeouts.push(options?.timeoutMs)
|
|
|
|
return { version: 'old' }
|
|
},
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
})
|
|
|
|
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
|
|
})
|
|
|
|
test('aborts as superseded when the bootstrap signal fires', async () => {
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
await assert.rejects(
|
|
waitForHermesReady('http://127.0.0.1:9000', {
|
|
signal: controller.signal,
|
|
fetchPublicJson: async () => {
|
|
throw new Error('should not probe after abort')
|
|
},
|
|
fetchJson: async () => {
|
|
throw new Error('should not probe after abort')
|
|
},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
}),
|
|
(error: any) => error.kind === 'superseded'
|
|
)
|
|
})
|
|
|
|
test('recognizes missing-route shapes only', () => {
|
|
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
|
|
assert.equal(
|
|
isMissingHealthEndpointError(
|
|
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
|
|
),
|
|
true
|
|
)
|
|
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
|
|
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
|
|
})
|
|
|
|
// --- Gated backends that predate /api/health (release 0.19.0 and earlier) ---
|
|
//
|
|
// The dashboard auth gate runs ahead of the SPA catch-all, so on a backend
|
|
// without the route an ANONYMOUS probe is rejected as unauthenticated rather
|
|
// than 404 — verified against a simulated 0.19.0 backend:
|
|
// credential-free: /api/health -> 401 no_cookie, /api/status -> 200
|
|
// credentialed: /api/health -> 404, /api/sessions -> 200
|
|
|
|
test('anonymous gate-shaped 401 falls back to /api/status (backend predates /api/health)', async () => {
|
|
const calls: string[][] = []
|
|
|
|
await waitForHermesReady('http://192.168.1.132:9119', {
|
|
token: null,
|
|
fetchPublicJson: async url => {
|
|
calls.push(['public', url])
|
|
throw new Error(GATE_401)
|
|
},
|
|
fetchJson: async (url, token) => {
|
|
calls.push(['token', url, token == null ? 'null' : token])
|
|
|
|
return { version: '0.19.0', auth_required: true }
|
|
},
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
})
|
|
|
|
assert.deepEqual(calls, [
|
|
['public', 'http://192.168.1.132:9119/api/health'],
|
|
['token', 'http://192.168.1.132:9119/api/status', 'null']
|
|
])
|
|
})
|
|
|
|
test('a credentialed 401 fails fast for reauth instead of reporting a dead session ready', async () => {
|
|
// The regression a blanket 401->fallback introduces: /api/status is public,
|
|
// so an expired session would answer 200 and boot would report "ready",
|
|
// deferring the no_cookie to the first real API call.
|
|
const calls: string[][] = []
|
|
|
|
await assert.rejects(
|
|
waitForHermesReady('https://gateway.example', {
|
|
token: 'session-token',
|
|
fetchPublicJson: async () => {
|
|
throw new Error('public probe must not be used when credentialed')
|
|
},
|
|
fetchJson: async url => {
|
|
calls.push(['status', url])
|
|
|
|
return { version: '0.19.0' }
|
|
},
|
|
probeHealth: async url => {
|
|
calls.push(['probe', url])
|
|
throw new Error(GATE_401)
|
|
},
|
|
probeIsCredentialed: true,
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
}),
|
|
(error: any) => {
|
|
assert.equal(isReauthRequiredError(error), true)
|
|
assert.equal(error.needsOauthLogin, true)
|
|
assert.match(error.message, /remote gateway session has expired/i)
|
|
|
|
return true
|
|
}
|
|
)
|
|
|
|
// Fail fast: never reached the public /api/status leg.
|
|
assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']])
|
|
})
|
|
|
|
test('a credentialed 403 is also a terminal reauth failure', async () => {
|
|
await assert.rejects(
|
|
waitForHermesReady('https://gateway.example', {
|
|
fetchPublicJson: async () => ({}),
|
|
fetchJson: async () => ({}),
|
|
probeHealth: async () => {
|
|
throw new Error('403: {"detail":"Forbidden"}')
|
|
},
|
|
probeIsCredentialed: true,
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
}),
|
|
(error: any) => isReauthRequiredError(error)
|
|
)
|
|
})
|
|
|
|
test('a credentialed probe still uses the 404 fallback for a genuinely missing route', async () => {
|
|
// With credentials the gate lets the request through to the SPA catch-all,
|
|
// so an old backend answers a real 404 — that must still fall back, not be
|
|
// mistaken for a rejected session.
|
|
const calls: string[][] = []
|
|
|
|
await waitForHermesReady('https://gateway.example', {
|
|
token: 'session-token',
|
|
fetchPublicJson: async () => {
|
|
throw new Error('public probe must not be used when credentialed')
|
|
},
|
|
fetchJson: async url => {
|
|
calls.push(['status', url])
|
|
|
|
return { version: '0.19.0' }
|
|
},
|
|
probeHealth: async url => {
|
|
calls.push(['probe', url])
|
|
throw new Error('404: {"detail":"Not Found"}')
|
|
},
|
|
probeIsCredentialed: true,
|
|
sleep: async () => {},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
})
|
|
|
|
assert.deepEqual(calls, [
|
|
['probe', 'https://gateway.example/api/health'],
|
|
['status', 'https://gateway.example/api/status']
|
|
])
|
|
})
|
|
|
|
test('a non-gate 401 keeps polling rather than skipping a misconfigured health route', async () => {
|
|
const calls: string[][] = []
|
|
let currentTime = 0
|
|
|
|
await assert.rejects(
|
|
waitForHermesReady('http://127.0.0.1:9000', {
|
|
fetchPublicJson: async url => {
|
|
calls.push(['public', url])
|
|
throw new Error('401: {"detail":"Unauthorized"}')
|
|
},
|
|
fetchJson: async url => {
|
|
calls.push(['token', url])
|
|
},
|
|
sleep: async () => {},
|
|
now: () => {
|
|
currentTime += 20
|
|
|
|
return currentTime
|
|
},
|
|
timeoutMs: 50,
|
|
pollMs: 1
|
|
}),
|
|
/401: \{"detail":"Unauthorized"\}/
|
|
)
|
|
|
|
assert.ok(calls.length > 0)
|
|
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
|
})
|
|
|
|
test('credentialed 5xx and 429 keep polling — only 401/403 are terminal', async () => {
|
|
for (const transient of ['500: boom', '429: {"detail":"Too Many Requests"}']) {
|
|
let attempts = 0
|
|
let currentTime = 0
|
|
|
|
await assert.rejects(
|
|
waitForHermesReady('https://gateway.example', {
|
|
fetchPublicJson: async () => ({}),
|
|
fetchJson: async () => ({}),
|
|
probeHealth: async () => {
|
|
attempts += 1
|
|
throw new Error(transient)
|
|
},
|
|
probeIsCredentialed: true,
|
|
sleep: async () => {},
|
|
now: () => {
|
|
currentTime += 20
|
|
|
|
return currentTime
|
|
},
|
|
timeoutMs: 100,
|
|
pollMs: 1
|
|
}),
|
|
(error: any) => isReauthRequiredError(error) === false
|
|
)
|
|
|
|
assert.ok(attempts > 1, `${transient} should have retried, got ${attempts} attempt(s)`)
|
|
}
|
|
})
|
|
|
|
test('error-shape predicates', () => {
|
|
assert.equal(isGatedMissingHealthError(new Error(GATE_401)), true)
|
|
assert.equal(isGatedMissingHealthError(new Error('401: {"detail":"Unauthorized"}')), false)
|
|
assert.equal(isGatedMissingHealthError(new Error('404: {"detail":"Not Found"}')), false)
|
|
|
|
assert.equal(isAuthRejectionError(new Error(GATE_401)), true)
|
|
assert.equal(isAuthRejectionError(new Error('403: {"detail":"Forbidden"}')), true)
|
|
assert.equal(isAuthRejectionError(new Error('404: {"detail":"Not Found"}')), false)
|
|
assert.equal(isAuthRejectionError(new Error('429: slow down')), false)
|
|
assert.equal(isAuthRejectionError(new Error('500: boom')), false)
|
|
|
|
// A gated 401 must NOT be conflated with a missing route by the 404 predicate.
|
|
assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false)
|
|
})
|