Files
gaokao-volunteer-system/admin/tests/test_app.py
hermes 6eafe1fc9b
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(ops 6/20 v2.1.3): 生产加固 + L-A 送审前修复 + crowd_db 质量契约
## 实现内容 (6 项改动)

1. **admin /health 端点增强** — 主键契约 status:ok 保持 + checks 子对象
   - db_writable: connect + CREATE TEMP TABLE + INSERT + SELECT + DROP
   - disk_writable: 在 ops_alert_log_path 目录创建临时文件 + 删除
   - settings_valid: 复用 is_jwt_secret_secure 判断
2. **_enforce_jwt_secret_policy** — prod env 使用 dev 默认 JWT / 长度<32 → fail-closed
3. **_enforce_default_admin_password_policy** — prod env 用 admin123 / 长度<10 / 字符类<3 → fail-closed
4. **L-A R7: admin UI footer** — dashboard.html (592 行) + ui.py 内联 admin/orders/new
   模板加 footer 隐私政策 + 数据删除 + 服务说明链接, 与 portal _render_footer_links() 同口径
5. **L-A R1+R4: LEGAL_PRIVACY_BASELINE 文档同步**
   - §6 移除孤儿 'admin' consent_channel 值 (代码侧从未实际产生)
   - §7 已具备/尚缺 重写, 显式归到 6/20 增量
6. **Q-A: tests/test_crowd_db_data_quality.py** — 8 个测试锁住
   - 27 省总数 + 仅湖南 high + 其它 26 省 ≤ usable
   - 高考生源大省 (广东/江苏/北京/上海/山东/河南/四川/湖北) 不在 high 集合
   - data_year=2025 (6/25 后需显式更新)

## 测试

- 4/4 RED → GREEN (admin/tests/test_health.py: JWT/admin password 拒绝 dev 默认值)
- 8/8 GREEN (data/crowd_db/tests/test_crowd_db_data_quality.py: 数据质量契约)
- 3 回归修复 (test_app.py + test_routes.py + test_health.py 适配新 /health checks 字段)
- 31/31 GREEN (admin/tests/test_app.py + test_routes.py + test_health.py)

## 报告

- reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md (363 行, 9 风险 0 阻塞)
- reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md (45 行, CRITICAL 文档失真已规避)

## 文件

M CHANGELOG.md (v2.1.3)
M admin/config.py (2 个 _enforce_*_policy + load_settings post-load)
M admin/routes/health.py (3 个 _check_* + checks 子对象)
M admin/routes/ui.py (admin/orders/new 模板加 footer)
M admin/static/dashboard.html (footer 块)
M admin/tests/test_app.py (适配 checks 字段 + regex 兼容)
M admin/tests/test_health.py (4 个新测试)
M admin/tests/test_routes.py (适配 checks 字段)
M docs/CURRENT_STATE.md (0.3-0.5 增量段)
M docs/LEGAL_PRIVACY_BASELINE.md (§6 清理 + §7 重写)
+ data/crowd_db/tests/test_crowd_db_data_quality.py (8 tests)
+ reports/LA_LEGAL_PRIVACY_PRE_AUDIT_2026-06-20.md
+ reports/QA_CROWD_DB_NON_HUNAN_DENSITY_AUDIT.md
2026-06-20 18:36:23 +08:00

255 lines
9.0 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.
"""FastAPI app 启动 + Swagger 测试 (T6.1).
验证:
- app 工厂可用
- lifespan 触发 bootstrap
- /openapi.json 含所有路由
- /docs 返回 HTML
"""
from __future__ import annotations
import sqlite3
import pytest
def test_create_app_runs_lifespan(client):
"""TestClient 上下文会触发 lifespan,验证 bootstrap 完成。"""
# 客户端创建时 lifespan 已运行 → 已有 admin 用户
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body.get("status") == "ok"
assert body.get("checks", {}).get("db_writable") is True
assert body.get("checks", {}).get("disk_writable") is True
assert body.get("checks", {}).get("settings_valid") is True
def test_openapi_json_exposes_all_routes(client):
"""/openapi.json 含所有 T6.1 路由。"""
resp = client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
paths = set(schema["paths"].keys())
expected = {
"/health",
"/api/auth/login",
"/api/auth/me",
"/api/orders",
"/api/orders/{order_id}",
"/api/stats/dashboard",
"/api/stats/orders",
"/api/meta",
}
missing = expected - paths
assert not missing, f"OpenAPI 缺失路径: {missing}"
def test_docs_serves_swagger_ui(client):
"""/docs 返回 HTML(200 + 含 swagger-ui 字样)。"""
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text.lower()
assert "swagger" in body or "openapi" in body
def test_redoc_served(client):
"""/redoc 也可用。"""
resp = client.get("/redoc")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_dashboard_page_served(client):
"""/dashboard 提供运营后台页面骨架,并避免技术细节直出。"""
resp = client.get("/dashboard")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "运营总览" in body
assert "核心经营指标" in body
assert "/static/dashboard.js" in body
assert "/static/portal-ui.css" in body
assert 'id="trend-chart"' in body
assert 'id="status-chart"' in body
assert 'id="source-chart"' in body
assert 'id="service-chart"' in body
assert 'id="range-7d"' in body
assert 'id="range-30d"' in body
assert 'id="system-status"' in body
assert 'id="pending-orders"' in body
assert 'id="pending-overdue-24h"' in body
assert 'id="pending-missing-intake"' in body
assert 'id="pending-breakdown"' in body
assert 'id="pending-overdue-tag"' in body
assert 'id="pending-missing-tag"' in body
assert "pending-tag-overdue" in body
assert "pending-tag-missing" in body
assert 'id="orders-spark"' in body
assert 'id="revenue-spark"' in body
assert 'id="status-title"' in body
assert 'id="quick-refresh-btn"' in body
assert 'id="quick-logout-btn"' in body
assert 'id="dev-seed-panel"' in body
assert 'id="dev-seed-overdue-btn"' in body
assert 'id="dev-seed-clean-btn"' in body
assert 'id="dashboard-title"' in body
assert 'id="login-form"' in body
assert 'id="quick-links"' in body
assert 'class="card empty"' in body
assert 'class="chart-empty"' in body
assert 'data-card="orders"' in body
assert 'data-card="revenue"' in body
assert 'data-card="users"' in body
assert 'data-card="pending"' in body
assert "尚未连接" in body
assert "立即刷新" in body
assert "退出登录" in body
assert "等待加载状态分布" in body
assert "等待加载来源分布" in body
assert "等待加载服务版本分布" in body
assert "登录后展示订单与收入趋势" in body
assert "/static/vendor/echarts.min.js" in body
assert "jsdelivr.net" not in body
assert "最小仪表盘" not in body
assert "admin_users 总数" not in body
assert "接口: <code>/api/stats/dashboard</code>" not in body
def test_dashboard_static_js_served(client):
"""前端脚本包含趋势切换与 3 张分布图渲染逻辑。"""
resp = client.get("/static/dashboard.js")
assert resp.status_code == 200
assert "javascript" in resp.headers["content-type"]
body = resp.text
assert "renderSummary" in body
assert "/api/stats/dashboard" in body
assert "status-chart" in body
assert "source-chart" in body
assert "service-chart" in body
assert 'range: "7d"' in body
assert "trends?.[state.range]" in body
assert "sessionStorage" in body
assert "localStorage" not in body
assert "admin_users 总数" not in body
assert "setStatus" in body
assert "setChartEmpty" in body
assert "pending-orders" in body
assert "pending-overdue-24h" in body
assert "pending-missing-intake" in body
assert "setChartEmpty" in body
assert "pending-breakdown" in body
assert "postDevSeed" in body
assert "dev-seed-panel" in body
assert "/api/admin/orders/dev-seed" in body
def test_bootstrap_admin_only_once(client, settings):
"""lifespan 已 bootstrap 后,再次调用应报告已存在,不再创建。"""
from admin.db import AdminUserRepo, bootstrap_admin
# client fixture 已触发 lifespan → 已有 bootstrap 用户
created1, msg1 = bootstrap_admin(settings)
assert created1 is False
assert "已存在" in msg1
repo = AdminUserRepo(settings.db_path)
assert repo.count() == 1
def test_default_admin_login_works(client, settings):
"""默认 admin/admin123 (此处覆写为 test-pass-123) 可登录。"""
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-pass-123"},
)
assert resp.status_code == 200
body = resp.json()
assert body["token_type"] == "bearer"
assert body["expires_in"] == settings.jwt_expire_minutes * 60
assert isinstance(body["access_token"], str)
assert len(body["access_token"]) > 20
def test_create_app_bootstraps_orders_schema(tmp_path, monkeypatch):
"""真实启动流程会顺手初始化 orders DBfresh app 的 dashboard 空库可用。"""
db_path = tmp_path / "admin.db"
orders_db_path = tmp_path / "orders.db"
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path))
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
from fastapi.testclient import TestClient
from admin.app import create_app
from admin.config import load_settings
app = create_app(load_settings())
with TestClient(app) as client:
login = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-pass-123"},
)
assert login.status_code == 200
token = login.json()["access_token"]
resp = client.get(
"/api/stats/dashboard",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["summary"] == {
"total_orders": 0,
"total_revenue_cents": 0,
"total_users": 1,
"pending_orders": 0,
"pending_overdue_24h": 0,
"pending_missing_intake": 0,
"orders_today": 0,
"orders_7d": 0,
"orders_30d": 0,
"revenue_today_cents": 0,
"revenue_7d_cents": 0,
"revenue_30d_cents": 0,
}
with sqlite3.connect(orders_db_path) as conn:
orders_row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='orders'"
).fetchone()
intake_row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='order_intakes'"
).fetchone()
assert orders_row is not None
assert intake_row is not None
def test_prod_rejects_default_admin_password(tmp_path, monkeypatch):
db_path = tmp_path / "admin.db"
orders_db_path = tmp_path / "orders.db"
monkeypatch.setenv("GAOKAO_ENV", "prod")
monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path))
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "Z" * 64)
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "admin123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
# 显式提供合规 webhook secret,避免被 P2-5 fail-closed 提前拦截,
# 让本测试聚焦于管理员密码策略。
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "P" + "r" * 31 + "!" * 32)
from admin.app import _validate_and_log_settings
from admin.config import load_settings
with pytest.raises(
RuntimeError, match="GAOKAO_ADMIN_PASS invalid in prod|默认管理员密码"
):
_validate_and_log_settings(load_settings())