From ed21b73a431e89a6172a1e22a6705d326cdfd3d8 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 19 Mar 2026 19:39:15 +0000 Subject: [PATCH] Add monitor API tests (B8) - test_monitor.py: 12 test cases covering: - API key generation (success, 404, admin-only) - heartbeat-v2 endpoint (valid key, invalid key, missing key) - API key revocation and validation - ServerState data persistence (agents_json, cpu_pct, etc.) - Disabled server rejection - conftest.py: import app.models.monitor for test database setup All tests passing (pytest -v). --- tests/conftest.py | 4 + tests/test_monitor.py | 322 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 tests/test_monitor.py diff --git a/tests/conftest.py b/tests/conftest.py index 5e84575..4894da3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,10 @@ try: import app.models.webhook # noqa: F401 except ImportError: pass +try: + import app.models.monitor # noqa: F401 +except ImportError: + pass TEST_DATABASE_URL = "sqlite://" # in-memory diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..d4e7c79 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,322 @@ +"""Tests for monitor API — API Key and heartbeat-v2 endpoints.""" +import json +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def make_server(db): + """Factory to create a MonitoredServer row.""" + from app.models.monitor import MonitoredServer + + _counter = [0] + + def _make(identifier=None, display_name=None, is_enabled=True, api_key=None): + _counter[0] += 1 + n = _counter[0] + s = MonitoredServer( + identifier=identifier or f"test-server-{n}", + display_name=display_name or f"Test Server {n}", + is_enabled=is_enabled, + api_key=api_key, + ) + db.add(s) + db.commit() + db.refresh(s) + return s + + return _make + + +@pytest.fixture() +def admin_auth(make_user, auth_header): + """Create admin user and return auth header.""" + user = make_user(is_admin=True) + return auth_header(user), user + + +# --------------------------------------------------------------------------- +# API Key Generation Tests (POST /admin/servers/{id}/api-key) +# --------------------------------------------------------------------------- + +def test_generate_api_key_success(client, db, make_server, admin_auth): + """B8-1: api-key 生成成功返回 200 + api_key""" + headers, _ = admin_auth + server = make_server() + + response = client.post(f"/monitor/admin/servers/{server.id}/api-key", headers=headers) + + assert response.status_code == 200 + data = response.json() + assert "api_key" in data + assert data["server_id"] == server.id + assert len(data["api_key"]) > 20 # token_urlsafe(32) produces ~43 chars + + # Verify key is stored in DB + db.refresh(server) + assert server.api_key == data["api_key"] + + +def test_generate_api_key_not_found(client, db, admin_auth): + """B8-2: api-key 生成对不存在 server 返回 404""" + headers, _ = admin_auth + + response = client.post("/monitor/admin/servers/99999/api-key", headers=headers) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +# --------------------------------------------------------------------------- +# Heartbeat v2 Tests (POST /server/heartbeat-v2) +# --------------------------------------------------------------------------- + +def test_heartbeat_v2_valid_key(client, db, make_server): + """B8-3: heartbeat-v2 带有效 key 返回 200""" + api_key = "test-api-key-valid-123" + server = make_server(api_key=api_key) + + payload = { + "identifier": server.identifier, + "openclaw_version": "1.0.0", + "agents": [{"id": "agent1", "status": "idle"}], + "cpu_pct": 45.5, + "mem_pct": 60.0, + "disk_pct": 30.0, + "swap_pct": 10.0, + "load_avg": [0.5, 0.6, 0.7], + "uptime_seconds": 3600, + } + + response = client.post( + "/monitor/server/heartbeat-v2", + json=payload, + headers={"X-API-Key": api_key}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["server_id"] == server.id + assert data["identifier"] == server.identifier + + +def test_heartbeat_v2_invalid_key(client, db, make_server): + """B8-4: heartbeat-v2 带无效 key 返回 401""" + server = make_server(api_key="valid-key") + + payload = { + "identifier": server.identifier, + "cpu_pct": 45.5, + } + + response = client.post( + "/monitor/server/heartbeat-v2", + json=payload, + headers={"X-API-Key": "invalid-key"}, + ) + + assert response.status_code == 401 + assert "invalid" in response.json()["detail"].lower() or "api key" in response.json()["detail"].lower() + + +def test_heartbeat_v2_missing_key(client, db, make_server): + """B8-5: heartbeat-v2 不带 key 返回 401""" + server = make_server(api_key="valid-key") + + payload = {"identifier": server.identifier} + + response = client.post("/monitor/server/heartbeat-v2", json=payload) + + # FastAPI will reject missing required header with 422 (validation error) + # or we may expect 401 depending on implementation + assert response.status_code in [401, 422] + + +# --------------------------------------------------------------------------- +# Revoke API Key Tests (DELETE /admin/servers/{id}/api-key) +# --------------------------------------------------------------------------- + +def test_revoke_api_key_makes_key_invalid(client, db, make_server, admin_auth): + """B8-6: 测试 revoke 后 key 失效""" + headers, _ = admin_auth + api_key = "test-key-to-revoke" + server = make_server(api_key=api_key) + + # First, verify key works + payload = {"identifier": server.identifier, "cpu_pct": 50.0} + response1 = client.post( + "/monitor/server/heartbeat-v2", + json=payload, + headers={"X-API-Key": api_key}, + ) + assert response1.status_code == 200 + + # Revoke the key + response2 = client.delete(f"/monitor/admin/servers/{server.id}/api-key", headers=headers) + assert response2.status_code == 204 + + # Verify key no longer works + response3 = client.post( + "/monitor/server/heartbeat-v2", + json=payload, + headers={"X-API-Key": api_key}, + ) + assert response3.status_code == 401 + + # Verify DB + db.refresh(server) + assert server.api_key is None + + +def test_revoke_api_key_not_found(client, db, admin_auth): + """Revoke API key for non-existent server returns 404""" + headers, _ = admin_auth + + response = client.delete("/monitor/admin/servers/99999/api-key", headers=headers) + + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Data Storage Tests (ServerState) +# --------------------------------------------------------------------------- + +def test_heartbeat_v2_stores_data_correctly(client, db, make_server): + """B8-7: 测试数据是否正确写入 ServerState(agents_json, cpu_pct, etc.)""" + from app.models.monitor import ServerState + + api_key = "test-api-key-storage" + server = make_server(api_key=api_key) + + agents = [{"id": "agent1", "name": "TestAgent", "status": "busy"}] + payload = { + "identifier": server.identifier, + "openclaw_version": "2.5.1", + "agents": agents, + "cpu_pct": 75.5, + "mem_pct": 82.3, + "disk_pct": 45.0, + "swap_pct": 5.5, + "load_avg": [1.2, 1.5, 1.8], + "uptime_seconds": 7200, + } + + response = client.post( + "/monitor/server/heartbeat-v2", + json=payload, + headers={"X-API-Key": api_key}, + ) + + assert response.status_code == 200 + + # Verify ServerState in DB + state = db.query(ServerState).filter(ServerState.server_id == server.id).first() + assert state is not None + assert state.openclaw_version == "2.5.1" + assert state.cpu_pct == 75.5 + assert state.mem_pct == 82.3 + assert state.disk_pct == 45.0 + assert state.swap_pct == 5.5 + assert state.last_seen_at is not None + + # Verify agents_json + stored_agents = json.loads(state.agents_json) + assert stored_agents == agents + assert stored_agents[0]["id"] == "agent1" + assert stored_agents[0]["status"] == "busy" + + +def test_heartbeat_v2_updates_existing_state(client, db, make_server): + """Test that subsequent heartbeats update the existing ServerState""" + from app.models.monitor import ServerState + + api_key = "test-api-key-update" + server = make_server(api_key=api_key) + + # First heartbeat + response1 = client.post( + "/monitor/server/heartbeat-v2", + json={ + "identifier": server.identifier, + "cpu_pct": 30.0, + "mem_pct": 40.0, + "agents": [{"id": "a1"}], + }, + headers={"X-API-Key": api_key}, + ) + assert response1.status_code == 200 + + state1 = db.query(ServerState).filter(ServerState.server_id == server.id).first() + first_seen = state1.last_seen_at + + # Second heartbeat with different data + response2 = client.post( + "/monitor/server/heartbeat-v2", + json={ + "identifier": server.identifier, + "cpu_pct": 60.0, + "mem_pct": 70.0, + "agents": [{"id": "a1"}, {"id": "a2"}], + }, + headers={"X-API-Key": api_key}, + ) + assert response2.status_code == 200 + + # Verify update + db.refresh(state1) + assert state1.cpu_pct == 60.0 + assert state1.mem_pct == 70.0 + assert len(json.loads(state1.agents_json)) == 2 + # last_seen_at should be updated (newer timestamp) + assert state1.last_seen_at >= first_seen + + +# --------------------------------------------------------------------------- +# Authorization Tests +# --------------------------------------------------------------------------- + +def test_generate_api_key_requires_admin(client, make_user, auth_header, make_server): + """Only admin can generate API keys""" + non_admin = make_user(is_admin=False) + server = make_server() + + response = client.post( + f"/monitor/admin/servers/{server.id}/api-key", + headers=auth_header(non_admin), + ) + + assert response.status_code == 403 + + +def test_revoke_api_key_requires_admin(client, make_user, auth_header, make_server): + """Only admin can revoke API keys""" + non_admin = make_user(is_admin=False) + server = make_server(api_key="some-key") + + response = client.delete( + f"/monitor/admin/servers/{server.id}/api-key", + headers=auth_header(non_admin), + ) + + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Disabled Server Tests +# --------------------------------------------------------------------------- + +def test_heartbeat_v2_disabled_server(client, db, make_server): + """Heartbeat-v2 should reject disabled servers even with valid key""" + api_key = "test-key-disabled" + server = make_server(api_key=api_key, is_enabled=False) + + response = client.post( + "/monitor/server/heartbeat-v2", + json={"identifier": server.identifier, "cpu_pct": 50.0}, + headers={"X-API-Key": api_key}, + ) + + assert response.status_code == 401 # Should be treated as invalid key -- 2.49.1