From 2fe7785433d5204d740e617bcef594205c2cf235 Mon Sep 17 00:00:00 2001 From: HonzysClawdbot Date: Sat, 14 Mar 2026 10:39:02 +0100 Subject: [PATCH] test: add gateway health history e2e + utility unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- .../api/gateways/health/health-utils.test.ts | 270 ++++++++++++++++++ tests/gateway-health-history.spec.ts | 179 ++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/app/api/gateways/health/health-utils.test.ts create mode 100644 tests/gateway-health-history.spec.ts diff --git a/src/app/api/gateways/health/health-utils.test.ts b/src/app/api/gateways/health/health-utils.test.ts new file mode 100644 index 0000000..c1e330a --- /dev/null +++ b/src/app/api/gateways/health/health-utils.test.ts @@ -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): 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 | 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) + }) +}) diff --git a/tests/gateway-health-history.spec.ts b/tests/gateway-health-history.spec.ts new file mode 100644 index 0000000..3d6fa29 --- /dev/null +++ b/tests/gateway-health-history.spec.ts @@ -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) + } + }) +})