[#2] 增加ruijie支持,注册接口增加vendor类型
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ import sqlite3
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exporter.api import create_app
|
||||
from exporter.api import create_app, DeviceIn
|
||||
from exporter.config import DeviceConfig, GlobalConfig
|
||||
from exporter.metrics import TransceiverCollector
|
||||
from exporter.models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
@@ -46,6 +46,17 @@ 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")
|
||||
@@ -86,6 +97,35 @@ def test_post_device_creates_runtime_device(app_with_registry):
|
||||
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
|
||||
|
||||
@@ -152,6 +192,7 @@ def test_delete_static_device_fails(app_with_registry):
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
vendor="h3c",
|
||||
source="static",
|
||||
)
|
||||
registry.register_static_device(static_dev)
|
||||
@@ -181,3 +222,24 @@ def test_metrics_endpoint_returns_prometheus_format(app_with_registry):
|
||||
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
|
||||
|
||||
86
tests/test_api_sqlite_errors.py
Normal file
86
tests/test_api_sqlite_errors.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import sqlite3
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from exporter.api import create_app
|
||||
from exporter.config import DeviceConfig, GlobalConfig
|
||||
from exporter.metrics import TransceiverCollector
|
||||
from exporter.models import DeviceHealthState, DeviceMetricsSnapshot
|
||||
from exporter.registry import DeviceRegistry
|
||||
|
||||
|
||||
VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
class DummyStore:
|
||||
"""简单的测试用存储,实现 API 需要的接口,并在指定操作上抛 OperationalError。"""
|
||||
|
||||
def __init__(self, fail_on: str | None = None) -> None:
|
||||
self.fail_on = fail_on
|
||||
|
||||
# 与 SQLiteDeviceStore 接口对齐
|
||||
def init_db(self) -> None: # pragma: no cover - 在这些测试中不会调用
|
||||
return
|
||||
|
||||
def load_runtime_devices(self) -> List[DeviceConfig]:
|
||||
return []
|
||||
|
||||
def save_device(self, cfg: DeviceConfig) -> None:
|
||||
if self.fail_on == "save":
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
def delete_device(self, name: str) -> None:
|
||||
if self.fail_on == "delete":
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
def close(self) -> None: # pragma: no cover - 这里无需验证
|
||||
return
|
||||
|
||||
|
||||
def _build_app_with_dummy_store(fail_on: str | None) -> TestClient:
|
||||
gc = GlobalConfig()
|
||||
gc.api_token = "token"
|
||||
gc.runtime_db_path = ":memory:"
|
||||
gc.password_secret = VALID_FERNET_KEY
|
||||
|
||||
registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds)
|
||||
cache: Dict[str, DeviceMetricsSnapshot] = {}
|
||||
health: Dict[str, DeviceHealthState] = {}
|
||||
collector = TransceiverCollector(cache, health)
|
||||
|
||||
store = DummyStore(fail_on=fail_on)
|
||||
app = create_app(registry, store, collector, gc)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_post_device_sqlite_operational_error_returns_503():
|
||||
client = _build_app_with_dummy_store(fail_on="save")
|
||||
|
||||
payload = {
|
||||
"name": "dev-save-error",
|
||||
"host": "192.0.2.10",
|
||||
"port": 830,
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"enabled": True,
|
||||
}
|
||||
resp = client.post(
|
||||
"/api/v1/devices",
|
||||
headers={"X-API-Token": "token"},
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "database is locked" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_delete_device_sqlite_operational_error_returns_503():
|
||||
client = _build_app_with_dummy_store(fail_on="delete")
|
||||
|
||||
resp = client.delete(
|
||||
"/api/v1/devices/nonexistent",
|
||||
headers={"X-API-Token": "token"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert "database is locked" in resp.json()["detail"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -58,6 +59,31 @@ def test_config_invalid_fernet_key_raises():
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_config_missing_password_secret_raises():
|
||||
data = {
|
||||
"global": {
|
||||
"runtime_db_path": "./devices.db",
|
||||
# password_secret 缺失
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="global.password_secret must be configured"):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_config_invalid_fernet_key_length_raises():
|
||||
# 构造一个合法 base64 但长度不是 32 字节的 key
|
||||
bad_key_bytes = b"too-short"
|
||||
bad_key = base64.urlsafe_b64encode(bad_key_bytes).decode()
|
||||
data = {
|
||||
"global": {
|
||||
"runtime_db_path": "./devices.db",
|
||||
"password_secret": bad_key,
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="Invalid Fernet key length"):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_shutdown_timeout_too_small_warns():
|
||||
data = {
|
||||
"global": {
|
||||
@@ -84,3 +110,39 @@ def test_shutdown_timeout_too_small_warns():
|
||||
with pytest.warns(UserWarning):
|
||||
Config.from_dict(data)
|
||||
|
||||
|
||||
def test_deviceconfig_vendor_parsed_and_normalized_from_yaml(tmp_path: Path):
|
||||
yaml_content = f"""
|
||||
global:
|
||||
runtime_db_path: "./devices.db"
|
||||
password_secret: "{VALID_FERNET_KEY}"
|
||||
devices:
|
||||
- name: rj-1
|
||||
host: 192.0.2.10
|
||||
port: 830
|
||||
username: u
|
||||
password: p
|
||||
enabled: true
|
||||
supports_xpath: false
|
||||
vendor: " Ruijie "
|
||||
- name: h3c-1
|
||||
host: 198.51.100.10
|
||||
port: 830
|
||||
username: u2
|
||||
password: p2
|
||||
enabled: true
|
||||
supports_xpath: false
|
||||
"""
|
||||
cfg_file = tmp_path / "config_vendor.yaml"
|
||||
cfg_file.write_text(yaml_content)
|
||||
|
||||
cfg = Config.from_file(cfg_file)
|
||||
assert len(cfg.devices) == 2
|
||||
|
||||
rj = next(d for d in cfg.devices if d.name == "rj-1")
|
||||
h3c = next(d for d in cfg.devices if d.name == "h3c-1")
|
||||
|
||||
# vendor 显式配置应被 strip + lower
|
||||
assert rj.vendor == "ruijie"
|
||||
# 未配置 vendor 时应为 None
|
||||
assert h3c.vendor is None
|
||||
|
||||
@@ -123,3 +123,21 @@ def test_close_all_closes_all_sessions(monkeypatch, global_cfg):
|
||||
assert mgr_instances[0].closed is True
|
||||
assert mgr_instances[1].closed is True
|
||||
|
||||
|
||||
def test_acquire_session_reuses_existing_manager(monkeypatch, global_cfg, device_cfg):
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_connect(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return DummyManager()
|
||||
|
||||
monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect)
|
||||
|
||||
cm = ConnectionManager(global_cfg)
|
||||
# 第一次会触发 connect
|
||||
sess1 = cm.acquire_session(device_cfg)
|
||||
# 第二次在会话仍有效时应复用,不再调用 connect
|
||||
sess2 = cm.acquire_session(device_cfg)
|
||||
|
||||
assert sess1 is sess2
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -13,4 +13,9 @@ def test_classify_error_from_exception():
|
||||
assert classify_error(ET.ParseError()) == "XMLParseError"
|
||||
assert classify_error(PermissionError()) == "AuthenticationError"
|
||||
assert classify_error(RuntimeError("filter failed")) == "FilterError"
|
||||
|
||||
# SessionCloseError / SessionError 通过类名匹配
|
||||
SessionCloseErrorType = type("SessionCloseError", (Exception,), {})
|
||||
assert classify_error(SessionCloseErrorType("closed")) == "SessionCloseError"
|
||||
|
||||
assert classify_error(RuntimeError("something else")) == "UnknownError"
|
||||
|
||||
@@ -29,7 +29,8 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
|
||||
config: Dict[str, Any] = {
|
||||
"global": {
|
||||
"http_listen": "127.0.0.1:19100",
|
||||
# 使用 29200 端口,避免与独立部署冲突
|
||||
"http_listen": "127.0.0.1:29200",
|
||||
"scrape_interval_seconds": 2,
|
||||
"rpc_timeout_seconds": 2,
|
||||
"shutdown_timeout_seconds": 10,
|
||||
@@ -57,12 +58,12 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
text=True,
|
||||
)
|
||||
|
||||
base_url = "http://127.0.0.1:19100"
|
||||
base_url = "http://127.0.0.1:29200"
|
||||
|
||||
def _http_request(path: str, method: str = "GET", body: bytes | None = None, headers: Dict[str, str] | None = None):
|
||||
import http.client
|
||||
|
||||
conn = http.client.HTTPConnection("127.0.0.1", 19100, timeout=5)
|
||||
conn = http.client.HTTPConnection("127.0.0.1", 29200, timeout=5)
|
||||
try:
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
resp = conn.getresponse()
|
||||
@@ -93,29 +94,72 @@ def test_exporter_http_end_to_end(tmp_path) -> None:
|
||||
# server 可能尚未 ready,稍后重试
|
||||
time.sleep(0.5)
|
||||
|
||||
# 4. 通过 API 注册一个 runtime 设备
|
||||
device_payload = {
|
||||
"name": "e2e-device-1",
|
||||
# 4. 通过 API 注册两个 runtime 设备:一个 H3C、一个 Ruijie
|
||||
# 使用带时间戳的名称,避免受残留 runtime DB 状态影响
|
||||
base_name = f"e2e-{int(time.time() * 1000)}"
|
||||
h3c_name = f"{base_name}-h3c"
|
||||
ruijie_name = f"{base_name}-ruijie"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Token": "changeme",
|
||||
}
|
||||
|
||||
# H3C 设备(不显式设置 vendor 或设置为 h3c)
|
||||
h3c_payload = {
|
||||
"name": h3c_name,
|
||||
"host": "192.0.2.10",
|
||||
"port": 830,
|
||||
"username": "netconf_user",
|
||||
"password": "secret",
|
||||
"enabled": True,
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Token": "changeme",
|
||||
"vendor": "h3c",
|
||||
}
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="POST",
|
||||
body=json.dumps(device_payload).encode("utf-8"),
|
||||
body=json.dumps(h3c_payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices: {status}, body={data!r}"
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices (h3c): {status}, body={data!r}"
|
||||
body_json = json.loads(data.decode("utf-8"))
|
||||
assert body_json["name"] == "e2e-device-1"
|
||||
assert body_json["name"] == h3c_name
|
||||
assert body_json["source"] == "runtime"
|
||||
assert body_json.get("vendor") == "h3c"
|
||||
|
||||
# Ruijie 设备(vendor 应被规范化为 ruijie)
|
||||
ruijie_payload = {
|
||||
"name": ruijie_name,
|
||||
"host": "192.0.2.11",
|
||||
"port": 830,
|
||||
"username": "ruijie",
|
||||
"password": "secret",
|
||||
"enabled": True,
|
||||
"vendor": " Ruijie ",
|
||||
}
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="POST",
|
||||
body=json.dumps(ruijie_payload).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 201, f"unexpected status for POST /api/v1/devices (ruijie): {status}, body={data!r}"
|
||||
body_json = json.loads(data.decode("utf-8"))
|
||||
assert body_json["name"] == ruijie_name
|
||||
assert body_json["source"] == "runtime"
|
||||
assert body_json.get("vendor") == "ruijie"
|
||||
|
||||
# 验证 GET /api/v1/devices 能同时看到 H3C 和 Ruijie 两个设备
|
||||
status, _, data = _http_request(
|
||||
"/api/v1/devices",
|
||||
method="GET",
|
||||
headers=headers,
|
||||
)
|
||||
assert status == 200
|
||||
devices = json.loads(data.decode("utf-8"))
|
||||
names = {d["name"] for d in devices}
|
||||
assert h3c_name in names
|
||||
assert ruijie_name in names
|
||||
|
||||
# 5. 访问 /metrics,验证 Prometheus 输出可用
|
||||
status, headers_list, data = _http_request("/metrics")
|
||||
|
||||
@@ -75,3 +75,30 @@ def test_init_logging_configures_root_logger_handlers() -> None:
|
||||
for handler in root.handlers
|
||||
for flt in handler.filters
|
||||
)
|
||||
|
||||
|
||||
def test_init_logging_with_file_handler(tmp_path) -> None:
|
||||
"""当配置 log_file 时,应创建文件 handler 并挂载 DeviceFieldFilter。"""
|
||||
log_file = tmp_path / "exporter.log"
|
||||
gc = GlobalConfig(
|
||||
log_level="INFO",
|
||||
log_to_stdout=False,
|
||||
log_file=str(log_file),
|
||||
log_file_max_bytes=1024,
|
||||
log_file_backup_count=1,
|
||||
)
|
||||
|
||||
init_logging(gc)
|
||||
|
||||
root = logging.getLogger()
|
||||
# 应至少存在一个 RotatingFileHandler
|
||||
file_handlers = [
|
||||
h for h in root.handlers if isinstance(h, logging.handlers.RotatingFileHandler)
|
||||
]
|
||||
assert file_handlers
|
||||
# 并且这些 handler 上也应安装 DeviceFieldFilter
|
||||
assert any(
|
||||
isinstance(flt, DeviceFieldFilter)
|
||||
for h in file_handlers
|
||||
for flt in h.filters
|
||||
)
|
||||
|
||||
58
tests/test_netconf_parser_vendor_none_ruijie.py
Normal file
58
tests/test_netconf_parser_vendor_none_ruijie.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from exporter.netconf_client import parse_netconf_response
|
||||
|
||||
|
||||
RUJIE_SAMPLE_XML = """\
|
||||
<rpc-reply xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
|
||||
<data>
|
||||
<components xmlns="http://openconfig.net/yang/platform">
|
||||
<component>
|
||||
<name>TRANSCEIVER-1/0/129-FH0/1:1</name>
|
||||
<state>
|
||||
<type>TRANSCEIVER</type>
|
||||
</state>
|
||||
<transceiver xmlns="http://openconfig.net/yang/platform/transceiver">
|
||||
<state>
|
||||
<serial-no>ABC123</serial-no>
|
||||
</state>
|
||||
<physical-channels>
|
||||
<channel>
|
||||
<index>1</index>
|
||||
<state>
|
||||
<description>TRANSCEIVER-1/0/129/1-FH0/1:1</description>
|
||||
</state>
|
||||
</channel>
|
||||
</physical-channels>
|
||||
</transceiver>
|
||||
</component>
|
||||
</components>
|
||||
</data>
|
||||
</rpc-reply>
|
||||
"""
|
||||
|
||||
|
||||
def test_vendor_none_with_ruijie_sample_uses_h3c_strategy():
|
||||
txs, chs = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None)
|
||||
assert len(chs) == 1
|
||||
ch = chs[0]
|
||||
# H3C 默认策略:冒号前为端口
|
||||
assert ch.logical_port == "TRANSCEIVER-1/0/129/1-FH0/1"
|
||||
assert ch.logical_channel == "TRANSCEIVER-1/0/129/1-FH0/1:1"
|
||||
|
||||
|
||||
def test_vendor_none_to_ruijie_changes_labels():
|
||||
_, chs_none = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None)
|
||||
_, chs_ruijie = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor="ruijie")
|
||||
|
||||
assert len(chs_none) == len(chs_ruijie) == 1
|
||||
ch_none = chs_none[0]
|
||||
ch_rj = chs_ruijie[0]
|
||||
|
||||
assert ch_none.logical_port.startswith("TRANSCEIVER-")
|
||||
# Ruijie 策略应清洗出短端口 FH0/1
|
||||
assert ch_rj.logical_port == "FH0/1"
|
||||
assert ch_rj.logical_channel == "FH0/1:1"
|
||||
|
||||
117
tests/test_ruijie_live_netconf.py
Normal file
117
tests/test_ruijie_live_netconf.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
与真实 Ruijie 设备联调的“活体”测试用例。
|
||||
|
||||
说明:
|
||||
- 连接参数通过环境变量注入(你已经在 .env 中配置):
|
||||
- RUIJIE_NETCONF_HOST
|
||||
- RUIJIE_NETCONF_PORT
|
||||
- RUIJIE_NETCONF_USER
|
||||
- RUIJIE_NETCONF_PASSWORD
|
||||
|
||||
默认行为:
|
||||
- 若未设置 RUIJIE_NETCONF_PASSWORD,或无法建立到指定 host:port 的 TCP 连接,
|
||||
则使用 pytest.skip() 自动跳过,不影响普通单元测试/CI。
|
||||
- 仅在本地联调时、显式设置上述环境变量后,此测试才会真正访问设备。
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from ncclient import manager
|
||||
|
||||
from exporter.netconf_client import build_transceiver_filter, parse_netconf_response
|
||||
|
||||
|
||||
RUIJIE_HOST = os.getenv("RUIJIE_NETCONF_HOST", "127.0.0.1")
|
||||
RUIJIE_PORT = int(os.getenv("RUIJIE_NETCONF_PORT", "9830"))
|
||||
RUIJIE_USER = os.getenv("RUIJIE_NETCONF_USER", "ruijie1-admin")
|
||||
RUIJIE_PASSWORD = os.getenv("RUIJIE_NETCONF_PASSWORD", "")
|
||||
|
||||
|
||||
def _can_connect(host: str, port: int, timeout: float = 2.0) -> bool:
|
||||
"""快速探测 host:port 是否可连,用于决定是否跳过 live 测试。"""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout)
|
||||
sock.connect((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
@pytest.mark.ruijie_live
|
||||
def test_ruijie_live_transceiver_rpc_and_parse() -> None:
|
||||
"""
|
||||
使用真实 Ruijie 设备验证:
|
||||
- ncclient 能与设备建立 NETCONF 会话;
|
||||
- build_transceiver_filter() 构造的 subtree filter 在设备上可用;
|
||||
- parse_netconf_response(..., vendor='ruijie') 能正确解析设备返回的 XML。
|
||||
"""
|
||||
if not RUIJIE_PASSWORD:
|
||||
pytest.skip("RUIJIE_NETCONF_PASSWORD 未设置,跳过 Ruijie live 测试")
|
||||
|
||||
if not _can_connect(RUIJIE_HOST, RUIJIE_PORT):
|
||||
pytest.skip(f"Ruijie NETCONF {RUIJIE_HOST}:{RUIJIE_PORT} 不可达,跳过 live 测试")
|
||||
|
||||
flt = build_transceiver_filter()
|
||||
|
||||
with manager.connect(
|
||||
host=RUIJIE_HOST,
|
||||
port=RUIJIE_PORT,
|
||||
username=RUIJIE_USER,
|
||||
password=RUIJIE_PASSWORD,
|
||||
hostkey_verify=False,
|
||||
timeout=30,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
) as m:
|
||||
reply = m.get(filter=("subtree", flt))
|
||||
xml_str = str(reply)
|
||||
|
||||
# vendor="ruijie" 走厂商感知解析路径
|
||||
transceivers, channels = parse_netconf_response(
|
||||
xml_str,
|
||||
device_name=f"ruijie-{RUIJIE_HOST}",
|
||||
vendor="ruijie",
|
||||
)
|
||||
|
||||
# 只要返回非空结果,就说明 "连接 + filter + 解析" 在真实设备上可以工作
|
||||
assert transceivers or channels, "Ruijie 设备未返回任何 transceiver/channel 数据"
|
||||
|
||||
# 至少有一个 transceiver 拥有对应的 channel
|
||||
tx_by_component = {t.component_name: t for t in transceivers}
|
||||
ch_by_component = {}
|
||||
for ch in channels:
|
||||
ch_by_component.setdefault(ch.component_name, []).append(ch)
|
||||
|
||||
has_tx_with_channel = any(
|
||||
comp in tx_by_component and len(ch_list) > 0
|
||||
for comp, ch_list in ch_by_component.items()
|
||||
)
|
||||
assert has_tx_with_channel, (
|
||||
"Ruijie live 数据中未发现“同时存在 transceiver 与 channel”的组件,"
|
||||
"请检查设备返回的 transceiver/physical-channels 数据是否完整"
|
||||
)
|
||||
|
||||
# 至少有一个 channel 具有 rx 或 tx power 数值
|
||||
channels_with_power = [
|
||||
ch
|
||||
for ch in channels
|
||||
if ch.rx_power_dbm is not None or ch.tx_power_dbm is not None
|
||||
]
|
||||
assert channels_with_power, (
|
||||
"Ruijie live 数据中未发现带 rx/tx power 的通道,"
|
||||
"请检查设备是否开启了相关光功率采集"
|
||||
)
|
||||
|
||||
# 额外 sanity 检查:至少有一个端口 label 不以 TRANSCEIVER- 开头,验证清洗逻辑生效
|
||||
ports = {t.logical_port for t in transceivers} | {c.logical_port for c in channels}
|
||||
assert any(not p.startswith("TRANSCEIVER-") for p in ports), (
|
||||
"Ruijie live 数据中未发现清洗后的端口名,"
|
||||
"请检查 vendor='ruijie' 解析逻辑是否生效"
|
||||
)
|
||||
160
tests/test_sqlite_vendor_column.py
Normal file
160
tests/test_sqlite_vendor_column.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from exporter.config import DeviceConfig
|
||||
from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryptor() -> PasswordEncryptor:
|
||||
# 生成一个有效 Fernet key
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key().decode()
|
||||
return PasswordEncryptor(key)
|
||||
|
||||
|
||||
def test_init_db_creates_vendor_column_on_fresh_db(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "test_vendor.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(devices)").fetchall()]
|
||||
conn.close()
|
||||
|
||||
assert "vendor" in cols
|
||||
|
||||
|
||||
def test_init_db_alter_table_vendor_preserves_existing_rows(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
# 创建旧版本 devices 表(无 vendor 列)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE devices (
|
||||
name TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password_cipher BLOB NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
scrape_interval_seconds INTEGER,
|
||||
supports_xpath INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO devices (name, host, port, username, password_cipher, enabled, "
|
||||
"scrape_interval_seconds, supports_xpath, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("old-dev", "h", 830, "u", b"cipher", 1, None, 0, 1, 1),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
conn2 = sqlite3.connect(str(db_path))
|
||||
row = conn2.execute("SELECT name, vendor FROM devices WHERE name = 'old-dev'").fetchone()
|
||||
conn2.close()
|
||||
|
||||
assert row is not None
|
||||
assert row[0] == "old-dev"
|
||||
# 旧数据 vendor 应为空
|
||||
assert row[1] is None
|
||||
|
||||
|
||||
def test_save_and_load_device_persists_vendor(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "vendor_persist.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
dev = DeviceConfig(
|
||||
name="dev-vendor",
|
||||
host="h",
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
enabled=True,
|
||||
vendor="ruijie",
|
||||
source="runtime",
|
||||
)
|
||||
store.save_device(dev)
|
||||
loaded = store.load_runtime_devices()
|
||||
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].name == "dev-vendor"
|
||||
assert loaded[0].vendor == "ruijie"
|
||||
|
||||
|
||||
def test_save_and_load_device_vendor_none_roundtrip(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "vendor_none.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
store.init_db()
|
||||
|
||||
dev = DeviceConfig(
|
||||
name="dev-no-vendor",
|
||||
host="h",
|
||||
port=830,
|
||||
username="u",
|
||||
password="p",
|
||||
enabled=True,
|
||||
vendor=None,
|
||||
source="runtime",
|
||||
)
|
||||
store.save_device(dev)
|
||||
loaded = store.load_runtime_devices()
|
||||
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].name == "dev-no-vendor"
|
||||
assert loaded[0].vendor is None
|
||||
|
||||
|
||||
def test_init_db_alter_table_silently_ignores_duplicate_column_error(tmp_path: Path, encryptor: PasswordEncryptor):
|
||||
db_path = tmp_path / "dup_col.db"
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
# 第一次初始化,创建带 vendor 列的表
|
||||
store.init_db()
|
||||
# 第二次调用,不应抛异常
|
||||
store.init_db()
|
||||
|
||||
|
||||
def test_init_db_alter_table_raises_on_non_duplicate_errors(tmp_path: Path, encryptor: PasswordEncryptor, monkeypatch):
|
||||
db_path = tmp_path / "locked.db"
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
class ConnWrapper:
|
||||
def __init__(self, inner: sqlite3.Connection) -> None:
|
||||
self._inner = inner
|
||||
self._alter_attempted = False
|
||||
|
||||
def execute(self, sql: str, *args, **kwargs):
|
||||
# 在第一次尝试 ALTER TABLE 时注入错误
|
||||
if "ALTER TABLE devices ADD COLUMN vendor" in sql and not self._alter_attempted:
|
||||
self._alter_attempted = True
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
return self._inner.execute(sql, *args, **kwargs)
|
||||
|
||||
def commit(self) -> None:
|
||||
return self._inner.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
return self._inner.close()
|
||||
|
||||
def wrapped_connect(*args, **kwargs):
|
||||
conn = real_connect(*args, **kwargs)
|
||||
return ConnWrapper(conn)
|
||||
|
||||
monkeypatch.setattr(sqlite3, "connect", wrapped_connect)
|
||||
|
||||
store = SQLiteDeviceStore(str(db_path), encryptor)
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
store.init_db()
|
||||
|
||||
Reference in New Issue
Block a user