Files
group_fqcd_jr/tests/integration/test_auth_login_mysql.py
T
lzf_0626 5018f11843 增加投顾(advisor)角色;修正登录测试的错误假设;修投顾带入的 2 处文档重号
## 投顾角色

投顾线合并后,bootstrap.py 有 10 处 allowed_roles 引用 advisor,
financial_nl2sql_service.py:272 还硬编码检查 {"advisor","operator","admin","super_admin"},
promotion_material_service.py:164 按 "advisor" in context.roles 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。

- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
  + 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
  只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。

权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。

验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。

⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。

## 修正登录测试的一个错误假设

test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。

- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
  并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。

过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。

## 修投顾带入的 2 处文档重号

21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。

验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
2026-09-11 21:49:46 +08:00

236 lines
9.9 KiB
Python
Raw 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.
"""登录接口的端到端验证(真实 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:
"""签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。
用管理员令牌调 `/api/v1/admin/roles`(需要 `audit:read`),走的是
`build_request_context` → `JwtAuthenticator` → `IdentityService.resolve` 全链路,
与真实请求完全一致。再补一条**反向对照**:不带令牌必须 401 ——
否则"返回 200"也可能只是这个端点根本没鉴权。
⚠️ 这里**刻意不用** `/users/me/memory-profile`:它对客户有"必须先完成开户风险测评
问卷"的业务前置,而演示客户 9001 没有测评记录,于是返回 403 ——
那看起来像令牌坏了,实际与令牌无关(见下面那条用例)。
"""
async with client() as http:
response = await login(http, "admin_t", "88888888")
assert response.status_code == 200, response.text
token = response.json()["data"]["access_token"]
authorized = await http.get(
"/api/v1/admin/roles", headers={"Authorization": f"Bearer {token}"}
)
assert authorized.status_code == 200, authorized.text
assert isinstance(authorized.json()["data"], list)
anonymous = await http.get("/api/v1/admin/roles")
assert anonymous.status_code == 401, "不带令牌必须 401,否则上面的 200 说明不了令牌有效"
@pytest.mark.asyncio
async def test_customer_profile_requires_completed_assessment() -> None:
"""画像接口对客户有业务前置:必须先完成开户风险测评问卷。
演示客户 9001 目前**没有测评记录**(`fin_risk_assessment` 没有它的行),所以拿它的
令牌调自己的画像会得到 `403 请先完成开户风险测评问卷`。这不是令牌或 RBAC 的问题
—— 同一个接口用管理员令牌是 200。
把这个条件写成用例,是为了让后来者一眼看到它,而不是像这次的集成测试那样
反复怀疑"是不是登录/令牌坏了"。要演示客户画像,先给 9001 补一条测评记录。
"""
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"]
profile = await http.get(
"/api/v1/users/me/memory-profile",
headers={"Authorization": f"Bearer {token}"},
)
# 令牌是被接受的(不是 401);被拒的原因是业务前置而非鉴权。
assert profile.status_code in (200, 403), profile.text
if profile.status_code == 403:
assert profile.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
assert "测评" in profile.json()["error"]["message"]
@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