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

338 lines
13 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.
"""凭据中心化迁移测试(accounts.login_password → credentials)
模拟旧库(accounts 无 credential_id 列)→ 跑迁移 → 断言:
密文原样搬入、site 取平台显示名、账号关联回填、原字段置空、重跑幂等、
无密码账号不产生空条目,以及旧 credentials 表补 otp_verified_at 列并回填。
运行:.venv/bin/pytest tests/test_credential_migration.py -v
"""
import json
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from cryptography.fernet import Fernet
from sqlmodel import Session, SQLModel, create_engine, select
import app.database as db
from app.core import crypto
from app.core.config import settings
from app.models.asset import (
AIAccount,
Account,
Asset,
AssetType,
CloudflareDetail,
DomainDetail,
VPSDetail,
)
from app.models.credential import Credential, LoginType
from app.models.provider import Provider
from app.models.ssl import SiteCert, Subdomain
# 旧结构 accounts 表:无 credential_id 列(迁移应自动补列)
OLD_ACCOUNTS_DDL = """
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
platform VARCHAR,
remark VARCHAR,
login_user VARCHAR,
login_password_encrypted VARCHAR,
api_config_encrypted VARCHAR,
last_synced_at DATETIME,
created_at DATETIME NOT NULL
)
"""
@pytest.fixture()
def legacy_engine(tmp_path, monkeypatch):
"""旧结构测试库:新表齐备,但 accounts 缺 credential_id 列"""
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
crypto._get_fernet.cache_clear() # 避免复用其它测试缓存的 Fernet 实例
engine = create_engine(
f"sqlite:///{tmp_path / 'test.db'}", connect_args={"check_same_thread": False}
)
# 除 Account 外按当前模型建表(含新 credentials 表;accounts 用旧 DDL)
SQLModel.metadata.create_all(
engine,
tables=[
Provider.__table__, Asset.__table__, VPSDetail.__table__, DomainDetail.__table__,
AIAccount.__table__, CloudflareDetail.__table__, Subdomain.__table__,
SiteCert.__table__, Credential.__table__,
],
)
with engine.begin() as conn:
conn.execute(sa.text(OLD_ACCOUNTS_DDL))
# 迁移函数引用模块级 assets_engine → 指向测试库
monkeypatch.setattr(db, "assets_engine", engine)
yield engine
crypto._get_fernet.cache_clear()
def _seed(engine, pwd_aliyun: str, pwd_forum: str) -> None:
"""造迁移前现场:平台账号(有密码)/ 无平台账号(有密码)/ 无密码账号"""
with Session(engine) as s:
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
s.commit()
now = datetime.now(timezone.utc).replace(tzinfo=None)
with engine.begin() as conn:
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('me@gmail.com', 'aliyun', 'me@gmail.com', :pwd, :now)"
),
{"pwd": pwd_aliyun, "now": now},
)
# platform 为空串:site 应回退 '未分类';无 login_user:username 回退 name
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
"VALUES ('forum-user', '', NULL, :pwd, :now)"
),
{"pwd": pwd_forum, "now": now},
)
# 无密码账号:不应产生凭据条目
conn.execute(
sa.text(
"INSERT INTO accounts (name, platform, created_at) VALUES ('no-pwd', 'vultr', :now)"
),
{"now": now},
)
def test_migration_moves_passwords_to_vault(legacy_engine):
engine = legacy_engine
pwd_aliyun = crypto.encrypt("p@ss-阿里云")
pwd_forum = crypto.encrypt("forum-pass-中文")
_seed(engine, pwd_aliyun, pwd_forum)
db._migrate_assets_db()
with Session(engine) as s:
creds = s.exec(select(Credential).order_by(Credential.id)).all()
# 仅两条有密码的账号被迁移,无密码账号不产生空条目
assert len(creds) == 2
c1, c2 = creds
# 密文原样搬入(不重新加密,避免中间态明文暴露),仍可解密还原
assert c1.password_encrypted == pwd_aliyun
assert crypto.decrypt(c1.password_encrypted) == "p@ss-阿里云"
# site 取平台显示名(join providers),username 取 login_user
assert c1.site == "阿里云"
assert c1.username == "me@gmail.com"
assert c1.login_type == LoginType.PASSWORD
# 无 provider 匹配 + platform 空 → site 回退 '未分类';username 回退 name
assert c2.password_encrypted == pwd_forum
assert c2.site == "未分类"
assert c2.username == "forum-user"
# 账号侧:credential_id 回填、原密码字段置空(唯一事实源,防双份漂移)
accs = {a.name: a for a in s.exec(select(Account)).all()}
assert accs["me@gmail.com"].credential_id == c1.id
assert accs["me@gmail.com"].login_password_encrypted is None
assert accs["forum-user"].credential_id == c2.id
assert accs["forum-user"].login_password_encrypted is None
assert accs["no-pwd"].credential_id is None
def test_migration_is_idempotent(legacy_engine):
"""重跑迁移不得重复建条目(幂等条件:credential_id 已非空则跳过)"""
engine = legacy_engine
_seed(engine, crypto.encrypt("pw1"), crypto.encrypt("pw2"))
db._migrate_assets_db()
db._migrate_assets_db()
db._migrate_assets_db()
with Session(engine) as s:
assert len(s.exec(select(Credential)).all()) == 2
def test_migration_adds_credential_id_column(legacy_engine):
"""旧库缺 credential_id 列时迁移应自动补列"""
engine = legacy_engine
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
assert "credential_id" not in cols # 前置:确实是旧结构
db._migrate_assets_db()
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "credential_id" in cols
db._migrate_indexes()
with engine.connect() as conn:
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
assert "ix_accounts_credential_id" in indexes
# 旧结构 credentials 表:无 otp_verified_at 列(当时绑定强制校验过动态码)
OLD_CREDENTIALS_DDL = """
CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site VARCHAR NOT NULL,
username VARCHAR,
login_type VARCHAR NOT NULL,
oauth_provider VARCHAR,
password_encrypted VARCHAR,
otp_secret_encrypted VARCHAR,
url VARCHAR,
note VARCHAR,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
"""
def test_migration_backfills_otp_verified(legacy_engine):
"""旧库 credentials 缺列时自动补列,并把已绑定条目回填为「已验证」
这些条目在旧版是校验过当前动态码才落库的,若回填为 NULL 会被前端全线误报。
"""
engine = legacy_engine
now = datetime.now(timezone.utc).replace(tzinfo=None)
with engine.begin() as conn:
conn.execute(sa.text("DROP TABLE credentials"))
conn.execute(sa.text(OLD_CREDENTIALS_DDL))
conn.execute(
sa.text(
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
"VALUES ('GitHub', 'PASSWORD', 'legacy-cipher-text', :now, :now)"
),
{"now": now},
)
conn.execute(
sa.text(
"INSERT INTO credentials (site, login_type, otp_secret_encrypted, created_at, updated_at) "
"VALUES ('Forum', 'PASSWORD', NULL, :now, :now)"
),
{"now": now},
)
db._migrate_assets_db()
with engine.connect() as conn:
cols = {c["name"] for c in sa.inspect(conn).get_columns("credentials")}
assert "otp_verified_at" in cols
rows = conn.execute(
sa.text("SELECT site, otp_verified_at FROM credentials ORDER BY id")
).fetchall()
assert rows[0][1] is not None # 已绑 2FA → 视为已验证
assert rows[1][1] is None # 未绑定 → 保持空
def _seed_orphan_ai_asset(
engine, slug: str = "openai", name: str = "OpenAI", api_key: str = "sk-test-123"
) -> int:
"""造迁移前现场:一个没挂账号的 AI 资产 + 其 ai_accounts 详情"""
with Session(engine) as s:
provider = s.exec(select(Provider).where(Provider.slug == slug)).first()
if not provider:
provider = Provider(slug=slug, name=name, category="ai_agent", sdk_type=f"{slug}-api")
s.add(provider)
s.commit()
s.refresh(provider)
asset = Asset(
name=f"{name} 账号",
asset_type=AssetType.AI_AGENT,
provider=slug,
provider_id=provider.id,
external_id=f"ai-{slug}",
)
s.add(asset)
s.commit()
s.refresh(asset)
s.add(
AIAccount(
asset_id=asset.id,
provider=slug,
api_key_encrypted=crypto.encrypt(api_key),
)
)
s.commit()
assert asset.account_id is None # 前置:确实还没挂靠
return asset.id
def test_backfill_attaches_ai_assets_using_enum_member_name(legacy_engine):
"""AI 资产挂靠迁移必须用 Enum 成员名 'AI_AGENT'
SQLAlchemy Enum 列存的是成员名而不是枚举值('ai_agent'),
迁移 SQL 写值会静默零命中——AI 资产永远挂不上账号,且没有任何报错。
"""
engine = legacy_engine
asset_id = _seed_orphan_ai_asset(engine)
# 先把存储形态钉住:真库里 asset_type 存的就是成员名
with engine.connect() as conn:
assert conn.execute(sa.text("SELECT asset_type FROM assets")).scalar() == "AI_AGENT"
db._migrate_assets_db()
with Session(engine) as s:
asset = s.get(Asset, asset_id)
accounts = s.exec(select(Account)).all()
assert len(accounts) == 1
acc = accounts[0]
# 按 ai_accounts.provider 找/建账号(沿用「{slug}-默认」命名),回填外键与历史字符串字段
assert (acc.name, acc.platform) == ("openai-默认", "openai")
assert asset.account_id == acc.id
assert asset.account == "openai"
# 账号尚无 API 配置时,把资产的 api_key 重组 JSON 后加密写入
assert json.loads(crypto.decrypt(acc.api_config_encrypted)) == {"api_key": "sk-test-123"}
def test_backfill_reuses_existing_platform_account(legacy_engine):
"""同平台已有账号(步骤 1 由平台凭证下沉建出)时必须复用,不建重复账号
否则会同时存在「deepseek」与「deepseek-默认」两个账号,
新建那个没凭证,在 UI 上点同步/测试只会得到「缺少凭证配置」。
"""
engine = legacy_engine
with Session(engine) as s:
s.add(
Provider(
slug="deepseek",
name="DeepSeek",
category="ai_agent",
sdk_type="deepseek-api",
api_config_encrypted=crypto.encrypt(json.dumps({"api_key": "sk-platform"})),
)
)
s.commit()
asset_id = _seed_orphan_ai_asset(engine, "deepseek", "DeepSeek", "sk-asset")
db._migrate_assets_db()
with Session(engine) as s:
accounts = s.exec(select(Account)).all()
assert len(accounts) == 1 # 复用而不是另建一个
assert accounts[0].name == "deepseek-默认"
assert s.get(Asset, asset_id).account_id == accounts[0].id
# 账号已有平台级配置时不得被资产级 key 覆写
assert json.loads(crypto.decrypt(accounts[0].api_config_encrypted)) == {
"api_key": "sk-platform"
}
def test_backfill_ai_assets_is_idempotent(legacy_engine):
"""重跑不得重复建账号,也不得覆写已有的账号级 API 配置"""
engine = legacy_engine
_seed_orphan_ai_asset(engine)
db._migrate_assets_db()
db._migrate_assets_db()
db._migrate_assets_db()
with Session(engine) as s:
assert len(s.exec(select(Account)).all()) == 1
assert len(s.exec(select(Asset)).all()) == 1
assert json.loads(crypto.decrypt(s.exec(select(Account)).first().api_config_encrypted)) == {
"api_key": "sk-test-123"
}