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
This commit is contained in:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+716
View File
@@ -0,0 +1,716 @@
"""新增适配器测试:AWS(SigV4)/ Linode / Namesilo
重点覆盖三类真实会炸的问题:
1. AWS SigV4 的派生签名密钥必须与官方测试向量逐字节一致——手写签名没有 SDK 兜底,
错一步就是满屏 SignatureDoesNotMatch,且无法从错误信息反推是哪一步错了。
2. credential scope / canonical host 必须随 service 变化(ec2 / route53 / route53domains
共用一套签名代码),写死成 ec2 会让 Route53 全线鉴权失败。
3. EC2 查询协议转 JSON 后的驼峰包装:单元素时 AWS 返回 dict 而非 list,
不做归一会直接 TypeError。
运行:.venv/bin/pytest tests/test_new_adapters.py -v
"""
from datetime import date
from unittest.mock import MagicMock, patch
import httpx
import pytest
from app.adapters.aws import AWSAdapter, _signing_key
from app.adapters.linode import LinodeAdapter
from app.adapters.namesilo import NamesiloAdapter
# ----------------------------- AWS SigV4 ----------------------------- #
def test_aws_signing_key_matches_official_vector():
"""AWS 官方文档测试向量(Signature Version 4 派生签名密钥示例)
这是手写 SigV4 唯一可靠的正确性锚点:向量对得上,说明
AWS4 前缀、四级 HMAC 链、aws4_request 终止符全部正确。
"""
key = _signing_key(
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
"20120215",
"us-east-1",
"iam",
)
assert key.hex() == "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
def test_aws_signing_key_sensitive_to_every_input():
"""任一分量变化都必须产出不同密钥(漏拼 region/service 是常见错法)"""
base = _signing_key("secret", "20240101", "us-east-1", "ec2")
assert base != _signing_key("secret2", "20240101", "us-east-1", "ec2")
assert base != _signing_key("secret", "20240102", "us-east-1", "ec2")
assert base != _signing_key("secret", "20240101", "eu-west-1", "ec2")
assert base != _signing_key("secret", "20240101", "us-east-1", "route53")
def _capture_signed_headers(adapter: AWSAdapter, **kwargs) -> dict:
"""拦掉 httpx,捕获 _signed_request 实际发出的请求头与 body"""
captured = {}
def fake_request(method, url, content=None, headers=None):
captured.update(method=method, url=url, content=content, headers=headers)
resp = MagicMock()
resp.content = b"{}"
resp.json.return_value = {}
resp.raise_for_status.return_value = None
return resp
client = MagicMock()
client.request.side_effect = fake_request
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
adapter._signed_request(**kwargs)
return captured
def test_aws_authorization_header_format():
adapter = AWSAdapter({"access_key_id": "AKIDEXAMPLE", "secret_access_key": "SECRET"})
cap = _capture_signed_headers(
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
region="us-east-1", method="POST", path="/", body="Action=DescribeRegions",
)
auth = cap["headers"]["Authorization"]
assert auth.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/")
assert "/us-east-1/ec2/aws4_request" in auth
assert "SignedHeaders=content-type;host;x-amz-date" in auth
signature = auth.split("Signature=")[1]
assert len(signature) == 64
int(signature, 16)
def test_aws_credential_scope_follows_service():
"""ec2 / route53 / route53domains 共用签名代码,scope 必须随 service 变
写死成 ec2 的话 Route53 全部返回 SignatureDoesNotMatch。
"""
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
ec2 = _capture_signed_headers(
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
region="us-east-1", method="POST", path="/", body="",
)["headers"]["Authorization"]
r53 = _capture_signed_headers(
adapter, service="route53", host="route53.amazonaws.com",
region="us-east-1", method="GET", path="/2013-04-01/hostedzone/Z1/rrset", body="",
)["headers"]["Authorization"]
assert "/us-east-1/ec2/aws4_request" in ec2
assert "/us-east-1/route53/aws4_request" in r53
assert ec2 != r53
def test_aws_route53_uses_rest_json_content_type():
"""Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target),
用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken"""
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}) as m:
adapter.list_dns_records("Z123")
assert m.call_args.kwargs["content_type"] == "application/json"
assert m.call_args.kwargs["service"] == "route53"
def test_aws_host_header_excluded_from_send():
"""host 参与签名但不能手工发送,否则与 httpx 自动设置的值冲突"""
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
cap = _capture_signed_headers(
adapter, service="ec2", host="ec2.us-east-1.amazonaws.com",
region="us-east-1", method="POST", path="/", body="",
)
assert "host" not in cap["headers"]
# 但签名串里含 host(SignedHeaders 列出了它)
assert "host" in cap["headers"]["Authorization"]
def test_aws_signature_sensitive_to_payload():
adapter = AWSAdapter({"access_key_id": "AKID", "secret_access_key": "SECRET"})
a = _capture_signed_headers(
adapter, service="ec2", host="h", region="us-east-1",
method="POST", path="/", body="Action=A",
)["headers"]["Authorization"]
b = _capture_signed_headers(
adapter, service="ec2", host="h", region="us-east-1",
method="POST", path="/", body="Action=B",
)["headers"]["Authorization"]
assert a != b
def test_aws_region_defaults_and_override():
assert AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})._region() == "us-east-1"
assert AWSAdapter({"region": "ap-southeast-1"})._region() == "ap-southeast-1"
# ----------------------------- AWS EC2 响应解析 ----------------------------- #
def _ec2_response(instances, token=None):
return {
"reservationSet": {"item": [{"instancesSet": {"item": instances}}]},
**({"nextToken": token} if token else {}),
}
def test_aws_list_vps_unwraps_camelcase():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = _ec2_response([
{
"instanceId": "i-123",
"instanceState": {"name": "running"},
"ipAddress": "1.2.3.4",
"privateIpAddress": "10.0.0.5",
"placement": {"availabilityZone": "us-east-1a"},
"instanceLifecycle": "spot",
"tagSet": {"item": [{"key": "Name", "value": "web-01"}]},
}
])
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
vps = adapter.list_vps()
assert len(vps) == 1
v = vps[0]
assert v.external_id == "i-123"
assert v.name == "web-01" # 取 tagSet 的 Name,不是 instanceId
assert v.ip_address == "1.2.3.4" # 公网优先
assert v.region == "us-east-1a"
assert v.status == "active"
assert v.charge_type == "spot"
assert v.currency == "USD"
def test_aws_list_vps_single_reservation_is_dict():
"""EC2 查询协议转 JSON 后,单元素返回 dict 而不是 list——不归一会 TypeError"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = {
"reservationSet": {
"item": { # 注意:单个 reservation 时是 dict
"instancesSet": {
"item": { # 单个 instance 时也是 dict
"instanceId": "i-only",
"instanceState": {"name": "stopped"},
}
}
}
}
}
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
vps = adapter.list_vps()
assert len(vps) == 1
assert vps[0].external_id == "i-only"
assert vps[0].status == "stopped"
# 无 tagSet 时回退到 instanceId
assert vps[0].name == "i-only"
# 无公网 IP 时回退到私网
assert vps[0].ip_address is None
def test_aws_list_vps_filters_terminated():
"""terminated/shutting-down 不入台账,否则台账会越堆越多已销毁实例"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = _ec2_response([
{"instanceId": "i-live", "instanceState": {"name": "running"}},
{"instanceId": "i-dead", "instanceState": {"name": "terminated"}},
{"instanceId": "i-dying", "instanceState": {"name": "shutting-down"}},
{"instanceId": "i-pending", "instanceState": {"name": "pending"}},
])
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
vps = adapter.list_vps()
ids = {v.external_id for v in vps}
assert ids == {"i-live", "i-pending"}
def test_aws_list_vps_pagination():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
pages = [
_ec2_response([{"instanceId": "i-1", "instanceState": {"name": "running"}}], token="tok2"),
_ec2_response([{"instanceId": "i-2", "instanceState": {"name": "running"}}]),
]
with patch.object(AWSAdapter, "_ec2_call", side_effect=pages) as m:
vps = adapter.list_vps()
assert [v.external_id for v in vps] == ["i-1", "i-2"]
assert m.call_count == 2
# 第二页必须带上 NextToken
assert m.call_args_list[1].args[0]["NextToken"] == "tok2"
def test_aws_instance_action_sends_instance_id_list():
"""EC2 的启停接口参数是 InstanceId.1(列表编码),不是 InstanceId"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
with patch.object(AWSAdapter, "_ec2_call", return_value={}) as m:
assert adapter.start_instance("i-9")["ok"] is True
params = m.call_args.args[0]
assert params["Action"] == "StartInstances"
assert params["InstanceId.1"] == "i-9"
def test_aws_action_failure_returns_not_raises():
"""动作失败要回 ok=False + 原因,不能抛异常(上层按结果渲染提示)"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
with patch.object(AWSAdapter, "_ec2_call", side_effect=RuntimeError("boom")):
res = adapter.reboot_instance("i-1")
assert res["ok"] is False
assert "boom" in res["message"]
def test_aws_has_no_account_balance():
"""刻意不实现 get_account:AWS 没有「账户余额」概念,硬凑数字比报不支持更误导"""
caps = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"}).capabilities()
assert caps["get_account"] is False
assert caps["list_vps"] is True
assert caps["renew_instance"] is False # 按需计费无续费概念
# ----------------------------- AWS Route53 ----------------------------- #
def test_aws_hosted_zone_id_strips_prefix():
assert AWSAdapter._hosted_zone_id("/hostedzone/Z123ABC") == "Z123ABC"
assert AWSAdapter._hosted_zone_id("Z123ABC") == "Z123ABC"
def test_aws_epoch_to_date():
# 2027-01-01T00:00:00Z == 1798761600
assert AWSAdapter._epoch_to_date(1798761600) == date(2027, 1, 1)
assert AWSAdapter._epoch_to_date("1798761600") == date(2027, 1, 1)
assert AWSAdapter._epoch_to_date(None) is None
assert AWSAdapter._epoch_to_date("garbage") is None
def test_aws_list_domains_from_route53domains():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = {"Domains": [{"DomainName": "example.com", "Expiry": 1798761600}]}
with patch.object(AWSAdapter, "_r53domains_call", return_value=payload):
domains = adapter.list_domains()
assert len(domains) == 1
d = domains[0]
assert d.domain_name == "example.com"
assert isinstance(d.expiry_date, date) # 必须是 date,否则写库抛 TypeError 整批回滚
assert d.expiry_date == date(2027, 1, 1)
assert d.registrar == "aws-route53"
def test_aws_list_dns_records_normalization():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = {
"ResourceRecordSets": [
{
"Name": "www.example.com.", # Route53 总是带尾点
"Type": "A",
"TTL": 300,
"ResourceRecords": [{"Value": "1.2.3.4"}, {"Value": "5.6.7.8"}],
}
]
}
with patch.object(AWSAdapter, "_signed_request", return_value=payload):
records = adapter.list_dns_records("Z1")
assert len(records) == 1
r = records[0]
assert r.name == "www.example.com" # 尾点被剥掉
assert r.record_id == "www.example.com.|A" # record_id 用原始带尾点的 Name
assert r.content == "1.2.3.4, 5.6.7.8" # 多值合并
assert r.ttl == 300
def test_aws_dns_write_wraps_values_in_array():
"""Route53 的 ResourceRecords 必须是数组,单值也要包一层"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
with patch.object(AWSAdapter, "_signed_request", return_value={}) as m:
res = adapter.create_dns_record("Z1", {"name": "a.example.com", "type": "A", "content": "1.2.3.4", "ttl": 600})
assert res["ok"] is True
import json as _json
body = _json.loads(m.call_args.kwargs["body"])
rrs = body["Changes"][0]["ResourceRecordSet"]
assert rrs["ResourceRecords"] == [{"Value": "1.2.3.4"}]
assert rrs["TTL"] == 600
assert body["Changes"][0]["Action"] == "CREATE"
def test_aws_delete_requires_name_type_record_id():
"""Route53 删除必须提交完整记录集,record_id 缺 TYPE 无法构造"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
res = adapter.delete_dns_record("Z1", "no-pipe-here")
assert res["ok"] is False
def test_aws_delete_reads_existing_record_first():
"""DELETE 需要带上现存的值与 TTL,必须先读回记录"""
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = {
"ResourceRecordSets": [
{"Name": "a.example.com.", "Type": "A", "TTL": 300, "ResourceRecords": [{"Value": "1.2.3.4"}]}
]
}
with patch.object(AWSAdapter, "_signed_request", side_effect=[payload, {}]) as m:
res = adapter.delete_dns_record("Z1", "a.example.com.|A")
assert res["ok"] is True
import json as _json
body = _json.loads(m.call_args_list[1].kwargs["body"])
assert body["Changes"][0]["Action"] == "DELETE"
assert body["Changes"][0]["ResourceRecordSet"]["ResourceRecords"] == [{"Value": "1.2.3.4"}]
def test_aws_delete_missing_record():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
with patch.object(AWSAdapter, "_signed_request", return_value={"ResourceRecordSets": []}):
res = adapter.delete_dns_record("Z1", "gone.example.com.|A")
assert res["ok"] is False
assert "不存在" in res["message"]
def test_aws_test_connection_reports_region_count():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
payload = {"regionInfo": {"item": [{"regionName": "us-east-1"}, {"regionName": "eu-west-1"}]}}
with patch.object(AWSAdapter, "_ec2_call", return_value=payload):
res = adapter.test_connection()
assert res["ok"] is True
assert "2" in res["message"]
def test_aws_test_connection_failure_is_graceful():
adapter = AWSAdapter({"access_key_id": "a", "secret_access_key": "b"})
resp = httpx.Response(403, request=httpx.Request("POST", "https://ec2.us-east-1.amazonaws.com/"))
with patch.object(AWSAdapter, "_ec2_call", side_effect=httpx.HTTPStatusError("denied", request=resp.request, response=resp)):
res = adapter.test_connection()
assert res["ok"] is False
assert "403" in res["message"]
# ----------------------------- Linode ----------------------------- #
def test_linode_list_vps_normalization():
adapter = LinodeAdapter({"api_key": "x"})
payload = {
"pages": 1,
"data": [
{
"id": 12345,
"label": "prod-web",
"status": "running",
"region": "us-east",
"image": "linode/ubuntu22.04",
"ipv4": ["1.2.3.4", "10.0.0.1"],
"specs": {"memory": 4096, "vcpus": 2, "disk": 81920},
}
],
}
with patch.object(LinodeAdapter, "_get", return_value=payload):
vps = adapter.list_vps()
assert len(vps) == 1
v = vps[0]
assert v.external_id == "12345"
assert v.ip_address == "1.2.3.4" # 取第一个 IPv4
assert v.memory_gb == 4.0 # MB -> GB
assert v.cpu_cores == 2
assert v.disk_gb == 81920
assert v.status == "active"
assert v.charge_type == "postpaid" # 按小时计费,不会有到期日
assert v.expiry_date is None
def test_linode_status_mapping():
adapter = LinodeAdapter({"api_key": "x"})
for raw, expected in (("offline", "stopped"), ("shutting_down", "stopped"), ("booting", "active")):
payload = {"pages": 1, "data": [{"id": 1, "label": "n", "status": raw, "specs": {}}]}
with patch.object(LinodeAdapter, "_get", return_value=payload):
assert adapter.list_vps()[0].status == expected
def test_linode_negative_balance_is_credit():
"""Linode 的 balance 为负表示账户有预付余额,正数才是欠款——不要取绝对值"""
adapter = LinodeAdapter({"api_key": "x"})
with patch.object(LinodeAdapter, "_get", return_value={"balance": -25.5, "email": "me@x.com"}):
acc = adapter.get_account()
assert acc.balance == -25.5
assert acc.currency == "USD"
def test_linode_pagination_uses_pages():
adapter = LinodeAdapter({"api_key": "x"})
pages = [
{"pages": 2, "data": [{"id": 1, "label": "a", "status": "running", "specs": {}}]},
{"pages": 2, "data": [{"id": 2, "label": "b", "status": "running", "specs": {}}]},
]
with patch.object(LinodeAdapter, "_get", side_effect=pages) as m:
vps = adapter.list_vps()
assert [v.external_id for v in vps] == ["1", "2"]
assert "page=2" in m.call_args_list[1].args[0]
def test_linode_domains_expiry_nullable():
"""仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null,不能炸"""
adapter = LinodeAdapter({"api_key": "x"})
payload = {
"pages": 1,
"data": [
{"id": 1, "domain": "reg.com", "status": "active", "expiry": "2027-05-06T04:00:00"},
{"id": 2, "domain": "dns-only.com", "status": "active", "expiry": None},
],
}
with patch.object(LinodeAdapter, "_get", return_value=payload):
domains = adapter.list_domains()
assert domains[0].expiry_date == date(2027, 5, 6)
assert domains[1].expiry_date is None
def test_linode_error_message_reads_body_reason():
"""Linode 把原因放在 body.errors[0].reason,裸状态码对用户没用"""
adapter = LinodeAdapter({"api_key": "x"})
resp = httpx.Response(
403,
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
json={"errors": [{"reason": "Invalid token"}]},
)
err = httpx.HTTPStatusError("denied", request=resp.request, response=resp)
assert "Invalid token" in adapter._error_message(err)
def test_linode_error_message_survives_unparseable_body():
adapter = LinodeAdapter({"api_key": "x"})
resp = httpx.Response(
502,
request=httpx.Request("GET", "https://api.linode.com/v4/account"),
content=b"<html>bad gateway</html>",
)
err = httpx.HTTPStatusError("boom", request=resp.request, response=resp)
assert adapter._error_message(err) == "HTTP 502"
def test_linode_capabilities_no_renew():
"""按小时计费无「续费」概念,不能声明 renew_instance"""
caps = LinodeAdapter({"api_key": "x"}).capabilities()
assert caps["renew_instance"] is False
assert caps["start_instance"] is True
assert caps["list_dns_records"] is True
assert caps["get_account"] is True
def test_linode_instance_actions_hit_right_verbs():
adapter = LinodeAdapter({"api_key": "x"})
for fn, verb in (("start_instance", "boot"), ("stop_instance", "shutdown"), ("reboot_instance", "reboot")):
with patch.object(LinodeAdapter, "_request", return_value={}) as m:
assert getattr(adapter, fn)("999")["ok"] is True
assert m.call_args.args[1] == f"/linode/instances/999/{verb}"
def test_linode_record_body_mapping():
"""Linode 用 target/ttl_sec,不是 content/ttl"""
body = LinodeAdapter._record_body({"type": "MX", "name": "mail", "content": "mx.example.com", "ttl": 600, "priority": 10})
assert body == {"type": "MX", "name": "mail", "target": "mx.example.com", "ttl_sec": 600, "priority": 10}
# priority 为 None 时不应出现在 body 里(Linode 会校验类型)
assert "priority" not in LinodeAdapter._record_body({"type": "A", "name": "a", "content": "1.2.3.4"})
def test_linode_dns_crud_paths():
adapter = LinodeAdapter({"api_key": "x"})
with patch.object(LinodeAdapter, "_request", return_value={"id": 555}) as m:
assert adapter.create_dns_record("7", {"type": "A", "name": "a", "content": "1.2.3.4"})["record_id"] == "555"
assert m.call_args.args[0] == "POST"
assert m.call_args.args[1] == "/domains/7/records"
assert adapter.update_dns_record("7", "555", {"type": "A", "name": "a", "content": "1.2.3.5"})["ok"] is True
assert m.call_args.args[0] == "PUT"
assert m.call_args.args[1] == "/domains/7/records/555"
assert adapter.delete_dns_record("7", "555")["ok"] is True
assert m.call_args.args[0] == "DELETE"
def test_linode_list_dns_records_maps_target():
adapter = LinodeAdapter({"api_key": "x"})
payload = {"pages": 1, "data": [{"id": 1, "type": "A", "name": "www", "target": "1.2.3.4", "ttl_sec": 300}]}
with patch.object(LinodeAdapter, "_get", return_value=payload):
records = adapter.list_dns_records("7")
assert records[0].content == "1.2.3.4"
assert records[0].ttl == 300
assert records[0].zone_id == "7"
def test_linode_empty_body_handled():
"""POST 动作类接口返回 200 + 空 body,直接 .json() 会炸"""
adapter = LinodeAdapter({"api_key": "x"})
resp = MagicMock()
resp.content = b""
resp.raise_for_status.return_value = None
client = MagicMock()
client.request.return_value = resp
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
assert adapter.start_instance("1")["ok"] is True
# ----------------------------- Namesilo ----------------------------- #
def _namesilo_reply(reply: dict) -> dict:
return {"request": {"command": "x"}, "reply": reply}
def test_namesilo_success_code_300():
adapter = NamesiloAdapter({"api_key": "k"})
payload = _namesilo_reply({"code": 300, "detail": "success", "domains": []})
resp = MagicMock()
resp.json.return_value = payload
resp.raise_for_status.return_value = None
client = MagicMock()
client.get.return_value = resp
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
assert adapter._call("listDomains")["code"] == 300
def test_namesilo_string_code_accepted():
"""Namesilo 的 code 可能是 str,直接 != 300 会把成功误判成失败"""
adapter = NamesiloAdapter({"api_key": "k"})
resp = MagicMock()
resp.json.return_value = _namesilo_reply({"code": "300", "detail": "success"})
resp.raise_for_status.return_value = None
client = MagicMock()
client.get.return_value = resp
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
assert adapter._call("checkApiKey")["code"] == "300"
def test_namesilo_failure_raises_with_detail():
"""301 未授权 / 400 参数错必须抛出并带上 detail,否则用户只看到一个空失败"""
adapter = NamesiloAdapter({"api_key": "k"})
resp = MagicMock()
resp.json.return_value = _namesilo_reply({"code": 301, "detail": "Invalid or missing API key"})
resp.raise_for_status.return_value = None
client = MagicMock()
client.get.return_value = resp
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
with pytest.raises(RuntimeError) as ei:
adapter._call("listDomains")
assert "301" in str(ei.value)
assert "Invalid or missing API key" in str(ei.value)
def test_namesilo_api_key_sent_as_query_param():
adapter = NamesiloAdapter({"api_key": "SECRETKEY"})
resp = MagicMock()
resp.json.return_value = _namesilo_reply({"code": 300, "username": "me"})
resp.raise_for_status.return_value = None
client = MagicMock()
client.get.return_value = resp
cm = MagicMock()
cm.__enter__.return_value = client
with patch("httpx.Client", return_value=cm):
adapter.test_connection()
query = client.get.call_args.kwargs["params"]
assert query["key"] == "SECRETKEY"
assert query["version"] == "1"
assert query["type"] == "json"
def test_namesilo_test_connection_reports_username():
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300, "username": "neatstudio"}):
res = adapter.test_connection()
assert res["ok"] is True
assert "neatstudio" in res["message"]
def test_namesilo_list_domains_expiry():
adapter = NamesiloAdapter({"api_key": "k"})
reply = {"code": 300, "domains": [{"domain": "example.com", "expires": "2027-01-01"}]}
with patch.object(NamesiloAdapter, "_call", return_value=reply):
domains = adapter.list_domains()
assert len(domains) == 1
assert isinstance(domains[0].expiry_date, date)
assert domains[0].expiry_date == date(2027, 1, 1)
assert domains[0].registrar == "namesilo"
def test_namesilo_unparseable_expiry_is_none():
adapter = NamesiloAdapter({"api_key": "k"})
reply = {"code": 300, "domains": [{"domain": "x.com", "expires": "not-a-date"}]}
with patch.object(NamesiloAdapter, "_call", return_value=reply):
assert adapter.list_domains()[0].expiry_date is None
def test_namesilo_fqdn_host_roundtrip():
"""Namesilo 存子域(空/@ 表示裸域),台账展示需要完整域名"""
assert NamesiloAdapter._fqdn("", "example.com") == "example.com"
assert NamesiloAdapter._fqdn("@", "example.com") == "example.com"
assert NamesiloAdapter._fqdn("www", "example.com") == "www.example.com"
assert NamesiloAdapter._host("example.com", "example.com") == ""
assert NamesiloAdapter._host("www.example.com", "example.com") == "www"
assert NamesiloAdapter._host("", "example.com") == ""
def test_namesilo_dns_record_normalization():
adapter = NamesiloAdapter({"api_key": "k"})
reply = {
"code": 300,
"resource_record": [
{"record_id": 111, "host": "www", "type": "A", "value": "1.2.3.4", "ttl": 3600},
{"record_id": 222, "host": "", "type": "MX", "value": "mail.example.com", "ttl": 3600, "distance": 10},
],
}
with patch.object(NamesiloAdapter, "_call", return_value=reply):
records = adapter.list_dns_records("example.com")
assert records[0].name == "www.example.com"
assert records[0].record_id == "111"
assert records[0].ttl == 3600
assert records[1].name == "example.com" # 裸域
assert records[1].priority == 10
def test_namesilo_create_omits_rr_id():
"""dnsRecordUpdate 同时承担新增与修改:新增绝不能带 rr_id,否则改到别的记录上"""
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
res = adapter.create_dns_record("example.com", {"type": "A", "name": "www.example.com", "content": "1.2.3.4", "ttl": 600})
assert res["ok"] is True
assert m.call_args.args[0] == "dnsRecordUpdate"
params = m.call_args.args[1]
assert "rr_id" not in params
assert params["rr_host"] == "www"
assert params["rr_type"] == "A"
assert params["rr_value"] == "1.2.3.4"
assert params["rr_ttl"] == 600
def test_namesilo_update_sends_rr_id():
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
res = adapter.update_dns_record("example.com", "111", {"type": "A", "name": "example.com", "content": "9.9.9.9"})
assert res["ok"] is True
params = m.call_args.args[1]
assert params["rr_id"] == "111"
assert params["rr_host"] == "" # 裸域 -> 空串
assert params["rr_ttl"] == 3600 # 未给 ttl 时的默认值
def test_namesilo_mx_sends_distance():
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
adapter.create_dns_record("example.com", {"type": "MX", "name": "example.com", "content": "mx.x.com", "priority": 10})
assert m.call_args.args[1]["rr_distance"] == 10
def test_namesilo_delete_passes_rr_id():
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", return_value={"code": 300}) as m:
assert adapter.delete_dns_record("example.com", "111")["ok"] is True
assert m.call_args.args[0] == "dnsRecordDelete"
assert m.call_args.args[1] == {"domain": "example.com", "rr_id": "111"}
def test_namesilo_write_failure_returns_not_raises():
adapter = NamesiloAdapter({"api_key": "k"})
with patch.object(NamesiloAdapter, "_call", side_effect=RuntimeError("code=301: bad key")):
assert adapter.create_dns_record("x.com", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
assert adapter.update_dns_record("x.com", "1", {"type": "A", "name": "x.com", "content": "1.1.1.1"})["ok"] is False
assert adapter.delete_dns_record("x.com", "1")["ok"] is False
def test_namesilo_capabilities_domain_only():
"""纯域名注册商:无 VPS、无实例动作、无余额"""
caps = NamesiloAdapter({"api_key": "k"}).capabilities()
assert caps["list_domains"] is True
assert caps["list_dns_records"] is True
assert caps["list_vps"] is False
assert caps["get_account"] is False
assert caps["start_instance"] is False