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 天」。
196 lines
7.9 KiB
Python
196 lines
7.9 KiB
Python
"""域名到期日链路与同步容错测试
|
||
|
||
覆盖三处回归:
|
||
|
||
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
|