"""云资产同步链路测试:路由注册 + 定时脚本 + systemd 单元接线 这条链有三个环节,任一环断了都不会报错,只会「夜间同步悄悄不跑了」: 1. /api/sync/assets 没注册 -> 手动触发按钮 404 2. scripts/sync_all_assets.py 退出码恒为 0 -> systemd 永远记成功,失败无告警 3. deploy/*.timer 没被 setup.sh enable -> 全新部署后定时器根本不存在 运行:.venv/bin/pytest tests/test_sync_chain.py -v """ import importlib.util import re from datetime import time from pathlib import Path import pytest from sqlmodel import SQLModel, create_engine import app.models.asset # noqa: F401 import app.models.credential # noqa: F401 import app.models.monitor # noqa: F401 import app.models.provider # noqa: F401 import app.models.ssl # noqa: F401 REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPTS_DIR = REPO_ROOT / "scripts" DEPLOY_DIR = REPO_ROOT / "deploy" # --------------------------------------------------------------------------- # # 1. 路由注册 # --------------------------------------------------------------------------- # def test_sync_assets_endpoint_is_registered(): """/api/sync/assets 必须出现在 OpenAPI 里 这个 FastAPI 版本把 include_router 进来的路由包在 _IncludedRouter 里延迟展开, 遍历 app.routes 看不到,只能查 app.openapi()['paths']。 """ from app.main import app paths = app.openapi()["paths"] assert "/api/sync/assets" in paths, f"未注册;现有 sync 路由:{[p for p in paths if 'sync' in p]}" assert "post" in paths["/api/sync/assets"] def test_sync_assets_endpoint_is_key_protected(): """批量同步会解密所有账号凭证并打云端接口,不能匿名可调""" from app.core.security import require_api_key from app.routers.sync import router route = next(r for r in router.routes if r.path == "/api/sync/assets") calls = [d.call for d in route.dependant.dependencies] assert require_api_key in calls, "缺少 require_api_key 依赖,端点对外裸奔" # --------------------------------------------------------------------------- # # 2. 定时脚本 # --------------------------------------------------------------------------- # def _load_script(): """按文件路径加载 scripts/sync_all_assets.py(scripts/ 不是包)""" path = SCRIPTS_DIR / "sync_all_assets.py" spec = importlib.util.spec_from_file_location("_sync_all_assets_under_test", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod @pytest.fixture() def script(tmp_path, monkeypatch): eng = create_engine( f"sqlite:///{tmp_path / 'chain.db'}", connect_args={"check_same_thread": False} ) SQLModel.metadata.create_all(eng) # sync_service 内部是「函数里 from app.database import assets_engine」,所以 # 补丁打在 app.database 上才生效;而脚本自己在模块级 import 了同名对象, # 那份绑定必须单独换掉,否则 init_db() 会去建真的 data/*.db。 monkeypatch.setattr("app.database.assets_engine", eng) mod = _load_script() monkeypatch.setattr(mod, "init_db", lambda: None) monkeypatch.setattr(mod, "assets_engine", eng) yield mod eng.dispose() def test_script_summary_prints_every_key_the_service_returns(script, capsys): """端到端跑一次真实 sync_all_assets(空库),确保摘要行不因缺键 KeyError 脚本直接下标访问 result['skipped_no_config'] 等键;服务层返回结构一改, 这里就会在夜间定时任务里抛 KeyError,而 timer 的失败没人看。 """ rc = script.main() out = capsys.readouterr().out assert rc == 0 assert "[云资产同步]" in out for fragment in ("账号 0", "成功 0", "失败 0", "新增 0", "更新 0", "跳过(无凭证) 0", "跳过(无适配器) 0"): assert fragment in out, f"摘要缺少 {fragment}:{out!r}" def test_script_exits_zero_when_nothing_failed(script, monkeypatch): fake = { "total": 2, "success": 2, "failed": 0, "created": 3, "updated": 1, "skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [], "synced_at": "2026-01-01T00:00:00", } monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake) assert script.main() == 0 def test_script_exits_nonzero_when_any_account_failed(script, capsys, monkeypatch): """退出码是唯一的告警通道:返回 0 的话 systemd 记成功,凭证过期也没人知道""" fake = { "total": 2, "success": 0, "failed": 2, "created": 0, "updated": 0, "skipped_no_config": 0, "skipped_no_adapter": 0, "errors": ["vultr/main: HTTP 401:凭证无效或已过期", "aliyun/backup: HTTP 403:凭证权限不足"], "synced_at": "2026-01-01T00:00:00", } monkeypatch.setattr(script.sync_service, "sync_all_assets", lambda s, **kw: fake) assert script.main() == 1 out = capsys.readouterr().out assert "失败 2" in out for err in fake["errors"]: assert err in out, f"错误明细没打出来,journalctl 里查不到原因:{err}" def test_script_uses_session_bound_to_assets_engine(script, monkeypatch): """main() 传给服务层的 Session 必须开在 assets_engine 上 写错成 metrics_engine 的话,账号表查不到 -> total=0 -> 退出码 0, 表现是「同步一切正常,但一条数据都没进来」。 """ seen = {} def _fake(session, **kwargs): seen["bind"] = session.get_bind() return { "total": 0, "success": 0, "failed": 0, "created": 0, "updated": 0, "skipped_no_config": 0, "skipped_no_adapter": 0, "errors": [], "synced_at": "2026-01-01T00:00:00", } monkeypatch.setattr(script.sync_service, "sync_all_assets", _fake) script.main() assert seen["bind"] is script.assets_engine # --------------------------------------------------------------------------- # # 3. systemd 单元接线 # --------------------------------------------------------------------------- # def _read(name): return (DEPLOY_DIR / name).read_text(encoding="utf-8") def test_sync_assets_service_points_at_an_existing_script(): """ExecStart 指向的脚本必须真实存在 unit 里是部署机绝对路径 /opt/vps-manager/...,本地按仓库相对路径校验。 脚本改名后 timer 每晚会失败,但因为是 oneshot + 无人看日志,等同于静默停摆。 """ m = re.search(r"^ExecStart=(?P\S+)\s+(?P