Files
vps-manager/tests/test_adapters.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

290 lines
11 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.
"""云厂商适配器单元测试
通过 mock 各平台 API 响应,验证适配器的标准化转换逻辑、签名算法正确性
与注册表路由。确保用户填入真实凭证后,解析与签名链路可靠。
运行:.venv/bin/pytest tests/ -v
"""
import base64
from unittest.mock import patch
from app.adapters import registry
from app.adapters.ai import DeepSeekAdapter, KimiAdapter, OpenAIAdapter
from app.adapters.aliyun import AliyunAdapter
from app.adapters.cloudflare import CloudflareAdapter
from app.adapters.digitalocean import DigitalOceanAdapter
from app.adapters.tencent import (
BILLING_HOST,
CVM_HOST,
DNSPOD_HOST,
TencentAdapter,
_service_of,
)
from app.adapters.vultr import VultrAdapter
# ----------------------------- Vultr ----------------------------- #
def test_vultr_list_vps_normalization():
adapter = VultrAdapter({"api_key": "x"})
mock_resp = {
"instances": [
{
"id": "vps-1",
"label": "my-vps",
"main_ip": "1.2.3.4",
"region": "tokyo",
"os": "Debian 12",
"vcpu_count": 2,
"ram": 4096,
"disk": 80,
"status": "active",
"monthly_cost": 10.0,
}
]
}
with patch.object(VultrAdapter, "_get", return_value=mock_resp):
result = adapter.list_vps()
assert len(result) == 1
v = result[0]
assert v.external_id == "vps-1"
assert v.name == "my-vps"
assert v.memory_gb == 4.0 # 4096 MB -> 4 GB
assert v.ip_address == "1.2.3.4"
assert v.cpu_cores == 2
assert v.monthly_cost == 10.0
assert v.status == "active"
def test_vultr_get_account():
adapter = VultrAdapter({"api_key": "x"})
with patch.object(
VultrAdapter, "_get", return_value={"account": {"balance": -50.0, "pending_charges": 12.3}}
):
acc = adapter.get_account()
assert acc.balance == -50.0
assert acc.pending_charges == 12.3
assert acc.currency == "USD"
# --------------------------- DigitalOcean --------------------------- #
def test_do_list_vps_normalization():
adapter = DigitalOceanAdapter({"api_key": "x"})
mock_resp = {
"droplets": [
{
"id": 123,
"name": "droplet-1",
"vcpus": 1,
"memory": 2048,
"disk": 25,
"status": "active",
"region": {"slug": "sgp1"},
"image": {"distribution": "Debian", "name": "12 x64"},
"networks": {"v4": [{"ip_address": "5.6.7.8"}]},
}
]
}
with patch.object(DigitalOceanAdapter, "_get", return_value=mock_resp):
result = adapter.list_vps()
v = result[0]
assert v.external_id == "123"
assert v.memory_gb == 2.0 # 2048 MB -> 2 GB
assert v.ip_address == "5.6.7.8"
assert v.region == "sgp1"
assert "Debian" in v.os
# --------------------------- Cloudflare --------------------------- #
def test_cloudflare_list_domains():
adapter = CloudflareAdapter({"api_token": "x"})
mock_resp = {"result": [{"id": "z1", "name": "example.com", "status": "active"}]}
with patch.object(CloudflareAdapter, "_get", return_value=mock_resp):
domains = adapter.list_domains()
assert len(domains) == 1
assert domains[0].domain_name == "example.com"
assert domains[0].external_id == "z1"
assert domains[0].registrar == "cloudflare"
def test_cloudflare_capabilities_no_vps():
caps = CloudflareAdapter({"api_token": "x"}).capabilities()
assert caps["list_domains"] is True
assert caps["list_vps"] is False # Cloudflare 无传统 VPS
# --------------------------- 阿里云签名 --------------------------- #
def test_aliyun_sign_deterministic_and_valid():
adapter = AliyunAdapter({"access_key_id": "id", "access_key_secret": "secret"})
params = {
"Action": "DescribeRegions",
"RegionId": "cn-hangzhou",
"Timestamp": "2024-01-01T00:00:00Z",
"SignatureNonce": "fixed-nonce",
}
sig1 = adapter._sign(params)
sig2 = adapter._sign(params)
assert sig1 == sig2 # 确定性
base64.b64decode(sig1) # 合法 base64(HMAC-SHA1 -> 28 字符 base64)
assert len(sig1) == 28
def test_aliyun_sign_sensitive_to_secret():
params = {"Action": "DescribeRegions", "Timestamp": "2024-01-01T00:00:00Z", "SignatureNonce": "n"}
a1 = AliyunAdapter({"access_key_id": "id", "access_key_secret": "secret1"})
a2 = AliyunAdapter({"access_key_id": "id", "access_key_secret": "secret2"})
assert a1._sign(params) != a2._sign(params)
# --------------------------- 腾讯云签名 --------------------------- #
def test_tencent_authorization_format():
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
auth = adapter._build_authorization(
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
)
assert auth.startswith("TC3-HMAC-SHA256 Credential=SID/")
assert "2024-01-01/cvm/tc3_request" in auth
assert "SignedHeaders=content-type;host;x-tc-action" in auth
assert "Signature=" in auth
# 签名为 64 位十六进制(HMAC-SHA256)
signature = auth.split("Signature=")[1]
assert len(signature) == 64
int(signature, 16)
def test_tencent_sign_sensitive_to_payload():
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
a1 = adapter._build_authorization(
"DescribeInstances", '{"Limit":1}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
)
a2 = adapter._build_authorization(
"DescribeInstances", '{"Limit":2}', 1704067200, "2024-01-01", CVM_HOST, "cvm"
)
assert a1 != a2
def test_tencent_credential_scope_follows_service():
"""多产品复用同一套签名:credential scope 与 host 中的 canonical host 必须随 service 变化
若 scope 写死成 cvm,调用 billing/domain/dnspod 会全部返回 AuthFailure.SignatureFailure。
"""
adapter = TencentAdapter({"secret_id": "SID", "secret_key": "SKEY"})
cvm = adapter._build_authorization(
"DescribeInstances", "{}", 1704067200, "2024-01-01", CVM_HOST, "cvm"
)
billing = adapter._build_authorization(
"DescribeAccountBalance", "{}", 1704067200, "2024-01-01", BILLING_HOST, "billing"
)
assert "2024-01-01/cvm/tc3_request" in cvm
assert "2024-01-01/billing/tc3_request" in billing
assert cvm != billing
def test_tencent_service_of_host():
assert _service_of(CVM_HOST) == "cvm"
assert _service_of(BILLING_HOST) == "billing"
assert _service_of(DNSPOD_HOST) == "dnspod"
# --------------------------- 注册表路由 --------------------------- #
def test_registry_supported_types():
for t in [
"vultr-api",
"do-api",
"cloudflare-api",
"aliyun-sdk",
"alibabacloud-sdk",
"tencent-sdk",
"tencent-intl-sdk",
]:
assert registry.is_supported(t), f"{t} 应被支持"
assert not registry.is_supported("nonexistent-sdk")
def test_registry_get_adapter_routing():
assert isinstance(registry.get_adapter("vultr-api", {}), VultrAdapter)
assert isinstance(registry.get_adapter("do-api", {}), DigitalOceanAdapter)
assert isinstance(registry.get_adapter("cloudflare-api", {}), CloudflareAdapter)
assert isinstance(registry.get_adapter("aliyun-sdk", {}), AliyunAdapter)
assert isinstance(registry.get_adapter("alibabacloud-sdk", {}), AliyunAdapter)
assert isinstance(registry.get_adapter("tencent-sdk", {}), TencentAdapter)
assert isinstance(registry.get_adapter("tencent-intl-sdk", {}), TencentAdapter)
# --------------------------- AI 平台适配器 --------------------------- #
def test_deepseek_balance_normalization():
adapter = DeepSeekAdapter({"api_key": "x"})
mock = {"data": {"total_balance": "88.50", "granted_balance": "10.00"}}
with patch.object(DeepSeekAdapter, "_get", return_value=mock):
acc = adapter.get_account()
assert acc.balance == 88.5 # 字符串 -> float
assert acc.currency == "CNY"
def test_deepseek_test_connection_includes_balance():
adapter = DeepSeekAdapter({"api_key": "x"})
with patch.object(DeepSeekAdapter, "_get", return_value={"data": {"total_balance": "88.50"}}):
result = adapter.test_connection()
assert result["ok"] is True
assert "88.5" in result["message"]
def test_kimi_balance_normalization():
adapter = KimiAdapter({"api_key": "x"})
with patch.object(KimiAdapter, "_get", return_value={"data": {"available_balance": 123.45}}):
acc = adapter.get_account()
assert acc.balance == 123.45
assert acc.currency == "CNY"
def test_openai_test_connection_models():
adapter = OpenAIAdapter({"api_key": "x"})
mock = {"data": [{"id": "gpt-4"}, {"id": "gpt-3.5-turbo"}]}
with patch.object(OpenAIAdapter, "_get", return_value=mock):
result = adapter.test_connection()
assert result["ok"] is True
assert "2" in result["message"]
def test_ai_capabilities_account_only():
caps = DeepSeekAdapter({"api_key": "x"}).capabilities()
assert caps["get_account"] is True
assert caps["list_vps"] is False
assert caps["list_domains"] is False
def test_registry_supports_ai_types():
for t in ["deepseek-api", "moonshot-api", "openai-api", "minimax-api"]:
assert registry.is_supported(t), f"{t} 应被支持"
# --------------------------- 分页处理 --------------------------- #
def test_vultr_pagination_cursor():
adapter = VultrAdapter({"api_key": "x"})
page1 = {"instances": [{"id": "a", "label": "a", "ram": 1024}], "meta": {"links": {"next": "cursor2"}}}
page2 = {"instances": [{"id": "b", "label": "b", "ram": 2048}], "meta": {"links": {"next": ""}}}
with patch.object(VultrAdapter, "_get", side_effect=[page1, page2]):
result = adapter.list_vps()
assert len(result) == 2
assert {v.external_id for v in result} == {"a", "b"}
def test_do_pagination_page():
adapter = DigitalOceanAdapter({"api_key": "x"})
page1 = {"droplets": [{"id": 1, "name": "d1", "memory": 1024, "networks": {"v4": []}}], "links": {"pages": {"next": "x"}}}
page2 = {"droplets": [{"id": 2, "name": "d2", "memory": 1024, "networks": {"v4": []}}], "links": {}}
with patch.object(DigitalOceanAdapter, "_get", side_effect=[page1, page2]):
result = adapter.list_vps()
assert len(result) == 2
assert {v.external_id for v in result} == {"1", "2"}
def test_cloudflare_pagination_result_info():
adapter = CloudflareAdapter({"api_token": "x"})
page1 = {"result": [{"id": "z1", "name": "a.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 1}}
page2 = {"result": [{"id": "z2", "name": "b.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 2}}
with patch.object(CloudflareAdapter, "_registrar_expiry_map", return_value={}), \
patch.object(CloudflareAdapter, "_get", side_effect=[page1, page2]):
result = adapter.list_domains()
assert len(result) == 2
assert {d.domain_name for d in result} == {"a.com", "b.com"}