298 lines
13 KiB
Python
298 lines
13 KiB
Python
|
|
"""使用隔离 SQLite 事务验证监控落库、恢复、权限及脱敏,不接触开发业务库。"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
from datetime import timedelta
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from fastapi.encoders import jsonable_encoder
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from sqlalchemy import Integer, MetaData, create_engine, func, select
|
||
|
|
from sqlalchemy.orm import sessionmaker
|
||
|
|
from sqlalchemy.pool import StaticPool
|
||
|
|
|
||
|
|
from app.api.deps import get_current_user, get_db
|
||
|
|
from app.api.v1.system.monitor import router
|
||
|
|
from app.core.config import Settings
|
||
|
|
from app.core.exception_handlers import register_exception_handlers
|
||
|
|
from app.core.monitoring import MonitorMiddleware, Recorder, minute_of, now_local
|
||
|
|
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
|
||
|
|
from app.schemas.monitor import MonitorWindow
|
||
|
|
from app.services import monitor_service as service
|
||
|
|
from app.services.monitor_collector import MonitorCollector, RuntimeErrorHandler
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def monitor_case(monkeypatch):
|
||
|
|
engine = create_engine(
|
||
|
|
"sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False}
|
||
|
|
)
|
||
|
|
metadata = MetaData()
|
||
|
|
for model in (MonitorSnapshot, MonitorRequestMinute, MonitorEvent):
|
||
|
|
table = model.__table__.to_metadata(metadata)
|
||
|
|
table.c.id.type = Integer()
|
||
|
|
metadata.create_all(engine)
|
||
|
|
factory = sessionmaker(engine, expire_on_commit=False)
|
||
|
|
settings = Settings(_env_file=None, app_secret_key="x" * 32, monitor_enabled=False)
|
||
|
|
collector = MonitorCollector(settings, Recorder())
|
||
|
|
monkeypatch.setattr(collector, "_factory", lambda: factory)
|
||
|
|
yield collector, factory
|
||
|
|
engine.dispose()
|
||
|
|
|
||
|
|
|
||
|
|
def seed(collector, at):
|
||
|
|
collector.recorder.record("GET", "/api/items/{id}", 500, 2200, "a" * 32, at=at)
|
||
|
|
collector.recorder.record("GET", "/api/items/{id}", 400, 10, "b" * 32, at=at)
|
||
|
|
collector.samples[minute_of(at)] = [
|
||
|
|
{"memory_percent": 40, "cpu_percent": 10, "disk_percent": 25}
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def test_历史跨采集器重启保留且当前分钟不重复统计(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
at = minute_of(now_local()) - timedelta(minutes=2)
|
||
|
|
seed(collector, at)
|
||
|
|
collector._persist(now_local())
|
||
|
|
collector._persist(now_local())
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 1
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 1
|
||
|
|
assert service.overview(session, collector).requests["count"] == 2
|
||
|
|
restarted = MonitorCollector(collector.settings, Recorder())
|
||
|
|
trend = service.trends(session, restarted, MonitorWindow())
|
||
|
|
assert sum(point["request_count"] or 0 for point in trend["points"]) == 2
|
||
|
|
row = service.interfaces(session, restarted, MonitorWindow())["list"][0]
|
||
|
|
assert (row["count"], row["errors_5xx"], row["errors_4xx"]) == (2, 1, 1)
|
||
|
|
assert row["p95_ms"] == 2200
|
||
|
|
|
||
|
|
|
||
|
|
def test_持久化失败保留事件恢复后写入且不影响记录(monitor_case, monkeypatch):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
at = minute_of(now_local()) - timedelta(minutes=1)
|
||
|
|
seed(collector, at)
|
||
|
|
|
||
|
|
def fail():
|
||
|
|
raise RuntimeError("synthetic-db-failure")
|
||
|
|
|
||
|
|
monkeypatch.setattr(collector, "_factory", fail)
|
||
|
|
collector._persist(now_local())
|
||
|
|
assert len(collector.recorder.events) == 1
|
||
|
|
assert collector.current()["collection_status"] == "warning"
|
||
|
|
monkeypatch.setattr(collector, "_factory", lambda: factory)
|
||
|
|
collector._persist(now_local())
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
|
||
|
|
assert not collector.recorder.events
|
||
|
|
|
||
|
|
|
||
|
|
def test_同类健康异常合并恢复并跨重启复用首次时间(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
at = now_local() - timedelta(minutes=3)
|
||
|
|
bad = [{"name": "MySQL", "status": "error", "message": "连接检查失败。"}]
|
||
|
|
collector._evaluate(at, {}, bad, {})
|
||
|
|
collector._persist(at)
|
||
|
|
restarted = MonitorCollector(collector.settings, Recorder())
|
||
|
|
restarted._factory = lambda: factory
|
||
|
|
restarted._evaluate(at + timedelta(minutes=1), {}, bad, {})
|
||
|
|
restarted._persist(at + timedelta(minutes=1))
|
||
|
|
with factory() as session:
|
||
|
|
row = session.scalar(select(MonitorEvent))
|
||
|
|
assert row.first_seen == at and row.occurrences == 2 and row.resolved_at is None
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
|
||
|
|
restarted._evaluate(at + timedelta(minutes=2), {}, [], {})
|
||
|
|
restarted._persist(at + timedelta(minutes=2))
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(MonitorEvent)).resolved_at == at + timedelta(minutes=2)
|
||
|
|
|
||
|
|
|
||
|
|
def test_资源持续五分钟才告警且未知不算恢复(monitor_case):
|
||
|
|
collector, _ = monitor_case
|
||
|
|
at = now_local() - timedelta(minutes=6)
|
||
|
|
collector._evaluate(at, {"cpu_percent": 90}, [], {})
|
||
|
|
assert not collector.conditions
|
||
|
|
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 90}, [], {})
|
||
|
|
assert "CPU_HIGH" in collector.conditions
|
||
|
|
collector._evaluate(at + timedelta(minutes=6), {}, [], {})
|
||
|
|
assert "CPU_HIGH" in collector.conditions
|
||
|
|
collector._evaluate(at + timedelta(minutes=7), {"cpu_percent": 30}, [], {})
|
||
|
|
assert "CPU_HIGH" not in collector.conditions
|
||
|
|
|
||
|
|
|
||
|
|
def test_低请求量不报错误率且四百不触发系统故障(monitor_case):
|
||
|
|
collector, _ = monitor_case
|
||
|
|
at = now_local()
|
||
|
|
for i in range(19):
|
||
|
|
collector.recorder.record("GET", "/items", 500, 1, str(i), at=at)
|
||
|
|
collector._evaluate(at, {}, [], {})
|
||
|
|
assert "HTTP_ERROR_RATE" not in collector.conditions
|
||
|
|
collector.recorder.record("GET", "/items", 400, 1, "x", at=at)
|
||
|
|
collector._evaluate(at, {}, [], {})
|
||
|
|
assert "HTTP_ERROR_RATE" in collector.conditions
|
||
|
|
|
||
|
|
|
||
|
|
def test_过期清理仅限监控表且保留活动异常(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
old = now_local() - timedelta(days=8)
|
||
|
|
seed(collector, old)
|
||
|
|
collector._evaluate(old, {"disk_percent": 99}, [], {})
|
||
|
|
collector._persist(now_local())
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorSnapshot)) == 0
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorRequestMinute)) == 0
|
||
|
|
assert session.scalar(select(func.count()).select_from(MonitorEvent)) == 1
|
||
|
|
assert session.scalar(select(MonitorEvent)).code == "DISK_HIGH"
|
||
|
|
|
||
|
|
|
||
|
|
def make_api(collector, factory):
|
||
|
|
app = FastAPI()
|
||
|
|
app.state.monitor = collector
|
||
|
|
app.include_router(router, prefix="/api/v1/system")
|
||
|
|
register_exception_handlers(app)
|
||
|
|
app.add_middleware(MonitorMiddleware, recorder=collector.recorder)
|
||
|
|
|
||
|
|
def database():
|
||
|
|
with factory() as session:
|
||
|
|
yield session
|
||
|
|
|
||
|
|
app.dependency_overrides[get_db] = database
|
||
|
|
return app
|
||
|
|
|
||
|
|
|
||
|
|
def test_未登录与普通用户均不能读监控(monitor_case, monkeypatch):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
app = make_api(collector, factory)
|
||
|
|
client = TestClient(app)
|
||
|
|
assert client.get("/api/v1/system/monitor/overview").status_code == 401
|
||
|
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
|
||
|
|
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["user"])
|
||
|
|
for path in ("overview", "trends", "interfaces", "events", "events/1", "diagnostic"):
|
||
|
|
assert client.get(f"/api/v1/system/monitor/{path}").status_code == 403
|
||
|
|
|
||
|
|
|
||
|
|
def test_管理员契约查询约束与脱敏导出(monitor_case, monkeypatch):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
seed(collector, now_local() - timedelta(minutes=1))
|
||
|
|
collector._persist(now_local())
|
||
|
|
app = make_api(collector, factory)
|
||
|
|
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1)
|
||
|
|
monkeypatch.setattr("app.api.deps.user_repository.get_role_codes", lambda _: ["admin"])
|
||
|
|
client = TestClient(app)
|
||
|
|
for path in ("overview", "trends", "interfaces", "events", "events/1"):
|
||
|
|
response = client.get(f"/api/v1/system/monitor/{path}")
|
||
|
|
assert response.status_code == 200, response.text
|
||
|
|
assert response.json()["code"] == 200
|
||
|
|
assert client.get("/api/v1/system/monitor/trends?hours=999").status_code == 400
|
||
|
|
for hours in (1, 24, 168):
|
||
|
|
for endpoint in ("trends", "interfaces", "events"):
|
||
|
|
assert client.get(f"/api/v1/system/monitor/{endpoint}?hours={hours}").status_code == 200
|
||
|
|
assert client.get("/api/v1/system/monitor/events?request_id=invalid").status_code == 400
|
||
|
|
assert client.get("/api/v1/system/monitor/events?pageSize=201").status_code == 400
|
||
|
|
result = client.get("/api/v1/system/monitor/events?request_id=" + "a" * 32).json()["data"]
|
||
|
|
assert result["total"] == 1
|
||
|
|
export = client.get("/api/v1/system/monitor/diagnostic")
|
||
|
|
assert "attachment" in export.headers["content-disposition"]
|
||
|
|
assert not any(
|
||
|
|
field in export.text
|
||
|
|
for field in ("password", "secret", "token", "traceback", "request_data", "payload_json")
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_未处理异常编号匹配日志且只记录一次(monitor_case, caplog):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
app = make_api(collector, factory)
|
||
|
|
|
||
|
|
@app.get("/explode")
|
||
|
|
def explode():
|
||
|
|
raise RuntimeError("synthetic-private-details")
|
||
|
|
|
||
|
|
response = TestClient(app, raise_server_exceptions=False).get("/explode")
|
||
|
|
request_id = response.headers["x-request-id"]
|
||
|
|
assert response.status_code == 500
|
||
|
|
assert "synthetic-private-details" not in response.text
|
||
|
|
assert len(collector.recorder.events) == 1
|
||
|
|
assert collector.recorder.events[0]["request_id"] == request_id
|
||
|
|
assert any(getattr(record, "request_id", None) == request_id for record in caplog.records)
|
||
|
|
assert "synthetic-private-details" not in json.dumps(
|
||
|
|
jsonable_encoder(list(collector.recorder.events))
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_后台日志摘要不读取异常原文且请求日志不重复(monitor_case):
|
||
|
|
import logging
|
||
|
|
|
||
|
|
from app.core.monitoring import REQUEST_ID
|
||
|
|
|
||
|
|
collector, _ = monitor_case
|
||
|
|
handler = RuntimeErrorHandler(collector.recorder)
|
||
|
|
record = logging.LogRecord("app.jobs", logging.ERROR, "", 0, "password=synthetic", (), None)
|
||
|
|
handler.emit(record)
|
||
|
|
token = REQUEST_ID.set("a" * 32)
|
||
|
|
try:
|
||
|
|
handler.emit(record)
|
||
|
|
finally:
|
||
|
|
REQUEST_ID.reset(token)
|
||
|
|
assert len(collector.recorder.events) == 1
|
||
|
|
assert "synthetic" not in str(collector.recorder.events)
|
||
|
|
|
||
|
|
|
||
|
|
def test_过期采集不继续显示依赖正常(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
collector.state.update(
|
||
|
|
collected_at=now_local() - timedelta(minutes=2),
|
||
|
|
dependencies=[
|
||
|
|
{"name": "MySQL", "status": "normal", "message": "正常", "checked_at": now_local()}
|
||
|
|
],
|
||
|
|
)
|
||
|
|
with factory() as session:
|
||
|
|
result = service.overview(session, collector)
|
||
|
|
assert result.status == "unknown"
|
||
|
|
assert result.dependencies[0].status == "unknown"
|
||
|
|
|
||
|
|
|
||
|
|
def test_重启首次CPU未知不恢复已有告警且健康后恢复(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
at = now_local() - timedelta(minutes=7)
|
||
|
|
collector._evaluate(at, {"cpu_percent": 99}, [], {})
|
||
|
|
collector._evaluate(at + timedelta(minutes=5), {"cpu_percent": 99}, [], {})
|
||
|
|
collector._persist(at + timedelta(minutes=5))
|
||
|
|
restarted = MonitorCollector(collector.settings, Recorder())
|
||
|
|
restarted._factory = lambda: factory
|
||
|
|
restarted._evaluate(at + timedelta(minutes=6), {}, [], {})
|
||
|
|
restarted._persist(at + timedelta(minutes=6))
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(MonitorEvent)).resolved_at is None
|
||
|
|
restarted._evaluate(now_local(), {"cpu_percent": 30}, [], {})
|
||
|
|
restarted._persist(now_local())
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(MonitorEvent)).resolved_at is not None
|
||
|
|
|
||
|
|
|
||
|
|
def test_T加状态未知不误报恢复(monitor_case):
|
||
|
|
collector, factory = monitor_case
|
||
|
|
at = now_local() - timedelta(minutes=1)
|
||
|
|
collector._evaluate(
|
||
|
|
at,
|
||
|
|
{},
|
||
|
|
[],
|
||
|
|
{"enabled": True, "alerts": [{"code": "WORKER_OFFLINE", "message": "采集进程心跳中断"}]},
|
||
|
|
)
|
||
|
|
collector._persist(at)
|
||
|
|
collector._evaluate(now_local(), {}, [], {"status": "unknown", "alerts": []})
|
||
|
|
collector._persist(now_local())
|
||
|
|
with factory() as session:
|
||
|
|
assert session.scalar(select(MonitorEvent)).resolved_at is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_监控权限登记且不能授予普通角色(monitor_case):
|
||
|
|
from app.services.authorization_policy import permission_for
|
||
|
|
from app.services.authorization_service import calculate
|
||
|
|
from app.services.permission_catalog import CATALOG
|
||
|
|
|
||
|
|
_, factory = monitor_case
|
||
|
|
assert CATALOG["system:monitor:view"]["admin_only"]
|
||
|
|
assert permission_for("system.monitor", "diagnostic", "GET") == "system:monitor:view"
|
||
|
|
with factory() as session:
|
||
|
|
user = SimpleNamespace(id=1, status=1)
|
||
|
|
assert "system:monitor:view" in calculate(session, user, roles=["admin"])["permissions"]
|