1a7a624b87
用户/提现/上报/审计日志四个主列表页从「加载更多」改为页码分页: - CursorPage 加 total 字段(可选,不影响其它接口) - queries 新增 offset_paginate(items, next_cursor, total);count 与分页同源, 筛选条件一处构建避免漂移;list_users/withdraw/price_reports 接入返回 total - price_reports / audit_logs 从 id 游标改 offset 分页(cursor 即 offset),支持跳页 - 四个 router 透出 total;withdraw 详情内部调用同步解包 - 反馈页本轮排除(其 router/model 正在 feat/feedback-iteration 迭代,避免纠缠) 测试: test_audit_log_pagination 改为校验 offset+total;admin 套件 47 passed。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
"""Admin 后台 M1 测试:登录闭环 + 与 App 用户鉴权的彻底隔离。
|
||
|
||
admin app 是独立的 FastAPI(app.admin.main:admin_app),用独立 TestClient。
|
||
admin 表由 conftest 的 Base.metadata.create_all 一起建好(models/__init__ 已登记)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.admin.main import admin_app
|
||
from app.admin.repositories import admin_user as admin_repo
|
||
from app.core.security import create_token, hash_password
|
||
from app.db.session import SessionLocal
|
||
|
||
|
||
@pytest.fixture()
|
||
def admin_client() -> TestClient:
|
||
return TestClient(admin_app)
|
||
|
||
|
||
def _ensure_admin(
|
||
username: str = "test_admin",
|
||
password: str = "admin-pass-123",
|
||
role: str = "super_admin",
|
||
) -> tuple[str, str]:
|
||
"""create-or-reset 一个 admin(测试 DB 跨用例共享,需幂等)。"""
|
||
db = SessionLocal()
|
||
try:
|
||
a = admin_repo.get_by_username(db, username)
|
||
if a is None:
|
||
admin_repo.create_admin(db, username=username, password=password, role=role)
|
||
else:
|
||
a.password_hash = hash_password(password)
|
||
a.role = role
|
||
a.status = "active"
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
return username, password
|
||
|
||
|
||
def test_admin_login_and_me(admin_client: TestClient) -> None:
|
||
username, password = _ensure_admin()
|
||
|
||
r = admin_client.post(
|
||
"/admin/api/auth/login", json={"username": username, "password": password}
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
data = r.json()
|
||
assert "access_token" in data
|
||
assert data["admin"]["username"] == username
|
||
assert data["admin"]["role"] == "super_admin"
|
||
|
||
token = data["access_token"]
|
||
r = admin_client.get("/admin/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["username"] == username
|
||
|
||
|
||
def test_admin_login_wrong_password(admin_client: TestClient) -> None:
|
||
username, _ = _ensure_admin()
|
||
r = admin_client.post(
|
||
"/admin/api/auth/login", json={"username": username, "password": "definitely-wrong"}
|
||
)
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_admin_me_requires_token(admin_client: TestClient) -> None:
|
||
assert admin_client.get("/admin/api/auth/me").status_code == 401
|
||
|
||
|
||
def test_disabled_admin_cannot_login(admin_client: TestClient) -> None:
|
||
username, password = _ensure_admin(username="disabled_admin")
|
||
db = SessionLocal()
|
||
try:
|
||
a = admin_repo.get_by_username(db, username)
|
||
a.status = "disabled"
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
r = admin_client.post(
|
||
"/admin/api/auth/login", json={"username": username, "password": password}
|
||
)
|
||
assert r.status_code == 403
|
||
|
||
|
||
# ============================ 关键:鉴权隔离 ============================
|
||
|
||
def test_app_user_token_cannot_access_admin(admin_client: TestClient) -> None:
|
||
"""App 用户的 access_token(用 JWT_SECRET_KEY 签)不能访问 admin。
|
||
|
||
admin 用独立 ADMIN_JWT_SECRET 验签 → App token 直接验签失败 → 401。
|
||
这是后台防越权的第一道线。
|
||
"""
|
||
user_token, _ = create_token(user_id=1, token_type="access")
|
||
r = admin_client.get(
|
||
"/admin/api/auth/me", headers={"Authorization": f"Bearer {user_token}"}
|
||
)
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_admin_token_cannot_access_app_api(client: TestClient, admin_client: TestClient) -> None:
|
||
"""反向:admin token 也不能访问 App 用户接口(App 用 JWT_SECRET_KEY 验签,admin token 失败)。"""
|
||
username, password = _ensure_admin()
|
||
r = admin_client.post(
|
||
"/admin/api/auth/login", json={"username": username, "password": password}
|
||
)
|
||
admin_token = r.json()["access_token"]
|
||
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
# ============================ 回归:review 修掉的两个 bug ============================
|
||
|
||
def test_long_password_does_not_crash(admin_client: TestClient) -> None:
|
||
""">72 UTF-8 字节的密码(如多个中文)不能让建账号/登录崩(bcrypt 72 字节限制)。"""
|
||
username = "longpw_admin"
|
||
long_pw = "超长密码测试" * 8 # 6 中文 ×8 = 48 字 ≈ 144 字节 UTF-8,远超 72
|
||
_ensure_admin(username=username, password=long_pw) # 建账号不应抛 ValueError
|
||
r = admin_client.post(
|
||
"/admin/api/auth/login", json={"username": username, "password": long_pw}
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
|
||
|
||
def test_audit_log_pagination_no_gap() -> None:
|
||
"""审计分页跨页不丢/不重(offset 分页:cursor 即 offset,翻完覆盖全部)。"""
|
||
from app.admin.repositories import admin_user as admin_repo
|
||
from app.admin.repositories import audit_log as audit_repo
|
||
|
||
_ensure_admin() # 确保有 test_admin 供 FK 引用
|
||
db = SessionLocal()
|
||
try:
|
||
admin = admin_repo.get_by_username(db, "test_admin")
|
||
action = "test.pagination.probe"
|
||
created_ids = []
|
||
for i in range(5):
|
||
log = audit_repo.add_audit_log(
|
||
db, admin_id=admin.id, admin_username=admin.username,
|
||
action=action, target_type="probe", target_id=str(i),
|
||
)
|
||
created_ids.append(log.id)
|
||
|
||
# limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重);total 恒为符合条件总数
|
||
seen: list[int] = []
|
||
cursor = None
|
||
for _ in range(10): # 上限防死循环
|
||
rows, cursor, total = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor)
|
||
seen.extend(r.id for r in rows)
|
||
assert total == len(created_ids), f"total 应为 {len(created_ids)},得 {total}"
|
||
if cursor is None:
|
||
break
|
||
assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}"
|
||
finally:
|
||
db.close()
|