Files
vps-manager/tests/test_credential_migration.py
T
gouki c5982a5706 feat(2fa): 动态码改为可选校验,未验证条目支持就地补验
痛点:secret 多为历史留存,录入那一刻手上常常没有能出码的验证器,
原先「绑定必须 secret + 当前 6 位码校验通过」直接把用户挡在门外。
改为「能验则验,没验就标」,抄错风险由角标持续提醒 + 后置补验兜住。

- 后端:新增 otp_verified_at 记账;code 可选(填了必须通过,错码 400
  且不覆盖已绑 secret);POST /{id}/otp/verify 补验转正;更换 secret
  一律重置为未验证;_migrate_assets_db 幂等补列并回填旧数据,避免升级
  后全量误报未验证
- 前端:列表区分「2FA 已验证/未验证」徽章,展开区未验证提醒条 + 就地
  补验输入;账号查看密码弹窗增加未验证提示;移除 CredentialModal.save()
  里的必填预校验(这是留空保存连请求都不发出的根因)
- 测试:pytest 74 绿(新增契约反转 + 6 个补验用例 + 迁移回填用例);
  Playwright 端到端 13/13(留空保存 → 未验证角标 → 错码 400 保持未验证
  → 正码补验转正 → 移动端 375px 无横向溢出 → 无 JS 运行时报错)
- 文档:goal-spec §1/§2/§4/§5/§6/§7/§9 同步新决策,替换旧的必填校验约定
2026-09-18 08:40:09 +00:00

218 lines
8.4 KiB
Python
Raw 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
"""
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, 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 # 未绑定 → 保持空