评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 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
719 lines
30 KiB
Python
719 lines
30 KiB
Python
"""云资源管理动作(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
|