增加投顾(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 张。
This commit is contained in:
2026-09-11 21:49:46 +08:00
parent c4a73b735e
commit 5018f11843
7 changed files with 299 additions and 16 deletions
+41 -7
View File
@@ -126,22 +126,56 @@ async def test_each_role_can_login_with_its_own_role(
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`,角色与权限全部查库解析。
用管理员令牌调 `/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"]
authorized = await http.get(
profile = 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
# 令牌是被接受的(不是 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