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:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+718
View File
@@ -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