merge: integrate latest qyqy_develop (auth login + RBAC read) into ZSY branch
Incremental merge on top ofef701c8, which already integrated the earlier qyqy basebbf623a. qyqy_develop only added commits on top ofbbf623a, so this merge is conflict-free. Incoming: account/password login (POST /api/v1/auth/tokens), RBAC read-only query API, rate limit dependency, login test console and user management tools. Additive changes in app/main.py, requirements.txt and pyproject.toml from both sides are all preserved. ZSY side capabilities (visitor tokens, customer service agent, knowledge retrieval, profile projection) are unchanged.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""登录接口的端到端验证(真实 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:
|
||||
"""签出来的令牌必须能真的用 —— 这是本文件不用替身的理由。
|
||||
|
||||
`GET /api/v1/users/me/memory-profile` 需要 `memory:read:self`(客户角色有),
|
||||
走的是 `build_request_context` → `JwtAuthenticator` → `IdentityService.resolve`
|
||||
这条真实链路:令牌只带 `sub`,角色与权限全部查库解析。
|
||||
"""
|
||||
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(
|
||||
"/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
|
||||
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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"] == []
|
||||
Reference in New Issue
Block a user