Files
ouzhou 27f76918b2 feat(admin): 运营后台跟进——review 加固 + 反馈改版 + 列表页码分页 (#51)
本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。

## 1. review 加固 (436b2a3)
- 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局
- 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区
- 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱
- ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验

## 2. 反馈改版 (5a18dbb)
- contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs

## 3. 列表页码分页 (1a7a624)
- CursorPage 加 total;新增 offset_paginate(count 与分页同源)
- 上报/审计日志从 id 游标改 offset 分页(支持跳页)
- 用户 / 提现 / 上报 / 审计日志 四页接入页码分页

测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #51
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-14 22:54:07 +08:00

157 lines
5.8 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.
"""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()