Files
vps-manager/tests/test_domain_expiry.py
gouki 61372f7ceb fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。

- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
  适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
  TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
  (Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
  适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
  随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
  SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
  account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
  「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
  (dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
  的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
  stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
  「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
  OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
  拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
  (check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
  前端走 Fmt.countdownText

验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
2026-09-22 22:26:42 +00:00

196 lines
7.9 KiB
Python
Raw Permalink 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.
"""域名到期日链路与同步容错测试
覆盖三处回归:
1. 适配器产出的 expiry_date 必须是 date(NormalizedDomain 契约)。
直接塞 ISO 字符串会在写库时抛 TypeError,而 _sync_domains 的 commit 在循环外,
一个域名解析失败就会让整批域名同步全量回滚。
2. _sync_domains 写库前再归一一次,兼容仍返回字符串的旧/自定义适配器。
3. sync_account 单个能力失败后必须 rollback:否则 Session 停在 pending-rollback,
后面更新 last_synced_at 的 commit 会抛 PendingRollbackError,接口直接 500,
真实错误被掩盖成一条无信息的服务器异常。
运行:.venv/bin/pytest tests/test_domain_expiry.py -v
"""
from datetime import date
from unittest.mock import patch
import pytest
from sqlmodel import Session, SQLModel, create_engine, select
# create_all 需要 metadata 里齐备所有被外键引用的表(accounts.credential_id → credentials、
# assets.provider_id → providers 等),而 app/models/__init__.py 不导入子模块,
# 所以必须显式导入全部模型模块;否则单跑本文件会抛 NoReferencedTableError
# (跑全量套件时因其他测试先导入了模型而意外通过)
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 NormalizedDomain, parse_iso_date
from app.adapters.cloudflare import CloudflareAdapter
from app.adapters.digitalocean import DigitalOceanAdapter
from app.adapters.vultr import VultrAdapter
from app.models.asset import Account, Asset, DomainDetail
from app.models.provider import Provider
from app.services import sync_service
@pytest.fixture()
def engine(tmp_path):
eng = create_engine(
f"sqlite:///{tmp_path / 'expiry.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(eng)
yield eng
eng.dispose()
# ----------------------------- 到期日归一化 ----------------------------- #
def test_parse_iso_date_accepts_provider_shapes():
"""各厂商返回形态不一,统一解析为 date"""
assert parse_iso_date("2027-03-15") == date(2027, 3, 15)
assert parse_iso_date("2027-03-15T00:00:00Z") == date(2027, 3, 15)
assert parse_iso_date("2027-03-15T00:00:00+00:00") == date(2027, 3, 15)
assert parse_iso_date(date(2027, 3, 15)) == date(2027, 3, 15)
def test_parse_iso_date_bad_input_returns_none():
"""脏数据归 None,不能让整批同步炸掉"""
assert parse_iso_date(None) is None
assert parse_iso_date("") is None
assert parse_iso_date(" ") is None
assert parse_iso_date("not-a-date") is None
def test_cloudflare_domain_expiry_is_date():
"""Cloudflare Registrar 返回 ISO8601 时间戳,必须解析成 date 再交给上层"""
adapter = CloudflareAdapter({"api_token": "x"})
accounts = {"result": [{"id": "acc1"}]}
registrar = {"result": [{"id": "example.com", "expires_at": "2027-03-15T00:00:00Z"}]}
zones = {
"result": [{"id": "z1", "name": "example.com", "status": "active"}],
"result_info": {"total_pages": 1, "page": 1},
}
with patch.object(CloudflareAdapter, "_get", side_effect=[accounts, registrar, zones]):
domains = adapter.list_domains()
assert len(domains) == 1
assert isinstance(domains[0].expiry_date, date)
assert domains[0].expiry_date == date(2027, 3, 15)
# to_dict 对外仍是 ISO 字符串(JSON 可序列化,形态与旧契约一致)
assert domains[0].to_dict()["expiry_date"] == "2027-03-15"
def test_vultr_and_do_map_domain_expiry():
"""Vultr / DO 的域名到期日此前完全没映射,导致这两家域名永远不进续费提醒"""
vultr = VultrAdapter({"api_key": "x"})
vultr_resp = {
"domains": [{"domain": "a.com", "expiry_date": "2027-01-02T00:00:00+00:00"}],
"meta": {"links": {}},
}
with patch.object(VultrAdapter, "_get", return_value=vultr_resp):
assert vultr.list_domains()[0].expiry_date == date(2027, 1, 2)
do = DigitalOceanAdapter({"api_key": "x"})
do_resp = {"domains": [{"name": "b.com", "expiry_date": "2027-01-03T00:00:00Z"}], "links": {}}
with patch.object(DigitalOceanAdapter, "_get", return_value=do_resp):
assert do.list_domains()[0].expiry_date == date(2027, 1, 3)
# ----------------------------- 同步写库 ----------------------------- #
class _FakeAdapter:
"""最小适配器:只实现同步链路用到的接口"""
required_config: list = []
def __init__(self, domains):
self.config = {}
self._domains = domains
def capabilities(self):
return {"list_vps": False, "list_domains": True, "get_account": False}
def list_domains(self):
return self._domains
def _seed_provider_account(engine) -> tuple[int, int]:
with Session(engine) as s:
provider = Provider(
slug="cloudflare", name="Cloudflare", category="cloudflare", sdk_type="cloudflare-api"
)
s.add(provider)
s.commit()
s.refresh(provider)
account = Account(name="me@example.com", platform="cloudflare")
s.add(account)
s.commit()
s.refresh(account)
return provider.id, account.id
def test_sync_domains_coerces_string_expiry(engine):
"""适配器若仍返回字符串,写库前归一为 date,不应整批失败"""
provider_id, account_id = _seed_provider_account(engine)
adapter = _FakeAdapter(
[
NormalizedDomain(
external_id="z1",
domain_name="a.com",
registrar="cloudflare",
expiry_date="2027-03-15", # 故意给字符串
)
]
)
with Session(engine) as s:
result = sync_service._sync_domains(s, s.get(Provider, provider_id), adapter, account_id)
assert result == {"created": 1, "updated": 0}
with Session(engine) as s:
asset = s.exec(select(Asset)).first()
assert asset.expiry_date == date(2027, 3, 15)
detail = s.exec(select(DomainDetail)).first()
assert detail.domain_name == "a.com"
def test_sync_domains_updates_existing_expiry(engine):
"""重复同步走更新分支:到期日按新值刷新,不新建资产"""
provider_id, account_id = _seed_provider_account(engine)
def _run(expiry):
adapter = _FakeAdapter(
[NormalizedDomain(external_id="z1", domain_name="a.com", expiry_date=expiry)]
)
with Session(engine) as s:
return sync_service._sync_domains(s, s.get(Provider, provider_id), adapter, account_id)
assert _run("2027-03-15") == {"created": 1, "updated": 0}
assert _run("2028-03-15") == {"created": 0, "updated": 1}
with Session(engine) as s:
assert s.exec(select(Asset)).first().expiry_date == date(2028, 3, 15)
def test_sync_account_survives_failed_capability(engine, monkeypatch):
"""单个能力写库失败后必须回滚,否则 last_synced_at 的 commit 抛 PendingRollbackError"""
provider_id, account_id = _seed_provider_account(engine)
# domain_name=None 违反 Asset.name NOT NULL → flush 失败,Session 进入 pending-rollback
adapter = _FakeAdapter([NormalizedDomain(external_id="z1", domain_name=None)])
monkeypatch.setattr(
sync_service,
"_build_adapter_for_account",
lambda session, account: (session.get(Provider, provider_id), adapter),
)
with Session(engine) as s:
result = sync_service.sync_account(s, account_id) # 修复前此处抛 PendingRollbackError
# 真实错误被如实带回,而不是被 500 掩盖
assert result.get("domains") is None
assert "domains_error" in result
assert result["last_synced_at"]
with Session(engine) as s:
assert s.get(Account, account_id).last_synced_at is not None
# 失败的那批不残留半条数据
assert s.exec(select(Asset)).first() is None