b50495bebe
短信(SMS_MOCK 切 mock/real): - integrations/sms.py 重写: real 模式走极光短信 REST /v1/messages 自定义验证码(本服务 secrets 生成 6 位码 + 进程内存 + 本地校验一次性/防爆破), 鉴权复用极光一键登录 JG_APP_KEY/MASTER_SECRET (同一极光应用, 上线只需 SMS_MOCK=false); mock 仍"任意6位通过"不动其余测试 - 防刷四层: 单号冷却 + 单号每日上限 + 单IP rate_limit(/sms/send 10/min、/sms/login 20/min) + 单码失败次数作废; SmsError 带 status_code 映射 429/503/400 - config 增 SMS_SEND_ENDPOINT/SIGN_ID/TEMPLATE_ID/CODE_LENGTH/DAILY_LIMIT/MAX_VERIFY_ATTEMPTS; test_auth 加 real 模式单测; sms.md/后端技术实现/待办账本同步 admin 后台(app/admin/ 独立子应用, uvicorn app.admin.main:admin_app :8771): - 复用主仓 models/repositories/integrations + 同库, 鉴权完全隔离(ADMIN_JWT_SECRET≠JWT_SECRET_KEY + payload typ=admin + bcrypt 密码 + 可选 IP 白名单); 主 app 不 import 本包, admin 崩不影响主进程 - 路由: 登录 / 账号管理(RBAC: super_admin·finance·operator) / 用户列表+360详情+封禁+手动调币 / 钱包流水 / 提现重试对账 / 反馈工单 / 数据大盘; 全写操作落 admin_audit_log(涉钱与业务写同事务) - 涉钱逻辑(调微信/退款/对账)复用 app.repositories.wallet 不重写 - 新增 models/admin.py(AdminUser/AdminAuditLog) + admin_tables 迁移 + create_admin.py + deploy/shaguabijia-admin.service; 依赖加 bcrypt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.7 KiB
Python
156 lines
5.7 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:
|
||
"""审计游标分页跨页不丢/不重(回归 next_cursor off-by-one)。"""
|
||
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 条(无丢无重)
|
||
seen: list[int] = []
|
||
cursor = None
|
||
for _ in range(10): # 上限防死循环
|
||
rows, cursor = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor)
|
||
seen.extend(r.id for r in rows)
|
||
if cursor is None:
|
||
break
|
||
assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}"
|
||
finally:
|
||
db.close()
|