test: P2测试覆盖 — user/pool/schema_drift端点 43个新测试 (895→1017全通过)

新增测试文件:
- test_user_endpoint.py (18 tests): 用户CRUD/列表过滤/权限控制/自删保护
- test_pool_endpoint.py (12 tests): 连接池状态/重置/概览/mock外部依赖
- test_schema_drift_endpoint.py (13 tests): Schema快照捕获/漂移检测/历史查询

覆盖率提升:
- user端点: 0→18 tests (CRUD+权限+重复用户名+自删保护)
- pool端点: 0→12 tests (stats/reset/overview+角色访问控制)
- schema_drift端点: 0→13 tests (capture/check/history+dict序列化兼容)

修复: run.sh启动脚本同步
This commit is contained in:
logan committed 2026-05-23 23:05:03 +08:00
1 parent b39c426317
commit 305bbb153c
8 files changed
+1870 -3

No files matched your search

+32 -3
View File
@@ -9,8 +9,11 @@
- 集成测试: query/ticket 端点权限检查 - 集成测试: query/ticket 端点权限检查
""" """
import json import json
import sys
import pytest import pytest
from contextlib import asynccontextmanager
from fastapi import HTTPException from fastapi import HTTPException
from unittest.mock import patch, AsyncMock, MagicMock
from app.models.instance import Instance from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups from app.models.instance_group import InstanceGroup, user_groups
@@ -243,7 +246,7 @@ class TestPermissionEndpointIntegration:
async def test_query_with_permission( async def test_query_with_permission(
self, client, db, developer_user, group_with_permissions self, client, db, developer_user, group_with_permissions
): ):
"""有 query:select 权限 → 查询不被 403""" """有 query:select 权限 → 查询不被 403(mock DB连接避免挂起)"""
group, instance = group_with_permissions group, instance = group_with_permissions
await db.execute( await db.execute(
user_groups.insert().values( user_groups.insert().values(
@@ -254,12 +257,38 @@ class TestPermissionEndpointIntegration:
token = create_access_token( token = create_access_token(
data={"sub": str(developer_user.id), "role": developer_user.role} data={"sub": str(developer_user.id), "role": developer_user.role}
) )
# Mock aiomysql 防止真实连接挂起(见 skill #20, #37)
mock_cursor = AsyncMock()
mock_cursor.description = [("1",)]
mock_cursor.fetchall.return_value = [{"1": 1}]
mock_cursor.close = AsyncMock()
@asynccontextmanager
async def mock_cursor_ctx(*a, **kw):
yield mock_cursor
mock_conn = AsyncMock()
mock_conn.cursor = mock_cursor_ctx
mock_conn.commit = AsyncMock()
mock_conn.rollback = AsyncMock()
mock_conn.close = AsyncMock()
mock_aiomysql = MagicMock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
mock_aiomysql.DictCursor = MagicMock()
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post( resp = await client.post(
"/api/v1/query", "/api/v1/query",
json={"instance_id": instance.id, "database": "test", "sql": "SELECT 1"}, json={
"instance_id": instance.id,
"database": "test",
"sql": "SELECT 1",
},
headers={"Authorization": f"Bearer {token}"}, headers={"Authorization": f"Bearer {token}"},
) )
# 不应是 403(可能 400/500 因为连不上真DB,但权限通过) # 不应是 403(权限通过,mock 返回成功)
assert resp.status_code != 403 assert resp.status_code != 403
@pytest.mark.asyncio @pytest.mark.asyncio
+329
View File
@@ -0,0 +1,329 @@
"""连接池管理端点完整测试 — pool.py
覆盖端点:
GET /api/v1/pools/instances/{instance_id}/stats 连接池状态
POST /api/v1/pools/instances/{instance_id}/reset 重置连接池
GET /api/v1/pools/overview 所有连接池概览
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from tests.conftest import auth_header
# ═══════════════════════════════════════════════════════════════
# GET /pools/instances/{instance_id}/stats — 连接池状态
# ═══════════════════════════════════════════════════════════════
class TestPoolStats:
async def test_stats_not_initialized(
self, client: AsyncClient, admin_token, test_instance
):
"""连接池未初始化时返回 not_initialized。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.get_pool_stats.return_value = None
mock_pm.return_value = manager
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "not_initialized"
assert body["instance_id"] == test_instance.id
async def test_stats_with_active_pool(
self, client: AsyncClient, admin_token, test_instance
):
"""已初始化的连接池返回统计数据。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
stats = MagicMock()
stats.to_dict.return_value = {
"instance_id": test_instance.id,
"active": 3,
"idle": 2,
"max_size": 10,
}
manager = MagicMock()
manager.get_pool_stats.return_value = stats
mock_pm.return_value = manager
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["active"] == 3
assert body["idle"] == 2
async def test_stats_instance_not_found(
self, client: AsyncClient, admin_token
):
"""数据源不存在返回 404。"""
resp = await client.get(
"/api/v1/pools/instances/9999/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_stats_dba_allowed(
self, client: AsyncClient, dba_token, test_instance
):
"""DBA 角色可以查看连接池状态。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
stats = MagicMock()
stats.to_dict.return_value = {"active": 0, "idle": 0}
manager = MagicMock()
manager.get_pool_stats.return_value = stats
mock_pm.return_value = manager
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_stats_developer_forbidden(
self, client: AsyncClient, developer_token, test_instance
):
"""developer 角色无权查看连接池状态。"""
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_stats_guest_forbidden(
self, client: AsyncClient, guest_token, test_instance
):
"""guest 角色无权查看连接池状态。"""
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_stats_unauthenticated(
self, client: AsyncClient, test_instance
):
"""未认证请求返回 401。"""
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
)
assert resp.status_code == 401
async def test_stats_returns_not_initialized_message(
self, client: AsyncClient, admin_token, test_instance
):
"""未初始化时返回友好提示信息。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.get_pool_stats.return_value = None
mock_pm.return_value = manager
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(admin_token),
)
body = resp.json()
assert "message" in body
assert "首次查询" in body["message"] or "初始化" in body["message"]
# ═══════════════════════════════════════════════════════════════
# POST /pools/instances/{instance_id}/reset — 重置连接池
# ═══════════════════════════════════════════════════════════════
class TestPoolReset:
async def test_reset_success(
self, client: AsyncClient, admin_token, test_instance
):
"""管理员重置连接池成功。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.reset_pool = AsyncMock()
mock_pm.return_value = manager
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert "已重置" in body["message"]
assert body["instance_id"] == test_instance.id
async def test_reset_dba_allowed(
self, client: AsyncClient, dba_token, test_instance
):
"""DBA 角色可以重置连接池。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.reset_pool = AsyncMock()
mock_pm.return_value = manager
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_reset_developer_forbidden(
self, client: AsyncClient, developer_token, test_instance
):
"""developer 角色无权重置连接池。"""
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_reset_guest_forbidden(
self, client: AsyncClient, guest_token, test_instance
):
"""guest 角色无权重置连接池。"""
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_reset_unauthenticated(
self, client: AsyncClient, test_instance
):
"""未认证请求返回 401。"""
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
)
assert resp.status_code == 401
async def test_reset_calls_manager_reset_pool(
self, client: AsyncClient, admin_token, test_instance
):
"""验证 reset_pool 被正确调用。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.reset_pool = AsyncMock()
mock_pm.return_value = manager
await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(admin_token),
)
manager.reset_pool.assert_awaited_once_with(test_instance.id)
# ═══════════════════════════════════════════════════════════════
# GET /pools/overview — 所有连接池概览
# ═══════════════════════════════════════════════════════════════
class TestPoolsOverview:
async def test_overview_empty(self, client: AsyncClient, admin_token):
"""无活跃连接池时返回空概览。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.get_all_stats.return_value = {}
mock_pm.return_value = manager
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["total_pools"] == 0
assert body["pools"] == []
async def test_overview_with_pools(self, client: AsyncClient, admin_token):
"""有连接池时返回正确概览。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
stats1 = MagicMock()
stats1.to_dict.return_value = {"instance_id": 1, "active": 2}
stats2 = MagicMock()
stats2.to_dict.return_value = {"instance_id": 2, "active": 1}
manager = MagicMock()
manager.get_all_stats.return_value = {1: stats1, 2: stats2}
mock_pm.return_value = manager
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["total_pools"] == 2
assert len(body["pools"]) == 2
async def test_overview_dba_allowed(self, client: AsyncClient, dba_token):
"""DBA 角色可以查看连接池概览。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
manager = MagicMock()
manager.get_all_stats.return_value = {}
mock_pm.return_value = manager
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_overview_developer_forbidden(
self, client: AsyncClient, developer_token
):
"""developer 角色无权查看连接池概览。"""
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_overview_guest_forbidden(
self, client: AsyncClient, guest_token
):
"""guest 角色无权查看连接池概览。"""
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_overview_unauthenticated(self, client: AsyncClient):
"""未认证请求返回 401。"""
resp = await client.get("/api/v1/pools/overview")
assert resp.status_code == 401
async def test_overview_single_pool_data(
self, client: AsyncClient, admin_token
):
"""单个连接池数据验证。"""
with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm:
stats = MagicMock()
stats.to_dict.return_value = {
"instance_id": 42,
"active": 5,
"idle": 3,
"max_size": 10,
}
manager = MagicMock()
manager.get_all_stats.return_value = {42: stats}
mock_pm.return_value = manager
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(admin_token),
)
body = resp.json()
assert body["total_pools"] == 1
assert body["pools"][0]["instance_id"] == 42
assert body["pools"][0]["active"] == 5
+186
View File
@@ -0,0 +1,186 @@
"""连接池管理 API 端点测试。"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from httpx import AsyncClient
from app.models.user import User
from app.models.instance import Instance
from app.core.security import hash_password, create_access_token
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
class TestPoolStats:
"""GET /api/v1/pools/instances/{instance_id}/stats"""
async def test_pool_stats_initialized(
self, client: AsyncClient, admin_token: str, test_instance
):
mock_stats = MagicMock()
mock_stats.to_dict.return_value = {
"instance_id": test_instance.id,
"status": "active",
"total_connections": 5,
"active_connections": 2,
"idle_connections": 3,
}
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.get_pool_stats.return_value = mock_stats
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "active"
assert data["total_connections"] == 5
async def test_pool_stats_not_initialized(
self, client: AsyncClient, admin_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.get_pool_stats.return_value = None
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "not_initialized"
async def test_pool_stats_instance_not_found(
self, client: AsyncClient, admin_token: str
):
resp = await client.get(
"/api/v1/pools/instances/9999/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_pool_stats_forbidden_developer(
self, client: AsyncClient, developer_token: str, test_instance
):
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_pool_stats_dba_allowed(
self, client: AsyncClient, dba_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.get_pool_stats.return_value = None
resp = await client.get(
f"/api/v1/pools/instances/{test_instance.id}/stats",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
class TestResetPool:
"""POST /api/v1/pools/instances/{instance_id}/reset"""
async def test_reset_pool_success(
self, client: AsyncClient, admin_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.reset_pool = AsyncMock()
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "已重置" in data["message"]
assert data["instance_id"] == test_instance.id
async def test_reset_pool_dba_allowed(
self, client: AsyncClient, dba_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.reset_pool = AsyncMock()
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_reset_pool_forbidden_developer(
self, client: AsyncClient, developer_token: str, test_instance
):
resp = await client.post(
f"/api/v1/pools/instances/{test_instance.id}/reset",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
class TestPoolsOverview:
"""GET /api/v1/pools/overview"""
async def test_pools_overview_empty(
self, client: AsyncClient, admin_token: str
):
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.get_all_stats.return_value = {}
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["total_pools"] == 0
assert data["pools"] == []
async def test_pools_overview_with_pools(
self, client: AsyncClient, admin_token: str
):
mock_stats = MagicMock()
mock_stats.to_dict.return_value = {
"instance_id": 1,
"status": "active",
"total_connections": 10,
}
with patch(
"app.api.v1.endpoints.pool.get_pool_manager"
) as mock_mgr:
mock_mgr.return_value.get_all_stats.return_value = {1: mock_stats}
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["total_pools"] == 1
assert len(data["pools"]) == 1
async def test_pools_overview_unauthorized(self, client: AsyncClient):
resp = await client.get("/api/v1/pools/overview")
assert resp.status_code == 401
async def test_pools_overview_forbidden_guest(
self, client: AsyncClient, guest_token: str
):
resp = await client.get(
"/api/v1/pools/overview",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
+413
View File
@@ -0,0 +1,413 @@
"""Schema 漂移检测端点完整测试 — schema_drift.py
覆盖端点:
POST /api/v1/schema-drift/instances/{instance_id}/capture 捕获快照
POST /api/v1/schema-drift/instances/{instance_id}/check 检测漂移
GET /api/v1/schema-drift/instances/{instance_id}/history 变更历史
"""
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from tests.conftest import auth_header
# ═══════════════════════════════════════════════════════════════
# POST /schema-drift/instances/{instance_id}/capture — 捕获快照
# ═══════════════════════════════════════════════════════════════
class TestSchemaDriftCapture:
async def test_capture_success(
self, client: AsyncClient, admin_token, test_instance
):
"""管理员成功捕获 Schema 快照。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector_mock = MagicMock()
detector_mock.capture_snapshot = AsyncMock(
return_value=[{"table": "users", "column": "id"}]
)
detector_mock.save_baseline = AsyncMock()
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["message"] == "Schema 快照已捕获"
assert body["instance_id"] == test_instance.id
assert body["total_columns"] == 1
async def test_capture_with_explicit_database(
self, client: AsyncClient, admin_token, test_instance
):
"""通过 query param 指定 database。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector_mock = MagicMock()
detector_mock.capture_snapshot = AsyncMock(return_value=[])
detector_mock.save_baseline = AsyncMock()
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture?database=mydb",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["database"] == "mydb"
async def test_capture_uses_instance_database_as_default(
self, client: AsyncClient, admin_token, test_instance
):
"""不传 database 时使用 instance 的 database 字段。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector_mock = MagicMock()
detector_mock.capture_snapshot = AsyncMock(return_value=[])
detector_mock.save_baseline = AsyncMock()
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# test_instance.database == "test_db"
assert resp.json()["database"] == "test_db"
async def test_capture_instance_not_found(
self, client: AsyncClient, admin_token
):
"""数据源不存在返回 404。"""
resp = await client.post(
"/api/v1/schema-drift/instances/9999/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
async def test_capture_developer_forbidden(
self, client: AsyncClient, developer_token, test_instance
):
"""developer 角色无权捕获快照。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_capture_guest_forbidden(
self, client: AsyncClient, guest_token, test_instance
):
"""guest 角色无权捕获快照。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_capture_dba_allowed(
self, client: AsyncClient, dba_token, test_instance
):
"""DBA 角色可以捕获快照。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector_mock = MagicMock()
detector_mock.capture_snapshot = AsyncMock(return_value=[])
detector_mock.save_baseline = AsyncMock()
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_capture_unauthenticated(
self, client: AsyncClient, test_instance
):
"""未认证请求返回 401。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
)
assert resp.status_code == 401
async def test_capture_multiple_columns(
self, client: AsyncClient, admin_token, test_instance
):
"""快照包含多个列。"""
snapshot = [
{"table": "users", "column": "id"},
{"table": "users", "column": "name"},
{"table": "orders", "column": "id"},
]
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector_mock = MagicMock()
detector_mock.capture_snapshot = AsyncMock(return_value=snapshot)
detector_mock.save_baseline = AsyncMock()
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["total_columns"] == 3
# ═══════════════════════════════════════════════════════════════
# POST /schema-drift/instances/{instance_id}/check — 检测漂移
# ═══════════════════════════════════════════════════════════════
class TestSchemaDriftCheck:
async def test_check_no_drift(
self, client: AsyncClient, admin_token, test_instance
):
"""无漂移时返回正确报告。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
report = MagicMock()
report.to_dict.return_value = {
"drifted": False,
"changes": [],
"instance_id": test_instance.id,
}
detector_mock = MagicMock()
detector_mock.detect_drift = AsyncMock(return_value=report)
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["drifted"] is False
assert body["changes"] == []
async def test_check_with_drift(
self, client: AsyncClient, admin_token, test_instance
):
"""检测到漂移时返回变更列表。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
report = MagicMock()
report.to_dict.return_value = {
"drifted": True,
"changes": [
{"type": "column_added", "table": "users", "column": "age"},
],
}
detector_mock = MagicMock()
detector_mock.detect_drift = AsyncMock(return_value=report)
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["drifted"] is True
assert len(body["changes"]) == 1
async def test_check_instance_not_found(
self, client: AsyncClient, admin_token
):
"""数据源不存在返回 404。"""
resp = await client.post(
"/api/v1/schema-drift/instances/9999/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_check_dba_allowed(
self, client: AsyncClient, dba_token, test_instance
):
"""DBA 角色可以检测漂移。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
report = MagicMock()
report.to_dict.return_value = {"drifted": False, "changes": []}
detector_mock = MagicMock()
detector_mock.detect_drift = AsyncMock(return_value=report)
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_check_developer_forbidden(
self, client: AsyncClient, developer_token, test_instance
):
"""developer 角色无权检测漂移。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_check_guest_forbidden(
self, client: AsyncClient, guest_token, test_instance
):
"""guest 角色无权检测漂移。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_check_unauthenticated(
self, client: AsyncClient, test_instance
):
"""未认证请求返回 401。"""
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
)
assert resp.status_code == 401
async def test_check_with_explicit_database(
self, client: AsyncClient, admin_token, test_instance
):
"""通过 query param 指定 database 检测漂移。"""
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDet, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
report = MagicMock()
report.to_dict.return_value = {"drifted": False}
detector_mock = MagicMock()
detector_mock.detect_drift = AsyncMock(return_value=report)
MockDet.return_value = detector_mock
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check?database=other_db",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# ═══════════════════════════════════════════════════════════════
# GET /schema-drift/instances/{instance_id}/history — 变更历史
# ═══════════════════════════════════════════════════════════════
class TestSchemaHistory:
async def test_history_returns_data(
self, client: AsyncClient, admin_token, test_instance
):
"""管理员可以获取 Schema 变更历史。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
body = resp.json()
assert body["instance_id"] == test_instance.id
assert "history" in body
async def test_history_is_list(
self, client: AsyncClient, admin_token, test_instance
):
"""history 字段为列表。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(admin_token),
)
body = resp.json()
assert isinstance(body["history"], list)
async def test_history_dba_allowed(
self, client: AsyncClient, dba_token, test_instance
):
"""DBA 角色可以查看变更历史。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_history_developer_forbidden(
self, client: AsyncClient, developer_token, test_instance
):
"""developer 角色无权查看变更历史。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_history_guest_forbidden(
self, client: AsyncClient, guest_token, test_instance
):
"""guest 角色无权查看变更历史。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_history_unauthenticated(
self, client: AsyncClient, test_instance
):
"""未认证请求返回 401。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
)
assert resp.status_code == 401
async def test_history_contains_message(
self, client: AsyncClient, admin_token, test_instance
):
"""响应包含 message 字段。"""
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(admin_token),
)
body = resp.json()
assert "message" in body
+231
View File
@@ -0,0 +1,231 @@
"""Schema 漂移检测 API 端点测试。"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from httpx import AsyncClient
from app.models.user import User
from app.models.instance import Instance
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
class TestCaptureSchema:
"""POST /api/v1/schema-drift/instances/{instance_id}/capture"""
async def test_capture_schema_success(
self, client: AsyncClient, admin_token: str, test_instance
):
mock_snapshot = [
{"table": "users", "column": "id", "type": "int"},
{"table": "users", "column": "name", "type": "varchar"},
]
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector = MockDetector.return_value
detector.capture_snapshot = AsyncMock(return_value=mock_snapshot)
detector.save_baseline = AsyncMock()
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "已捕获" in data["message"]
assert data["total_columns"] == 2
assert data["instance_id"] == test_instance.id
async def test_capture_schema_with_database_param(
self, client: AsyncClient, admin_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="decrypted_pw",
):
detector = MockDetector.return_value
detector.capture_snapshot = AsyncMock(return_value=[])
detector.save_baseline = AsyncMock()
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture?database=mydb",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["database"] == "mydb"
async def test_capture_schema_instance_not_found(
self, client: AsyncClient, admin_token: str
):
resp = await client.post(
"/api/v1/schema-drift/instances/9999/capture",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_capture_schema_forbidden_developer(
self, client: AsyncClient, developer_token: str, test_instance
):
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_capture_schema_dba_allowed(
self, client: AsyncClient, dba_token: str, test_instance
):
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="pw",
):
detector = MockDetector.return_value
detector.capture_snapshot = AsyncMock(return_value=[])
detector.save_baseline = AsyncMock()
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/capture",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
class TestCheckDrift:
"""POST /api/v1/schema-drift/instances/{instance_id}/check"""
async def test_check_drift_no_changes(
self, client: AsyncClient, admin_token: str, test_instance
):
mock_report = MagicMock()
mock_report.to_dict.return_value = {
"has_drift": False,
"changes": [],
"instance_id": test_instance.id,
}
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="pw",
):
detector = MockDetector.return_value
detector.detect_drift = AsyncMock(return_value=mock_report)
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["has_drift"] is False
async def test_check_drift_with_changes(
self, client: AsyncClient, admin_token: str, test_instance
):
mock_report = MagicMock()
mock_report.to_dict.return_value = {
"has_drift": True,
"changes": [
{"type": "column_added", "table": "users", "column": "email"},
],
"instance_id": test_instance.id,
}
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="pw",
):
detector = MockDetector.return_value
detector.detect_drift = AsyncMock(return_value=mock_report)
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["has_drift"] is True
assert len(data["changes"]) == 1
async def test_check_drift_instance_not_found(
self, client: AsyncClient, admin_token: str
):
resp = await client.post(
"/api/v1/schema-drift/instances/9999/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_check_drift_dict_report(
self, client: AsyncClient, admin_token: str, test_instance
):
"""Detector 返回普通 dict 而非对象时的序列化。"""
plain_dict = {"has_drift": False, "instance_id": test_instance.id}
with patch(
"app.api.v1.endpoints.schema_drift.SchemaDriftDetector"
) as MockDetector, patch(
"app.api.v1.endpoints.schema_drift.decrypt_password",
return_value="pw",
):
detector = MockDetector.return_value
detector.detect_drift = AsyncMock(return_value=plain_dict)
resp = await client.post(
f"/api/v1/schema-drift/instances/{test_instance.id}/check",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
class TestSchemaHistory:
"""GET /api/v1/schema-drift/instances/{instance_id}/history"""
async def test_schema_history(
self, client: AsyncClient, admin_token: str, test_instance
):
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["instance_id"] == test_instance.id
assert "history" in data
async def test_schema_history_dba_allowed(
self, client: AsyncClient, dba_token: str, test_instance
):
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
async def test_schema_history_forbidden_guest(
self, client: AsyncClient, guest_token: str, test_instance
):
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_schema_history_unauthorized(self, client: AsyncClient, test_instance):
resp = await client.get(
f"/api/v1/schema-drift/instances/{test_instance.id}/history",
)
assert resp.status_code == 401
+449
View File
@@ -0,0 +1,449 @@
"""用户管理端点完整测试 — user.py
覆盖端点:
GET /api/v1/users 列表(支持 role / is_active 筛选)
POST /api/v1/users 创建用户
GET /api/v1/users/{user_id} 获取单个用户
PUT /api/v1/users/{user_id} 更新用户
DELETE /api/v1/users/{user_id} 删除用户
"""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
# ═══════════════════════════════════════════════════════════════
# GET /users — 列表
# ═══════════════════════════════════════════════════════════════
class TestListUsers:
async def test_list_all_users(self, client: AsyncClient, admin_user, admin_token):
"""管理员可以获取全部用户列表。"""
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) >= 1
async def test_list_users_role_filter(self, client: AsyncClient, admin_user, admin_token):
"""按 role 筛选用户。"""
resp = await client.get(
"/api/v1/users?role=admin",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
assert u["role"] == "admin"
async def test_list_users_is_active_filter(
self, client: AsyncClient, admin_user, disabled_user, admin_token
):
"""按 is_active 筛选用户。"""
resp = await client.get(
"/api/v1/users?is_active=false",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
assert u["is_active"] is False
async def test_list_users_combined_filters(
self, client: AsyncClient, admin_user, disabled_user, admin_token
):
"""同时使用 role + is_active 筛选。"""
resp = await client.get(
"/api/v1/users?role=developer&is_active=false",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
for u in resp.json():
assert u["role"] == "developer"
assert u["is_active"] is False
async def test_list_users_empty_result(self, client: AsyncClient, admin_token):
"""不存在的角色返回空列表。"""
resp = await client.get(
"/api/v1/users?role=guest",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json() == []
async def test_list_users_non_admin_forbidden(
self, client: AsyncClient, developer_token
):
"""developer 角色无权访问用户列表。"""
resp = await client.get(
"/api/v1/users", headers=auth_header(developer_token)
)
assert resp.status_code == 403
async def test_list_users_guest_forbidden(
self, client: AsyncClient, guest_token
):
"""guest 角色无权访问用户列表。"""
resp = await client.get(
"/api/v1/users", headers=auth_header(guest_token)
)
assert resp.status_code == 403
async def test_list_users_unauthenticated(self, client: AsyncClient):
"""未认证请求返回 401。"""
resp = await client.get("/api/v1/users")
assert resp.status_code == 401
# ═══════════════════════════════════════════════════════════════
# POST /users — 创建
# ═══════════════════════════════════════════════════════════════
class TestCreateUser:
async def test_create_user_success(self, client: AsyncClient, admin_token):
"""成功创建用户。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "newuser",
"display_name": "新用户",
"password": "secure123",
"role": "developer",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "newuser"
assert data["display_name"] == "新用户"
assert data["role"] == "developer"
assert "id" in data
assert "hashed_password" not in data
async def test_create_user_with_all_fields(self, client: AsyncClient, admin_token):
"""创建用户时填写所有可选字段。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "fulluser",
"display_name": "完整用户",
"password": "secure123",
"email": "full@example.com",
"phone": "13800138000",
"role": "dba",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "full@example.com"
assert data["phone"] == "13800138000"
assert data["role"] == "dba"
async def test_create_duplicate_username(
self, client: AsyncClient, admin_user, admin_token
):
"""用户名重复返回 400。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "admin",
"display_name": "重复",
"password": "123456",
"role": "developer",
},
)
assert resp.status_code == 400
assert "已存在" in resp.json()["detail"]
async def test_create_user_short_password(self, client: AsyncClient, admin_token):
"""密码少于 6 位返回 422。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "shortpw",
"display_name": "短密码",
"password": "123",
"role": "developer",
},
)
assert resp.status_code == 422
async def test_create_user_invalid_role(self, client: AsyncClient, admin_token):
"""无效角色返回 422。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "badrole",
"display_name": "坏角色",
"password": "123456",
"role": "superadmin",
},
)
assert resp.status_code == 422
async def test_create_user_missing_username(self, client: AsyncClient, admin_token):
"""缺少 username 字段返回 422。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"display_name": "无用户名",
"password": "123456",
"role": "developer",
},
)
assert resp.status_code == 422
async def test_create_user_non_admin_forbidden(
self, client: AsyncClient, developer_token
):
"""developer 角色无权创建用户。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(developer_token),
json={
"username": "unauthorized",
"display_name": "不应成功",
"password": "123456",
"role": "developer",
},
)
assert resp.status_code == 403
async def test_create_user_default_role(self, client: AsyncClient, admin_token):
"""不传 role 时默认为 developer。"""
resp = await client.post(
"/api/v1/users",
headers=auth_header(admin_token),
json={
"username": "defaultrole",
"display_name": "默认角色",
"password": "123456",
},
)
assert resp.status_code == 201
assert resp.json()["role"] == "developer"
# ═══════════════════════════════════════════════════════════════
# GET /users/{user_id} — 获取单个
# ═══════════════════════════════════════════════════════════════
class TestGetUser:
async def test_get_user_success(
self, client: AsyncClient, admin_user, admin_token
):
"""成功获取单个用户。"""
resp = await client.get(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
async def test_get_user_nonexistent(self, client: AsyncClient, admin_token):
"""查询不存在的用户返回 404。"""
resp = await client.get(
"/api/v1/users/9999",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
async def test_get_user_non_admin_forbidden(
self, client: AsyncClient, admin_user, developer_token
):
"""developer 角色无权查看用户详情。"""
resp = await client.get(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_get_user_unauthenticated(self, client: AsyncClient, admin_user):
"""未认证请求返回 401。"""
resp = await client.get(f"/api/v1/users/{admin_user.id}")
assert resp.status_code == 401
# ═══════════════════════════════════════════════════════════════
# PUT /users/{user_id} — 更新
# ═══════════════════════════════════════════════════════════════
class TestUpdateUser:
async def test_update_display_name(
self, client: AsyncClient, admin_user, admin_token
):
"""更新 display_name。"""
resp = await client.put(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(admin_token),
json={"display_name": "管理员改名"},
)
assert resp.status_code == 200
assert resp.json()["display_name"] == "管理员改名"
async def test_update_email(
self, client: AsyncClient, admin_user, admin_token
):
"""更新 email。"""
resp = await client.put(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(admin_token),
json={"email": "admin@example.com"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "admin@example.com"
async def test_update_role(
self, client: AsyncClient, admin_user, dba_user, admin_token
):
"""更新用户角色。"""
resp = await client.put(
f"/api/v1/users/{dba_user.id}",
headers=auth_header(admin_token),
json={"role": "developer"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "developer"
async def test_update_is_active(
self, client: AsyncClient, dba_user, admin_token
):
"""禁用用户。"""
resp = await client.put(
f"/api/v1/users/{dba_user.id}",
headers=auth_header(admin_token),
json={"is_active": False},
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
async def test_update_multiple_fields(
self, client: AsyncClient, dba_user, admin_token
):
"""同时更新多个字段。"""
resp = await client.put(
f"/api/v1/users/{dba_user.id}",
headers=auth_header(admin_token),
json={
"display_name": "DBA改名",
"phone": "13900139000",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["display_name"] == "DBA改名"
assert data["phone"] == "13900139000"
async def test_update_nonexistent_user(self, client: AsyncClient, admin_token):
"""更新不存在的用户返回 404。"""
resp = await client.put(
"/api/v1/users/9999",
headers=auth_header(admin_token),
json={"display_name": "不存在"},
)
assert resp.status_code == 404
async def test_update_invalid_role(
self, client: AsyncClient, admin_user, admin_token
):
"""无效角色返回 422。"""
resp = await client.put(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(admin_token),
json={"role": "superadmin"},
)
assert resp.status_code == 422
async def test_update_non_admin_forbidden(
self, client: AsyncClient, admin_user, developer_token
):
"""developer 角色无权更新用户。"""
resp = await client.put(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(developer_token),
json={"display_name": "越权"},
)
assert resp.status_code == 403
# ═══════════════════════════════════════════════════════════════
# DELETE /users/{user_id} — 删除
# ═══════════════════════════════════════════════════════════════
class TestDeleteUser:
async def test_delete_user_success(
self, client: AsyncClient, dba_user, admin_token
):
"""成功删除用户。"""
resp = await client.delete(
f"/api/v1/users/{dba_user.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert "已删除" in resp.json()["message"]
# 确认已被删除
resp2 = await client.get(
f"/api/v1/users/{dba_user.id}",
headers=auth_header(admin_token),
)
assert resp2.status_code == 404
async def test_delete_self(
self, client: AsyncClient, admin_user, admin_token
):
"""不能删除自己。"""
resp = await client.delete(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "不能删除自己" in resp.json()["detail"]
async def test_delete_nonexistent_user(self, client: AsyncClient, admin_token):
"""删除不存在的用户返回 404。"""
resp = await client.delete(
"/api/v1/users/9999",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_delete_non_admin_forbidden(
self, client: AsyncClient, admin_user, developer_token
):
"""developer 角色无权删除用户。"""
resp = await client.delete(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
async def test_delete_unauthenticated(self, client: AsyncClient, admin_user):
"""未认证请求返回 401。"""
resp = await client.delete(f"/api/v1/users/{admin_user.id}")
assert resp.status_code == 401
async def test_delete_guest_forbidden(
self, client: AsyncClient, admin_user, guest_token
):
"""guest 角色无权删除用户。"""
resp = await client.delete(
f"/api/v1/users/{admin_user.id}",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
+230
View File
@@ -0,0 +1,230 @@
"""用户管理 API 端点测试。"""
import pytest
from httpx import AsyncClient
from app.core.security import hash_password, create_access_token
from app.models.user import User
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
class TestListUsers:
"""GET /api/v1/users"""
async def test_list_users_empty(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
# admin_user fixture 创建了 1 个
assert isinstance(data, list)
async def test_list_users_filter_role(
self, client: AsyncClient, db, admin_token: str
):
# 创建一个 dba 用户
dba = User(
username="dba_test",
display_name="DBA",
role="dba",
hashed_password=hash_password("123456"),
)
db.add(dba)
await db.commit()
resp = await client.get(
"/api/v1/users?role=dba", headers=auth_header(admin_token)
)
assert resp.status_code == 200
data = resp.json()
assert all(u["role"] == "dba" for u in data)
async def test_list_users_filter_active(
self, client: AsyncClient, db, admin_token: str
):
dev = User(
username="inactive_dev",
display_name="离线",
role="developer",
is_active=False,
hashed_password=hash_password("123456"),
)
db.add(dev)
await db.commit()
resp = await client.get(
"/api/v1/users?is_active=false", headers=auth_header(admin_token)
)
assert resp.status_code == 200
data = resp.json()
assert all(u["is_active"] is False for u in data)
async def test_list_users_forbidden_non_admin(
self, client: AsyncClient, developer_token: str
):
resp = await client.get("/api/v1/users", headers=auth_header(developer_token))
assert resp.status_code == 403
async def test_list_users_unauthorized(self, client: AsyncClient):
resp = await client.get("/api/v1/users")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestCreateUser:
"""POST /api/v1/users"""
async def test_create_user_success(self, client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/users",
json={
"username": "newuser",
"display_name": "新用户",
"email": "new@test.com",
"phone": "13800001111",
"role": "developer",
"password": "pass123",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "newuser"
assert data["role"] == "developer"
assert "hashed_password" not in data
async def test_create_user_duplicate_username(
self, client: AsyncClient, admin_token: str, admin_user: User
):
resp = await client.post(
"/api/v1/users",
json={
"username": "admin",
"display_name": "重复",
"role": "developer",
"password": "123456",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "已存在" in resp.json()["detail"]
async def test_create_user_forbidden_dba(
self, client: AsyncClient, dba_token: str
):
resp = await client.post(
"/api/v1/users",
json={
"username": "hack",
"display_name": "hack",
"role": "developer",
"password": "123",
},
headers=auth_header(dba_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
class TestGetUser:
"""GET /api/v1/users/{user_id}"""
async def test_get_user_success(
self, client: AsyncClient, admin_token: str, admin_user: User
):
resp = await client.get(
f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token)
)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "admin"
async def test_get_user_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.get(
"/api/v1/users/9999", headers=auth_header(admin_token)
)
assert resp.status_code == 404
async def test_get_user_forbidden(self, client: AsyncClient, developer_token: str):
resp = await client.get(
"/api/v1/users/1", headers=auth_header(developer_token)
)
assert resp.status_code == 403
@pytest.mark.asyncio
class TestUpdateUser:
"""PUT /api/v1/users/{user_id}"""
async def test_update_user_display_name(
self, client: AsyncClient, admin_token: str, dba_user
):
resp = await client.put(
f"/api/v1/users/{dba_user.id}",
json={"display_name": "DBA王五"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["display_name"] == "DBA王五"
async def test_update_user_role(
self, client: AsyncClient, admin_token: str, developer_user
):
resp = await client.put(
f"/api/v1/users/{developer_user.id}",
json={"role": "dba"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["role"] == "dba"
async def test_update_user_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.put(
"/api/v1/users/9999",
json={"display_name": "x"},
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_update_user_partial(
self, client: AsyncClient, admin_token: str, dba_user
):
resp = await client.put(
f"/api/v1/users/{dba_user.id}",
json={"email": "dba@test.com"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["email"] == "dba@test.com"
@pytest.mark.asyncio
class TestDeleteUser:
"""DELETE /api/v1/users/{user_id}"""
async def test_delete_user_success(
self, client: AsyncClient, admin_token: str, developer_user
):
resp = await client.delete(
f"/api/v1/users/{developer_user.id}", headers=auth_header(admin_token)
)
assert resp.status_code == 200
assert "已删除" in resp.json()["message"]
async def test_delete_self_forbidden(
self, client: AsyncClient, admin_token: str, admin_user: User
):
resp = await client.delete(
f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token)
)
assert resp.status_code == 400
assert "不能删除自己" in resp.json()["detail"]
async def test_delete_user_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.delete(
"/api/v1/users/9999", headers=auth_header(admin_token)
)
assert resp.status_code == 404
Regular → Executable
View File
File mode changed.