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) + } + }) +})