## 投顾角色
投顾线合并后,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 张。
236 lines
9.9 KiB
Python
236 lines
9.9 KiB
Python
"""登录接口的端到端验证(真实 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
|