test: add gateway health history e2e + utility unit tests

* fix: show all agents in command select, disable those without active session

Previously the agent select in the OrchestrationBar only listed agents
with a session_key set (agents.filter(a => a.session_key)), causing the
dropdown to appear completely empty when agents exist but none have an
active session. This was confusing — users thought no agents were
registered.

Fix: show all registered agents in the dropdown. Agents without an
active session_key are shown as disabled with a '— no session' suffix
and a tooltip explaining why they can't be messaged. The 'No agents
registered' placeholder is shown only when the agents array is truly
empty.

The send button remains correctly disabled when no valid agent is
selected.

Fixes #321

* test: add gateway health history e2e + utility unit tests

- Add tests/gateway-health-history.spec.ts: Playwright e2e tests for
  GET /api/gateways/health/history and POST /api/gateways/health
  covering auth guards, response shape validation, chronological
  ordering, and gateway name resolution.

- Add src/app/api/gateways/health/health-utils.test.ts: 30 vitest
  unit tests for the pure utility functions in the health probe route:
  ipv4InCidr, isBlockedUrl (SSRF protection), buildGatewayProbeUrl,
  parseGatewayVersion, and hasOpenClaw32ToolsProfileRisk.

All 30 unit tests pass. Covers the health history feature introduced
in feat(gateways): add health history logging and timeline (#300).
This commit is contained in:
HonzysClawdbot 2026-03-14 10:39:02 +01:00 committed by GitHub
parent 5d7b05b4f6
commit 2fe7785433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 449 additions and 0 deletions

View File

@ -0,0 +1,270 @@
/**
* Unit tests for utility functions extracted from gateway health route.
* These functions handle SSRF protection and URL construction.
*/
import { describe, it, expect } from 'vitest'
// --- Copied/extracted utilities (pure functions) for testability ---
function ipv4ToNum(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let num = 0
for (const p of parts) {
const n = Number(p)
if (!Number.isFinite(n) || n < 0 || n > 255) return null
num = (num << 8) | n
}
return num >>> 0
}
function ipv4InCidr(ip: string, cidr: string): boolean {
const [base, bits] = cidr.split('/')
const mask = ~((1 << (32 - Number(bits))) - 1) >>> 0
const ipNum = ipv4ToNum(ip)
const baseNum = ipv4ToNum(base)
if (ipNum === null || baseNum === null) return false
return (ipNum & mask) === (baseNum & mask)
}
const BLOCKED_PRIVATE_CIDRS = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'169.254.0.0/16',
'127.0.0.0/8',
]
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.internal',
'instance-data',
])
function isBlockedUrl(urlStr: string, userConfiguredHosts: Set<string>): boolean {
try {
const url = new URL(urlStr)
const hostname = url.hostname
if (userConfiguredHosts.has(hostname)) return false
if (BLOCKED_HOSTNAMES.has(hostname)) return true
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
for (const cidr of BLOCKED_PRIVATE_CIDRS) {
if (ipv4InCidr(hostname, cidr)) return true
}
}
return false
} catch {
return true
}
}
function buildGatewayProbeUrl(host: string, port: number): string | null {
const rawHost = String(host || '').trim()
if (!rawHost) return null
const hasProtocol =
rawHost.startsWith('ws://') ||
rawHost.startsWith('wss://') ||
rawHost.startsWith('http://') ||
rawHost.startsWith('https://')
if (hasProtocol) {
try {
const parsed = new URL(rawHost)
if (parsed.protocol === 'ws:') parsed.protocol = 'http:'
if (parsed.protocol === 'wss:') parsed.protocol = 'https:'
if (!parsed.port && Number.isFinite(port) && port > 0) {
parsed.port = String(port)
}
if (!parsed.pathname) parsed.pathname = '/'
return parsed.toString()
} catch {
return null
}
}
if (!Number.isFinite(port) || port <= 0) return null
return `http://${rawHost}:${port}/`
}
function parseGatewayVersion(headers: Record<string, string | null>): string | null {
const direct = headers['x-openclaw-version'] || headers['x-clawdbot-version']
if (direct) return direct.trim()
const server = headers['server'] || ''
const m = server.match(/(\d{4}\.\d+\.\d+)/)
return m?.[1] || null
}
function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean {
if (!version) return false
const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/)
if (!m) return false
const year = Number(m[1])
const major = Number(m[2])
const minor = Number(m[3])
if (year > 2026) return true
if (year < 2026) return false
if (major > 3) return true
if (major < 3) return false
return minor >= 2
}
// --- Tests ---
describe('ipv4InCidr', () => {
it('matches IP within 10.0.0.0/8', () => {
expect(ipv4InCidr('10.0.0.1', '10.0.0.0/8')).toBe(true)
expect(ipv4InCidr('10.255.255.255', '10.0.0.0/8')).toBe(true)
})
it('does not match IP outside 10.0.0.0/8', () => {
expect(ipv4InCidr('11.0.0.1', '10.0.0.0/8')).toBe(false)
})
it('matches 192.168.x.x in 192.168.0.0/16', () => {
expect(ipv4InCidr('192.168.1.1', '192.168.0.0/16')).toBe(true)
expect(ipv4InCidr('192.169.0.0', '192.168.0.0/16')).toBe(false)
})
it('matches loopback 127.0.0.1 in 127.0.0.0/8', () => {
expect(ipv4InCidr('127.0.0.1', '127.0.0.0/8')).toBe(true)
})
it('returns false for invalid IPs', () => {
expect(ipv4InCidr('not-an-ip', '10.0.0.0/8')).toBe(false)
expect(ipv4InCidr('256.0.0.1', '10.0.0.0/8')).toBe(false)
})
})
describe('isBlockedUrl', () => {
it('blocks private RFC1918 IPs', () => {
expect(isBlockedUrl('http://192.168.1.100:3000/', new Set())).toBe(true)
expect(isBlockedUrl('http://10.0.0.1/', new Set())).toBe(true)
expect(isBlockedUrl('http://172.20.0.1/', new Set())).toBe(true)
})
it('blocks loopback', () => {
expect(isBlockedUrl('http://127.0.0.1:8080/', new Set())).toBe(true)
})
it('blocks link-local', () => {
expect(isBlockedUrl('http://169.254.169.254/', new Set())).toBe(true)
})
it('blocks cloud metadata hostnames', () => {
expect(isBlockedUrl('http://metadata.google.internal/', new Set())).toBe(true)
expect(isBlockedUrl('http://metadata.internal/', new Set())).toBe(true)
expect(isBlockedUrl('http://instance-data/', new Set())).toBe(true)
})
it('allows user-configured hosts even if private', () => {
// Operators explicitly configure their own infra
expect(isBlockedUrl('http://192.168.1.100:3000/', new Set(['192.168.1.100']))).toBe(false)
expect(isBlockedUrl('http://10.0.0.1/', new Set(['10.0.0.1']))).toBe(false)
})
it('allows public external hosts', () => {
expect(isBlockedUrl('https://example.tailnet.ts.net:4443/', new Set())).toBe(false)
expect(isBlockedUrl('https://gateway.example.com/', new Set())).toBe(false)
})
it('blocks malformed URLs', () => {
expect(isBlockedUrl('not-a-url', new Set())).toBe(true)
expect(isBlockedUrl('', new Set())).toBe(true)
})
})
describe('buildGatewayProbeUrl', () => {
it('builds URL from bare host + port', () => {
expect(buildGatewayProbeUrl('example.com', 8080)).toBe('http://example.com:8080/')
})
it('preserves https:// protocol', () => {
const result = buildGatewayProbeUrl('https://gateway.example.com/', 0)
expect(result).toContain('https://')
})
it('converts ws:// to http://', () => {
const result = buildGatewayProbeUrl('ws://gateway.example.com:4443/', 0)
expect(result).toContain('http://')
})
it('converts wss:// to https://', () => {
const result = buildGatewayProbeUrl('wss://gateway.example.com:4443/', 0)
expect(result).toContain('https://')
})
it('appends port to URL without port when port is provided', () => {
const result = buildGatewayProbeUrl('https://gateway.example.com', 18789)
expect(result).toContain('18789')
})
it('does not overwrite existing port in URL', () => {
const result = buildGatewayProbeUrl('https://gateway.example.com:9000', 18789)
expect(result).toContain('9000')
expect(result).not.toContain('18789')
})
it('returns null for empty host', () => {
expect(buildGatewayProbeUrl('', 8080)).toBeNull()
})
it('returns null for bare host with no port', () => {
expect(buildGatewayProbeUrl('example.com', 0)).toBeNull()
expect(buildGatewayProbeUrl('example.com', -1)).toBeNull()
})
it('handles URL with query string token', () => {
const result = buildGatewayProbeUrl('https://gw.example.com/sessions?token=abc', 0)
expect(result).toContain('token=abc')
})
})
describe('parseGatewayVersion', () => {
it('reads x-openclaw-version header', () => {
expect(parseGatewayVersion({ 'x-openclaw-version': '2026.3.7', 'server': null, 'x-clawdbot-version': null })).toBe('2026.3.7')
})
it('reads x-clawdbot-version header', () => {
expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': '2026.2.1', 'server': null })).toBe('2026.2.1')
})
it('extracts version from server header', () => {
expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': null, 'server': 'openclaw/2026.3.5' })).toBe('2026.3.5')
})
it('returns null when no version headers', () => {
expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': null, 'server': null })).toBeNull()
})
})
describe('hasOpenClaw32ToolsProfileRisk', () => {
it('returns false for null version', () => {
expect(hasOpenClaw32ToolsProfileRisk(null)).toBe(false)
})
it('returns false for versions before 2026.3.2', () => {
expect(hasOpenClaw32ToolsProfileRisk('2026.3.1')).toBe(false)
expect(hasOpenClaw32ToolsProfileRisk('2026.2.9')).toBe(false)
expect(hasOpenClaw32ToolsProfileRisk('2025.10.0')).toBe(false)
})
it('returns true for version 2026.3.2', () => {
expect(hasOpenClaw32ToolsProfileRisk('2026.3.2')).toBe(true)
})
it('returns true for versions after 2026.3.2', () => {
expect(hasOpenClaw32ToolsProfileRisk('2026.3.7')).toBe(true)
expect(hasOpenClaw32ToolsProfileRisk('2026.4.0')).toBe(true)
expect(hasOpenClaw32ToolsProfileRisk('2027.1.0')).toBe(true)
})
it('returns false for unrecognized version format', () => {
expect(hasOpenClaw32ToolsProfileRisk('invalid')).toBe(false)
expect(hasOpenClaw32ToolsProfileRisk('')).toBe(false)
})
})

View File

@ -0,0 +1,179 @@
import { expect, test } from '@playwright/test'
import { API_KEY_HEADER } from './helpers'
test.describe('Gateway Health History API', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await request.delete('/api/gateways', {
headers: API_KEY_HEADER,
data: { id },
}).catch(() => {})
}
cleanup.length = 0
})
test('GET /api/gateways/health/history returns history array', async ({ request }) => {
const res = await request.get('/api/gateways/health/history', {
headers: API_KEY_HEADER,
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body).toHaveProperty('history')
expect(Array.isArray(body.history)).toBe(true)
})
test('GET /api/gateways/health/history requires authentication', async ({ request }) => {
const res = await request.get('/api/gateways/health/history')
expect([401, 403]).toContain(res.status())
})
test('history entries have correct structure', async ({ request }) => {
// First, create a gateway and trigger a health probe to generate log entries
const createRes = await request.post('/api/gateways', {
headers: API_KEY_HEADER,
data: {
name: `e2e-history-${Date.now()}`,
host: 'https://example-gateway.invalid',
port: 18789,
token: 'test-token',
},
})
// Gateway creation may or may not succeed depending on config
if (createRes.status() === 201) {
const body = await createRes.json()
cleanup.push(body.gateway?.id)
}
// Trigger health probe (this generates log entries)
await request.post('/api/gateways/health', {
headers: API_KEY_HEADER,
})
const res = await request.get('/api/gateways/health/history', {
headers: API_KEY_HEADER,
})
expect(res.status()).toBe(200)
const body = await res.json()
// If there are entries, validate structure
for (const gatewayHistory of body.history) {
expect(gatewayHistory).toHaveProperty('gatewayId')
expect(typeof gatewayHistory.gatewayId).toBe('number')
expect(gatewayHistory).toHaveProperty('entries')
expect(Array.isArray(gatewayHistory.entries)).toBe(true)
for (const entry of gatewayHistory.entries) {
expect(entry).toHaveProperty('status')
expect(['online', 'offline', 'error']).toContain(entry.status)
expect(entry).toHaveProperty('probed_at')
expect(typeof entry.probed_at).toBe('number')
// latency can be null or a number
if (entry.latency !== null) {
expect(typeof entry.latency).toBe('number')
}
// error can be null or a string
if (entry.error !== null) {
expect(typeof entry.error).toBe('string')
}
}
}
})
test('POST /api/gateways/health probes all gateways and logs results', async ({ request }) => {
// Create a gateway
const createRes = await request.post('/api/gateways', {
headers: API_KEY_HEADER,
data: {
name: `e2e-probe-${Date.now()}`,
host: 'https://unreachable-host-for-testing.invalid',
port: 18789,
token: 'probe-token',
},
})
if (createRes.status() === 201) {
const body = await createRes.json()
cleanup.push(body.gateway?.id)
}
// Run the health probe
const probeRes = await request.post('/api/gateways/health', {
headers: API_KEY_HEADER,
})
expect(probeRes.status()).toBe(200)
const probeBody = await probeRes.json()
expect(probeBody).toHaveProperty('results')
expect(probeBody).toHaveProperty('probed_at')
expect(Array.isArray(probeBody.results)).toBe(true)
expect(typeof probeBody.probed_at).toBe('number')
// Each result should have proper shape
for (const result of probeBody.results) {
expect(result).toHaveProperty('id')
expect(result).toHaveProperty('name')
expect(result).toHaveProperty('status')
expect(['online', 'offline', 'error']).toContain(result.status)
expect(result).toHaveProperty('agents')
expect(result).toHaveProperty('sessions_count')
}
})
test('POST /api/gateways/health requires authentication', async ({ request }) => {
const res = await request.post('/api/gateways/health')
expect([401, 403]).toContain(res.status())
})
test('history is ordered by most recent first', async ({ request }) => {
// Trigger a couple of probes
await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
const res = await request.get('/api/gateways/health/history', {
headers: API_KEY_HEADER,
})
expect(res.status()).toBe(200)
const body = await res.json()
for (const gatewayHistory of body.history) {
const timestamps = gatewayHistory.entries.map((e: { probed_at: number }) => e.probed_at)
// Verify descending order (most recent first)
for (let i = 1; i < timestamps.length; i++) {
expect(timestamps[i - 1]).toBeGreaterThanOrEqual(timestamps[i])
}
}
})
test('gateway name is included in history when available', async ({ request }) => {
const uniqueName = `e2e-named-gw-${Date.now()}`
const createRes = await request.post('/api/gateways', {
headers: API_KEY_HEADER,
data: {
name: uniqueName,
host: 'https://unreachable-named.invalid',
port: 18789,
token: 'named-token',
},
})
if (createRes.status() !== 201) return // skip if creation fails
const createdId = (await createRes.json()).gateway?.id as number
cleanup.push(createdId)
// Probe to generate a log entry for this gateway
await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
const histRes = await request.get('/api/gateways/health/history', {
headers: API_KEY_HEADER,
})
expect(histRes.status()).toBe(200)
const histBody = await histRes.json()
const found = histBody.history.find((h: { gatewayId: number }) => h.gatewayId === createdId)
if (found) {
expect(found.name).toBe(uniqueName)
}
})
})