Files
group_fqcd_jr/tests/integration/test_rbac_read_mysql.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

185 lines
7.2 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.
"""RBAC 只读接口的端到端验证(真实 MySQL + 真实 HTTP 栈)。
重点不在"能不能查出数据",而在三件事:
1. **门槛对不对** —— 这三个接口暴露的是"谁能访问什么",属管理员级只读,
客户令牌必须 403。权限码复用 `audit:read`(理由见 `rbac_query_service` 的模块文档)。
2. **两个接口说的是不是同一件事** —— `/admin/users/{id}/roles` 走
`IdentityService.resolve`,而登录响应的 `roles` 也走同一条链路;两者必须一致,
否则排障时会被引到错方向。
3. **信封形状** —— 列表的 `data` 是纯数组、分页元数据在 `meta`(`docs/05` §3.3)。
前置:`tools/seed_test_rbac.py` + `tools/set_user_password.py`。
"""
from typing import Any
import httpx
import pytest
from app.main import app
pytestmark = pytest.mark.integration
LOGIN_PATH = "/api/v1/auth/tokens"
class _AlwaysAllowBackend:
"""恒放行,隔离跨用例的限流计数累积(同 test_auth_login_mysql.py)。"""
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
del key, window_seconds
return (1, 0)
@pytest.fixture(autouse=True)
def _replace_rate_limit_backend(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_counter_backend",
lambda: _AlwaysAllowBackend(),
)
def client() -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
)
async def token_for(http: httpx.AsyncClient, username: str, password: str) -> str:
response = await http.post(
LOGIN_PATH, json={"username": username, "password": password}
)
assert response.status_code == 200, response.text
return str(response.json()["data"]["access_token"])
def auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_admin_can_list_roles_with_shape() -> None:
async with client() as http:
token = await token_for(http, "admin_t", "88888888")
response = await http.get("/api/v1/admin/roles", headers=auth(token))
assert response.status_code == 200, response.text
body: dict[str, Any] = response.json()
# §3.3:列表的 data 是纯数组,分页元数据在 meta。
assert isinstance(body["data"], list)
assert set(body["meta"]) == {"trace_id", "next_cursor", "has_more"}
codes = {row["role_code"] for row in body["data"]}
assert {"customer", "risk_operator", "admin"} <= codes, f"角色清单缺内置角色:{codes}"
for row in body["data"]:
assert isinstance(row["permission_count"], int)
assert isinstance(row["user_count"], int)
assert row["status"]
@pytest.mark.asyncio
async def test_role_permissions_are_listed_and_sorted() -> None:
async with client() as http:
token = await token_for(http, "admin_t", "88888888")
response = await http.get(
"/api/v1/admin/roles/customer/permissions", headers=auth(token)
)
assert response.status_code == 200, response.text
body = response.json()
assert isinstance(body["data"], list) and body["data"], "客户角色不该没有任何权限"
codes = [row["permission_code"] for row in body["data"]]
assert codes == sorted(codes), "权限清单应按权限码排序,便于与代码里的 require() 对照"
assert {"agent:run", "suitability:read"} <= set(codes)
@pytest.mark.asyncio
async def test_role_detail_is_separate_from_permissions() -> None:
"""权限为空的角色也必须能查到详情,不能被当成"角色不存在"。"""
async with client() as http:
token = await token_for(http, "admin_t", "88888888")
detail = await http.get("/api/v1/admin/roles/operator", headers=auth(token))
assert detail.status_code == 200, detail.text
assert detail.json()["data"]["role_code"] == "operator"
@pytest.mark.asyncio
async def test_customer_token_is_denied() -> None:
"""门槛验证:客户不能读"谁能访问什么"。"""
async with client() as http:
token = await token_for(http, "cust_t", "123456")
for path in (
"/api/v1/admin/roles",
"/api/v1/admin/roles/customer/permissions",
"/api/v1/admin/users/9001/roles",
):
response = await http.get(path, headers=auth(token))
assert response.status_code == 403, f"{path} 不该让客户访问({response.status_code})"
assert response.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
@pytest.mark.asyncio
async def test_anonymous_is_unauthorized() -> None:
async with client() as http:
response = await http.get("/api/v1/admin/roles")
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
@pytest.mark.asyncio
async def test_unknown_role_and_user_are_not_found() -> None:
async with client() as http:
token = await token_for(http, "admin_t", "88888888")
role = await http.get("/api/v1/admin/roles/no_such_role", headers=auth(token))
user = await http.get("/api/v1/admin/users/99999999/roles", headers=auth(token))
assert role.status_code == 404
assert user.status_code == 404
@pytest.mark.asyncio
async def test_user_identity_agrees_with_login_response() -> None:
"""交叉验证:两个接口必须说同一件事。
登录响应的 `roles` 与 `/admin/users/{id}/roles` 的 `roles` 都来自
`IdentityService.resolve`;若哪天有人给其中一条路径加了缓存或另写一份 SQL,
这个断言会立刻发现。
"""
async with client() as http:
login = await http.post(
LOGIN_PATH, json={"username": "risk_t", "password": "666666"}
)
assert login.status_code == 200, login.text
login_data = login.json()["data"]
admin_token = await token_for(http, "admin_t", "88888888")
identity = await http.get(
f"/api/v1/admin/users/{login_data['user_id']}/roles", headers=auth(admin_token)
)
assert identity.status_code == 200, identity.text
data = identity.json()["data"]
assert data["roles"] == login_data["roles"], "两个接口解析出的角色不一致"
assert data["data_scope"] == login_data["data_scope"], "两个接口解析出的数据范围不一致"
assert data["username"] == "risk_t"
assert "audit:read" in data["permissions"]
@pytest.mark.asyncio
async def test_deactivated_account_reports_empty_permissions_not_404() -> None:
"""被停用的账号:**存在**但没有权限。返回空权限集比 404 更有助于排障。"""
async with client() as http:
admin_token = await token_for(http, "admin_t", "88888888")
# 9004(review_t) 是种子里的账号:仅绑了角色但没设密码,且此处不依赖密码。
response = await http.get(
"/api/v1/admin/users/9004/roles", headers=auth(admin_token)
)
assert response.status_code == 200, response.text
data = response.json()["data"]
assert data["username"] == "review_t"
# 它没有 sys_user_role 绑定,因此 roles 为空 —— 但不该是 404。
assert data["roles"] == []