from typing import Tuple import sqlite3 import pytest from fastapi.testclient import TestClient from exporter.api import create_app, DeviceIn from exporter.config import DeviceConfig, GlobalConfig from exporter.metrics import TransceiverCollector from exporter.models import DeviceHealthState, DeviceMetricsSnapshot from exporter.registry import DeviceRegistry from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" @pytest.fixture def global_cfg(tmp_path) -> GlobalConfig: cfg = GlobalConfig() cfg.api_token = "changeme" cfg.runtime_db_path = str(tmp_path / "devices.db") cfg.password_secret = VALID_FERNET_KEY return cfg def _make_app_and_registry(global_cfg: GlobalConfig) -> Tuple[TestClient, DeviceRegistry]: encryptor = PasswordEncryptor(global_cfg.password_secret) store = SQLiteDeviceStore(global_cfg.runtime_db_path, encryptor) store.init_db() registry = DeviceRegistry(global_scrape_interval=global_cfg.scrape_interval_seconds) # cache/health 可共享空 dict cache: dict[str, DeviceMetricsSnapshot] = {} health: dict[str, DeviceHealthState] = {} collector = TransceiverCollector(cache, health) app = create_app(registry, store, collector, global_cfg) return TestClient(app), registry @pytest.fixture def app_with_registry(global_cfg) -> Tuple[TestClient, DeviceRegistry]: return _make_app_and_registry(global_cfg) def test_devicein_vendor_validator_accepts_none(): # vendor 省略时应保持为 None,走 validator 的 None 分支 d = DeviceIn( name="dev1", host="192.0.2.1", username="u", password="p", ) assert d.vendor is None def test_get_devices_requires_auth(app_with_registry): client, _ = app_with_registry resp = client.get("/api/v1/devices") assert resp.status_code == 401 def test_get_devices_returns_list(app_with_registry): client, _ = app_with_registry resp = client.get("/api/v1/devices", headers={"X-API-Token": "changeme"}) assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_post_device_creates_runtime_device(app_with_registry): client, registry = app_with_registry device_data = { "name": "new-device", "host": "192.168.1.100", "port": 830, "username": "admin", "password": "secret", "enabled": True, } resp = client.post( "/api/v1/devices", headers={"X-API-Token": "changeme"}, json=device_data, ) assert resp.status_code == 201 body = resp.json() assert body["name"] == "new-device" assert body["source"] == "runtime" # registry 中也应该有 devices = registry.list_devices() assert any(d.name == "new-device" and d.source == "runtime" for d in devices) def test_post_device_accepts_vendor_and_normalizes(app_with_registry): client, registry = app_with_registry device_data = { "name": "rj-dev", "host": "192.168.1.200", "port": 830, "username": "admin", "password": "secret", "enabled": True, "vendor": " Ruijie ", } resp = client.post( "/api/v1/devices", headers={"X-API-Token": "changeme"}, json=device_data, ) assert resp.status_code == 201 body = resp.json() # API 返回的 vendor 应已被 strip + lower assert body["vendor"] == "ruijie" # registry 中也应保存规范化后的 vendor devices = registry.list_devices() dev = next(d for d in devices if d.name == "rj-dev") assert dev.vendor == "ruijie" def test_post_duplicate_device_returns_409(app_with_registry): client, _ = app_with_registry device_data = { "name": "dup-dev", "host": "192.168.1.100", "port": 830, "username": "admin", "password": "secret", "enabled": True, } resp1 = client.post( "/api/v1/devices", headers={"X-API-Token": "changeme"}, json=device_data, ) assert resp1.status_code == 201 resp2 = client.post( "/api/v1/devices", headers={"X-API-Token": "changeme"}, json=device_data, ) assert resp2.status_code == 409 def test_delete_runtime_device(app_with_registry): client, _ = app_with_registry device_data = { "name": "to-delete", "host": "192.168.1.100", "port": 830, "username": "admin", "password": "secret", "enabled": True, } client.post( "/api/v1/devices", headers={"X-API-Token": "changeme"}, json=device_data, ) resp = client.delete( "/api/v1/devices/to-delete", headers={"X-API-Token": "changeme"}, ) assert resp.status_code == 204 devices = client.get( "/api/v1/devices", headers={"X-API-Token": "changeme"}, ).json() assert "to-delete" not in [d["name"] for d in devices] def test_delete_static_device_fails(app_with_registry): client, registry = app_with_registry static_dev = DeviceConfig( name="static-1", host="10.0.0.2", port=830, username="u", password="p", vendor="h3c", source="static", ) registry.register_static_device(static_dev) resp = client.delete( "/api/v1/devices/static-1", headers={"X-API-Token": "changeme"}, ) assert resp.status_code == 400 assert "static device" in resp.json()["detail"].lower() def test_healthz_endpoint(app_with_registry): client, _ = app_with_registry resp = client.get("/healthz") assert resp.status_code == 200 data = resp.json() assert "status" in data assert "devices_total" in data def test_metrics_endpoint_returns_prometheus_format(app_with_registry): client, _ = app_with_registry resp = client.get("/metrics") assert resp.status_code == 200 assert "text/plain" in resp.headers["content-type"] assert "# HELP" in resp.text assert "netconf_scrape_success" in resp.text def test_get_devices_when_api_token_disabled(tmp_path): # 当 global.api_token 为空时,/api/v1/devices 不应要求鉴权 gc = GlobalConfig() gc.api_token = "" gc.runtime_db_path = str(tmp_path / "devices.db") gc.password_secret = VALID_FERNET_KEY encryptor = PasswordEncryptor(gc.password_secret) store = SQLiteDeviceStore(gc.runtime_db_path, encryptor) store.init_db() registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds) cache: dict[str, DeviceMetricsSnapshot] = {} health: dict[str, DeviceHealthState] = {} collector = TransceiverCollector(cache, health) app = create_app(registry, store, collector, gc) client = TestClient(app) resp = client.get("/api/v1/devices") assert resp.status_code == 200