185 lines
7.2 KiB
Python
185 lines
7.2 KiB
Python
"""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"] == []
|