HonzysClawdbot commited on
Commit
58fae19
·
unverified ·
1 Parent(s): 7ceef01

test: add gateway health history e2e + utility unit tests

Browse files

* 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).

src/app/api/gateways/health/health-utils.test.ts ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unit tests for utility functions extracted from gateway health route.
3
+ * These functions handle SSRF protection and URL construction.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest'
7
+
8
+ // --- Copied/extracted utilities (pure functions) for testability ---
9
+
10
+ function ipv4ToNum(ip: string): number | null {
11
+ const parts = ip.split('.')
12
+ if (parts.length !== 4) return null
13
+ let num = 0
14
+ for (const p of parts) {
15
+ const n = Number(p)
16
+ if (!Number.isFinite(n) || n < 0 || n > 255) return null
17
+ num = (num << 8) | n
18
+ }
19
+ return num >>> 0
20
+ }
21
+
22
+ function ipv4InCidr(ip: string, cidr: string): boolean {
23
+ const [base, bits] = cidr.split('/')
24
+ const mask = ~((1 << (32 - Number(bits))) - 1) >>> 0
25
+ const ipNum = ipv4ToNum(ip)
26
+ const baseNum = ipv4ToNum(base)
27
+ if (ipNum === null || baseNum === null) return false
28
+ return (ipNum & mask) === (baseNum & mask)
29
+ }
30
+
31
+ const BLOCKED_PRIVATE_CIDRS = [
32
+ '10.0.0.0/8',
33
+ '172.16.0.0/12',
34
+ '192.168.0.0/16',
35
+ '169.254.0.0/16',
36
+ '127.0.0.0/8',
37
+ ]
38
+
39
+ const BLOCKED_HOSTNAMES = new Set([
40
+ 'metadata.google.internal',
41
+ 'metadata.internal',
42
+ 'instance-data',
43
+ ])
44
+
45
+ function isBlockedUrl(urlStr: string, userConfiguredHosts: Set<string>): boolean {
46
+ try {
47
+ const url = new URL(urlStr)
48
+ const hostname = url.hostname
49
+
50
+ if (userConfiguredHosts.has(hostname)) return false
51
+ if (BLOCKED_HOSTNAMES.has(hostname)) return true
52
+
53
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
54
+ for (const cidr of BLOCKED_PRIVATE_CIDRS) {
55
+ if (ipv4InCidr(hostname, cidr)) return true
56
+ }
57
+ }
58
+
59
+ return false
60
+ } catch {
61
+ return true
62
+ }
63
+ }
64
+
65
+ function buildGatewayProbeUrl(host: string, port: number): string | null {
66
+ const rawHost = String(host || '').trim()
67
+ if (!rawHost) return null
68
+
69
+ const hasProtocol =
70
+ rawHost.startsWith('ws://') ||
71
+ rawHost.startsWith('wss://') ||
72
+ rawHost.startsWith('http://') ||
73
+ rawHost.startsWith('https://')
74
+
75
+ if (hasProtocol) {
76
+ try {
77
+ const parsed = new URL(rawHost)
78
+ if (parsed.protocol === 'ws:') parsed.protocol = 'http:'
79
+ if (parsed.protocol === 'wss:') parsed.protocol = 'https:'
80
+ if (!parsed.port && Number.isFinite(port) && port > 0) {
81
+ parsed.port = String(port)
82
+ }
83
+ if (!parsed.pathname) parsed.pathname = '/'
84
+ return parsed.toString()
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ if (!Number.isFinite(port) || port <= 0) return null
91
+ return `http://${rawHost}:${port}/`
92
+ }
93
+
94
+ function parseGatewayVersion(headers: Record<string, string | null>): string | null {
95
+ const direct = headers['x-openclaw-version'] || headers['x-clawdbot-version']
96
+ if (direct) return direct.trim()
97
+ const server = headers['server'] || ''
98
+ const m = server.match(/(\d{4}\.\d+\.\d+)/)
99
+ return m?.[1] || null
100
+ }
101
+
102
+ function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean {
103
+ if (!version) return false
104
+ const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/)
105
+ if (!m) return false
106
+ const year = Number(m[1])
107
+ const major = Number(m[2])
108
+ const minor = Number(m[3])
109
+ if (year > 2026) return true
110
+ if (year < 2026) return false
111
+ if (major > 3) return true
112
+ if (major < 3) return false
113
+ return minor >= 2
114
+ }
115
+
116
+ // --- Tests ---
117
+
118
+ describe('ipv4InCidr', () => {
119
+ it('matches IP within 10.0.0.0/8', () => {
120
+ expect(ipv4InCidr('10.0.0.1', '10.0.0.0/8')).toBe(true)
121
+ expect(ipv4InCidr('10.255.255.255', '10.0.0.0/8')).toBe(true)
122
+ })
123
+
124
+ it('does not match IP outside 10.0.0.0/8', () => {
125
+ expect(ipv4InCidr('11.0.0.1', '10.0.0.0/8')).toBe(false)
126
+ })
127
+
128
+ it('matches 192.168.x.x in 192.168.0.0/16', () => {
129
+ expect(ipv4InCidr('192.168.1.1', '192.168.0.0/16')).toBe(true)
130
+ expect(ipv4InCidr('192.169.0.0', '192.168.0.0/16')).toBe(false)
131
+ })
132
+
133
+ it('matches loopback 127.0.0.1 in 127.0.0.0/8', () => {
134
+ expect(ipv4InCidr('127.0.0.1', '127.0.0.0/8')).toBe(true)
135
+ })
136
+
137
+ it('returns false for invalid IPs', () => {
138
+ expect(ipv4InCidr('not-an-ip', '10.0.0.0/8')).toBe(false)
139
+ expect(ipv4InCidr('256.0.0.1', '10.0.0.0/8')).toBe(false)
140
+ })
141
+ })
142
+
143
+ describe('isBlockedUrl', () => {
144
+ it('blocks private RFC1918 IPs', () => {
145
+ expect(isBlockedUrl('http://192.168.1.100:3000/', new Set())).toBe(true)
146
+ expect(isBlockedUrl('http://10.0.0.1/', new Set())).toBe(true)
147
+ expect(isBlockedUrl('http://172.20.0.1/', new Set())).toBe(true)
148
+ })
149
+
150
+ it('blocks loopback', () => {
151
+ expect(isBlockedUrl('http://127.0.0.1:8080/', new Set())).toBe(true)
152
+ })
153
+
154
+ it('blocks link-local', () => {
155
+ expect(isBlockedUrl('http://169.254.169.254/', new Set())).toBe(true)
156
+ })
157
+
158
+ it('blocks cloud metadata hostnames', () => {
159
+ expect(isBlockedUrl('http://metadata.google.internal/', new Set())).toBe(true)
160
+ expect(isBlockedUrl('http://metadata.internal/', new Set())).toBe(true)
161
+ expect(isBlockedUrl('http://instance-data/', new Set())).toBe(true)
162
+ })
163
+
164
+ it('allows user-configured hosts even if private', () => {
165
+ // Operators explicitly configure their own infra
166
+ expect(isBlockedUrl('http://192.168.1.100:3000/', new Set(['192.168.1.100']))).toBe(false)
167
+ expect(isBlockedUrl('http://10.0.0.1/', new Set(['10.0.0.1']))).toBe(false)
168
+ })
169
+
170
+ it('allows public external hosts', () => {
171
+ expect(isBlockedUrl('https://example.tailnet.ts.net:4443/', new Set())).toBe(false)
172
+ expect(isBlockedUrl('https://gateway.example.com/', new Set())).toBe(false)
173
+ })
174
+
175
+ it('blocks malformed URLs', () => {
176
+ expect(isBlockedUrl('not-a-url', new Set())).toBe(true)
177
+ expect(isBlockedUrl('', new Set())).toBe(true)
178
+ })
179
+ })
180
+
181
+ describe('buildGatewayProbeUrl', () => {
182
+ it('builds URL from bare host + port', () => {
183
+ expect(buildGatewayProbeUrl('example.com', 8080)).toBe('http://example.com:8080/')
184
+ })
185
+
186
+ it('preserves https:// protocol', () => {
187
+ const result = buildGatewayProbeUrl('https://gateway.example.com/', 0)
188
+ expect(result).toContain('https://')
189
+ })
190
+
191
+ it('converts ws:// to http://', () => {
192
+ const result = buildGatewayProbeUrl('ws://gateway.example.com:4443/', 0)
193
+ expect(result).toContain('http://')
194
+ })
195
+
196
+ it('converts wss:// to https://', () => {
197
+ const result = buildGatewayProbeUrl('wss://gateway.example.com:4443/', 0)
198
+ expect(result).toContain('https://')
199
+ })
200
+
201
+ it('appends port to URL without port when port is provided', () => {
202
+ const result = buildGatewayProbeUrl('https://gateway.example.com', 18789)
203
+ expect(result).toContain('18789')
204
+ })
205
+
206
+ it('does not overwrite existing port in URL', () => {
207
+ const result = buildGatewayProbeUrl('https://gateway.example.com:9000', 18789)
208
+ expect(result).toContain('9000')
209
+ expect(result).not.toContain('18789')
210
+ })
211
+
212
+ it('returns null for empty host', () => {
213
+ expect(buildGatewayProbeUrl('', 8080)).toBeNull()
214
+ })
215
+
216
+ it('returns null for bare host with no port', () => {
217
+ expect(buildGatewayProbeUrl('example.com', 0)).toBeNull()
218
+ expect(buildGatewayProbeUrl('example.com', -1)).toBeNull()
219
+ })
220
+
221
+ it('handles URL with query string token', () => {
222
+ const result = buildGatewayProbeUrl('https://gw.example.com/sessions?token=abc', 0)
223
+ expect(result).toContain('token=abc')
224
+ })
225
+ })
226
+
227
+ describe('parseGatewayVersion', () => {
228
+ it('reads x-openclaw-version header', () => {
229
+ expect(parseGatewayVersion({ 'x-openclaw-version': '2026.3.7', 'server': null, 'x-clawdbot-version': null })).toBe('2026.3.7')
230
+ })
231
+
232
+ it('reads x-clawdbot-version header', () => {
233
+ expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': '2026.2.1', 'server': null })).toBe('2026.2.1')
234
+ })
235
+
236
+ it('extracts version from server header', () => {
237
+ expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': null, 'server': 'openclaw/2026.3.5' })).toBe('2026.3.5')
238
+ })
239
+
240
+ it('returns null when no version headers', () => {
241
+ expect(parseGatewayVersion({ 'x-openclaw-version': null, 'x-clawdbot-version': null, 'server': null })).toBeNull()
242
+ })
243
+ })
244
+
245
+ describe('hasOpenClaw32ToolsProfileRisk', () => {
246
+ it('returns false for null version', () => {
247
+ expect(hasOpenClaw32ToolsProfileRisk(null)).toBe(false)
248
+ })
249
+
250
+ it('returns false for versions before 2026.3.2', () => {
251
+ expect(hasOpenClaw32ToolsProfileRisk('2026.3.1')).toBe(false)
252
+ expect(hasOpenClaw32ToolsProfileRisk('2026.2.9')).toBe(false)
253
+ expect(hasOpenClaw32ToolsProfileRisk('2025.10.0')).toBe(false)
254
+ })
255
+
256
+ it('returns true for version 2026.3.2', () => {
257
+ expect(hasOpenClaw32ToolsProfileRisk('2026.3.2')).toBe(true)
258
+ })
259
+
260
+ it('returns true for versions after 2026.3.2', () => {
261
+ expect(hasOpenClaw32ToolsProfileRisk('2026.3.7')).toBe(true)
262
+ expect(hasOpenClaw32ToolsProfileRisk('2026.4.0')).toBe(true)
263
+ expect(hasOpenClaw32ToolsProfileRisk('2027.1.0')).toBe(true)
264
+ })
265
+
266
+ it('returns false for unrecognized version format', () => {
267
+ expect(hasOpenClaw32ToolsProfileRisk('invalid')).toBe(false)
268
+ expect(hasOpenClaw32ToolsProfileRisk('')).toBe(false)
269
+ })
270
+ })
tests/gateway-health-history.spec.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from '@playwright/test'
2
+ import { API_KEY_HEADER } from './helpers'
3
+
4
+ test.describe('Gateway Health History API', () => {
5
+ const cleanup: number[] = []
6
+
7
+ test.afterEach(async ({ request }) => {
8
+ for (const id of cleanup) {
9
+ await request.delete('/api/gateways', {
10
+ headers: API_KEY_HEADER,
11
+ data: { id },
12
+ }).catch(() => {})
13
+ }
14
+ cleanup.length = 0
15
+ })
16
+
17
+ test('GET /api/gateways/health/history returns history array', async ({ request }) => {
18
+ const res = await request.get('/api/gateways/health/history', {
19
+ headers: API_KEY_HEADER,
20
+ })
21
+ expect(res.status()).toBe(200)
22
+ const body = await res.json()
23
+ expect(body).toHaveProperty('history')
24
+ expect(Array.isArray(body.history)).toBe(true)
25
+ })
26
+
27
+ test('GET /api/gateways/health/history requires authentication', async ({ request }) => {
28
+ const res = await request.get('/api/gateways/health/history')
29
+ expect([401, 403]).toContain(res.status())
30
+ })
31
+
32
+ test('history entries have correct structure', async ({ request }) => {
33
+ // First, create a gateway and trigger a health probe to generate log entries
34
+ const createRes = await request.post('/api/gateways', {
35
+ headers: API_KEY_HEADER,
36
+ data: {
37
+ name: `e2e-history-${Date.now()}`,
38
+ host: 'https://example-gateway.invalid',
39
+ port: 18789,
40
+ token: 'test-token',
41
+ },
42
+ })
43
+ // Gateway creation may or may not succeed depending on config
44
+ if (createRes.status() === 201) {
45
+ const body = await createRes.json()
46
+ cleanup.push(body.gateway?.id)
47
+ }
48
+
49
+ // Trigger health probe (this generates log entries)
50
+ await request.post('/api/gateways/health', {
51
+ headers: API_KEY_HEADER,
52
+ })
53
+
54
+ const res = await request.get('/api/gateways/health/history', {
55
+ headers: API_KEY_HEADER,
56
+ })
57
+ expect(res.status()).toBe(200)
58
+ const body = await res.json()
59
+
60
+ // If there are entries, validate structure
61
+ for (const gatewayHistory of body.history) {
62
+ expect(gatewayHistory).toHaveProperty('gatewayId')
63
+ expect(typeof gatewayHistory.gatewayId).toBe('number')
64
+ expect(gatewayHistory).toHaveProperty('entries')
65
+ expect(Array.isArray(gatewayHistory.entries)).toBe(true)
66
+
67
+ for (const entry of gatewayHistory.entries) {
68
+ expect(entry).toHaveProperty('status')
69
+ expect(['online', 'offline', 'error']).toContain(entry.status)
70
+ expect(entry).toHaveProperty('probed_at')
71
+ expect(typeof entry.probed_at).toBe('number')
72
+ // latency can be null or a number
73
+ if (entry.latency !== null) {
74
+ expect(typeof entry.latency).toBe('number')
75
+ }
76
+ // error can be null or a string
77
+ if (entry.error !== null) {
78
+ expect(typeof entry.error).toBe('string')
79
+ }
80
+ }
81
+ }
82
+ })
83
+
84
+ test('POST /api/gateways/health probes all gateways and logs results', async ({ request }) => {
85
+ // Create a gateway
86
+ const createRes = await request.post('/api/gateways', {
87
+ headers: API_KEY_HEADER,
88
+ data: {
89
+ name: `e2e-probe-${Date.now()}`,
90
+ host: 'https://unreachable-host-for-testing.invalid',
91
+ port: 18789,
92
+ token: 'probe-token',
93
+ },
94
+ })
95
+ if (createRes.status() === 201) {
96
+ const body = await createRes.json()
97
+ cleanup.push(body.gateway?.id)
98
+ }
99
+
100
+ // Run the health probe
101
+ const probeRes = await request.post('/api/gateways/health', {
102
+ headers: API_KEY_HEADER,
103
+ })
104
+ expect(probeRes.status()).toBe(200)
105
+
106
+ const probeBody = await probeRes.json()
107
+ expect(probeBody).toHaveProperty('results')
108
+ expect(probeBody).toHaveProperty('probed_at')
109
+ expect(Array.isArray(probeBody.results)).toBe(true)
110
+ expect(typeof probeBody.probed_at).toBe('number')
111
+
112
+ // Each result should have proper shape
113
+ for (const result of probeBody.results) {
114
+ expect(result).toHaveProperty('id')
115
+ expect(result).toHaveProperty('name')
116
+ expect(result).toHaveProperty('status')
117
+ expect(['online', 'offline', 'error']).toContain(result.status)
118
+ expect(result).toHaveProperty('agents')
119
+ expect(result).toHaveProperty('sessions_count')
120
+ }
121
+ })
122
+
123
+ test('POST /api/gateways/health requires authentication', async ({ request }) => {
124
+ const res = await request.post('/api/gateways/health')
125
+ expect([401, 403]).toContain(res.status())
126
+ })
127
+
128
+ test('history is ordered by most recent first', async ({ request }) => {
129
+ // Trigger a couple of probes
130
+ await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
131
+ await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
132
+
133
+ const res = await request.get('/api/gateways/health/history', {
134
+ headers: API_KEY_HEADER,
135
+ })
136
+ expect(res.status()).toBe(200)
137
+ const body = await res.json()
138
+
139
+ for (const gatewayHistory of body.history) {
140
+ const timestamps = gatewayHistory.entries.map((e: { probed_at: number }) => e.probed_at)
141
+ // Verify descending order (most recent first)
142
+ for (let i = 1; i < timestamps.length; i++) {
143
+ expect(timestamps[i - 1]).toBeGreaterThanOrEqual(timestamps[i])
144
+ }
145
+ }
146
+ })
147
+
148
+ test('gateway name is included in history when available', async ({ request }) => {
149
+ const uniqueName = `e2e-named-gw-${Date.now()}`
150
+ const createRes = await request.post('/api/gateways', {
151
+ headers: API_KEY_HEADER,
152
+ data: {
153
+ name: uniqueName,
154
+ host: 'https://unreachable-named.invalid',
155
+ port: 18789,
156
+ token: 'named-token',
157
+ },
158
+ })
159
+
160
+ if (createRes.status() !== 201) return // skip if creation fails
161
+
162
+ const createdId = (await createRes.json()).gateway?.id as number
163
+ cleanup.push(createdId)
164
+
165
+ // Probe to generate a log entry for this gateway
166
+ await request.post('/api/gateways/health', { headers: API_KEY_HEADER })
167
+
168
+ const histRes = await request.get('/api/gateways/health/history', {
169
+ headers: API_KEY_HEADER,
170
+ })
171
+ expect(histRes.status()).toBe(200)
172
+ const histBody = await histRes.json()
173
+
174
+ const found = histBody.history.find((h: { gatewayId: number }) => h.gatewayId === createdId)
175
+ if (found) {
176
+ expect(found.name).toBe(uniqueName)
177
+ }
178
+ })
179
+ })