Files
live-hub-py/tests/test_login_attempt_isolation.py
T
yml2213andCursor ab4c0a5422 fix(login): 重登成功后把 CK 检测结论写回账号
登录凭据快照盖掉了 ORM 账号,on_ck_verdict 读不到 ck_check_status,新 Cookie 被丢掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:20:10 +08:00

271 lines
9.8 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.
"""回归:替换 CK 必须是最后一步,只有登录成功才会写回**账号**。
事故背景:批次启动时会把复用的成功记录直接改成 pending(旧 CK 仍保留),
批次被停止或失败后没有任何地方把它改回成功,账号于是从工作台消失。
现在尝试状态独立存放在 attempt_*,而 CK 自 2026-10-04 起挂在**账号**上
(`accounts.cookie`),登录记录只是一次尝试的历史 ——
停止、失败、进程重启都不可能破坏账号已有的 CK。
"""
from datetime import UTC, datetime
from types import SimpleNamespace
from sqlalchemy.orm import sessionmaker
import web.backend.database as database_module
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, LoginTask, User
from web.backend.routers.login import list_tasks
from web.backend.services import login_service
class _FakeLogin:
"""替身登录器:不发网络请求,结果由类属性控制。"""
success = True
cookie = "new-cookie"
message = "登录成功"
code = None
calls = 0
def __init__(self, *_args, **_kwargs):
pass
def login(self):
type(self).calls += 1
return SimpleNamespace(
success=type(self).success,
cookie=type(self).cookie if type(self).success else "",
message=type(self).message,
code=type(self).code,
)
def check_account(self):
return self.login()
class TestLoginAttemptIsolation:
def setup_method(self):
self.engine = get_test_engine()
database_module.SessionLocal.configure(bind=self.engine)
self.session = make_test_session(self.engine)
self.user = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="远行者103",
password="password",
email="yuanxingzhe@example.com",
email_password="mail-password",
# CK 是账号的属性(解耦前它挂在 login_tasks.cookie 上)
cookie="old-cookie",
)
self.session.add(self.account)
self.session.flush()
self.task = LoginTask(
batch_id="old-batch",
account_id=self.account.id,
created_by=self.user.id,
status="success",
message="登录成功",
# 上一次尝试失败过 —— 解耦后「复用行为」按 attempt_status 判定,
# 批次会复用这一行继续重试(而不是每次失败都新建一行)。
attempt_status="failed",
attempt_message="上一次登录失败",
)
self.session.add(self.task)
self.session.commit()
self.task_id = self.task.id
_FakeLogin.success = True
_FakeLogin.cookie = "new-cookie"
_FakeLogin.message = "登录成功"
_FakeLogin.calls = 0
def teardown_method(self):
self.session.close()
def _run_batch(self, monkeypatch, *, mode="login", account_ids=None, stop=False):
monkeypatch.setattr(login_service, "DouyuLogin", _FakeLogin)
# 批次结束时 runner 会关掉自己的会话,因此单独给它一个。
runner_db = sessionmaker(bind=self.engine)()
runner = login_service.LoginBatchRunner(
db=runner_db,
account_ids=account_ids if account_ids is not None else [self.account.id],
created_by=self.user.id,
creator_permissions=["login:view_all"],
proxy_config=None,
concurrency=1,
mode=mode,
)
if stop:
runner.stop()
runner.run()
return runner
def _fresh(self, task_id: int) -> LoginTask:
session = sessionmaker(bind=self.engine)()
try:
return session.query(LoginTask).filter(LoginTask.id == task_id).one()
finally:
session.close()
def _fresh_account(self) -> Account:
"""重新读账号 —— CK 现在挂在它身上,断言要跨会话读才作数。"""
session = sessionmaker(bind=self.engine)()
try:
return session.query(Account).filter(Account.id == self.account.id).one()
finally:
session.close()
def _fresh_user(self, session) -> User:
return session.query(User).filter(User.id == self.user.id).one()
def test_failed_attempt_keeps_existing_cookie(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
# 登录记录只是历史,它的状态字段没被碰过
assert task.status == "success"
assert task.message == "登录成功"
assert task.batch_id == "old-batch"
# 失败只体现在尝试字段上
assert task.attempt_status == "failed"
assert task.attempt_message == "账号密码错误"
assert task.attempt_at is not None
# 账号的 CK 完全没被碰过
assert self._fresh_account().cookie == "old-cookie"
def test_failed_attempt_reuses_the_failed_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
session = sessionmaker(bind=self.engine)()
try:
rows = (
session.query(LoginTask)
.filter(LoginTask.account_id == self.account.id)
.all()
)
finally:
session.close()
assert [row.id for row in rows] == [self.task_id]
def test_successful_attempt_replaces_cookie(self, monkeypatch):
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
account = self._fresh_account()
assert task.status == "success"
assert task.message == "登录成功"
assert task.attempt_status == "success"
assert task.attempt_at is not None
assert task.batch_id != "old-batch"
# 唯一替换 CK 的入口:写的是**账号**
assert account.cookie == "new-cookie"
assert account.cookie_updated_at is not None
assert account.cookie_source == "web"
def test_relogin_success_writes_ck_verdict_on_the_account(self, monkeypatch):
"""重登成功后的复检必须写到账号上,不能写到登录器的临时对象上。
2026-10-05 生产:登录已经成功,但 `on_ck_verdict` 收到的是
SimpleNamespace,读 `ck_check_status` 抛异常,新 Cookie 被丢弃,
页面上全部是「重新登录异常(旧 Cookie 已保留)」。
"""
self.account.ck_check_status = "invalid"
self.session.commit()
monkeypatch.setattr(
login_service,
"check_douyu_cookie",
lambda _cookie: {
"valid": True,
"message": "ok",
"fish_ball": 1,
"nickname": "n",
"level": 1,
"checked_at": datetime.now(UTC),
},
)
self._run_batch(monkeypatch, mode="relogin")
task = self._fresh(self.task_id)
account = self._fresh_account()
assert task.attempt_status == "success"
assert "SimpleNamespace" not in (task.attempt_message or "")
assert account.cookie == "new-cookie"
assert account.ck_check_status == "valid"
def test_stopped_batch_keeps_existing_cookie(self, monkeypatch):
self._run_batch(monkeypatch, stop=True)
task = self._fresh(self.task_id)
assert _FakeLogin.calls == 0
assert self._fresh_account().cookie == "old-cookie"
assert task.status == "success"
assert task.attempt_status == "failed"
assert "旧 Cookie 已保留" in task.attempt_message
def test_list_tasks_reports_attempt_state_without_losing_ck_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
runner = self._run_batch(monkeypatch)
read_db = sessionmaker(bind=self.engine)()
try:
rows = list_tasks(
batch_id=runner.batch_id, db=read_db, current=self._fresh_user(read_db)
)
finally:
read_db.close()
assert len(rows) == 1
row = rows[0]
# 页面看到的是「这次尝试怎么样了」,而账号仍然持有 CK
assert row.status == "failed"
assert row.message == "账号密码错误"
assert row.batch_id == runner.batch_id
assert self._fresh_account().cookie == "old-cookie"
def test_account_without_cookie_still_reports_failure(self, monkeypatch):
"""首次登录失败的账号没有 CK 需要保护,保持原来的失败语义。"""
fresh_account = Account(
username="no-cookie",
password="password",
email="nocookie@example.com",
email_password="mail-password",
)
self.session.add(fresh_account)
self.session.commit()
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch, account_ids=[fresh_account.id])
session = sessionmaker(bind=self.engine)()
try:
task = (
session.query(LoginTask)
.filter(LoginTask.account_id == fresh_account.id)
.one()
)
assert task.status == "failed"
assert task.message == "账号密码错误"
assert task.attempt_status == "failed"
assert (
session.query(Account)
.filter(Account.id == fresh_account.id)
.one()
.cookie
== ""
)
finally:
session.close()