"""登录接口的端到端验证(真实 MySQL + 真实 HTTP 栈)。 这里刻意**不用替身**:登录的价值就在于"签出来的令牌能不能真的用", 用 mock 验证等于只测了自己写的桩。所以每个用例都走 `app.main.app` 的 ASGI 栈, 并且至少有一个用例拿令牌去调**另一个真实接口**。 前置:`python tools/seed_test_rbac.py`(用户与角色)与 `python tools/set_user_password.py`(演示口令)。 覆盖的安全约定(与 `app/service/auth_service.py` 的模块文档一一对应): 1. 三个角色各自能登录,且拿到的 `roles` 正确 —— 这正是"区分客户/员工/管理员"的落点; 2. 密码错与外挂账号**返回完全相同的响应**,接口不能当账号枚举器; 3. 从没设过密码的账号(占位符哈希)不能登录,且不能变成 500。 """ from typing import Any import httpx import pytest from app.api.dependencies.rate_limit import LOGIN_MAX_ATTEMPTS from app.main import app pytestmark = pytest.mark.integration LOGIN_PATH = "/api/v1/auth/tokens" #: 演示账号(tools/set_user_password.py 设置)。 DEMO_ACCOUNTS = ( ("cust_t", "123456", "customer"), ("risk_t", "666666", "risk_operator"), ("admin_t", "88888888", "admin"), ) #: `sys_user.password_hash` 仍是占位符的账号(没设过密码,不该能登录)。 PLACEHOLDER_ACCOUNTS = ("review_t", "offsite_worker") def client() -> httpx.AsyncClient: return httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30 ) class _AlwaysAllowBackend: """恒放行:计数 1,远低于上限。""" async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None: del key, window_seconds return (1, 0) class _AlwaysDenyBackend: """恒超限:用来验证登录闸门确实会拦。""" async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None: del key, window_seconds return (LOGIN_MAX_ATTEMPTS + 1, 30) @pytest.fixture(autouse=True) def _replace_rate_limit_backend(monkeypatch: pytest.MonkeyPatch) -> None: """把限流后端换成恒放行替身,只作用于本文件。 为什么必须换:本文件所有用例加起来要发十几次登录请求,而登录闸门是 60 秒 10 次。 限流对所有请求生效(包括测试自己发的),Redis 里的计数还会**跨测试累积** —— 于是后面的用例拿到 429 而不是想断言的 200/401。那是用例互相污染,不是产品缺陷。 `get_counter_backend` 正是为此留的替换点(见它的文档字符串:"模块级函数是唯一的 替换点(测试注入替身,不连 Redis)")。限流本身由下面那个用例单独验证, 不会被这个替身掩盖掉。 """ monkeypatch.setattr( "app.api.dependencies.rate_limit.get_counter_backend", lambda: _AlwaysAllowBackend(), ) @pytest.mark.asyncio async def test_login_is_actually_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None: """登录闸门必须真的会拦 —— 它是密码爆破的唯一防线。 用一个恒超限的替身后端验证"接了闸门且会抛 429",与上面那些替身用例互补: 那些证明认证逻辑对,这个证明防线在。 """ monkeypatch.setattr( "app.api.dependencies.rate_limit.get_counter_backend", lambda: _AlwaysDenyBackend(), ) async with client() as http: response = await login(http, "cust_t", "123456") assert response.status_code == 429 assert response.json()["error"]["code"] == "RATE_LIMITED" assert response.json()["error"]["retryable"] is True async def login( http: httpx.AsyncClient, username: str, password: str ) -> httpx.Response: return await http.post(LOGIN_PATH, json={"username": username, "password": password}) @pytest.mark.parametrize(("username", "password", "expected_role"), DEMO_ACCOUNTS) @pytest.mark.asyncio async def test_each_role_can_login_with_its_own_role( username: str, password: str, expected_role: str ) -> None: """客户、员工、管理员各自登录,拿到的 `roles` 就是区分三种登录的落点。""" async with client() as http: response = await login(http, username, password) assert response.status_code == 200, response.text body: dict[str, Any] = response.json() # docs/05 §3.3:业务字段全在 data 里,meta 只有 trace_id。 assert set(body) == {"data", "meta"} assert set(body["meta"]) == {"trace_id"} data = body["data"] assert data["token_type"] == "Bearer" assert data["expires_in"] == 1800 assert expected_role in data["roles"], f"{username} 的角色里没有 {expected_role}" assert data["access_token"] @pytest.mark.asyncio async def test_issued_token_actually_works_on_a_real_endpoint() -> None: """签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。 `GET /api/v1/users/me/memory-profile` 需要 `memory:read:self`(客户角色有), 走的是 `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` 这条真实链路:令牌只带 `sub`,角色与权限全部查库解析。 """ async with client() as http: response = await login(http, "cust_t", "123456") assert response.status_code == 200, response.text token = response.json()["data"]["access_token"] authorized = await http.get( "/api/v1/users/me/memory-profile", headers={"Authorization": f"Bearer {token}"}, ) # 200=有画像,404=该客户还没有画像行;两者都说明**令牌被接受并通过了 RBAC**。 # 401/403 则说明令牌或身份解析链有问题。 assert authorized.status_code in (200, 404), authorized.text @pytest.mark.asyncio async def test_missing_and_malformed_token_are_rejected() -> None: async with client() as http: missing = await http.get("/api/v1/users/me/memory-profile") malformed = await http.get( "/api/v1/users/me/memory-profile", headers={"Authorization": "Bearer not-a-jwt"}, ) assert missing.status_code == 401 assert malformed.status_code == 401 # auth.py 的约定:令牌缺失/非法/吊销不区分,都不泄露内部原因。 assert missing.json()["error"]["code"] == "AUTHENTICATION_REQUIRED" assert malformed.json()["error"]["code"] == "AUTHENTICATION_REQUIRED" @pytest.mark.asyncio async def test_wrong_password_and_unknown_user_are_indistinguishable() -> None: """接口不能当账号枚举器:两种失败的**状态码与消息**必须完全一致。""" async with client() as http: wrong_password = await login(http, "cust_t", "definitely-wrong") unknown_user = await login(http, "no-such-user-at-all", "whatever") assert wrong_password.status_code == 401 assert unknown_user.status_code == 401 assert wrong_password.json()["error"]["message"] == unknown_user.json()["error"]["message"] assert wrong_password.json()["error"]["code"] == unknown_user.json()["error"]["code"] # 也不该回显是哪个字段错了。 assert wrong_password.json()["error"]["field_errors"] == [] @pytest.mark.parametrize("username", PLACEHOLDER_ACCOUNTS) @pytest.mark.asyncio async def test_account_without_real_password_cannot_login(username: str) -> None: """没设过密码的账号(`password_hash` 是占位符)必须 401,而不是 500。 `'x'` 与 `!worker-only-no-password-login!` 都不是合法 bcrypt 格式, `bcrypt.checkpw` 会抛 `ValueError` —— `verify_password` 吞掉它并返回 False。 """ async with client() as http: response = await login(http, username, "123456") assert response.status_code == 401, response.text @pytest.mark.asyncio async def test_extra_fields_in_login_body_are_rejected() -> None: """`extra="forbid"`:调用方不能借登录接口塞身份字段。""" async with client() as http: response = await http.post( LOGIN_PATH, json={"username": "cust_t", "password": "123456", "roles": ["admin"]}, ) assert response.status_code == 422