Files
live-hub-py/tests/test_huya_browser_exchange.py
yml2213 c3fd299f3a test(db): 测试改用真实 MySQL,方言差异不再漏到生产
生产是 MySQL 8、测试是 SQLite,两者的方言差异在本地完全测不出来。今天为此
付出了两次代价:

1. `ORDER BY ck_checked_at ASC NULLS FIRST` 在 SQLite 上完全正常,本地 258 条
   全绿,一部署到生产第一次巡检就 1064 语法错误(MySQL 不支持 NULLS FIRST);
2. P1b 自动重登的结算复用调度器事务读取,在 SQLite 下看不出问题,但在 MySQL 的
   REPEATABLE READ 下读到的是批次开始前的快照 —— 10 条实际成功的重登全部被记成
   失败。SQLite 的读一致性与 MySQL 不同,这类问题跑一万遍也复现不出来。

改动:
- 新增 tests/support/db.py:测试专用引擎/会话工具,连接串从 .env 读取,
  默认指向独立的 douyu_login_test 库;
- 新增 tests/conftest.py:会话级建好表结构,每个用例前后清空所有表做隔离;
- 根 conftest.py 接管连接串设置:它在 pytest 收集阶段最先执行,在这里把
  DATABASE_URL 指向测试库,应用模块首次 import 拿到就是 MySQL,不依赖任何
  import 顺序(原先这里是 sqlite:// 兜底,改造后会变成「先建 SQLite engine
  再打补丁换成 MySQL」,顺序一变就静默跑错库);
- 21 个测试文件的 setup 样板统一改用共享测试引擎;
- 两个迁移测试改为在临时库 douyu_login_mig_<随机> 上跑完整 alembic upgrade head,
  跑完 drop —— 迁移脚本本身也终于在 MySQL 上被验证;
- dev.sh 与 README 的过时说明一并更新(原先写着「不需要 MySQL / .env 配置」;
  「测试各自建 SQLite 内存库」)。

安全与易用:
- 测试库名与开发库同名时**直接拒绝运行**(clear_tables 会 DELETE 所有表,
  一旦 TEST_MYSQL_DATABASE 被指到 douyu_login 就是数据灾难);
- 连不上 MySQL 时给出可操作提示(先 ./dev.sh 起库 / 用 TEST_MYSQL_DATABASE 覆盖),
  而不是抛一段 SQLAlchemy 原始堆栈。

验收:
- 全量 301 条通过,连跑两次结果一致(隔离有效),耗时约 12.5 秒(原 SQLite 约 3.6 秒);
- 单文件独立运行通过(不依赖全量执行顺序);
- `grep -rn sqlite tests/ conftest.py` 已无实际用法;
- **方言确实生效**:临时把一条查询改回 `NULLS FIRST`,13 条测试立刻变红(含运行期
  失败),还原即恢复;这条由 test_generated_sql_is_mysql_compatible 长期守着。

代价(已在 README 与规划附录 C.2 记明):跑测试前必须先有 MySQL,pre-push 钩子
同理;CI 需要 service container。
2026-10-02 21:22:08 +08:00

238 lines
10 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.
"""虎牙「浏览器兑换」:目标页三选一、凭证签发审计与扩展注入边界。"""
import json
import re
from pathlib import Path
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from tests.support.db import get_test_engine, make_test_session
from web.backend.huya_defaults import HUYA_DEFAULT_ROOM_PID
from web.backend.models import AuditLog, HuyaAccount, HuyaConfig, User
from web.backend.routers.huya import create_browser_exchange_kit
from web.backend.schemas import HuyaBrowserExchangeRequest
from web.backend.services.huya_exchange_targets import (
EXCHANGE_TARGET_KEYS,
exchange_target_url,
)
from web.backend.services.huya_runner_bind import HUYA_BIND_ROOM_ID
REPO_ROOT = Path(__file__).resolve().parents[1]
EXTENSION_DIR = REPO_ROOT / "web" / "extension" / "huya-exchange-helper"
class TestHuyaBrowserExchangeKit:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="operator", password_hash="hash", role="super_admin")
self.account = HuyaAccount(
uid="100", yyuid="100", cookie="yyuid=100; udb_uid=100"
)
self.session.add_all([self.user, self.account])
self.session.commit()
def teardown_method(self):
self.session.close()
def _kit(self, **kwargs):
return create_browser_exchange_kit(
self.account.id, db=self.session, current=self.user, **kwargs
)
def test_default_target_is_elite_and_three_options_are_returned(self):
kit = self._kit()
assert kit["target"] == "elite"
assert [option["key"] for option in kit["targets"]] == [
"live_room",
"elite",
"esports",
]
assert [option["label"] for option in kit["targets"]] == [
"直播间",
"精英宝典",
"电竞宝典",
]
# 默认目标地址=精英宝典(沿用历史行为:绑定/充值活动的 zt 活动页)
assert kit["target_url"].startswith(
"https://zt.huya.com/b02faae1/pc/index.html?"
)
assert f"pid={HUYA_DEFAULT_ROOM_PID}" in kit["target_url"]
assert kit["cookie_string"] == "yyuid=100; udb_uid=100"
assert kit["cookie_script"].endswith("})();")
def test_each_target_resolves_to_its_own_page(self):
live_room = exchange_target_url("live_room", HUYA_DEFAULT_ROOM_PID)
esports = exchange_target_url("esports", HUYA_DEFAULT_ROOM_PID)
assert live_room == f"https://www.huya.com/{HUYA_BIND_ROOM_ID}"
assert esports.startswith("https://zt.huya.com/50ea007f/pc/index.html?")
assert f"roomid={HUYA_BIND_ROOM_ID}" in esports
# 三个目标必须互不相同,否则选择器没有意义
urls = {
exchange_target_url(key, HUYA_DEFAULT_ROOM_PID)
for key in EXCHANGE_TARGET_KEYS
}
assert len(urls) == len(EXCHANGE_TARGET_KEYS)
for key in EXCHANGE_TARGET_KEYS:
kit = self._kit(req=HuyaBrowserExchangeRequest(target=key))
assert kit["target"] == key
expected = exchange_target_url(key, HUYA_DEFAULT_ROOM_PID)
assert kit["target_url"] == expected
# 控制台脚本里的跳转地址必须与所选目标一致
assert json.dumps(expected, ensure_ascii=False) in kit["cookie_script"]
assert [option["url"] for option in kit["targets"]].count(expected) == 1
def test_schema_rejects_unknown_target(self):
with pytest.raises(ValidationError):
HuyaBrowserExchangeRequest(target="unknown")
for key in EXCHANGE_TARGET_KEYS:
assert HuyaBrowserExchangeRequest(target=key).target == key
# 三个 key 都在 schemas 的正则白名单里(两处常量不许漂移)
assert HuyaBrowserExchangeRequest().target is None
def test_kit_issuance_is_audited_with_target(self):
self._kit(req=HuyaBrowserExchangeRequest(target="esports"))
rows = self.session.query(AuditLog).all()
assert len(rows) == 1
entry = rows[0]
assert entry.action == "huya:exchange:kit"
assert entry.target == f"huya_account:{self.account.id}"
assert entry.user_id == self.user.id
assert json.loads(entry.detail) == {
"account_id": self.account.id,
"target": "esports",
}
def test_account_without_cookie_is_rejected(self):
self.account.cookie = ""
self.session.commit()
with pytest.raises(HTTPException) as excinfo:
self._kit()
assert excinfo.value.status_code == 400
def test_support_user_only_reaches_assigned_accounts(self):
support = User(username="cs", password_hash="hash", role="support")
foreign = HuyaAccount(
uid="200", yyuid="200", cookie="yyuid=200", assigned_to=self.user.id
)
assigned = HuyaAccount(
uid="300", yyuid="300", cookie="yyuid=300", assigned_to=None
)
self.session.add_all([support, foreign, assigned])
self.session.commit()
assigned.assigned_to = support.id
self.session.commit()
# 客服只能兑换分配给自己的账号,他人账号按"不存在"处理
with pytest.raises(HTTPException) as excinfo:
create_browser_exchange_kit(foreign.id, db=self.session, current=support)
assert excinfo.value.status_code == 404
kit = create_browser_exchange_kit(assigned.id, db=self.session, current=support)
assert kit["cookie_string"] == "yyuid=300"
def test_user_without_task_permission_is_rejected(self):
guest = User(username="guest", password_hash="hash", role="guest")
self.session.add(guest)
self.session.commit()
with pytest.raises(HTTPException) as excinfo:
create_browser_exchange_kit(self.account.id, db=self.session, current=guest)
assert excinfo.value.status_code == 403
# 被拒时既不签发凭证,也不留"已签发"审计
assert self.session.query(AuditLog).count() == 0
def test_missing_account_returns_404(self):
with pytest.raises(HTTPException) as excinfo:
create_browser_exchange_kit(999999, db=self.session, current=self.user)
assert excinfo.value.status_code == 404
def test_kit_issuance_commits_audit_row(self):
self._kit()
# 未提交的审计行会停在 session.new:这条断言钉住 record_audit 之后确实 commit
assert not self.session.new, "审计日志未提交(缺少 db.commit())"
assert self.session.query(AuditLog).count() == 1
def test_room_pid_config_changes_zt_urls_but_not_live_room(self):
self.session.add(HuyaConfig(room_pid="9527"))
self.session.commit()
assert (
"pid=9527"
in self._kit(req=HuyaBrowserExchangeRequest(target="esports"))["target_url"]
)
assert (
self._kit(req=HuyaBrowserExchangeRequest(target="live_room"))["target_url"]
== f"https://www.huya.com/{HUYA_BIND_ROOM_ID}"
)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_extension_content_script_whitelist_is_narrow_and_parseable():
"""内容脚本白名单必须收窄到后台站点,且每个模式都能被 background.js 解析成来源。"""
manifest = json.loads(_read(EXTENSION_DIR / "manifest.json"))
matches = [
pattern for entry in manifest["content_scripts"] for pattern in entry["matches"]
]
background = _read(EXTENSION_DIR / "background.js")
assert matches, "内容脚本白名单不能为空"
assert "<all_urls>" not in matches
for pattern in matches:
# background.js 用 pattern.replace(/\/\*$/, '/') + new URL(...).origin 推导允许来源,
# 所以每个模式都必须是「具体源 + /*」,主机里不能有通配(通配会被 new URL 抛错并静默丢弃)
assert pattern.endswith("/*"), pattern
origin = pattern[: -len("/*")]
assert re.fullmatch(r"https?://[a-z0-9.-]+(:\d+)?", origin), pattern
# 内容脚本只需在后台页面生效:注入走 chrome.cookies,虎牙侧不需要内容脚本
assert "huya.com" not in origin
assert "originAllowed" in background
def test_inject_ack_contract_is_paired_across_three_layers():
"""注入回执链路(页面 ↔ 内容脚本 ↔ service worker)的字段必须成对,改名即失败。"""
page = _read(REPO_ROOT / "web" / "frontend" / "src" / "utils" / "huyaExchange.ts")
relay = _read(EXTENSION_DIR / "relay.js")
background = _read(EXTENSION_DIR / "background.js")
# 页面 → 内容脚本:事件名与参数名
assert "huya-exchange-kit" in page
assert "huya-exchange-kit" in relay
assert "request_id: requestId" in page
assert "kit.request_id" in relay
# 内容脚本 → 页面:回执事件名与字段
assert "huya-exchange-result" in page
assert "huya-exchange-result" in relay
assert "request_id: requestId" in relay
assert "parsed.request_id !== requestId" in page
assert "Boolean(result.ok)" in relay
assert "parsed.ok === true" in page
assert "result.written" in relay
assert "parsed.written" in page
# service worker → 内容脚本:延迟回执(等真正写完)与真实统计
assert "return true;" in background
assert "written > 0 && failed === 0" in background
assert "failedNames.push" in background
# 来源校验是本次安全修复的核心,不能被删掉
assert "originAllowed" in background
assert "sender.tab.url" in background
# 兜底路径同样要来源校验,且失败不再静默
assert "world: 'MAIN'" in background
assert "badge(tab.id, '失败')" in background
assert "badge(tab.id, '非后台页')" in background
# 一个都没写成功时不得打开目标页
assert "written > 0 && kit && kit.target_url" in background
# 只写 huya 域 Cookie
assert ".huya.com" in background
def test_console_script_guards_domain_and_expiry():
script = _read(REPO_ROOT / "core" / "huya" / "cookie_utils.py")
assert "location.hostname" in script
assert "ttl_days: int = 180" in script