Files
vps-manager/tests/test_platform_adapters.py
T
gouki 103f48e38b feat(adapters): 接入 CloudCone/Zeabur/Dynadot/新网/Agnes 五个平台
线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空,
平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐:

- CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费
- Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格
- Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD
- 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单
- Agnes:/models 校验 Key;无余额接口,如实标记不支持

配套改动:
- _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效),
  避免新适配器上线后线上仍停留在「未对接」
- 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入
- PRESET_PROVIDERS 五条记录填上对应 sdk_type

测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
2026-10-26 09:16:45 +00:00

326 lines
12 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.
"""补齐平台适配器测试:CloudCone / Zeabur / Dynadot / Agnes / 新网
这五家此前 sdk_type 为空,平台卡片只能显示「未对接」。测试重点:
- 归一化后的字段直接决定台账展示,不能把 MB 当 GB、毫核当核;
- 到期日必须是 date,否则写库时整批 commit 抛 TypeError;
- 余额/到期拿不到时如实报告不支持,不能伪造 0;
- 新网响应是 URL 编码文本且 checksum 有严格拼接顺序,错一位就是 auth-failure。
运行:.venv/bin/python -m pytest tests/test_platform_adapters.py -v
"""
import hashlib
from datetime import date
from unittest.mock import patch
import httpx
import pytest
from app.adapters import registry
from app.adapters.ai import AgnesAdapter
from app.adapters.cloudcone import CloudConeAdapter
from app.adapters.dynadot import DynadotAdapter, _epoch_ms_to_date
from app.adapters.xinnet import XinnetAdapter
from app.adapters.zeabur import ZeaburAdapter
# ----------------------------- CloudCone ----------------------------- #
def test_cloudcone_headers_use_documented_names():
adapter = CloudConeAdapter({"api_key": "KEY", "api_hash": "HASH"})
assert adapter._headers() == {"App-Secret": "KEY", "Hash": "HASH"}
def test_cloudcone_list_vps_normalization():
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
payload = {
"status": 1,
"__data": {
"instances": [
{
"id": 236441,
"nodename": "S64",
"state": {"name": "active"},
"ips": "10.10.10.1,10.10.10.2",
"hostname": "web-01",
"distro": "ubuntu",
"ram": 3,
"cpu": 1,
"disk": 30,
},
{
"id": 2,
"nodename": "S53",
"state": {"name": "stopped"},
"ips": "10.10.10.3",
"hostname": "off-01",
"ram": 7.5,
"cpu": 8,
"disk": 150,
},
]
},
}
with patch.object(CloudConeAdapter, "_get", return_value=payload):
vps = adapter.list_vps()
assert [v.external_id for v in vps] == ["236441", "2"]
assert vps[0].name == "web-01"
assert vps[0].ip_address == "10.10.10.1" # 多 IP 取第一个
assert vps[0].memory_gb == 3 # ram 已经是 GB,不能再 /1024
assert vps[0].disk_gb == 30
assert vps[0].cpu_cores == 1
assert vps[0].status == "active"
assert vps[1].status == "stopped"
assert vps[0].charge_type == "prepaid"
def test_cloudcone_non_success_status_raises():
"""status=0 时响应体是 HTTP 200,只能在 _request 里翻译成异常"""
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
resp = httpx.Response(
200,
request=httpx.Request("GET", "https://api.cloudcone.com/api/v2/compute/instances"),
json={"status": 0, "message": "Invalid hash"},
)
with patch("httpx.Client.request", return_value=resp):
with pytest.raises(RuntimeError) as ei:
adapter.list_vps()
assert "Invalid hash" in str(ei.value)
def test_cloudcone_power_actions_hit_documented_paths():
adapter = CloudConeAdapter({"api_key": "k", "api_hash": "h"})
with patch.object(CloudConeAdapter, "_post", return_value={"status": 1}) as m:
assert adapter.start_instance("42")["ok"] is True
assert m.call_args.args[0] == "/compute/42/boot"
assert adapter.stop_instance("42")["ok"] is True
assert m.call_args.args[0] == "/compute/42/shutdown"
assert adapter.reboot_instance("42")["ok"] is True
assert m.call_args.args[0] == "/compute/42/reboot"
def test_cloudcone_has_no_balance_or_renew():
caps = CloudConeAdapter({"api_key": "k", "api_hash": "h"}).capabilities()
assert caps["list_vps"] is True
assert caps["get_account"] is False # 没有公开账单接口
assert caps["renew_instance"] is False # 拿不到到期日就不提供续费
assert caps["start_instance"] is True
# ----------------------------- Zeabur ----------------------------- #
def test_zeabur_list_vps_normalization():
adapter = ZeaburAdapter({"api_token": "t"})
data = {
"servers": [
{
"_id": "s1",
"name": "hk-01",
"ip": "1.2.3.4",
"country": "HK",
"city": "Hong Kong",
"providerInfo": {"name": "Tencent"},
"hasK3s": False,
"status": {"isOnline": True, "totalCPU": 4000, "totalMemory": 8192, "totalDisk": 102400},
},
{
"_id": "s2",
"name": "legacy",
"ip": "5.6.7.8",
"hasK3s": None,
"status": {"isOnline": False, "totalCPU": 0, "totalMemory": 0, "totalDisk": 0},
},
]
}
with patch.object(ZeaburAdapter, "_graphql", return_value=data):
vps = adapter.list_vps()
assert [v.external_id for v in vps] == ["s1", "s2"]
assert vps[0].region == "Hong Kong, HK"
assert vps[0].cpu_cores == 4 # 毫核 -> 核
assert vps[0].memory_gb == 8.0 # MB -> GB
assert vps[0].disk_gb == 100
assert vps[0].status == "active"
assert vps[0].os == "Ubuntu"
assert vps[1].status == "stopped"
assert vps[1].os == "ZeaburOS" # hasK3s=None 是旧机器,不能标成纯净 Ubuntu
assert vps[1].cpu_cores is None # 未采集到规格时为 None,不能伪造 1 核
def test_zeabur_graphql_error_is_readable():
adapter = ZeaburAdapter({"api_token": "t"})
with patch.object(ZeaburAdapter, "_graphql", side_effect=RuntimeError("Zeabur GraphQL 错误:unauthorized")):
res = adapter.test_connection()
assert res["ok"] is False
assert "unauthorized" in res["message"]
def test_zeabur_reboot_mutation():
adapter = ZeaburAdapter({"api_token": "t"})
with patch.object(ZeaburAdapter, "_graphql", return_value={"rebootServer": True}) as m:
res = adapter.reboot_instance("s1")
assert res["ok"] is True
assert "rebootServer" in m.call_args.args[0]
assert m.call_args.args[1] == {"id": "s1"}
def test_zeabur_capabilities():
caps = ZeaburAdapter({"api_token": "t"}).capabilities()
assert caps["list_vps"] is True
assert caps["reboot_instance"] is True
assert caps["start_instance"] is False
assert caps["get_account"] is False
# ----------------------------- Dynadot ----------------------------- #
def test_dynadot_epoch_millis_to_date():
assert _epoch_ms_to_date("1798761600000") == date(2027, 1, 1)
assert _epoch_ms_to_date("1798761600") == date(2027, 1, 1)
assert _epoch_ms_to_date("2027-01-01") == date(2027, 1, 1)
assert _epoch_ms_to_date(None) is None
assert _epoch_ms_to_date("garbage") is None
def test_dynadot_list_domains_nested_shape():
adapter = DynadotAdapter({"api_key": "k"})
root = {
"MainDomains": [
{
"Domain": {
"Name": "example.com",
"Expiration": "1798761600000",
"Status": "active",
}
}
]
}
with patch.object(DynadotAdapter, "_call", return_value=root):
domains = adapter.list_domains()
assert len(domains) == 1
assert domains[0].domain_name == "example.com"
assert isinstance(domains[0].expiry_date, date) # 写库前必须是 date
assert domains[0].expiry_date == date(2027, 1, 1)
assert domains[0].registrar == "dynadot"
def test_dynadot_list_domains_flat_shape():
"""JSON 版本可能把字段摊平到 MainDomains 元素上"""
adapter = DynadotAdapter({"api_key": "k"})
root = {"MainDomains": [{"Name": "flat.com", "Expiration": "1798761600000", "Status": "active"}]}
with patch.object(DynadotAdapter, "_call", return_value=root):
assert adapter.list_domains()[0].domain_name == "flat.com"
def test_dynadot_balance_prefers_usd():
adapter = DynadotAdapter({"api_key": "k"})
root = {"BalanceList": [{"Currency": "CNY", "Amount": "100.00"}, {"Currency": "USD", "Amount": "300.00"}]}
with patch.object(DynadotAdapter, "_call", return_value=root):
acc = adapter.get_account()
assert acc.balance == 300.0
assert acc.currency == "USD"
def test_dynadot_error_code_raises():
adapter = DynadotAdapter({"api_key": "bad"})
resp = httpx.Response(
200,
request=httpx.Request("GET", "https://api.dynadot.com/api3.json"),
json={"ListDomainInfoResponse": {"ResponseCode": -1, "Status": "error", "Error": "invalid key"}},
)
client = httpx.Client()
with patch("httpx.Client.get", return_value=resp):
with pytest.raises(RuntimeError) as ei:
adapter._call("list_domain")
assert "invalid key" in str(ei.value)
client.close()
def test_dynadot_capabilities_domain_only():
caps = DynadotAdapter({"api_key": "k"}).capabilities()
assert caps["list_domains"] is True
assert caps["get_account"] is True
assert caps["list_vps"] is False
assert caps["list_dns_records"] is False # set_dns2 位置参数无法安全做单条增删改
# ----------------------------- Agnes ----------------------------- #
def test_agnes_test_connection_lists_models():
adapter = AgnesAdapter({"api_key": "k"})
with patch.object(AgnesAdapter, "_get", return_value={"data": [{"id": "agnes-3.0-flash"}]}):
res = adapter.test_connection()
assert res["ok"] is True
assert "1" in res["message"]
def test_agnes_has_no_balance_api():
caps = AgnesAdapter({"api_key": "k"}).capabilities()
assert caps["get_account"] is False
assert caps["list_vps"] is False
def test_agnes_base_url_override():
adapter = AgnesAdapter({"api_key": "k", "base_url": "https://api.agnes-ai.com/v1/"})
assert adapter._base_url() == "https://api.agnes-ai.com/v1"
# ----------------------------- 新网 ----------------------------- #
def test_xinnet_checksum_order():
"""checksum=MD5("GetProductKey"+client+password+dn+enc),顺序错一位就是 auth-failure"""
adapter = XinnetAdapter({"client": "agent1", "password": "secret", "domains": "example.com"})
expected = hashlib.md5("GetProductKeyagent1secretexample.comE".encode()).hexdigest()
assert adapter._sign("GetProductKey", ["example.com", "E"]) == expected
def test_xinnet_domain_list_parsing_dedupes():
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com, b.com;a.com\nc.com"})
assert adapter._domains() == ["a.com", "b.com", "c.com"]
def test_xinnet_list_domains_uses_expire_date():
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"})
with patch.object(XinnetAdapter, "_post", return_value={"name": "a.com", "key": "2028-03-01"}) as m:
domains = adapter.list_domains()
assert domains[0].expiry_date == date(2028, 3, 1)
assert domains[0].registrar == "xinnet"
params = m.call_args.args[1]
assert params["name"] == "a.com"
assert params["keyname"] == "ExpireDate"
def test_xinnet_error_ret_raises_with_reason():
"""新网失败时 HTTP 仍是 200,只靠响应体里的 ret/err 区分"""
adapter = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"})
resp = httpx.Response(
200,
request=httpx.Request("POST", "https://api.xinnet.com/domain/api.gb"),
text="ret=0&err=auth-failure",
)
with patch("httpx.Client.post", return_value=resp):
with pytest.raises(RuntimeError) as ei:
adapter.list_domains()
assert "auth-failure" in str(ei.value)
def test_xinnet_capabilities_domain_only():
caps = XinnetAdapter({"client": "c", "password": "p", "domains": "a.com"}).capabilities()
assert caps["list_domains"] is True
assert caps["list_vps"] is False
assert caps["list_dns_records"] is False
# ----------------------------- 注册表 ----------------------------- #
def test_new_platform_sdk_types_are_registered():
for sdk_type in (
"cloudcone-api",
"zeabur-api",
"dynadot-api",
"agnes-api",
"xinnet-api",
):
assert registry.is_supported(sdk_type), f"{sdk_type} 未注册"
def test_adapter_meta_exposes_plain_config():
meta = {m["sdk_types"][0]: m for m in registry.adapter_meta()}
assert meta["xinnet-api"]["required_config"] == ["client", "password", "domains"]
assert set(meta["xinnet-api"]["plain_config"]) == {"client", "domains"}