feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
This commit is contained in:
@@ -0,0 +1,718 @@
|
||||
"""云资源管理动作(action_service)与请求 Schema 测试
|
||||
|
||||
这层的每个动作都会改变云端真实资源:关机会中断线上服务、续费会真实扣款。
|
||||
所以测试重点是「该拒绝的一律拒绝,且拒绝理由如实」,而不是「能不能跑通」:
|
||||
|
||||
- 能力不足 / 手工登记无 external_id / 资产类型不符 → 400,绝不静默 no-op
|
||||
- 适配器明确返回 ok=False → 原样带回,不能包装成成功
|
||||
- 云端调用抛异常 → 502(区别于 400 的「参数/能力问题」)
|
||||
- 每个动作都落 EventLog 审计,且审计失败不能拖垮业务动作
|
||||
- 路由层写操作必须挂 require_api_key(fail-closed)
|
||||
|
||||
运行:.venv/bin/pytest tests/test_actions.py -v
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
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
|
||||
from app.adapters.base import cloud_error_text, http_error_message
|
||||
from app.core.security import require_api_key
|
||||
from app.models.asset import Account, Asset, AssetType, DomainDetail
|
||||
from app.models.monitor import EventLog
|
||||
from app.models.provider import Provider
|
||||
from app.routers import actions as actions_router
|
||||
from app.schemas.action import DNSRecordIn, InstanceActionRequest
|
||||
from app.services import action_service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'actions.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# audit() 在函数内部 import metrics_engine,必须指到临时库,否则污染本地 data/metrics.db
|
||||
monkeypatch.setattr("app.database.metrics_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
"""可控能力的假适配器"""
|
||||
|
||||
required_config: list = []
|
||||
sdk_types = ["fake-sdk"]
|
||||
|
||||
def __init__(self, caps=None, result=None, raises=None):
|
||||
self.config = {}
|
||||
self._caps = caps if caps is not None else {
|
||||
"list_vps": True, "list_domains": True, "get_account": False,
|
||||
"start_instance": True, "stop_instance": True,
|
||||
"reboot_instance": True, "renew_instance": True,
|
||||
"list_dns_records": True, "create_dns_record": True,
|
||||
"update_dns_record": True, "delete_dns_record": True,
|
||||
}
|
||||
self._result = result if result is not None else {"ok": True, "message": "done"}
|
||||
self._raises = raises
|
||||
self.calls = []
|
||||
|
||||
def capabilities(self):
|
||||
return dict(self._caps)
|
||||
|
||||
def _record(self, name, args):
|
||||
self.calls.append((name, args))
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return self._result
|
||||
|
||||
def start_instance(self, external_id):
|
||||
return self._record("start_instance", (external_id,))
|
||||
|
||||
def stop_instance(self, external_id):
|
||||
return self._record("stop_instance", (external_id,))
|
||||
|
||||
def reboot_instance(self, external_id):
|
||||
return self._record("reboot_instance", (external_id,))
|
||||
|
||||
def renew_instance(self, external_id, period_months=1):
|
||||
return self._record("renew_instance", (external_id, period_months))
|
||||
|
||||
def list_dns_records(self, zone_id):
|
||||
from app.adapters.base import NormalizedDNSRecord
|
||||
self.calls.append(("list_dns_records", (zone_id,)))
|
||||
if self._raises:
|
||||
raise self._raises
|
||||
return [NormalizedDNSRecord(record_id="r1", zone_id=zone_id, name="a.com", type="A", content="1.2.3.4")]
|
||||
|
||||
def create_dns_record(self, zone_id, record):
|
||||
return self._record("create_dns_record", (zone_id, record))
|
||||
|
||||
def update_dns_record(self, zone_id, record_id, record):
|
||||
return self._record("update_dns_record", (zone_id, record_id, record))
|
||||
|
||||
def delete_dns_record(self, zone_id, record_id):
|
||||
return self._record("delete_dns_record", (zone_id, record_id))
|
||||
|
||||
|
||||
def _seed_asset(engine, asset_type=AssetType.VPS, external_id="i-1", with_account=True,
|
||||
provider_slug="fake", sdk_type="fake-sdk", name="web-01"):
|
||||
"""建 provider + account + asset,并把适配器构造过程替换成 _FakeAdapter 工厂"""
|
||||
with Session(engine) as s:
|
||||
provider = Provider(slug=provider_slug, name=provider_slug, category="vps", sdk_type=sdk_type)
|
||||
s.add(provider)
|
||||
s.commit()
|
||||
s.refresh(provider)
|
||||
|
||||
account_id = None
|
||||
if with_account:
|
||||
account = Account(name="acc", platform=provider_slug, api_config_encrypted="enc")
|
||||
s.add(account)
|
||||
s.commit()
|
||||
s.refresh(account)
|
||||
account_id = account.id
|
||||
|
||||
asset = Asset(
|
||||
name=name, asset_type=asset_type, provider=provider_slug,
|
||||
provider_id=provider.id, external_id=external_id, account_id=account_id,
|
||||
)
|
||||
s.add(asset)
|
||||
s.commit()
|
||||
s.refresh(asset)
|
||||
return provider.id, account_id, asset.id
|
||||
|
||||
|
||||
def _use_fake(monkeypatch, engine, provider_id, adapter):
|
||||
monkeypatch.setattr(
|
||||
action_service, "_build_adapter_for_account",
|
||||
lambda session, account: (session.get(Provider, provider_id), adapter),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _events(engine) -> list:
|
||||
with Session(engine) as s:
|
||||
return s.exec(select(EventLog)).all()
|
||||
|
||||
|
||||
# ----------------------------- 解析与鉴权 ----------------------------- #
|
||||
def test_resolve_rejects_missing_asset(engine):
|
||||
_seed_asset(engine)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, 99999)
|
||||
assert ei.value.status_code == 404
|
||||
|
||||
|
||||
def test_resolve_rejects_asset_without_account(engine):
|
||||
"""手工登记、没挂账号的资产无从确定用哪套凭证——必须明确拒绝"""
|
||||
_, _, asset_id = _seed_asset(engine, with_account=False)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "未关联账号" in ei.value.detail
|
||||
|
||||
|
||||
def test_resolve_rejects_missing_credentials(engine, monkeypatch):
|
||||
"""required_config 未填齐时如实报缺哪个字段,别让用户猜"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _FakeAdapter()
|
||||
adapter.required_config = ["api_key", "region"]
|
||||
adapter.config = {"api_key": "k"}
|
||||
_use_fake(monkeypatch, engine, provider_id, adapter)
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "region" in ei.value.detail
|
||||
|
||||
|
||||
def test_resolve_rejects_dangling_account_reference(engine, monkeypatch):
|
||||
provider_id, account_id, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
s.delete(s.get(Account, account_id))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service._resolve(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "账号已不存在" in ei.value.detail
|
||||
|
||||
|
||||
# ----------------------------- 实例动作 ----------------------------- #
|
||||
def test_unknown_action_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "destroy")
|
||||
assert ei.value.status_code == 400
|
||||
assert "未知动作" in ei.value.detail
|
||||
|
||||
|
||||
def test_start_success(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is True
|
||||
assert res["action"] == "start"
|
||||
assert res["external_id"] == "i-1"
|
||||
assert res["provider"] == "fake"
|
||||
assert adapter.calls == [("start_instance", ("i-1",))]
|
||||
|
||||
|
||||
def test_renew_passes_period_months(engine, monkeypatch):
|
||||
"""续费周期必须透传,否则默认 1 个月与用户选择不符"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "renew", period_months=12)
|
||||
assert adapter.calls == [("renew_instance", ("i-1", 12))]
|
||||
|
||||
|
||||
def test_non_vps_asset_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="example.com")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
assert "仅 VPS" in ei.value.detail
|
||||
|
||||
|
||||
def test_manual_asset_without_external_id_rejected(engine, monkeypatch):
|
||||
"""手工登记的资产没有云端 ID,无法下发指令"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, external_id=None)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
assert "external_id" in ei.value.detail
|
||||
|
||||
|
||||
def test_unsupported_capability_rejected(engine, monkeypatch):
|
||||
"""适配器没实现该动作 → 400,绝不能静默 no-op 让调用方以为成功了"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
caps = {"renew_instance": False, "start_instance": True}
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "renew")
|
||||
assert ei.value.status_code == 400
|
||||
assert "不支持" in ei.value.detail
|
||||
|
||||
|
||||
def test_adapter_returning_ok_false_is_passed_through(engine, monkeypatch):
|
||||
"""云端明确拒绝(如余额不足)时如实带回 ok=False + 原因,不能包装成成功"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id,
|
||||
_FakeAdapter(result={"ok": False, "message": "余额不足"}))
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is False
|
||||
assert res["message"] == "余额不足"
|
||||
|
||||
|
||||
def test_not_implemented_error_becomes_400(engine, monkeypatch):
|
||||
"""capabilities() 与真实实现不一致时的兜底:按能力问题(400)而非网关问题"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=NotImplementedError()))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
def test_cloud_error_becomes_502(engine, monkeypatch):
|
||||
"""云端调用异常是上游问题,用 502 与 400(参数/能力问题)区分开"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("connection reset")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 502
|
||||
assert "connection reset" in ei.value.detail
|
||||
|
||||
|
||||
# ----------------------------- 审计 ----------------------------- #
|
||||
def test_every_action_is_audited(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "stop")
|
||||
events = _events(engine)
|
||||
# 下发前 + 结果,各一条
|
||||
assert len(events) == 2
|
||||
assert all(e.source == "action" for e in events)
|
||||
assert all(e.asset_id == asset_id for e in events)
|
||||
assert "关机动作下发" in events[0].message
|
||||
assert "关机成功" in events[1].message
|
||||
|
||||
|
||||
def test_failed_action_audited_as_error(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("boom")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException):
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
events = _events(engine)
|
||||
assert any(e.level == "error" for e in events), "异常必须留下 error 级审计"
|
||||
|
||||
|
||||
def test_rejected_action_audited_as_error(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(result={"ok": False, "message": "被拒"}))
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert any(e.level == "error" for e in _events(engine))
|
||||
|
||||
|
||||
def test_audit_failure_does_not_break_action(engine, monkeypatch):
|
||||
"""审计写失败绝不能让业务动作失败——云端指令可能已经下发成功,
|
||||
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
|
||||
# 让 metrics_engine 指向不可用值,触发 audit 内部 except 分支
|
||||
monkeypatch.setattr("app.database.metrics_engine", None)
|
||||
with Session(engine) as s:
|
||||
res = action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert res["ok"] is True
|
||||
assert adapter.calls == [("start_instance", ("i-1",))]
|
||||
|
||||
|
||||
def test_audit_uses_own_session(engine, monkeypatch):
|
||||
"""审计走独立 Session 提交,主流程回滚时记录不能一起丢"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
s.rollback() # 主 Session 回滚
|
||||
assert len(_events(engine)) == 2, "审计已独立提交,不应受主流程回滚影响"
|
||||
|
||||
|
||||
# ----------------------------- 能力探测 ----------------------------- #
|
||||
def test_asset_actions_for_cloud_vps(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert res["available"] is True
|
||||
assert res["provider"] == "fake"
|
||||
assert res["actions"]["start"] is True
|
||||
assert res["actions"]["renew"] is True
|
||||
assert res["actions"]["dns_records"] is False # VPS 不该出现 DNS 按钮
|
||||
|
||||
|
||||
def test_asset_actions_for_manual_vps(engine, monkeypatch):
|
||||
"""手工登记(无 external_id)的 VPS 所有实例动作都必须是 False,
|
||||
否则用户点了才收到 400"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, external_id=None)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert all(v is False for v in res["actions"].values()), res["actions"]
|
||||
assert res["available"] is False
|
||||
|
||||
|
||||
def test_asset_actions_for_unsupported_provider(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
caps = {"start_instance": True, "stop_instance": False, "reboot_instance": False, "renew_instance": False}
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps=caps))
|
||||
with Session(engine) as s:
|
||||
actions = action_service.asset_actions(s, asset_id)["actions"]
|
||||
assert actions == {"start": True, "stop": False, "reboot": False, "renew": False,
|
||||
"dns_records": False, "dns_write": False}
|
||||
|
||||
|
||||
def test_asset_actions_unresolvable_returns_reason_not_raise(engine):
|
||||
"""探测端点不能抛 500:前端要拿它决定按钮是否渲染"""
|
||||
_seed_asset(engine)
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, 99999)
|
||||
assert res["available"] is False
|
||||
assert res["reason"]
|
||||
assert res["actions"] == {}
|
||||
|
||||
|
||||
def test_asset_actions_for_domain(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-uuid")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.asset_actions(s, asset_id)
|
||||
assert res["actions"]["dns_records"] is True
|
||||
assert res["actions"]["dns_write"] is True
|
||||
assert res["actions"]["start"] is False # 域名不该出现启停按钮
|
||||
|
||||
|
||||
# ----------------------------- DNS ----------------------------- #
|
||||
def test_list_dns_records(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
res = action_service.list_dns_records(s, asset_id)
|
||||
assert res["zone_id"] == "z-1"
|
||||
assert res["records"][0]["content"] == "1.2.3.4"
|
||||
assert isinstance(res["records"][0], dict) # 必须是可 JSON 序列化的 dict
|
||||
|
||||
|
||||
def test_zone_id_falls_back_to_domain_detail(engine, monkeypatch):
|
||||
"""external_id 为空时回退 DomainDetail.domain_name——手工登记的域名也能管 DNS"""
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="fallback")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
s.add(DomainDetail(asset_id=asset_id, domain_name="example.com", registrar="x"))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
assert action_service.list_dns_records(s, asset_id)["zone_id"] == "example.com"
|
||||
|
||||
|
||||
def test_zone_id_falls_back_to_asset_name(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id=None, name="named.com")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
assert action_service.list_dns_records(s, asset_id)["zone_id"] == "named.com"
|
||||
|
||||
|
||||
def test_dns_on_vps_asset_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.VPS)
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
assert "仅域名资产" in ei.value.detail
|
||||
|
||||
|
||||
def test_create_dns_record(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id,
|
||||
_FakeAdapter(result={"ok": True, "message": "created", "record_id": "r9"}))
|
||||
with Session(engine) as s:
|
||||
res = action_service.create_dns_record(s, asset_id, {"type": "A", "name": "a.com", "content": "1.2.3.4"})
|
||||
assert res["ok"] is True
|
||||
assert res["record_id"] == "r9"
|
||||
assert adapter.calls[-1][0] == "create_dns_record"
|
||||
assert adapter.calls[-1][1][0] == "z-1" # zone_id 必须是第一个参数
|
||||
|
||||
|
||||
def test_update_and_delete_dns_record(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
adapter = _use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
assert action_service.update_dns_record(s, asset_id, "r1", {"type": "A", "name": "a", "content": "2.2.2.2"})["ok"]
|
||||
assert action_service.delete_dns_record(s, asset_id, "r1")["ok"]
|
||||
names = [c[0] for c in adapter.calls]
|
||||
assert "update_dns_record" in names
|
||||
assert "delete_dns_record" in names
|
||||
|
||||
|
||||
def test_dns_write_audited(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter())
|
||||
with Session(engine) as s:
|
||||
action_service.delete_dns_record(s, asset_id, "r1")
|
||||
events = _events(engine)
|
||||
assert any("记录删除" in e.message and e.source == "action" for e in events)
|
||||
|
||||
|
||||
def test_dns_capability_missing_rejected(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(caps={"list_dns_records": False}))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
def test_dns_cloud_error_becomes_502(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=RuntimeError("timeout")))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
|
||||
# ----------------------------- 路由层安全 ----------------------------- #
|
||||
def _route(path_suffix: str, method: str):
|
||||
for r in actions_router.router.routes:
|
||||
if r.path.endswith(path_suffix) and method in getattr(r, "methods", set()):
|
||||
return r
|
||||
raise AssertionError(f"路由未注册:{method} {path_suffix}")
|
||||
|
||||
|
||||
def test_all_write_routes_require_api_key():
|
||||
"""动作会真实改云端资源,写端点必须 fail-closed"""
|
||||
protected = [
|
||||
("/instance", "POST"),
|
||||
("/dns", "GET"),
|
||||
("/dns", "POST"),
|
||||
("/dns/{record_id}", "PUT"),
|
||||
("/dns/{record_id}", "DELETE"),
|
||||
]
|
||||
for suffix, method in protected:
|
||||
route = _route(suffix, method)
|
||||
deps = [d.dependency for d in route.dependencies]
|
||||
assert require_api_key in deps, f"{method} {suffix} 缺少 require_api_key"
|
||||
|
||||
|
||||
def test_capabilities_route_is_read_only():
|
||||
"""能力探测无副作用,前端要在渲染按钮前调用,不该被鉴权挡住"""
|
||||
route = _route("/capabilities", "GET")
|
||||
deps = [d.dependency for d in route.dependencies]
|
||||
assert require_api_key not in deps
|
||||
|
||||
|
||||
def test_router_prefix_and_registration():
|
||||
assert actions_router.router.prefix == "/api/actions"
|
||||
from app.main import app
|
||||
paths = app.openapi()["paths"]
|
||||
assert "/api/actions/{asset_id}/instance" in paths
|
||||
assert "/api/actions/{asset_id}/dns" in paths
|
||||
assert "/api/actions/{asset_id}/capabilities" in paths
|
||||
|
||||
|
||||
# ----------------------------- 请求 Schema ----------------------------- #
|
||||
@pytest.mark.parametrize("action", ["start", "stop", "reboot", "renew"])
|
||||
def test_valid_actions_accepted(action):
|
||||
assert InstanceActionRequest(action=action).action == action
|
||||
|
||||
|
||||
def test_invalid_action_rejected():
|
||||
with pytest.raises(Exception):
|
||||
InstanceActionRequest(action="terminate")
|
||||
|
||||
|
||||
def test_period_months_defaults_to_one():
|
||||
assert InstanceActionRequest(action="renew").period_months == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("months", [0, -1, 61, 9999])
|
||||
def test_period_months_out_of_range_rejected(months):
|
||||
"""必须有上界,否则一次误传 9999 就会真实扣掉一笔巨额续费账单"""
|
||||
with pytest.raises(Exception):
|
||||
InstanceActionRequest(action="renew", period_months=months)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("months", [1, 3, 12, 60])
|
||||
def test_period_months_in_range_accepted(months):
|
||||
assert InstanceActionRequest(action="renew", period_months=months).period_months == months
|
||||
|
||||
|
||||
def test_dns_record_type_uppercased():
|
||||
assert DNSRecordIn(name="a.com", type="cname", content="b.com").type == "CNAME"
|
||||
|
||||
|
||||
def test_dns_record_invalid_type_rejected():
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="HINFO", content="x")
|
||||
|
||||
|
||||
def test_dns_record_requires_name_and_content():
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="", type="A", content="1.2.3.4")
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="A", content="")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ttl", [0, 59, 86401])
|
||||
def test_dns_ttl_out_of_range_rejected(ttl):
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="A", content="1.2.3.4", ttl=ttl)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("priority", [-1, 65536])
|
||||
def test_dns_priority_out_of_range_rejected(priority):
|
||||
with pytest.raises(Exception):
|
||||
DNSRecordIn(name="a.com", type="MX", content="mx.x.com", priority=priority)
|
||||
|
||||
|
||||
def test_dns_record_optional_fields_default_none():
|
||||
r = DNSRecordIn(name="a.com", type="A", content="1.2.3.4")
|
||||
assert r.ttl is None and r.priority is None and r.proxied is None and r.comment is None
|
||||
|
||||
|
||||
# ----------------------------- 云端错误可读化 ----------------------------- #
|
||||
# 动机:裸 str(httpx.HTTPStatusError) 是多行文本,末尾还挂一段 MDN 链接
|
||||
# ("For more information check: https://developer.mozilla.org/..."),
|
||||
# 直接透到前端弹窗里既难看也没法排查。各厂商把原因放的位置还都不一样。
|
||||
|
||||
|
||||
def _http_error(status_code, *, json_body=None, text=None):
|
||||
req = httpx.Request("GET", "https://api.example.com/v2/x")
|
||||
if text is not None:
|
||||
resp = httpx.Response(status_code, request=req, text=text)
|
||||
elif json_body is not None:
|
||||
resp = httpx.Response(status_code, request=req, json=json_body)
|
||||
else:
|
||||
resp = httpx.Response(status_code, request=req, content=b"")
|
||||
return httpx.HTTPStatusError("boom", request=req, response=resp)
|
||||
|
||||
|
||||
def test_linode_errors_reason_extracted():
|
||||
msg = http_error_message(_http_error(401, json_body={"errors": [{"reason": "Invalid token"}]}))
|
||||
assert "HTTP 401" in msg
|
||||
assert "Invalid token" in msg
|
||||
assert "凭证无效或已过期" in msg
|
||||
|
||||
|
||||
def test_cloudflare_errors_message_extracted():
|
||||
msg = http_error_message(_http_error(403, json_body={"errors": [{"code": 10000, "message": "Invalid zone"}]}))
|
||||
assert "Invalid zone" in msg and "HTTP 403" in msg
|
||||
|
||||
|
||||
def test_tencent_nested_response_error_extracted():
|
||||
body = {"Response": {"Error": {"Code": "AuthFailure", "Message": "签名失败"}, "RequestId": "x"}}
|
||||
msg = http_error_message(_http_error(400, json_body=body))
|
||||
assert "签名失败" in msg and "HTTP 400" in msg
|
||||
|
||||
|
||||
def test_aws_xml_message_extracted():
|
||||
body = (
|
||||
'<?xml version="1.0"?><ErrorResponse><Error>'
|
||||
"<Code>UnauthorizedOperation</Code><Message>You are not authorized</Message>"
|
||||
"</Error></ErrorResponse>"
|
||||
)
|
||||
msg = http_error_message(_http_error(403, text=body))
|
||||
assert "You are not authorized" in msg
|
||||
assert "<" not in msg # 不能把 XML 标签漏给用户
|
||||
|
||||
|
||||
def test_vultr_bare_401_falls_back_to_hint():
|
||||
"""Vultr 401 常常没有 body,此时至少给方向性提示"""
|
||||
msg = http_error_message(_http_error(401))
|
||||
assert msg == "HTTP 401:凭证无效或已过期"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,hint", [(403, "凭证权限不足"), (404, "资源不存在"), (429, "触发平台限流")])
|
||||
def test_status_hints(code, hint):
|
||||
assert hint in http_error_message(_http_error(code))
|
||||
|
||||
|
||||
def test_500_without_body_has_no_hint():
|
||||
assert http_error_message(_http_error(500)) == "HTTP 500"
|
||||
|
||||
|
||||
def test_long_message_truncated():
|
||||
msg = http_error_message(_http_error(400, json_body={"message": "x" * 5000}))
|
||||
assert len(msg) < 300
|
||||
|
||||
|
||||
def test_hint_not_duplicated_when_body_already_says_it():
|
||||
msg = http_error_message(_http_error(401, json_body={"message": "凭证无效或已过期,请重新生成"}))
|
||||
assert msg.count("凭证无效或已过期") == 1
|
||||
|
||||
|
||||
def test_deeply_nested_body_does_not_recurse_forever():
|
||||
node = {"leaf": "too deep"}
|
||||
for _ in range(10):
|
||||
node = {"nested": node}
|
||||
msg = http_error_message(_http_error(400, json_body=node))
|
||||
assert msg.startswith("HTTP 400")
|
||||
|
||||
|
||||
def test_cloud_error_passes_non_http_exceptions_through():
|
||||
"""超时/连接错误本来就很短,不需要加工"""
|
||||
assert cloud_error_text(RuntimeError("connection reset")) == "connection reset"
|
||||
|
||||
|
||||
def test_http_status_error_502_detail_is_readable(engine, monkeypatch):
|
||||
"""回归:浏览器里实测到的原始 httpx 文本 + MDN 链接不能透到前端"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
err = _http_error(401, json_body={"error": {"msg": "Invalid API token"}})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.perform_instance_action(s, asset_id, "start")
|
||||
assert ei.value.status_code == 502
|
||||
assert "Invalid API token" in ei.value.detail
|
||||
assert "developer.mozilla.org" not in ei.value.detail
|
||||
assert "\n" not in ei.value.detail
|
||||
|
||||
|
||||
def test_dns_http_error_detail_is_readable(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
err = _http_error(404, json_body={"error": {"msg": "Domain not found"}})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.list_dns_records(s, asset_id)
|
||||
assert ei.value.status_code == 502
|
||||
assert "Domain not found" in ei.value.detail
|
||||
assert "developer.mozilla.org" not in ei.value.detail
|
||||
|
||||
|
||||
def test_dns_write_http_error_detail_is_readable(engine, monkeypatch):
|
||||
provider_id, _, asset_id = _seed_asset(engine, asset_type=AssetType.DOMAIN, external_id="z-1")
|
||||
err = _http_error(429, json_body={"message": "rate limit exceeded"})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
action_service.create_dns_record(s, asset_id, {"name": "a", "type": "A", "content": "1.2.3.4"})
|
||||
assert ei.value.status_code == 502
|
||||
assert "rate limit exceeded" in ei.value.detail
|
||||
assert "触发平台限流" in ei.value.detail
|
||||
|
||||
|
||||
def test_http_error_message_is_audited_not_raw(engine, monkeypatch):
|
||||
"""审计日志里也要是可读原因,否则事后排查只看到一串 httpx repr"""
|
||||
provider_id, _, asset_id = _seed_asset(engine)
|
||||
err = _http_error(403, json_body={"errors": [{"reason": "Forbidden by policy"}]})
|
||||
_use_fake(monkeypatch, engine, provider_id, _FakeAdapter(raises=err))
|
||||
with Session(engine) as s:
|
||||
with pytest.raises(HTTPException):
|
||||
action_service.perform_instance_action(s, asset_id, "reboot")
|
||||
text = " ".join(e.message for e in _events(engine))
|
||||
assert "Forbidden by policy" in text
|
||||
assert "developer.mozilla.org" not in text
|
||||
+39
-4
@@ -13,7 +13,13 @@ from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter
|
||||
from app.adapters.aliyun import AliyunAdapter
|
||||
from app.adapters.cloudflare import CloudflareAdapter
|
||||
from app.adapters.digitalocean import DigitalOceanAdapter
|
||||
from app.adapters.tencent import TencentAdapter
|
||||
from app.adapters.tencent import (
|
||||
BILLING_HOST,
|
||||
CVM_HOST,
|
||||
DNSPOD_HOST,
|
||||
TencentAdapter,
|
||||
_service_of,
|
||||
)
|
||||
from app.adapters.vultr import VultrAdapter
|
||||
|
||||
|
||||
@@ -132,7 +138,9 @@ def test_aliyun_sign_sensitive_to_secret():
|
||||
# --------------------------- 腾讯云签名 --------------------------- #
|
||||
def test_tencent_authorization_format():
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
auth = adapter._build_authorization("DescribeInstances", "{}", 1704067200, "2024-01-01")
|
||||
auth = adapter._build_authorization(
|
||||
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
assert auth.startswith("TC3-HMAC-SHA256 Credential=SID/")
|
||||
assert "2024-01-01/cvm/tc3_request" in auth
|
||||
assert "SignedHeaders=content-type;host;x-tc-action" in auth
|
||||
@@ -145,11 +153,38 @@ def test_tencent_authorization_format():
|
||||
|
||||
def test_tencent_sign_sensitive_to_payload():
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
a1 = adapter._build_authorization("DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01")
|
||||
a2 = adapter._build_authorization("DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01")
|
||||
a1 = adapter._build_authorization(
|
||||
"DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
a2 = adapter._build_authorization(
|
||||
"DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
assert a1 != a2
|
||||
|
||||
|
||||
def test_tencent_credential_scope_follows_service():
|
||||
"""多产品复用同一套签名:credential scope 与 host 中的 canonical host 必须随 service 变化
|
||||
|
||||
若 scope 写死成 cvm,调用 billing/domain/dnspod 会全部返回 AuthFailure.SignatureFailure。
|
||||
"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
cvm = adapter._build_authorization(
|
||||
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
|
||||
)
|
||||
billing = adapter._build_authorization(
|
||||
"DescribeAccountBalance", "{}", 1704067200, "2024-01-01", BILLING_HOST, "billing"
|
||||
)
|
||||
assert "2024-01-01/cvm/tc3_request" in cvm
|
||||
assert "2024-01-01/billing/tc3_request" in billing
|
||||
assert cvm != billing
|
||||
|
||||
|
||||
def test_tencent_service_of_host():
|
||||
assert _service_of(CVM_HOST) == "cvm"
|
||||
assert _service_of(BILLING_HOST) == "billing"
|
||||
assert _service_of(DNSPOD_HOST) == "dnspod"
|
||||
|
||||
|
||||
# --------------------------- 注册表路由 --------------------------- #
|
||||
def test_registry_supported_types():
|
||||
for t in [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""资产导出 / 导入的往返一致性测试
|
||||
|
||||
export_assets 自称「用于数据迁移/备份」,那它导出的字段就必须在 import_assets
|
||||
里真的落库。external_id 是 sync_service 的去重键 (provider_id, external_id, asset_type),
|
||||
AssetCreate/AssetUpdate 不收这个字段——导入时若被 pydantic 静默丢弃,
|
||||
迁移后首次同步会把所有云端资产当成新增再建一份,台账直接翻倍。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_asset_export_import.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
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
|
||||
from app.models.asset import Asset, AssetType, DomainDetail, VPSDetail
|
||||
from app.services.asset_service import export_assets, import_assets
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# _cleanup_metrics_for_asset 在函数内部取 metrics_engine,指到临时库避免污染本地 data/
|
||||
monkeypatch.setattr("app.database.metrics_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _seed_vps(engine, name="web-01", external_id="i-abc123"):
|
||||
with Session(engine) as s:
|
||||
a = Asset(
|
||||
name=name, asset_type=AssetType.VPS, provider="vultr",
|
||||
external_id=external_id, cost=5.0, currency="USD",
|
||||
)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
s.add(VPSDetail(asset_id=a.id, ip_address="1.2.3.4"))
|
||||
s.commit()
|
||||
return a.id
|
||||
|
||||
|
||||
def _seed_domain(engine, name="example.com", external_id="example.com"):
|
||||
with Session(engine) as s:
|
||||
a = Asset(name=name, asset_type=AssetType.DOMAIN, provider="namesilo", external_id=external_id)
|
||||
s.add(a)
|
||||
s.commit()
|
||||
s.refresh(a)
|
||||
s.add(DomainDetail(asset_id=a.id, domain_name=name))
|
||||
s.commit()
|
||||
return a.id
|
||||
|
||||
|
||||
def _external_ids(engine):
|
||||
with Session(engine) as s:
|
||||
return {a.name: a.external_id for a in s.exec(select(Asset)).all()}
|
||||
|
||||
|
||||
def _wipe(engine):
|
||||
"""清空资产与详情行
|
||||
|
||||
必须连详情一起删:SQLite 默认不强制外键,只删 Asset 会留下孤儿详情行;
|
||||
而 assets.id 是 INTEGER PRIMARY KEY(无 AUTOINCREMENT),删掉后新建资产
|
||||
必然复用同一个 id,撞上孤儿行的 UNIQUE(asset_id)。
|
||||
"""
|
||||
with Session(engine) as s:
|
||||
for model in (VPSDetail, DomainDetail):
|
||||
for row in s.exec(select(model)).all():
|
||||
s.delete(row)
|
||||
for a in s.exec(select(Asset)).all():
|
||||
s.delete(a)
|
||||
s.commit()
|
||||
|
||||
|
||||
# ----------------------------- 导出 ----------------------------- #
|
||||
def test_export_includes_external_id(engine):
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
data = export_assets(s)
|
||||
assert data["count"] == 1
|
||||
assert data["assets"][0]["external_id"] == "i-abc123"
|
||||
|
||||
|
||||
def test_export_carries_detail_and_count(engine):
|
||||
_seed_vps(engine)
|
||||
_seed_domain(engine)
|
||||
with Session(engine) as s:
|
||||
data = export_assets(s)
|
||||
assert data["count"] == 2
|
||||
by_name = {a["name"]: a for a in data["assets"]}
|
||||
assert by_name["web-01"]["vps_detail"]["ip_address"] == "1.2.3.4"
|
||||
assert by_name["example.com"]["domain_detail"]["domain_name"] == "example.com"
|
||||
|
||||
|
||||
# ----------------------------- 导入落库 ----------------------------- #
|
||||
def test_import_creates_asset_with_external_id(engine):
|
||||
"""空库导入:external_id 必须落到行上,不能被 AssetCreate 静默吞掉"""
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == []
|
||||
assert result["created"] == 1
|
||||
assert _external_ids(engine)["web-01"] == "i-abc123"
|
||||
|
||||
|
||||
def test_import_updates_existing_and_keeps_external_id(engine):
|
||||
"""同名同类型同平台 -> 走更新分支,external_id 不能丢"""
|
||||
asset_id = _seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
with Session(engine) as s:
|
||||
a = s.get(Asset, asset_id)
|
||||
a.external_id = None # 模拟历史数据没有 external_id
|
||||
s.add(a)
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["updated"] == 1 and result["created"] == 0
|
||||
assert _external_ids(engine)["web-01"] == "i-abc123"
|
||||
|
||||
|
||||
def test_import_does_not_clobber_a_fresher_external_id(engine):
|
||||
"""已有值时不覆盖:导入包可能比线上同步到的旧,覆盖会让去重键指向已销毁的实例"""
|
||||
asset_id = _seed_vps(engine, external_id="i-old")
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
with Session(engine) as s:
|
||||
a = s.get(Asset, asset_id)
|
||||
a.external_id = "i-new"
|
||||
s.add(a)
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
import_assets(s, payload)
|
||||
assert _external_ids(engine)["web-01"] == "i-new"
|
||||
|
||||
|
||||
def test_import_ignores_computed_fields(engine):
|
||||
"""days_to_expiry / provider_name / account_name 是派生值,导入时必须跳过而不是写库"""
|
||||
_seed_vps(engine)
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
payload[0]["days_to_expiry"] = 999
|
||||
payload[0]["provider_name"] = "不存在的平台"
|
||||
payload[0]["account_name"] = "不存在的账号"
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == []
|
||||
assert result["created"] == 1
|
||||
|
||||
|
||||
def test_import_without_external_id_key_still_works(engine):
|
||||
"""老导出包没有这个字段,不能因此报错"""
|
||||
payload = [{
|
||||
"name": "legacy", "asset_type": "vps", "provider": "vultr",
|
||||
"vps_detail": {"ip_address": "9.9.9.9"},
|
||||
}]
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["created"] == 1 and result["errors"] == []
|
||||
assert _external_ids(engine)["legacy"] is None
|
||||
|
||||
|
||||
def test_import_all_or_nothing_on_failure(engine):
|
||||
"""单事务语义:一条失败则整体回滚,不能留下半截数据"""
|
||||
_seed_vps(engine, name="good")
|
||||
payload = [
|
||||
{"name": "ok-1", "asset_type": "vps", "provider": "vultr",
|
||||
"external_id": "i-1", "vps_detail": {"ip_address": "1.1.1.1"}},
|
||||
{"name": "bad-1", "asset_type": "vps", "provider": "vultr"}, # 缺 vps_detail -> 400
|
||||
]
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["created"] == 0 and result["updated"] == 0
|
||||
assert len(result["errors"]) == 1
|
||||
names = _external_ids(engine)
|
||||
assert "ok-1" not in names # 成功那条也回滚了
|
||||
assert set(names) == {"good"}
|
||||
|
||||
|
||||
def test_round_trip_preserves_everything(engine):
|
||||
"""完整往返:导出 -> 清空 -> 导入,external_id 与详情都在"""
|
||||
_seed_vps(engine, name="web-01", external_id="i-111")
|
||||
_seed_domain(engine, name="example.com", external_id="example.com")
|
||||
with Session(engine) as s:
|
||||
payload = export_assets(s)["assets"]
|
||||
_wipe(engine)
|
||||
with Session(engine) as s:
|
||||
result = import_assets(s, payload)
|
||||
assert result["errors"] == [] and result["created"] == 2
|
||||
assert _external_ids(engine) == {"web-01": "i-111", "example.com": "example.com"}
|
||||
with Session(engine) as s:
|
||||
vps = s.exec(select(Asset).where(Asset.name == "web-01")).one()
|
||||
detail = s.exec(select(VPSDetail).where(VPSDetail.asset_id == vps.id)).one()
|
||||
assert detail.ip_address == "1.2.3.4"
|
||||
@@ -0,0 +1,716 @@
|
||||
"""新增适配器测试:AWS(SigV4)/ Linode / Namesilo
|
||||
|
||||
重点覆盖三类真实会炸的问题:
|
||||
|
||||
1. AWS SigV4 的派生签名密钥必须与官方测试向量逐字节一致——手写签名没有 SDK 兜底,
|
||||
错一步就是满屏 SignatureDoesNotMatch,且无法从错误信息反推是哪一步错了。
|
||||
2. credential scope / canonical host 必须随 service 变化(ec2 / route53 / route53domains
|
||||
共用一套签名代码),写死成 ec2 会让 Route53 全线鉴权失败。
|
||||
3. EC2 查询协议转 JSON 后的驼峰包装:单元素时 AWS 返回 dict 而非 list,
|
||||
不做归一会直接 TypeError。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_new_adapters.py -v
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.adapters.aws import AWSAdapter, _signing_key
|
||||
from app.adapters.linode import LinodeAdapter
|
||||
from app.adapters.namesilo import NamesiloAdapter
|
||||
|
||||
|
||||
# ----------------------------- AWS SigV4 ----------------------------- #
|
||||
def test_aws_signing_key_matches_official_vector():
|
||||
"""AWS 官方文档测试向量(Signature Version 4 派生签名密钥示例)
|
||||
|
||||
这是手写 SigV4 唯一可靠的正确性锚点:向量对得上,说明
|
||||
AWS4 前缀、四级 HMAC 链、aws4_request 终止符全部正确。
|
||||
"""
|
||||
key = _signing_key(
|
||||
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
"20120215",
|
||||
"us-east-1",
|
||||
"iam",
|
||||
)
|
||||
assert key.hex() == "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
|
||||
|
||||
|
||||
def test_aws_signing_key_sensitive_to_every_input():
|
||||
"""任一分量变化都必须产出不同密钥(漏拼 region/service 是常见错法)"""
|
||||
base = _signing_key("secret", "20240101", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret2", "20240101", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240102", "us-east-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240101", "eu-west-1", "ec2")
|
||||
assert base != _signing_key("secret", "20240101", "us-east-1", "route53")
|
||||
|
||||
|
||||
def _capture_signed_headers(adapter: AWSAdapter, **kwargs) -> dict:
|
||||
"""拦掉 httpx,捕获 _signed_request 实际发出的请求头与 body"""
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, content=None, headers=None):
|
||||
captured.update(method=method, url=url, content=content, headers=headers)
|
||||
resp = MagicMock()
|
||||
resp.content = b"{}"
|
||||
resp.json.return_value = {}
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
client = MagicMock()
|
||||
client.request.side_effect = fake_request
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
adapter._signed_request(**kwargs)
|
||||
return captured
|
||||
|
||||
|
||||
def test_aws_authorization_header_format():
|
||||
adapter = AWSAdapter({"access_key_id": "AKIDEXAMPLE", "secret_access_key": "SECRET"})
|
||||
cap = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="Action=DescribeRegions",
|
||||
)
|
||||
auth = cap["headers"]["Authorization"]
|
||||
assert auth.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/")
|
||||
assert "/us-east-1/ec2/aws4_request" in auth
|
||||
assert "SignedHeaders=content-type;host;x-amz-date" in auth
|
||||
signature = auth.split("Signature=")[1]
|
||||
assert len(signature) == 64
|
||||
int(signature, 16)
|
||||
|
||||
|
||||
def test_aws_credential_scope_follows_service():
|
||||
"""ec2 / route53 / route53domains 共用签名代码,scope 必须随 service 变
|
||||
|
||||
写死成 ec2 的话 Route53 全部返回 SignatureDoesNotMatch。
|
||||
"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
ec2 = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="",
|
||||
)["headers"]["Authorization"]
|
||||
r53 = _capture_signed_headers(
|
||||
adapter, service="route53", host="route53.amazonaws.com",
|
||||
region="us-east-1", method="GET", path="/2013-04-01/hostedzone/Z1/rrset", body="",
|
||||
)["headers"]["Authorization"]
|
||||
assert "/us-east-1/ec2/aws4_request" in ec2
|
||||
assert "/us-east-1/route53/aws4_request" in r53
|
||||
assert ec2 != r53
|
||||
|
||||
|
||||
def test_aws_route53_uses_rest_json_content_type():
|
||||
"""Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target),
|
||||
用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}) as m:
|
||||
adapter.list_dns_records("Z123")
|
||||
assert m.call_args.kwargs["content_type"] == "application/json"
|
||||
assert m.call_args.kwargs["service"] == "route53"
|
||||
|
||||
|
||||
def test_aws_host_header_excluded_from_send():
|
||||
"""host 参与签名但不能手工发送,否则与 httpx 自动设置的值冲突"""
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
cap = _capture_signed_headers(
|
||||
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
|
||||
region="us-east-1", method="POST", path="/", body="",
|
||||
)
|
||||
assert "host" not in cap["headers"]
|
||||
# 但签名串里含 host(SignedHeaders 列出了它)
|
||||
assert "host" in cap["headers"]["Authorization"]
|
||||
|
||||
|
||||
def test_aws_signature_sensitive_to_payload():
|
||||
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
|
||||
a = _capture_signed_headers(
|
||||
adapter, service="ec2", host="h", region="us-east-1",
|
||||
method="POST", path="/", body="Action=A",
|
||||
)["headers"]["Authorization"]
|
||||
b = _capture_signed_headers(
|
||||
adapter, service="ec2", host="h", region="us-east-1",
|
||||
method="POST", path="/", body="Action=B",
|
||||
)["headers"]["Authorization"]
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_aws_region_defaults_and_override():
|
||||
assert AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})._region() == "us-east-1"
|
||||
assert AWSAdapter({"region": "ap-southeast-1"})._region() == "ap-southeast-1"
|
||||
|
||||
|
||||
# ----------------------------- AWS EC2 响应解析 ----------------------------- #
|
||||
def _ec2_response(instances, token=None):
|
||||
return {
|
||||
"reservationSet": {"item": [{"instancesSet": {"item": instances}}]},
|
||||
**({"nextToken": token} if token else {}),
|
||||
}
|
||||
|
||||
|
||||
def test_aws_list_vps_unwraps_camelcase():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = _ec2_response([
|
||||
{
|
||||
"instanceId": "i-123",
|
||||
"instanceState": {"name": "running"},
|
||||
"ipAddress": "1.2.3.4",
|
||||
"privateIpAddress": "10.0.0.5",
|
||||
"placement": {"availabilityZone": "us-east-1a"},
|
||||
"instanceLifecycle": "spot",
|
||||
"tagSet": {"item": [{"key": "Name", "value": "web-01"}]},
|
||||
}
|
||||
])
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
v = vps[0]
|
||||
assert v.external_id == "i-123"
|
||||
assert v.name == "web-01" # 取 tagSet 的 Name,不是 instanceId
|
||||
assert v.ip_address == "1.2.3.4" # 公网优先
|
||||
assert v.region == "us-east-1a"
|
||||
assert v.status == "active"
|
||||
assert v.charge_type == "spot"
|
||||
assert v.currency == "USD"
|
||||
|
||||
|
||||
def test_aws_list_vps_single_reservation_is_dict():
|
||||
"""EC2 查询协议转 JSON 后,单元素返回 dict 而不是 list——不归一会 TypeError"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"reservationSet": {
|
||||
"item": { # 注意:单个 reservation 时是 dict
|
||||
"instancesSet": {
|
||||
"item": { # 单个 instance 时也是 dict
|
||||
"instanceId": "i-only",
|
||||
"instanceState": {"name": "stopped"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].external_id == "i-only"
|
||||
assert vps[0].status == "stopped"
|
||||
# 无 tagSet 时回退到 instanceId
|
||||
assert vps[0].name == "i-only"
|
||||
# 无公网 IP 时回退到私网
|
||||
assert vps[0].ip_address is None
|
||||
|
||||
|
||||
def test_aws_list_vps_filters_terminated():
|
||||
"""terminated/shutting-down 不入台账,否则台账会越堆越多已销毁实例"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = _ec2_response([
|
||||
{"instanceId": "i-live", "instanceState": {"name": "running"}},
|
||||
{"instanceId": "i-dead", "instanceState": {"name": "terminated"}},
|
||||
{"instanceId": "i-dying", "instanceState": {"name": "shutting-down"}},
|
||||
{"instanceId": "i-pending", "instanceState": {"name": "pending"}},
|
||||
])
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
ids = {v.external_id for v in vps}
|
||||
assert ids == {"i-live", "i-pending"}
|
||||
|
||||
|
||||
def test_aws_list_vps_pagination():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
pages = [
|
||||
_ec2_response([{"instanceId": "i-1", "instanceState": {"name": "running"}}], token="tok2"),
|
||||
_ec2_response([{"instanceId": "i-2", "instanceState": {"name": "running"}}]),
|
||||
]
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=pages) as m:
|
||||
vps = adapter.list_vps()
|
||||
assert [v.external_id for v in vps] == ["i-1", "i-2"]
|
||||
assert m.call_count == 2
|
||||
# 第二页必须带上 NextToken
|
||||
assert m.call_args_list[1].args[0]["NextToken"] == "tok2"
|
||||
|
||||
|
||||
def test_aws_instance_action_sends_instance_id_list():
|
||||
"""EC2 的启停接口参数是 InstanceId.1(列表编码),不是 InstanceId"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value={}) as m:
|
||||
assert adapter.start_instance("i-9")["ok"] is True
|
||||
params = m.call_args.args[0]
|
||||
assert params["Action"] == "StartInstances"
|
||||
assert params["InstanceId.1"] == "i-9"
|
||||
|
||||
|
||||
def test_aws_action_failure_returns_not_raises():
|
||||
"""动作失败要回 ok=False + 原因,不能抛异常(上层按结果渲染提示)"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=RuntimeError("boom")):
|
||||
res = adapter.reboot_instance("i-1")
|
||||
assert res["ok"] is False
|
||||
assert "boom" in res["message"]
|
||||
|
||||
|
||||
def test_aws_has_no_account_balance():
|
||||
"""刻意不实现 get_account:AWS 没有「账户余额」概念,硬凑数字比报不支持更误导"""
|
||||
caps = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}).capabilities()
|
||||
assert caps["get_account"] is False
|
||||
assert caps["list_vps"] is True
|
||||
assert caps["renew_instance"] is False # 按需计费无续费概念
|
||||
|
||||
|
||||
# ----------------------------- AWS Route53 ----------------------------- #
|
||||
def test_aws_hosted_zone_id_strips_prefix():
|
||||
assert AWSAdapter._hosted_zone_id("/hostedzone/Z123ABC") == "Z123ABC"
|
||||
assert AWSAdapter._hosted_zone_id("Z123ABC") == "Z123ABC"
|
||||
|
||||
|
||||
def test_aws_epoch_to_date():
|
||||
# 2027-01-01T00:00:00Z == 1798761600
|
||||
assert AWSAdapter._epoch_to_date(1798761600) == date(2027, 1, 1)
|
||||
assert AWSAdapter._epoch_to_date("1798761600") == date(2027, 1, 1)
|
||||
assert AWSAdapter._epoch_to_date(None) is None
|
||||
assert AWSAdapter._epoch_to_date("garbage") is None
|
||||
|
||||
|
||||
def test_aws_list_domains_from_route53domains():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {"Domains": [{"DomainName": "example.com", "Expiry": 1798761600}]}
|
||||
with patch.object(AWSAdapter, "_r53domains_call", return_value=payload):
|
||||
domains = adapter.list_domains()
|
||||
assert len(domains) == 1
|
||||
d = domains[0]
|
||||
assert d.domain_name == "example.com"
|
||||
assert isinstance(d.expiry_date, date) # 必须是 date,否则写库抛 TypeError 整批回滚
|
||||
assert d.expiry_date == date(2027, 1, 1)
|
||||
assert d.registrar == "aws-route53"
|
||||
|
||||
|
||||
def test_aws_list_dns_records_normalization():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"ResourceRecordSets": [
|
||||
{
|
||||
"Name": "www.example.com.", # Route53 总是带尾点
|
||||
"Type": "A",
|
||||
"TTL": 300,
|
||||
"ResourceRecords": [{"Value": "1.2.3.4"}, {"Value": "5.6.7.8"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value=payload):
|
||||
records = adapter.list_dns_records("Z1")
|
||||
assert len(records) == 1
|
||||
r = records[0]
|
||||
assert r.name == "www.example.com" # 尾点被剥掉
|
||||
assert r.record_id == "www.example.com.|A" # record_id 用原始带尾点的 Name
|
||||
assert r.content == "1.2.3.4, 5.6.7.8" # 多值合并
|
||||
assert r.ttl == 300
|
||||
|
||||
|
||||
def test_aws_dns_write_wraps_values_in_array():
|
||||
"""Route53 的 ResourceRecords 必须是数组,单值也要包一层"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={}) as m:
|
||||
res = adapter.create_dns_record("Z1", {"name": "a.example.com", "type": "A", "content": "1.2.3.4", "ttl": 600})
|
||||
assert res["ok"] is True
|
||||
import json as _json
|
||||
body = _json.loads(m.call_args.kwargs["body"])
|
||||
rrs = body["Changes"][0]["ResourceRecordSet"]
|
||||
assert rrs["ResourceRecords"] == [{"Value": "1.2.3.4"}]
|
||||
assert rrs["TTL"] == 600
|
||||
assert body["Changes"][0]["Action"] == "CREATE"
|
||||
|
||||
|
||||
def test_aws_delete_requires_name_type_record_id():
|
||||
"""Route53 删除必须提交完整记录集,record_id 缺 TYPE 无法构造"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
res = adapter.delete_dns_record("Z1", "no-pipe-here")
|
||||
assert res["ok"] is False
|
||||
|
||||
|
||||
def test_aws_delete_reads_existing_record_first():
|
||||
"""DELETE 需要带上现存的值与 TTL,必须先读回记录"""
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {
|
||||
"ResourceRecordSets": [
|
||||
{"Name": "a.example.com.", "Type": "A", "TTL": 300, "ResourceRecords": [{"Value": "1.2.3.4"}]}
|
||||
]
|
||||
}
|
||||
with patch.object(AWSAdapter, "_signed_request", side_effect=[payload, {}]) as m:
|
||||
res = adapter.delete_dns_record("Z1", "a.example.com.|A")
|
||||
assert res["ok"] is True
|
||||
import json as _json
|
||||
body = _json.loads(m.call_args_list[1].kwargs["body"])
|
||||
assert body["Changes"][0]["Action"] == "DELETE"
|
||||
assert body["Changes"][0]["ResourceRecordSet"]["ResourceRecords"] == [{"Value": "1.2.3.4"}]
|
||||
|
||||
|
||||
def test_aws_delete_missing_record():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}):
|
||||
res = adapter.delete_dns_record("Z1", "gone.example.com.|A")
|
||||
assert res["ok"] is False
|
||||
assert "不存在" in res["message"]
|
||||
|
||||
|
||||
def test_aws_test_connection_reports_region_count():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
payload = {"regionInfo": {"item": [{"regionName": "us-east-1"}, {"regionName": "eu-west-1"}]}}
|
||||
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is True
|
||||
assert "2" in res["message"]
|
||||
|
||||
|
||||
def test_aws_test_connection_failure_is_graceful():
|
||||
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
|
||||
resp = httpx.Response(403, request=httpx.Request("POST", "https://ec2.us-east-1.amazonaws.com/"))
|
||||
with patch.object(AWSAdapter, "_ec2_call", side_effect=httpx.HTTPStatusError("denied", request=resp.request, response=resp)):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is False
|
||||
assert "403" in res["message"]
|
||||
|
||||
|
||||
# ----------------------------- Linode ----------------------------- #
|
||||
def test_linode_list_vps_normalization():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {
|
||||
"pages": 1,
|
||||
"data": [
|
||||
{
|
||||
"id": 12345,
|
||||
"label": "prod-web",
|
||||
"status": "running",
|
||||
"region": "us-east",
|
||||
"image": "linode/ubuntu22.04",
|
||||
"ipv4": ["1.2.3.4", "10.0.0.1"],
|
||||
"specs": {"memory": 4096, "vcpus": 2, "disk": 81920},
|
||||
}
|
||||
],
|
||||
}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
v = vps[0]
|
||||
assert v.external_id == "12345"
|
||||
assert v.ip_address == "1.2.3.4" # 取第一个 IPv4
|
||||
assert v.memory_gb == 4.0 # MB -> GB
|
||||
assert v.cpu_cores == 2
|
||||
assert v.disk_gb == 81920
|
||||
assert v.status == "active"
|
||||
assert v.charge_type == "postpaid" # 按小时计费,不会有到期日
|
||||
assert v.expiry_date is None
|
||||
|
||||
|
||||
def test_linode_status_mapping():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
for raw, expected in (("offline", "stopped"), ("shutting_down", "stopped"), ("booting", "active")):
|
||||
payload = {"pages": 1, "data": [{"id": 1, "label": "n", "status": raw, "specs": {}}]}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
assert adapter.list_vps()[0].status == expected
|
||||
|
||||
|
||||
def test_linode_negative_balance_is_credit():
|
||||
"""Linode 的 balance 为负表示账户有预付余额,正数才是欠款——不要取绝对值"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
with patch.object(LinodeAdapter, "_get", return_value={"balance": -25.5, "email": "me@x.com"}):
|
||||
acc = adapter.get_account()
|
||||
assert acc.balance == -25.5
|
||||
assert acc.currency == "USD"
|
||||
|
||||
|
||||
def test_linode_pagination_uses_pages():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
pages = [
|
||||
{"pages": 2, "data": [{"id": 1, "label": "a", "status": "running", "specs": {}}]},
|
||||
{"pages": 2, "data": [{"id": 2, "label": "b", "status": "running", "specs": {}}]},
|
||||
]
|
||||
with patch.object(LinodeAdapter, "_get", side_effect=pages) as m:
|
||||
vps = adapter.list_vps()
|
||||
assert [v.external_id for v in vps] == ["1", "2"]
|
||||
assert "page=2" in m.call_args_list[1].args[0]
|
||||
|
||||
|
||||
def test_linode_domains_expiry_nullable():
|
||||
"""仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null,不能炸"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {
|
||||
"pages": 1,
|
||||
"data": [
|
||||
{"id": 1, "domain": "reg.com", "status": "active", "expiry": "2027-05-06T04:00:00"},
|
||||
{"id": 2, "domain": "dns-only.com", "status": "active", "expiry": None},
|
||||
],
|
||||
}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
domains = adapter.list_domains()
|
||||
assert domains[0].expiry_date == date(2027, 5, 6)
|
||||
assert domains[1].expiry_date is None
|
||||
|
||||
|
||||
def test_linode_error_message_reads_body_reason():
|
||||
"""Linode 把原因放在 body.errors[0].reason,裸状态码对用户没用"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = httpx.Response(
|
||||
403,
|
||||
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
|
||||
json={"errors": [{"reason": "Invalid token"}]},
|
||||
)
|
||||
err = httpx.HTTPStatusError("denied", request=resp.request, response=resp)
|
||||
assert "Invalid token" in adapter._error_message(err)
|
||||
|
||||
|
||||
def test_linode_error_message_survives_unparseable_body():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = httpx.Response(
|
||||
502,
|
||||
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
|
||||
content=b"<html>bad gateway</html>",
|
||||
)
|
||||
err = httpx.HTTPStatusError("boom", request=resp.request, response=resp)
|
||||
assert adapter._error_message(err) == "HTTP 502"
|
||||
|
||||
|
||||
def test_linode_capabilities_no_renew():
|
||||
"""按小时计费无「续费」概念,不能声明 renew_instance"""
|
||||
caps = LinodeAdapter({"api_key": "x"}).capabilities()
|
||||
assert caps["renew_instance"] is False
|
||||
assert caps["start_instance"] is True
|
||||
assert caps["list_dns_records"] is True
|
||||
assert caps["get_account"] is True
|
||||
|
||||
|
||||
def test_linode_instance_actions_hit_right_verbs():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
for fn, verb in (("start_instance", "boot"), ("stop_instance", "shutdown"), ("reboot_instance", "reboot")):
|
||||
with patch.object(LinodeAdapter, "_request", return_value={}) as m:
|
||||
assert getattr(adapter, fn)("999")["ok"] is True
|
||||
assert m.call_args.args[1] == f"/linode/instances/999/{verb}"
|
||||
|
||||
|
||||
def test_linode_record_body_mapping():
|
||||
"""Linode 用 target/ttl_sec,不是 content/ttl"""
|
||||
body = LinodeAdapter._record_body({"type": "MX", "name": "mail", "content": "mx.example.com", "ttl": 600, "priority": 10})
|
||||
assert body == {"type": "MX", "name": "mail", "target": "mx.example.com", "ttl_sec": 600, "priority": 10}
|
||||
# priority 为 None 时不应出现在 body 里(Linode 会校验类型)
|
||||
assert "priority" not in LinodeAdapter._record_body({"type": "A", "name": "a", "content": "1.2.3.4"})
|
||||
|
||||
|
||||
def test_linode_dns_crud_paths():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
with patch.object(LinodeAdapter, "_request", return_value={"id": 555}) as m:
|
||||
assert adapter.create_dns_record("7", {"type": "A", "name": "a", "content": "1.2.3.4"})["record_id"] == "555"
|
||||
assert m.call_args.args[0] == "POST"
|
||||
assert m.call_args.args[1] == "/domains/7/records"
|
||||
|
||||
assert adapter.update_dns_record("7", "555", {"type": "A", "name": "a", "content": "1.2.3.5"})["ok"] is True
|
||||
assert m.call_args.args[0] == "PUT"
|
||||
assert m.call_args.args[1] == "/domains/7/records/555"
|
||||
|
||||
assert adapter.delete_dns_record("7", "555")["ok"] is True
|
||||
assert m.call_args.args[0] == "DELETE"
|
||||
|
||||
|
||||
def test_linode_list_dns_records_maps_target():
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
payload = {"pages": 1, "data": [{"id": 1, "type": "A", "name": "www", "target": "1.2.3.4", "ttl_sec": 300}]}
|
||||
with patch.object(LinodeAdapter, "_get", return_value=payload):
|
||||
records = adapter.list_dns_records("7")
|
||||
assert records[0].content == "1.2.3.4"
|
||||
assert records[0].ttl == 300
|
||||
assert records[0].zone_id == "7"
|
||||
|
||||
|
||||
def test_linode_empty_body_handled():
|
||||
"""POST 动作类接口返回 200 + 空 body,直接 .json() 会炸"""
|
||||
adapter = LinodeAdapter({"api_key": "x"})
|
||||
resp = MagicMock()
|
||||
resp.content = b""
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.request.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter.start_instance("1")["ok"] is True
|
||||
|
||||
|
||||
# ----------------------------- Namesilo ----------------------------- #
|
||||
def _namesilo_reply(reply: dict) -> dict:
|
||||
return {"request": {"command": "x"}, "reply": reply}
|
||||
|
||||
|
||||
def test_namesilo_success_code_300():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
payload = _namesilo_reply({"code": 300, "detail": "success", "domains": []})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter._call("listDomains")["code"] == 300
|
||||
|
||||
|
||||
def test_namesilo_string_code_accepted():
|
||||
"""Namesilo 的 code 可能是 str,直接 != 300 会把成功误判成失败"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": "300", "detail": "success"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
assert adapter._call("checkApiKey")["code"] == "300"
|
||||
|
||||
|
||||
def test_namesilo_failure_raises_with_detail():
|
||||
"""301 未授权 / 400 参数错必须抛出并带上 detail,否则用户只看到一个空失败"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": 301, "detail": "Invalid or missing API key"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
with pytest.raises(RuntimeError) as ei:
|
||||
adapter._call("listDomains")
|
||||
assert "301" in str(ei.value)
|
||||
assert "Invalid or missing API key" in str(ei.value)
|
||||
|
||||
|
||||
def test_namesilo_api_key_sent_as_query_param():
|
||||
adapter = NamesiloAdapter({"api_key": "SECRETKEY"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = _namesilo_reply({"code": 300, "username": "me"})
|
||||
resp.raise_for_status.return_value = None
|
||||
client = MagicMock()
|
||||
client.get.return_value = resp
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = client
|
||||
with patch("httpx.Client", return_value=cm):
|
||||
adapter.test_connection()
|
||||
query = client.get.call_args.kwargs["params"]
|
||||
assert query["key"] == "SECRETKEY"
|
||||
assert query["version"] == "1"
|
||||
assert query["type"] == "json"
|
||||
|
||||
|
||||
def test_namesilo_test_connection_reports_username():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300, "username": "neatstudio"}):
|
||||
res = adapter.test_connection()
|
||||
assert res["ok"] is True
|
||||
assert "neatstudio" in res["message"]
|
||||
|
||||
|
||||
def test_namesilo_list_domains_expiry():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {"code": 300, "domains": [{"domain": "example.com", "expires": "2027-01-01"}]}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
domains = adapter.list_domains()
|
||||
assert len(domains) == 1
|
||||
assert isinstance(domains[0].expiry_date, date)
|
||||
assert domains[0].expiry_date == date(2027, 1, 1)
|
||||
assert domains[0].registrar == "namesilo"
|
||||
|
||||
|
||||
def test_namesilo_unparseable_expiry_is_none():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {"code": 300, "domains": [{"domain": "x.com", "expires": "not-a-date"}]}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
assert adapter.list_domains()[0].expiry_date is None
|
||||
|
||||
|
||||
def test_namesilo_fqdn_host_roundtrip():
|
||||
"""Namesilo 存子域(空/@ 表示裸域),台账展示需要完整域名"""
|
||||
assert NamesiloAdapter._fqdn("", "example.com") == "example.com"
|
||||
assert NamesiloAdapter._fqdn("@", "example.com") == "example.com"
|
||||
assert NamesiloAdapter._fqdn("www", "example.com") == "www.example.com"
|
||||
|
||||
assert NamesiloAdapter._host("example.com", "example.com") == ""
|
||||
assert NamesiloAdapter._host("www.example.com", "example.com") == "www"
|
||||
assert NamesiloAdapter._host("", "example.com") == ""
|
||||
|
||||
|
||||
def test_namesilo_dns_record_normalization():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
reply = {
|
||||
"code": 300,
|
||||
"resource_record": [
|
||||
{"record_id": 111, "host": "www", "type": "A", "value": "1.2.3.4", "ttl": 3600},
|
||||
{"record_id": 222, "host": "", "type": "MX", "value": "mail.example.com", "ttl": 3600, "distance": 10},
|
||||
],
|
||||
}
|
||||
with patch.object(NamesiloAdapter, "_call", return_value=reply):
|
||||
records = adapter.list_dns_records("example.com")
|
||||
assert records[0].name == "www.example.com"
|
||||
assert records[0].record_id == "111"
|
||||
assert records[0].ttl == 3600
|
||||
assert records[1].name == "example.com" # 裸域
|
||||
assert records[1].priority == 10
|
||||
|
||||
|
||||
def test_namesilo_create_omits_rr_id():
|
||||
"""dnsRecordUpdate 同时承担新增与修改:新增绝不能带 rr_id,否则改到别的记录上"""
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
res = adapter.create_dns_record("example.com", {"type": "A", "name": "www.example.com", "content": "1.2.3.4", "ttl": 600})
|
||||
assert res["ok"] is True
|
||||
assert m.call_args.args[0] == "dnsRecordUpdate"
|
||||
params = m.call_args.args[1]
|
||||
assert "rr_id" not in params
|
||||
assert params["rr_host"] == "www"
|
||||
assert params["rr_type"] == "A"
|
||||
assert params["rr_value"] == "1.2.3.4"
|
||||
assert params["rr_ttl"] == 600
|
||||
|
||||
|
||||
def test_namesilo_update_sends_rr_id():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
res = adapter.update_dns_record("example.com", "111", {"type": "A", "name": "example.com", "content": "9.9.9.9"})
|
||||
assert res["ok"] is True
|
||||
params = m.call_args.args[1]
|
||||
assert params["rr_id"] == "111"
|
||||
assert params["rr_host"] == "" # 裸域 -> 空串
|
||||
assert params["rr_ttl"] == 3600 # 未给 ttl 时的默认值
|
||||
|
||||
|
||||
def test_namesilo_mx_sends_distance():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
adapter.create_dns_record("example.com", {"type": "MX", "name": "example.com", "content": "mx.x.com", "priority": 10})
|
||||
assert m.call_args.args[1]["rr_distance"] == 10
|
||||
|
||||
|
||||
def test_namesilo_delete_passes_rr_id():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
|
||||
assert adapter.delete_dns_record("example.com", "111")["ok"] is True
|
||||
assert m.call_args.args[0] == "dnsRecordDelete"
|
||||
assert m.call_args.args[1] == {"domain": "example.com", "rr_id": "111"}
|
||||
|
||||
|
||||
def test_namesilo_write_failure_returns_not_raises():
|
||||
adapter = NamesiloAdapter({"api_key": "k"})
|
||||
with patch.object(NamesiloAdapter, "_call", side_effect=RuntimeError("code=301: bad key")):
|
||||
assert adapter.create_dns_record("x.com", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
|
||||
assert adapter.update_dns_record("x.com", "1", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
|
||||
assert adapter.delete_dns_record("x.com", "1")["ok"] is False
|
||||
|
||||
|
||||
def test_namesilo_capabilities_domain_only():
|
||||
"""纯域名注册商:无 VPS、无实例动作、无余额"""
|
||||
caps = NamesiloAdapter({"api_key": "k"}).capabilities()
|
||||
assert caps["list_domains"] is True
|
||||
assert caps["list_dns_records"] is True
|
||||
assert caps["list_vps"] is False
|
||||
assert caps["get_account"] is False
|
||||
assert caps["start_instance"] is False
|
||||
@@ -0,0 +1,196 @@
|
||||
"""平台注册表对齐测试
|
||||
|
||||
覆盖两处回归:
|
||||
|
||||
1. `_align_preset_providers`:seed_providers 只插入缺失 slug、不更新已有行,
|
||||
所以代码侧的能力变化必须靠迁移落地。迁移必须是「增量」的——线上 aliyun-intl
|
||||
的 services 被用户手工加过 ai_agent/other,按种子值整体覆盖会静默丢用户配置。
|
||||
2. 种子与注册表的一致性:任何预设平台都不得声明未实现的 sdk_type。
|
||||
声明了前端就会显示「API 对接: xxx」,用户建好账号点同步才撞
|
||||
「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_provider_alignment.py -v
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
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
|
||||
from app.adapters import registry
|
||||
from app.core.seed import PRESET_PROVIDERS, seed_providers
|
||||
from app.database import _align_preset_providers
|
||||
from app.models.provider import Provider
|
||||
|
||||
#: 线上 GET /api/providers 抓下来的真实状态(2026-10-11),
|
||||
#: 用它做 fixture 才能保证迁移验证的是生产形态而不是理想形态
|
||||
LIVE_PROVIDERS = [
|
||||
("agnes", None, "ai_agent"),
|
||||
("deepseek", "deepseek-api", "ai_agent"),
|
||||
("kimi", "moonshot-api", "ai_agent"),
|
||||
("minimax", "minimax-api", "ai_agent"),
|
||||
("openai", "openai-api", "ai_agent"),
|
||||
("cloudflare", "cloudflare-api", "cloudflare,dns,ssl_cert,cdn"),
|
||||
("dynadot", "dynadot-api", "domain,dns"),
|
||||
("namesilo", "namesilo-api", "domain,dns"),
|
||||
("xinwang", None, "domain,dns"),
|
||||
("aliyun", "aliyun-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("aliyun-intl", "alibabacloud-sdk", "vps,domain,ssl_cert,cdn,dns,ai_agent,other"),
|
||||
("cloudcone", "cloudcone-api", "vps"),
|
||||
("digitalocean", "do-api", "vps,domain"),
|
||||
("linode", "linode-api", "vps"),
|
||||
("tencent", "tencent-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("tencent-intl", "tencent-intl-sdk", "vps,domain,ssl_cert,cdn,dns"),
|
||||
("tothost", None, "vps"),
|
||||
("vultr", "vultr-api", "vps"),
|
||||
("zeabur", "zeabur-api", "vps,domain"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'providers.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_db(engine):
|
||||
"""复刻线上 providers 表"""
|
||||
with Session(engine) as s:
|
||||
for slug, sdk, svc in LIVE_PROVIDERS:
|
||||
s.add(Provider(slug=slug, name=slug, category="vps", services=svc, sdk_type=sdk))
|
||||
s.commit()
|
||||
return engine
|
||||
|
||||
|
||||
def _snapshot(engine) -> dict:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sa.text("SELECT slug, sdk_type, services FROM providers")).fetchall()
|
||||
return {r[0]: (r[1], r[2]) for r in rows}
|
||||
|
||||
|
||||
def _run_migration(engine) -> None:
|
||||
with engine.begin() as conn:
|
||||
_align_preset_providers(conn)
|
||||
|
||||
|
||||
# ----------------------------- 种子 / 注册表一致性 ----------------------------- #
|
||||
def test_no_preset_declares_unimplemented_sdk_type():
|
||||
"""预设平台不得声明未实现的 sdk_type(AWS/Linode/Namesilo 落地后的不变量)"""
|
||||
supported = set(registry.supported_types())
|
||||
bogus = [(p["slug"], p["sdk_type"]) for p in PRESET_PROVIDERS
|
||||
if p.get("sdk_type") and p["sdk_type"] not in supported]
|
||||
assert bogus == [], f"这些平台声明了未实现的适配器:{bogus}"
|
||||
|
||||
|
||||
def test_new_adapters_are_registered():
|
||||
"""本轮新增的三家必须有对应注册项"""
|
||||
for sdk_type in ("aws-api", "linode-api", "namesilo-api"):
|
||||
assert registry.is_supported(sdk_type), f"{sdk_type} 未注册"
|
||||
|
||||
|
||||
def test_seed_providers_inserts_missing_slug(live_db):
|
||||
"""线上没有 aws 行,seed_providers 必须补上"""
|
||||
assert "aws" not in _snapshot(live_db)
|
||||
with Session(live_db) as s:
|
||||
added = seed_providers(s)
|
||||
snap = _snapshot(live_db)
|
||||
assert added >= 1
|
||||
assert snap["aws"][0] == "aws-api"
|
||||
assert "vps" in snap["aws"][1]
|
||||
|
||||
|
||||
def test_seed_providers_is_idempotent(live_db):
|
||||
with Session(live_db) as s:
|
||||
first = seed_providers(s)
|
||||
second = seed_providers(s)
|
||||
assert second == 0
|
||||
assert len(_snapshot(live_db)) == len(LIVE_PROVIDERS) + first
|
||||
|
||||
|
||||
# ----------------------------- 迁移行为 ----------------------------- #
|
||||
def test_migration_clears_bogus_sdk_type(live_db):
|
||||
"""cloudcone/zeabur/dynadot 声明的适配器从未实现,必须清空"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for slug in ("cloudcone", "zeabur", "dynadot"):
|
||||
assert snap[slug][0] is None, f"{slug}.sdk_type 未清空:{snap[slug][0]!r}"
|
||||
|
||||
|
||||
def test_migration_keeps_valid_sdk_type(live_db):
|
||||
"""有效 sdk_type 不能被误伤,否则同步能力全丢"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for slug, expected in (
|
||||
("aliyun", "aliyun-sdk"), ("tencent", "tencent-sdk"), ("vultr", "vultr-api"),
|
||||
("digitalocean", "do-api"), ("linode", "linode-api"), ("namesilo", "namesilo-api"),
|
||||
("cloudflare", "cloudflare-api"), ("openai", "openai-api"),
|
||||
):
|
||||
assert snap[slug][0] == expected
|
||||
|
||||
|
||||
def test_migration_preserves_user_customized_services(live_db):
|
||||
"""aliyun-intl 被用户手工加过 ai_agent/other,覆盖式迁移会静默丢配置"""
|
||||
_run_migration(live_db)
|
||||
tags = set(_snapshot(live_db)["aliyun-intl"][1].split(","))
|
||||
assert {"ai_agent", "other"} <= tags
|
||||
|
||||
|
||||
def test_migration_adds_missing_service_tags(live_db):
|
||||
"""适配器新增了域名/DNS 能力,services 必须跟上,
|
||||
否则 modals.js 按 services 过滤时选不到这些平台"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
assert {"domain", "dns"} <= set(snap["vultr"][1].split(","))
|
||||
assert {"domain", "dns"} <= set(snap["linode"][1].split(","))
|
||||
assert "dns" in snap["digitalocean"][1].split(",")
|
||||
|
||||
|
||||
def test_migration_ignores_user_created_providers(live_db):
|
||||
"""tothost 不在种子里,一个字都不能动"""
|
||||
before = _snapshot(live_db)["tothost"]
|
||||
_run_migration(live_db)
|
||||
assert _snapshot(live_db)["tothost"] == before
|
||||
|
||||
|
||||
def test_migration_is_idempotent(live_db):
|
||||
"""迁移跑两次结果必须一致(update.sh 每分钟可能触发重启)"""
|
||||
_run_migration(live_db)
|
||||
once = _snapshot(live_db)
|
||||
_run_migration(live_db)
|
||||
_run_migration(live_db)
|
||||
assert _snapshot(live_db) == once
|
||||
|
||||
|
||||
def test_migration_leaves_no_unimplemented_declaration(live_db):
|
||||
"""终态不变量:迁移 + seed 后,库里不存在「声明但未实现」的 sdk_type"""
|
||||
_run_migration(live_db)
|
||||
with Session(live_db) as s:
|
||||
seed_providers(s)
|
||||
_run_migration(live_db)
|
||||
|
||||
supported = set(registry.supported_types())
|
||||
snap = _snapshot(live_db)
|
||||
bad = [(slug, v[0]) for slug, v in snap.items() if v[0] and v[0] not in supported]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_migration_result_covers_all_seed_services(live_db):
|
||||
"""终态:每个预设平台的 services 至少包含种子声明的标签"""
|
||||
_run_migration(live_db)
|
||||
snap = _snapshot(live_db)
|
||||
for data in PRESET_PROVIDERS:
|
||||
slug = data["slug"]
|
||||
if slug not in snap:
|
||||
continue
|
||||
seed_tags = {t.strip() for t in (data.get("services") or "").split(",") if t.strip()}
|
||||
cur_tags = {t.strip() for t in (snap[slug][1] or "").split(",") if t.strip()}
|
||||
assert seed_tags <= cur_tags, f"{slug} 缺标签 {sorted(seed_tags - cur_tags)}"
|
||||
@@ -0,0 +1,183 @@
|
||||
"""资产总览统计测试(get_overview)
|
||||
|
||||
覆盖的核心问题:「本年支出」是只把填了费用的资产加起来的和。cost 默认 0,
|
||||
所以没填费用和真的免费在数据上无法区分——合计因此天然是个下限,而不是准数。
|
||||
前端必须能拿到缺口大小(year_cost_missing)才能如实说明,否则用户会把下限
|
||||
当成实际支出。
|
||||
|
||||
顺带回归到期口径:已过期(overdue)与 30 天内到期(expiring_30)分开计数,
|
||||
已注销资产两边都不进。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_stats_overview.py -v
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, 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
|
||||
from app.models.asset import Asset, AssetStatus, AssetType
|
||||
from app.services.asset_service import get_overview
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'stats.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _add(engine, name, *, expiry=None, cost=0.0, status=AssetStatus.ACTIVE,
|
||||
asset_type=AssetType.VPS):
|
||||
with Session(engine) as s:
|
||||
s.add(Asset(
|
||||
name=name, asset_type=asset_type, provider="vultr",
|
||||
expiry_date=expiry, cost=cost, status=status,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _overview(engine):
|
||||
with Session(engine) as s:
|
||||
return get_overview(s)
|
||||
|
||||
|
||||
def test_empty_db_returns_all_contract_keys(engine):
|
||||
"""空库也必须给出完整结构:/api/stats/overview 是前端总览页的数据源,
|
||||
缺键会让 dashboard.js 渲染出 undefined"""
|
||||
got = _overview(engine)
|
||||
assert got == {
|
||||
"total": 0,
|
||||
"by_type": {},
|
||||
"by_status": {},
|
||||
"abnormal_count": 0,
|
||||
"expiring_30": 0,
|
||||
"overdue_count": 0,
|
||||
"year_cost": 0,
|
||||
"month_cost": 0,
|
||||
"year_cost_missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_year_cost_sums_only_filled_costs(engine):
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=12.5)
|
||||
_add(engine, "also-paid", expiry=today + timedelta(days=3), cost=7.25)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 19.75
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_unfilled_cost_is_counted_as_gap_not_zero_spend(engine):
|
||||
"""cost 默认 0,没填的资产既不进合计、也不能悄悄消失"""
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=10.0)
|
||||
_add(engine, "unfilled-a", expiry=today + timedelta(days=1))
|
||||
_add(engine, "unfilled-b", expiry=today + timedelta(days=2))
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 10.0
|
||||
assert got["year_cost_missing"] == 2
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_assets_without_expiry_do_not_enter_cost_window(engine):
|
||||
"""没有到期日的资产不属于任何年度窗口:既不该加进合计,也不该算作缺口,
|
||||
否则「另有 N 项未填费用」会把一批跟今年无关的资产算进去"""
|
||||
_add(engine, "no-expiry-unfilled")
|
||||
_add(engine, "no-expiry-paid", cost=99.0)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 0
|
||||
assert got["year_cost_missing"] == 0
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_other_year_expiry_excluded_from_cost(engine):
|
||||
"""跨年资产不进本年合计,也不进本年缺口"""
|
||||
this_year = date.today()
|
||||
other_year = date(this_year.year + 1, 6, 15)
|
||||
_add(engine, "this-year", expiry=this_year, cost=5.0)
|
||||
_add(engine, "next-year-unfilled", expiry=other_year)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 5.0
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_month_cost_only_current_month(engine):
|
||||
today = date.today()
|
||||
# 取本月内的另一天:若今天是 1 号则往后挪,避免算出上/下月的日期
|
||||
same_month = today.replace(day=1) if today.day > 1 else today
|
||||
next_month = (today.replace(day=1) + timedelta(days=32)).replace(day=2)
|
||||
_add(engine, "this-month", expiry=same_month, cost=20.0)
|
||||
_add(engine, "next-month", expiry=next_month, cost=30.0)
|
||||
got = _overview(engine)
|
||||
assert got["month_cost"] == 20.0
|
||||
# 下月那笔仍属于本年
|
||||
assert got["year_cost"] == 50.0
|
||||
|
||||
|
||||
def test_year_cost_rounded_to_two_decimals(engine):
|
||||
"""币种最小单位是分,浮点累加的 0.1+0.2 尾巴不能漏到界面上"""
|
||||
today = date.today()
|
||||
_add(engine, "a", expiry=today, cost=0.1)
|
||||
_add(engine, "b", expiry=today, cost=0.2)
|
||||
assert _overview(engine)["year_cost"] == 0.3
|
||||
|
||||
|
||||
def test_overdue_and_expiring_counted_separately(engine):
|
||||
"""已过期比 30 天内到期更该被看见,不能因为不在未来窗口里就从统计消失"""
|
||||
today = date.today()
|
||||
_add(engine, "overdue", expiry=today - timedelta(days=1))
|
||||
_add(engine, "soon", expiry=today + timedelta(days=10))
|
||||
_add(engine, "far", expiry=today + timedelta(days=90))
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 1
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_today_counts_as_expiring_not_overdue(engine):
|
||||
"""到期日就是今天:还能续费,属于倒计时而不是已错过"""
|
||||
_add(engine, "due-today", expiry=date.today())
|
||||
got = _overview(engine)
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["overdue_count"] == 0
|
||||
|
||||
|
||||
def test_cancelled_assets_excluded_from_expiry_counts(engine):
|
||||
"""已注销资产不该再触发续费提醒"""
|
||||
today = date.today()
|
||||
_add(engine, "cancelled-overdue", expiry=today - timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
_add(engine, "cancelled-soon", expiry=today + timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 0
|
||||
assert got["expiring_30"] == 0
|
||||
# 但仍计入总数:台账要能查到已注销的历史资产
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_abnormal_counts_stopped_and_expired(engine):
|
||||
_add(engine, "stopped", status=AssetStatus.STOPPED)
|
||||
_add(engine, "expired", status=AssetStatus.EXPIRED)
|
||||
_add(engine, "active", status=AssetStatus.ACTIVE)
|
||||
got = _overview(engine)
|
||||
assert got["abnormal_count"] == 2
|
||||
assert got["by_status"] == {"stopped": 1, "expired": 1, "active": 1}
|
||||
|
||||
|
||||
def test_by_type_uses_enum_values_not_names(engine):
|
||||
"""前端按 'vps'/'domain' 匹配图标与文案,给出 'VPS' 会全部落到兜底分支"""
|
||||
_add(engine, "v1", asset_type=AssetType.VPS)
|
||||
_add(engine, "d1", asset_type=AssetType.DOMAIN)
|
||||
_add(engine, "d2", asset_type=AssetType.DOMAIN)
|
||||
got = _overview(engine)
|
||||
assert got["by_type"] == {"vps": 1, "domain": 2}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""云资产同步链路测试:路由注册 + 定时脚本 + 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<interp>\S+)\s+(?P<script>\S+)\s*$",
|
||||
_read("vps-sync-assets.service"), re.M)
|
||||
assert m, "vps-sync-assets.service 缺少 ExecStart"
|
||||
assert m.group("interp").endswith("/.venv/bin/python"), "必须用虚拟环境解释器,系统 python 没有项目依赖"
|
||||
rel = m.group("script").replace("/opt/vps-manager/", "")
|
||||
assert (REPO_ROOT / rel).is_file(), f"ExecStart 指向的脚本不存在:{rel}"
|
||||
|
||||
|
||||
def test_every_timer_points_at_an_existing_service():
|
||||
for timer in sorted(DEPLOY_DIR.glob("*.timer")):
|
||||
m = re.search(r"^Unit=(\S+)\s*$", timer.read_text(encoding="utf-8"), re.M)
|
||||
assert m, f"{timer.name} 缺少 Unit="
|
||||
assert (DEPLOY_DIR / m.group(1)).is_file(), f"{timer.name} 指向不存在的 {m.group(1)}"
|
||||
|
||||
|
||||
def test_every_timer_is_enabled_by_setup_sh():
|
||||
"""deploy/ 下每个 timer 都必须在 setup.sh 里 enable --now
|
||||
|
||||
只 cp 不 enable:单元文件躺在 /etc/systemd/system 里但从不触发,
|
||||
全新部署后所有夜间任务都是死的。
|
||||
"""
|
||||
enable_lines = [ln for ln in _read("setup.sh").splitlines() if "enable --now" in ln]
|
||||
assert enable_lines, "setup.sh 里找不到任何 enable --now"
|
||||
for timer in sorted(p.name for p in DEPLOY_DIR.glob("*.timer")):
|
||||
assert any(timer in ln for ln in enable_lines), f"{timer} 未在 setup.sh 中 enable --now"
|
||||
|
||||
|
||||
def test_update_sh_enables_rather_than_restarts_new_timers():
|
||||
"""自动更新同步单元时,timer 分支必须是 enable --now
|
||||
|
||||
新 timer 只 restart 不会建开机自启软链:当下能跑,宿主一重启就永久消失。
|
||||
"""
|
||||
text = _read("update.sh")
|
||||
m = re.search(r"\*\.timer\)(?P<body>.*?);;", text, re.S)
|
||||
assert m, "update.sh 没有 *.timer) 分支"
|
||||
assert "enable --now" in m.group("body"), "timer 分支未使用 enable --now"
|
||||
|
||||
|
||||
def _oncalendar(name):
|
||||
m = re.search(r"^OnCalendar=\*-\*-\*\s+(\d{2}):(\d{2}):(\d{2})\s*$", _read(name), re.M)
|
||||
assert m, f"{name} 的 OnCalendar 不是固定的每日 HH:MM:SS 格式"
|
||||
return time(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
|
||||
|
||||
def test_asset_sync_runs_before_renewal_check():
|
||||
"""资产同步必须早于续费检查
|
||||
|
||||
续费提醒读的是本地库的 expiry_date。顺序反了,提醒就用上一天的旧到期日,
|
||||
当天新到期的资产不会进通知——而这正是这套台账存在的理由。
|
||||
"""
|
||||
assert _oncalendar("vps-sync-assets.timer") < _oncalendar("vps-renewal-check.timer")
|
||||
|
||||
|
||||
def test_sync_timers_are_persistent():
|
||||
"""Persistent=true:机器在预定时刻关机时,开机后补跑一次
|
||||
|
||||
没有它,一次夜间重启就会整天跳过同步。
|
||||
"""
|
||||
for name in ("vps-sync-assets.timer", "vps-renewal-check.timer"):
|
||||
assert re.search(r"^Persistent=true\s*$", _read(name), re.M), f"{name} 缺少 Persistent=true"
|
||||
|
||||
|
||||
def test_sync_assets_service_is_oneshot_and_loads_env():
|
||||
"""oneshot + EnvironmentFile:前者让 systemd 正确判定成败,
|
||||
后者提供 MASTER_KEY(缺了就解密不了任何账号凭证,同步全部失败)"""
|
||||
text = _read("vps-sync-assets.service")
|
||||
assert re.search(r"^Type=oneshot\s*$", text, re.M)
|
||||
assert re.search(r"^EnvironmentFile=-?\S*\.env\s*$", text, re.M), "未加载 .env,拿不到 MASTER_KEY"
|
||||
@@ -0,0 +1,402 @@
|
||||
"""VPS 到期日同步链路 + 批量资产同步测试
|
||||
|
||||
覆盖的核心回归:NormalizedVPS 此前没有 expiry_date 字段,阿里云/腾讯云把
|
||||
ExpiredTime 抓进 raw 后直接丢弃,_sync_vps 也从不写 expiry_date(而
|
||||
_sync_domains 一直有写)。结果是「云端同步进来的 VPS 永远进不了续费提醒」——
|
||||
而续费提醒正是这个台账存在的理由。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_vps_expiry_sync.py -v
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
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
|
||||
from app.adapters.aliyun import AliyunAdapter
|
||||
from app.adapters.base import NormalizedVPS
|
||||
from app.adapters.tencent import TencentAdapter
|
||||
from app.models.asset import Account, Asset, AssetType, VPSDetail
|
||||
from app.models.provider import Provider
|
||||
from app.services import sync_service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path, monkeypatch):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'sync.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
# sync_all_assets 的 worker 会自己开 Session(assets_engine),必须一并指到临时库
|
||||
monkeypatch.setattr("app.database.assets_engine", eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
class _FakeVPSAdapter:
|
||||
"""最小适配器:只提供 _sync_vps 用到的接口"""
|
||||
|
||||
required_config: list = []
|
||||
|
||||
def __init__(self, vps_list, domains_list=None):
|
||||
self.config = {}
|
||||
self._vps = vps_list
|
||||
self._domains = domains_list or []
|
||||
|
||||
def capabilities(self):
|
||||
return {
|
||||
"list_vps": bool(self._vps),
|
||||
"list_domains": bool(self._domains),
|
||||
"get_account": False,
|
||||
}
|
||||
|
||||
def list_vps(self):
|
||||
return self._vps
|
||||
|
||||
def list_domains(self):
|
||||
return self._domains
|
||||
|
||||
|
||||
def _seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config=None):
|
||||
with Session(engine) as s:
|
||||
provider = Provider(slug=slug, name=slug, category="vps", sdk_type=sdk_type)
|
||||
s.add(provider)
|
||||
s.commit()
|
||||
s.refresh(provider)
|
||||
account = Account(name="acc", platform=slug, api_config_encrypted=api_config)
|
||||
s.add(account)
|
||||
s.commit()
|
||||
s.refresh(account)
|
||||
return provider.id, account.id
|
||||
|
||||
|
||||
# ----------------------------- 到期日链路 ----------------------------- #
|
||||
def test_normalized_vps_has_expiry_field():
|
||||
"""契约层面先钉住:字段必须存在且默认 None(后付费平台没有到期日)"""
|
||||
v = NormalizedVPS(external_id="i-1", name="n")
|
||||
assert v.expiry_date is None
|
||||
assert v.charge_type is None
|
||||
|
||||
|
||||
def test_normalized_vps_to_dict_serializes_expiry():
|
||||
"""to_dict 对外必须是 ISO 字符串,date 对象会让 JSON 序列化炸掉"""
|
||||
v = NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15))
|
||||
assert v.to_dict()["expiry_date"] == "2027-03-15"
|
||||
assert NormalizedVPS(external_id="i-2", name="n").to_dict()["expiry_date"] is None
|
||||
|
||||
|
||||
def test_sync_vps_creates_with_expiry(engine):
|
||||
"""新建分支必须写 expiry_date,否则云端同步的 VPS 进不了续费提醒"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(
|
||||
external_id="i-prepaid", name="包年包月机", ip_address="1.2.3.4",
|
||||
status="active", expiry_date=date(2027, 3, 15), charge_type="PrePaid",
|
||||
)
|
||||
])
|
||||
with Session(engine) as s:
|
||||
result = sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
assert result == {"created": 1, "updated": 0}
|
||||
|
||||
with Session(engine) as s:
|
||||
asset = s.exec(select(Asset)).first()
|
||||
assert asset.expiry_date == date(2027, 3, 15)
|
||||
assert asset.asset_type == AssetType.VPS
|
||||
assert asset.account_id == account_id
|
||||
detail = s.exec(select(VPSDetail)).first()
|
||||
assert detail.ip_address == "1.2.3.4"
|
||||
|
||||
|
||||
def test_sync_vps_coerces_string_expiry(engine):
|
||||
"""适配器若仍返回 ISO 字符串,写库前归一为 date(否则 SQLite 抛 TypeError)"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(external_id="i-1", name="n", expiry_date="2027-03-15")
|
||||
])
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_postpaid_leaves_expiry_null(engine):
|
||||
"""后付费(AWS 按需 / Linode / DO / Vultr)没有到期日,必须是 None 而不是假日期"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
adapter = _FakeVPSAdapter([
|
||||
NormalizedVPS(external_id="i-1", name="n", charge_type="postpaid", monthly_cost=12.0)
|
||||
])
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
with Session(engine) as s:
|
||||
asset = s.exec(select(Asset)).first()
|
||||
assert asset.expiry_date is None
|
||||
assert asset.cost == 12.0
|
||||
|
||||
|
||||
def test_sync_vps_updates_existing_expiry(engine):
|
||||
"""重复同步走更新分支:到期日按新值刷新,不新建资产"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
|
||||
def _run(expiry):
|
||||
adapter = _FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=expiry)])
|
||||
with Session(engine) as s:
|
||||
return sync_service._sync_vps(s, s.get(Provider, provider_id), adapter, account_id)
|
||||
|
||||
assert _run(date(2027, 3, 15)) == {"created": 1, "updated": 0}
|
||||
assert _run(date(2028, 3, 15)) == {"created": 0, "updated": 1}
|
||||
with Session(engine) as s:
|
||||
assets = s.exec(select(Asset)).all()
|
||||
assert len(assets) == 1
|
||||
assert assets[0].expiry_date == date(2028, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_never_clears_expiry(engine):
|
||||
"""云端返回 None 时不能把已有的到期日抹掉
|
||||
|
||||
阿里云 DescribeInstances 偶发不返回 ExpiredTime,若照搬 None 就会让
|
||||
一台明明要到期的机器从续费提醒里消失。
|
||||
"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 3, 15))]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", expiry_date=None)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().expiry_date == date(2027, 3, 15)
|
||||
|
||||
|
||||
def test_sync_vps_updates_cost_only_when_provided(engine):
|
||||
"""monthly_cost 为 None 时保留原值,别把台账里手填的成本冲成 0"""
|
||||
provider_id, account_id = _seed(engine)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=20.0)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
sync_service._sync_vps(
|
||||
s, s.get(Provider, provider_id),
|
||||
_FakeVPSAdapter([NormalizedVPS(external_id="i-1", name="n", monthly_cost=None)]),
|
||||
account_id,
|
||||
)
|
||||
with Session(engine) as s:
|
||||
assert s.exec(select(Asset)).first().cost == 20.0
|
||||
|
||||
|
||||
# ----------------------------- 真实适配器的到期日映射 ----------------------------- #
|
||||
def test_aliyun_maps_expired_time_to_expiry():
|
||||
"""阿里云包年包月实例的 ExpiredTime 此前被抓进 raw 后直接丢弃"""
|
||||
adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec", "region_id": "cn-hangzhou"})
|
||||
payload = {
|
||||
"Instances": {
|
||||
"Instance": [
|
||||
{
|
||||
"InstanceId": "i-ali",
|
||||
"InstanceName": "ali-web",
|
||||
"Status": "Running",
|
||||
"PublicIpAddress": {"IpAddress": ["1.2.3.4"]},
|
||||
"ExpiredTime": "2027-03-15T16:00Z",
|
||||
"InstanceChargeType": "PrePaid",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(AliyunAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].expiry_date == date(2027, 3, 15)
|
||||
assert vps[0].charge_type == "prepaid" # 适配器统一归一为小写,前端好判断
|
||||
|
||||
|
||||
def test_aliyun_postpaid_has_no_expiry():
|
||||
adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "sec"})
|
||||
payload = {
|
||||
"Instances": {
|
||||
"Instance": [
|
||||
{"InstanceId": "i-post", "InstanceName": "n", "Status": "Running",
|
||||
"InstanceChargeType": "PostPaid"}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch.object(AliyunAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert vps[0].expiry_date is None
|
||||
assert vps[0].charge_type == "postpaid"
|
||||
|
||||
|
||||
def test_tencent_maps_expired_time_to_expiry():
|
||||
"""腾讯云 CVM 的 ExpiredTime 同样此前被丢弃
|
||||
|
||||
注意 _call 返回的是已剥掉 Response 信封的内层 dict(TC3 的统一约定)。
|
||||
"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY", "region": "ap-guangzhou"})
|
||||
payload = {
|
||||
"InstanceSet": [
|
||||
{
|
||||
"InstanceId": "ins-tx",
|
||||
"InstanceName": "tx-web",
|
||||
"InstanceState": "RUNNING",
|
||||
"PublicIpAddresses": ["5.6.7.8"],
|
||||
"ExpiredTime": "2027-03-15T16:00:00Z",
|
||||
"InstanceChargeType": "PREPAID",
|
||||
}
|
||||
],
|
||||
"TotalCount": 1,
|
||||
}
|
||||
with patch.object(TencentAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert len(vps) == 1
|
||||
assert vps[0].expiry_date == date(2027, 3, 15)
|
||||
assert vps[0].charge_type == "prepaid"
|
||||
|
||||
|
||||
def test_tencent_postpaid_by_hour_has_no_expiry():
|
||||
"""腾讯云按量计费(POSTPAID_BY_HOUR)没有到期日"""
|
||||
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
|
||||
payload = {
|
||||
"InstanceSet": [
|
||||
{"InstanceId": "ins-h", "InstanceName": "n", "InstanceState": "RUNNING",
|
||||
"InstanceChargeType": "POSTPAID_BY_HOUR"}
|
||||
],
|
||||
"TotalCount": 1,
|
||||
}
|
||||
with patch.object(TencentAdapter, "_call", return_value=payload):
|
||||
vps = adapter.list_vps()
|
||||
assert vps[0].expiry_date is None
|
||||
assert vps[0].charge_type == "postpaid"
|
||||
|
||||
|
||||
# ----------------------------- 批量同步 ----------------------------- #
|
||||
def test_sync_all_assets_skips_accounts_without_config(engine, monkeypatch):
|
||||
"""未配凭证的账号计入 skipped 而非 failed
|
||||
|
||||
线上 12 个账号全部 has_api=False,若算 failed,每次定时同步都会报一堆错。
|
||||
"""
|
||||
_seed(engine, slug="aliyun", api_config=None)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_config"] == 1
|
||||
assert result["failed"] == 0
|
||||
|
||||
|
||||
def test_sync_all_assets_skips_unsupported_sdk_type(engine, monkeypatch):
|
||||
"""平台没有可用适配器时计入 skipped_no_adapter,不该报成错误"""
|
||||
_seed(engine, slug="cloudcone", sdk_type=None, api_config="enc")
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_adapter"] == 1
|
||||
assert result["failed"] == 0
|
||||
|
||||
|
||||
def test_sync_all_assets_skips_unknown_platform(engine, monkeypatch):
|
||||
"""账号挂在一个不存在的平台上,不能被当成同步失败"""
|
||||
with Session(engine) as s:
|
||||
s.add(Account(name="orphan", platform="no-such-platform", api_config_encrypted="enc"))
|
||||
s.commit()
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 0
|
||||
assert result["skipped_no_adapter"] == 1
|
||||
|
||||
|
||||
def test_sync_all_assets_aggregates_counts(engine, monkeypatch):
|
||||
"""成功路径:created/updated 要把 vps 与 domains 两类都累加进来"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
fake = _FakeVPSAdapter(
|
||||
[NormalizedVPS(external_id="i-1", name="n", expiry_date=date(2027, 1, 1))],
|
||||
domains_list=[],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {"vps": {"created": 2, "updated": 1}, "domains": {"created": 3, "updated": 0}},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["total"] == 1
|
||||
assert result["success"] == 1
|
||||
assert result["failed"] == 0
|
||||
assert result["created"] == 5
|
||||
assert result["updated"] == 1
|
||||
assert fake.capabilities()["list_vps"] is True
|
||||
|
||||
|
||||
def test_sync_all_assets_surfaces_partial_failure(engine, monkeypatch):
|
||||
"""某一类资产同步失败不算账号整体失败,但必须在 errors 里如实上报"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {
|
||||
"vps": None, "vps_error": "签名失败", "domains": {"created": 1, "updated": 0},
|
||||
},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["success"] == 1
|
||||
assert result["failed"] == 0
|
||||
assert any("vps_error" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sync_all_assets_all_subtasks_failed_counts_as_failed(engine, monkeypatch):
|
||||
"""每一项尝试都失败时必须算 failed
|
||||
|
||||
凭证过期的典型现场:list_vps / list_domains / get_account 全部 401。
|
||||
这时若仍计「成功」,前端汇总行会显示「成功 1,失败 0」,
|
||||
用户以为数据拉到了、实际一条都没有——比报错更危险。
|
||||
"""
|
||||
_seed(engine, slug="vultr", sdk_type="vultr-api", api_config="enc")
|
||||
monkeypatch.setattr(
|
||||
sync_service, "sync_account",
|
||||
lambda session, account_id: {
|
||||
"vps": None, "vps_error": "HTTP 401:Invalid API token.",
|
||||
"domains": None, "domains_error": "HTTP 401:Invalid API token.",
|
||||
"account_info": None, "account_info_error": "HTTP 401:Invalid API token.",
|
||||
},
|
||||
)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["success"] == 0
|
||||
assert result["failed"] == 1
|
||||
assert result["created"] == 0 and result["updated"] == 0
|
||||
assert len(result["errors"]) == 3 # 三类错误都要如实列出
|
||||
|
||||
|
||||
def test_sync_all_assets_counts_hard_failure(engine, monkeypatch):
|
||||
"""sync_account 直接抛错时算 failed,且带上账号标签便于定位"""
|
||||
_seed(engine, slug="aliyun", sdk_type="aliyun-sdk", api_config="enc")
|
||||
|
||||
def _boom(session, account_id):
|
||||
raise RuntimeError("凭证解密失败")
|
||||
|
||||
monkeypatch.setattr(sync_service, "sync_account", _boom)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
assert result["failed"] == 1
|
||||
assert result["success"] == 0
|
||||
assert any("凭证解密失败" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_sync_all_assets_result_shape(engine, monkeypatch):
|
||||
"""脚本 sync_all_assets.py 依赖这些键存在,改名会静默打出 None"""
|
||||
_seed(engine, slug="cloudcone", sdk_type=None, api_config=None)
|
||||
with Session(engine) as s:
|
||||
result = sync_service.sync_all_assets(s)
|
||||
for key in ("total", "success", "failed", "created", "updated",
|
||||
"skipped_no_config", "skipped_no_adapter", "errors", "synced_at"):
|
||||
assert key in result, f"返回结构缺少 {key}"
|
||||
Reference in New Issue
Block a user