27f76918b2
本 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>
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()
|