fix:批量修复了一些问题

This commit is contained in:
shangfangjian
2026-09-19 22:51:35 +08:00
parent 835d7217d0
commit 8e6ad5394e
44 changed files with 4921 additions and 395 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"_note": "脱敏样例:球队名/日期/ID 已替换为占位符,字段结构对齐真实 bzzoiro 响应。待获取真实响应后替换。",
"id": "evt_placeholder_001",
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"league": { "id": 1, "code": "E0", "name": "Premier League" },
"season": "2026-2027",
"round_number": 5,
"round_name": null,
"home_team": "Home United FC",
"away_team": "Away City FC",
"home_score": 2,
"away_score": 1,
"home_score_ht": 1,
"away_score_ht": 0,
"home_shots": 14,
"away_shots": 8,
"home_shots_on_target": 5,
"away_shots_on_target": 3,
"home_corners": 6,
"away_corners": 4,
"home_possession": 58.5,
"home_xg": 1.85,
"away_xg": 0.92,
"home_yellow_cards": 2,
"away_yellow_cards": 3,
"home_red_cards": 0,
"away_red_cards": 0
}
+189
View File
@@ -0,0 +1,189 @@
"""FastAPI 关键路径测试:鉴权、限流、游标方向。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_api_critical.py -v
设计:
- 鉴权:直接测 require_admin / auth_configured 逻辑,monkeypatch 切换环境,
避免启动完整 app lifespan(异步 DB 引擎与同步 TestClient 不兼容)。
- 限流:直接测 _RateLimiter 单元。
- 游标:直接构造 SQL 验证 scheduled ASC / 其他 DESC 方向。
- 不依赖真实 LLM / 数据库:纯逻辑测试。
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from src.api import deps
from src.api.deps import (
_RateLimiter,
auth_configured,
require_admin,
)
from src.core import runtime_config
# ── 1. 鉴权:fail-closed(生产) vs fail-open(开发) ─────────────────────
class TestAuthFailClosed:
"""未配置鉴权策略时,production 环境应拒绝(503),development 应放行。"""
@pytest.mark.asyncio
async def test_production_no_auth_returns_503(self):
"""APP_ENV=production + 未配置任何鉴权 → require_admin 抛 503。"""
from fastapi import HTTPException
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
with patch.object(deps, "settings") as s, \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
s.REQUIRE_ADMIN_AUTH = False
s.APP_ENV = "production"
s.ADMIN_API_KEY = ""
s.ADMIN_PASSWORD = ""
with pytest.raises(HTTPException) as exc:
await require_admin(request, x_api_key=None)
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_development_no_auth_passes(self):
"""APP_ENV=development + 未配置鉴权 → 放行(只打 warning)。"""
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
with patch.object(deps, "settings") as s, \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
s.REQUIRE_ADMIN_AUTH = False
s.APP_ENV = "development"
s.ADMIN_API_KEY = ""
s.ADMIN_PASSWORD = ""
# 不应抛异常
await require_admin(request, x_api_key=None)
@pytest.mark.asyncio
async def test_require_admin_key_valid(self):
"""配置 ADMIN_API Key 后,带正确 X-API-Key 头 → 通过。"""
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
"headers": [(b"x-api-key", b"test-secret-key")]})
with patch.object(deps, "settings") as s, \
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
s.REQUIRE_ADMIN_AUTH = True
s.APP_ENV = "production"
s.ADMIN_API_KEY = "test-secret-key"
# 不应抛异常
await require_admin(request, x_api_key="test-secret-key")
@pytest.mark.asyncio
async def test_require_admin_key_invalid(self):
"""API Key 错误 → 401。"""
from fastapi import HTTPException
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
"headers": [(b"x-api-key", b"wrong")]})
with patch.object(deps, "settings") as s, \
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
s.REQUIRE_ADMIN_AUTH = True
s.APP_ENV = "production"
s.ADMIN_API_KEY = "test-secret-key"
with pytest.raises(HTTPException) as exc:
await require_admin(request, x_api_key="wrong")
assert exc.value.status_code == 401
# ── 2. 限流:滑动窗口逻辑 ─────────────────────────────────────────────
class TestRateLimit:
"""预测限流:_RateLimiter 单元测试。"""
def test_rate_limit_triggers_after_max(self):
"""max=2/60s → 第 3 次被拒。"""
limiter = _RateLimiter(max_requests=2, window_seconds=60)
ip = "1.2.3.4"
assert limiter.is_allowed(ip) is True
assert limiter.is_allowed(ip) is True
assert limiter.is_allowed(ip) is False # 超限
assert limiter.remaining(ip) == 0
def test_rate_limit_remaining_decrements(self):
"""剩余配额计算准确。"""
limiter = _RateLimiter(max_requests=5, window_seconds=60)
ip = "5.6.7.8"
assert limiter.remaining(ip) == 5
limiter.is_allowed(ip)
limiter.is_allowed(ip)
assert limiter.remaining(ip) == 3
def test_rate_limit_per_ip_isolated(self):
"""不同 IP 独立计数。"""
limiter = _RateLimiter(max_requests=1, window_seconds=60)
assert limiter.is_allowed("1.1.1.1") is True
assert limiter.is_allowed("1.1.1.1") is False # 同 IP 超限
assert limiter.is_allowed("2.2.2.2") is True # 不同 IP 不受影响
# ── 3. 游标方向:scheduled ASC 用 > ───────────────────────────────────
class TestCursorDirection:
"""验证 scheduled 状态查询时排序方向为 ASC(使用 > 游标)。"""
def test_scheduled_uses_ascending_order(self):
"""scheduled → match_date ASC(最近的未开赛排最前)。"""
from src.api.routes import matches as matches_mod
from sqlalchemy import select
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "scheduled")
q = q.order_by(matches_mod.Match.match_date.asc(), matches_mod.Match.id.asc())
sql = str(q)
assert "ORDER BY matches.match_date ASC" in sql, sql
def test_finished_uses_descending_order(self):
"""finished/其他 → DESC(最新赛果在前)。"""
from src.api.routes import matches as matches_mod
from sqlalchemy import select
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "finished")
q = q.order_by(matches_mod.Match.match_date.desc(), matches_mod.Match.id.desc())
sql = str(q)
assert "ORDER BY matches.match_date DESC" in sql, sql
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
class TestEvalCalibration:
"""验证 settled 预测按置信度分桶统计命中率。"""
def test_confidence_bucketing(self):
"""置信度落入正确的桶。"""
assert _bucket_key(0.3) == "low(0-0.5)"
assert _bucket_key(0.5) == "medium(0.5-0.7)"
assert _bucket_key(0.6) == "medium(0.5-0.7)"
assert _bucket_key(0.7) == "high(0.7-1)"
assert _bucket_key(0.95) == "high(0.7-1)"
def _bucket_key(conf: float) -> str:
if conf < 0.5:
return "low(0-0.5)"
elif conf < 0.7:
return "medium(0.5-0.7)"
else:
return "high(0.7-1)"
+130
View File
@@ -0,0 +1,130 @@
"""测试极简基线预测:不调用 LLM,基于主客场场均进球估计,写入 prediction 表。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_baseline.py -v
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from src.llm.baseline import _avg_goals, predict_baseline
@pytest.mark.asyncio
async def test_avg_goals_no_data_returns_zero():
"""无历史数据时场均进球为 0(不抛异常)。"""
class FakeRow:
avg_goals = None
cnt = 0
class FakeResult:
def one(self):
return FakeRow()
class FakeSession:
async def execute(self, stmt):
return FakeResult()
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
assert avg == 0.0
@pytest.mark.asyncio
async def test_avg_goals_with_data():
"""有数据时返回正确均值。"""
class FakeRow:
avg_goals = 1.5
cnt = 10
class FakeResult:
def one(self):
return FakeRow()
class FakeSession:
async def execute(self, stmt):
return FakeResult()
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
assert avg == 1.5
@pytest.mark.asyncio
async def test_predict_baseline_no_llm():
"""基线预测不调用 LLM(provider=model=baseline),latency_ms=0。"""
captured = {}
async def fake_avg(db, *, team_id, side, league_id, before):
captured[f"{side}_{team_id}"] = True
return 2.4 if side == "home" else 1.6
class FakeMatch:
id = 1
match_id = 1
home_team_id = 10
away_team_id = 20
league_id = 1
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
class FakeCM:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, *a):
return None
SLC.return_value = FakeCM()
result = await predict_baseline(1)
assert result["provider"] == "baseline"
assert result["model"] == "baseline"
assert result["mode"] == "baseline"
assert result["latency_ms"] == 0
assert result["prompt_tokens"] == 0
assert result["completion_tokens"] == 0
# 2.4 → round = 2, 1.6 → round = 2 → 平局 X
assert result["pred_home_goals"] == 2.0
assert result["pred_away_goals"] == 2.0
assert result["pred_1x2"] == "X"
assert result["subjective_confidence"] == 0.5
assert "非投注建议" in result["reasoning"]
# 确认未调用任何 LLM 相关模块
assert "home_10" in captured and "away_20" in captured
@pytest.mark.asyncio
async def test_predict_baseline_clamps_to_range():
"""预测进球数裁剪到 [0, 10]。"""
async def fake_avg(db, *, team_id, side, league_id, before):
return 15.0 if side == "home" else -3.0
class FakeMatch:
id = 2
match_id = 2
home_team_id = 10
away_team_id = 20
league_id = 1
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
class FakeCM:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, *a):
return None
SLC.return_value = FakeCM()
result = await predict_baseline(2)
assert result["pred_home_goals"] == 10.0 # clamped
assert result["pred_away_goals"] == 0.0 # clamped
assert result["pred_1x2"] == "1" # 10:0 主胜
+176
View File
@@ -0,0 +1,176 @@
"""测试 bzzoiro 事件规范化:基于 fixtures/bzzoiro_event.json 的真实字段映射。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_bzzoiro_normalize.py -v
若真实 bzzoiro 字段名与样例不同,断言会失败 —— 这正是本测试的目的:
锁定 normalize_bzzoiro 所依赖的字段名,避免上游静默变更导致数据丢失。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src.data.config import LEAGUE_NAMES
from src.data.normalize import normalize_bzzoiro
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
def load_event() -> dict:
with open(FIXTURE_DIR / "bzzoiro_event.json", encoding="utf-8") as f:
return json.load(f)
class TestBzzoiroNormalize:
"""验证 normalize_bzzoiro 对 fixture 样例的解析结果。"""
def test_basic_fields(self):
"""基础字段:日期、状态、对阵、进球。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m is not None
assert m.home_team == "Home United FC"
assert m.away_team == "Away City FC"
assert m.match_status == "finished"
assert m.home_goals == 2
assert m.away_goals == 1
assert m.home_ht_goals == 1
assert m.away_ht_goals == 0
def test_shots_mapping(self):
"""射门数映射到 home_shots / away_shots。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_shots == 14
assert m.away_shots == 8
def test_shots_on_target_mapping(self):
"""射正数映射到 home_shots_on_target / away_shots_on_target。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_shots_on_target == 5
assert m.away_shots_on_target == 3
def test_corners_mapping(self):
"""角球映射。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_corners == 6
assert m.away_corners == 4
def test_possession_mapping(self):
"""控球率:API 提供 home 值。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_possession == 58.5
def test_xg_mapping(self):
"""xG 映射到 home_xg / away_xg。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_xg == 1.85
assert m.away_xg == 0.92
def test_cards_mapping(self):
"""黄牌、红牌映射。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_yellow_cards == 2
assert m.away_yellow_cards == 3
assert m.home_red_cards == 0
assert m.away_red_cards == 0
def test_fallback_aliases(self):
"""回退别名:shots_home → home_shots, xg_home → home_xg。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "FC Alpha",
"away_team": "FC Beta",
"home_goals": 1,
"away_goals": 1,
"shots_home": 10, "shots_away": 5,
"sot_home": 4, "sot_away": 2,
"corners_home": 3, "corners_away": 2,
"possession": 55.0,
"xg_home": 1.2, "xg_away": 0.8,
"yellow_cards_home": 1, "yellow_cards_away": 2,
"red_cards_home": 0, "red_cards_away": 1,
}
m = normalize_bzzoiro(raw, "SP1")
assert m is not None
assert m.home_shots == 10
assert m.away_shots == 5
assert m.home_shots_on_target == 4
assert m.away_shots_on_target == 2
assert m.home_corners == 3
assert m.away_corners == 2
assert m.home_possession == 55.0
assert m.home_xg == 1.2
assert m.away_xg == 0.8
assert m.home_yellow_cards == 1
assert m.away_yellow_cards == 2
assert m.home_red_cards == 0
assert m.away_red_cards == 1
def test_missing_stats_still_normalizes(self):
"""缺少统计字段时仍应解析基础数据,统计字段为 None(不伪造)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "FC One",
"away_team": "FC Two",
"home_goals": 3,
"away_goals": 0,
}
m = normalize_bzzoiro(raw, "D1")
assert m is not None
assert m.home_goals == 3
assert m.away_goals == 0
# 无数据字段保持 None,不伪造
assert m.home_shots is None
assert m.home_xg is None
assert m.home_possession is None
def test_unknown_status_drops(self):
"""未知状态 → 丢弃(None)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "weird_status",
"home_team": "A",
"away_team": "B",
}
assert normalize_bzzoiro(raw, "E0") is None
def test_invalid_date_drops(self):
"""无效日期 → 丢弃(None)。"""
raw = {
"event_date": "not-a-date",
"status": "finished",
"home_team": "A",
"away_team": "B",
"home_goals": 1,
"away_goals": 0,
}
assert normalize_bzzoiro(raw, "E0") is None
def test_same_team_drops(self):
"""主客队同名(规范化后) → 丢弃(None)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "Same FC",
"away_team": "Same FC",
"home_goals": 1,
"away_goals": 0,
}
assert normalize_bzzoiro(raw, "E0") is None
# ── 真实响应校验(占位,待替换后取消 skip) ─────────────────────────
@pytest.mark.skip(reason="待提供真实 bzzoiro event 响应后替换 fixture 并取消 skip")
def test_real_response_matches_fixture_structure():
"""真实响应应能被 fixture 结构覆盖(字段名一致)。"""
# 真实响应粘贴于此,验证 normalize_bzzoiro 解析成功
real_response = {}
if not real_response:
pytest.skip("未提供真实响应")
m = normalize_bzzoiro(real_response, "E0")
assert m is not None
+71
View File
@@ -0,0 +1,71 @@
"""验证就绪探针:数据库不可用时 /health/ready 返回 503 而非 200。
运行方式(在宿主机上):
python tests/test_health_ready.py
脚本经本地 8000 端口直调 API,通过启停 postgres 容器验证:
- 健康时返回 HTTP 200
- postgres 停止后返回 HTTP 503(不再误报 200)
- postgres 恢复后回到 200
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
import urllib.request
BASE = "http://localhost:8000"
def api_status() -> tuple[int, dict]:
try:
with urllib.request.urlopen(f"{BASE}/health/ready", timeout=5) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def compose(*args: str) -> None:
subprocess.run(["docker", "compose", *args], check=False, capture_output=True)
def wait_for(target: int, timeout: int = 30) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
code, _ = api_status()
if code == target:
return True
except Exception:
pass
time.sleep(1)
return False
def main() -> int:
code, _ = api_status()
if code != 200:
print(f"FAIL: 初始状态期望 200,得到 {code}"); return 1
print(f"PASS: 健康时 HTTP 200")
compose("stop", "postgres")
try:
if not wait_for(503, timeout=30):
print("FAIL: postgres 停止后未返回 503"); return 1
print("PASS: postgres 停止后 HTTP 503(就绪探针正确拒绝)")
finally:
compose("start", "postgres")
if not wait_for(200, timeout=30):
print("FAIL: postgres 恢复后未回到 200"); return 1
print("PASS: postgres 恢复后 HTTP 200")
print("ALL PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+23 -87
View File
@@ -1,89 +1,25 @@
"""回归测试: 比赛列表游标页方向修复
"""验证: matches 游标页方向正确且无重复 id
验证:
- status=scheduled 时,游标条件为「大于」(ASC 方向)
- 其它 status 时,游标条件为「小于」(DESC 方向)
- 无 cursor 时行为不变
scheduled 升序游标条件必须为 > 而非 <;翻页返回的 id 集合无重复。
端到端验证脚本(容器内运行,通过 nginx 代理):
python - <<'PY'
import urllib.parse, urllib.request, json
base = "http://localhost:3000/api/v1/matches?league=E0&status=scheduled&limit=50&cursor="
seen = set(); cursor = None; pages = 0
while True:
url = base + ("" if cursor is None else urllib.parse.quote(cursor, safe=""))
d = json.load(urllib.request.urlopen(url))
ids = [m["id"] for m in d["items"]]
dup = seen.intersection(ids)
assert not dup, f"{pages}出现重复id: {dup}"
seen.update(ids); pages += 1
if not d["has_more"] or not d["next_cursor"]: break
cursor = d["next_cursor"]
import subprocess
total = int(subprocess.check_output(
["psql","-U","football","-d","football","-tAc",
"SELECT count(*) FROM matches WHERE match_status='scheduled' AND league_id=(SELECT id FROM leagues WHERE code='E0')"]))
assert len(seen) == total, f"翻页得{len(seen)}条,库中{total}"
print(f"PASS: {pages}页共{len(seen)}条,无重复")
PY
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from src.db.models import Match
class TestCursorPaginationDirection:
"""验证游标条件方向与排序方向一致。"""
def _build_query(self, status=None, cursor=None):
"""复现 list_matches 的查询构造逻辑,返回 where 条件列表。"""
q = select(Match)
if cursor:
last_date_str, last_id_str = cursor.split("|", 1)
last_date = datetime.fromisoformat(last_date_str)
last_id = int(last_id_str)
if status == "scheduled":
q = q.where(
(Match.match_date > last_date) |
((Match.match_date == last_date) & (Match.id > last_id))
)
else:
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
if status:
q = q.where(Match.match_status == status)
if status == "scheduled":
order = (Match.match_date.asc(), Match.id.asc())
else:
order = (Match.match_date.desc(), Match.id.desc())
return q.order_by(*order)
def test_scheduled_uses_greater_than(self):
"""scheduled + cursor: 应使用 > 条件(ASC 方向)。"""
q = self._build_query(
status="scheduled",
cursor="2026-01-15T15:00:00|100"
)
sql = str(q)
assert ">" in sql, f"scheduled 游标应使用 >,SQL: {sql}"
assert "<" not in sql or "match_date <" not in sql, f"不应出现 < 条件"
def test_other_status_uses_less_than(self):
"""finished + cursor: 应使用 < 条件(DESC 方向)。"""
q = self._build_query(
status="finished",
cursor="2026-01-15T15:00:00|100"
)
sql = str(q)
assert "<" in sql, f"finished 游标应使用 <,SQL: {sql}"
assert "match_date >" not in sql, f"不应出现 > 条件"
def test_no_cursor_no_direction(self):
"""无 cursor 时不应有游标条件。"""
q = self._build_query(status="scheduled", cursor=None)
sql = str(q)
# 应无 match_date 比较条件(只有 status filter)
assert "match_date >" not in sql
assert "match_date <" not in sql
def test_scheduled_order_is_asc(self):
"""scheduled 排序应为 ASC。"""
q = self._build_query(status="scheduled", cursor=None)
sql = str(q)
assert "ASC" in sql, f"scheduled 应 ASC 排序,SQL: {sql}"
assert "DESC" not in sql, f"不应出现 DESC"
def test_finished_order_is_desc(self):
"""finished 排序应为 DESC。"""
q = self._build_query(status="finished", cursor=None)
sql = str(q)
assert "DESC" in sql, f"finished 应 DESC 排序,SQL: {sql}"
+128
View File
@@ -0,0 +1,128 @@
"""单测:生产环境启动安全校验。
覆盖:
- production 缺 SECRET_KEY → 阻断(sys.exit)
- production 缺鉴权 → 阻断
- production 全配置 → 通过
- development 缺配置 → 仅警告(不退出)
- DATABASE_URL 弱密码 → production 阻断 / development 仅警告
"""
from __future__ import annotations
import asyncio
import logging
from unittest.mock import AsyncMock, patch
import pytest
from src.core.config import Settings
from src.core.security_check import (
_WEAK_SECRET_KEYS,
_WEAK_DB_PATTERNS,
assert_security_on_startup,
validate_security,
)
def _base_settings(**overrides) -> Settings:
"""构造测试用 Settings,默认模拟一个"已合规"的基线。"""
defaults = dict(
APP_ENV="production",
SECRET_KEY="aSwLuw2mqoQdSKUfB3eFVfW2Tv7VnJRRixMxOwZZi5M=",
ADMIN_PASSWORD="",
ADMIN_API_KEY="",
DATABASE_URL="postgresql+asyncpg://user:StrongP@ssw0rd@db:5432/prod",
REQUIRE_ADMIN_AUTH=False,
)
defaults.update(overrides)
return Settings(**defaults)
class TestValidateSecurity:
"""validate_security 逻辑。"""
@pytest.mark.asyncio
async def test_production_fully_configured_passes(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is True
assert result["errors"] == []
@pytest.mark.asyncio
async def test_production_missing_secret_key_fails(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("SECRET_KEY" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_weak_secret_key_fails(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="changeme")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("SECRET_KEY" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_missing_auth_fails(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
result = await validate_security()
assert result["ok"] is False
assert any("鉴权" in e or "ADMIN" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_weak_db_password_fails(self):
with patch("src.core.security_check.settings",
_base_settings(DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("DATABASE_URL" in e or "弱密码" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_development_missing_config_only_warns(self):
"""development:缺 SECRET_KEY/鉴权 → errors 存在但弱 DB 密码不进 errors。"""
with patch("src.core.security_check.settings",
_base_settings(APP_ENV="development", SECRET_KEY="", ADMIN_API_KEY="",
DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
result = await validate_security()
# development 下:弱 DB 密码只在 warnings,不会升级到 errors
assert result["ok"] is False # 仍有 errors(缺密钥 + 缺鉴权)
assert any("DATABASE_URL" in w for w in result["warnings"])
class TestAssertSecurityOnStartup:
"""assert_security_on_startup 退出行为。"""
@pytest.mark.asyncio
async def test_production_failure_exits(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
with pytest.raises(SystemExit) as exc:
await assert_security_on_startup()
assert exc.value.code == 1
@pytest.mark.asyncio
async def test_production_pass_does_not_exit(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
# 不应抛异常 / 退出
await assert_security_on_startup()
@pytest.mark.asyncio
async def test_development_failure_does_not_exit(self):
with patch("src.core.security_check.settings",
_base_settings(APP_ENV="development", SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
# development 即使有问题也不退出
await assert_security_on_startup()
def test_weak_secret_keys_list_not_empty():
"""防御性:弱密钥表应包含常见弱值。"""
assert "changeme" in _WEAK_SECRET_KEYS
assert "football:football@" in _WEAK_DB_PATTERNS