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 天」。
338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""凭据中心化迁移测试(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"
|
||
}
|