Files
vps-manager/tests/test_actions.py
T
gouki 46faadbcae 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
2026-10-12 00:43:37 +00:00

719 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云资源管理动作(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