"""云资源管理动作(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 = ( '' "UnauthorizedOperationYou are not authorized" "" ) 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