"""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()