Files
opsxsql/tests/test_permission_workflow.py
T
logan 88e5f4e7ee feat: Go后端LDAP真实对接 — AES-256-GCM密码加解密 + 登录LDAP fallback
- 新增 crypto.go: AES-256-GCM 加解密,与 Python backend 兼容
- auth.go: Login 添加 LDAP fallback(本地优先→LDAP绑定→自动创建本地用户)
- ldap.go: 实现真实 LDAP 连接/用户搜索/密码验证/OU列表/用户列表
- 所有 LDAP 操作解密 admin_password 后使用
- 新增/更新密码存储时加密
- router.go: 补充 delete-user 路由
- 添加 go-ldap/v3 依赖
2026-05-25 23:32:15 +08:00

468 lines
17 KiB
Python

"""权限组 + 审批流 全链路测试。
覆盖:
1. 查询权限: 用户只能查自己被授权的数据源
2. 工单权限: 用户只能对被授权的数据源提交变更
3. 审批流: 工单创建自动启动审批流,多级审批
"""
import json
import sys
import pytest
from contextlib import asynccontextmanager
from unittest.mock import patch, AsyncMock, MagicMock
from app.core.security import create_access_token, hash_password
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.models.ticket import Ticket
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
from tests.conftest import auth_header
def _make_aiomysql_mock():
aiomysql = MagicMock()
aiomysql.connect = AsyncMock()
aiomysql.DictCursor = MagicMock()
return aiomysql
def _make_mock_conn():
mock_cursor = AsyncMock()
mock_cursor.description = [("id",), ("name",)]
mock_cursor.fetchall.return_value = [{"id": 1, "name": "test"}]
mock_cursor.execute = AsyncMock()
@asynccontextmanager
async def mock_cursor_ctx(*args, **kwargs):
yield mock_cursor
mock_conn = AsyncMock()
mock_conn.cursor = mock_cursor_ctx
mock_conn.close = MagicMock()
return mock_conn
# ── Fixtures ─────────────────────────────────────────────────────────
@pytest.fixture
async def grouped_instance(db):
inst = Instance(
name="生产MySQL",
db_type="mysql",
host="10.0.0.1",
port=3306,
username="root",
password="test123",
database="prod_db",
env="production",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def public_instance(db):
inst = Instance(
name="测试PG",
db_type="postgres",
host="127.0.0.1",
port=5432,
username="postgres",
password="test123",
database="test_db",
env="development",
status="disconnected",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@pytest.fixture
async def dev_group(db, grouped_instance):
group = InstanceGroup(
name="开发组",
description="开发人员可用数据源",
db_type="mysql",
permissions=json.dumps(["query:select", "ticket:create"]),
)
db.add(group)
await db.commit()
await db.refresh(group)
grouped_instance.group_id = group.id
await db.commit()
await db.refresh(grouped_instance)
return group
@pytest.fixture
async def dev_in_group(db, dev_group, developer_user):
await db.execute(
user_groups.insert().values(user_id=developer_user.id, group_id=dev_group.id)
)
await db.commit()
return developer_user
@pytest.fixture
async def dev_in_group_token(dev_in_group):
return create_access_token(data={"sub": str(dev_in_group.id), "role": dev_in_group.role})
@pytest.fixture
async def default_workflow(db):
wf = WorkflowDefinition(
name="标准审批流",
description="DBA审核后Admin确认",
is_default=True,
is_active=True,
)
db.add(wf)
await db.commit()
await db.refresh(wf)
step1 = WorkflowStep(
workflow_id=wf.id,
step_number=1,
name="DBA审核",
step_type="serial",
approver_role="dba",
is_required=True,
timeout_hours=24,
)
step2 = WorkflowStep(
workflow_id=wf.id,
step_number=2,
name="管理员确认",
step_type="serial",
approver_role="admin",
is_required=True,
timeout_hours=24,
)
db.add_all([step1, step2])
await db.commit()
return wf
# ── 1. 查询权限测试 ──────────────────────────────────────────────────
class TestQueryPermission:
async def test_admin_can_query_grouped_instance(self, client, admin_token, grouped_instance, dev_group):
"""admin 可以查询绑定权限组的实例"""
mock_conn = _make_mock_conn()
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(admin_token),
)
assert resp.status_code != 403
async def test_dev_in_group_can_query(self, client, dev_in_group_token, grouped_instance, dev_group):
"""developer 在权限组内 → 可以查询"""
mock_conn = _make_mock_conn()
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(dev_in_group_token),
)
assert resp.status_code != 403
async def test_dev_not_in_group_blocked(self, client, developer_token, grouped_instance, dev_group):
"""developer 不在权限组 → 403"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
assert "权限" in resp.json()["detail"]
async def test_dev_can_query_public_instance(self, client, developer_token, public_instance):
"""无权限组的公共实例 → 所有认证用户可查"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(developer_token),
)
assert resp.status_code != 403
async def test_guest_still_blocked(self, client, guest_token, public_instance):
"""guest 被禁止查询"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_dba_not_in_group_blocked(self, client, dba_token, grouped_instance, dev_group):
"""dba 不在权限组也 → 403"""
resp = await client.post(
"/api/v1/query",
json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"},
params={"save_history": "false"},
headers=auth_header(dba_token),
)
assert resp.status_code == 403
# ── 2. 工单权限测试 ──────────────────────────────────────────────────
class TestTicketPermission:
async def test_admin_can_create_ticket_any(self, client, admin_token, grouped_instance, dev_group):
"""admin 可以为任何实例创建工单"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "管理员修改表结构",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "ALTER TABLE users ADD COLUMN age INT",
"reason": "业务需求,增加年龄字段",
"priority": "medium",
},
headers=auth_header(admin_token),
)
assert resp.status_code != 403
async def test_dev_in_group_can_create_ticket(self, client, dev_in_group_token, grouped_instance, dev_group):
"""developer 在权限组内 → 可以创建工单"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "开发修正错误数据",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "UPDATE users SET age = 20 WHERE id = 1",
"reason": "修正用户年龄数据错误",
"priority": "low",
},
headers=auth_header(dev_in_group_token),
)
assert resp.status_code != 403
async def test_dev_not_in_group_cannot_create_ticket(self, client, developer_token, grouped_instance, dev_group):
"""developer 不在权限组 → 403"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "未授权操作工单",
"db_type": "mysql",
"instance_id": grouped_instance.id,
"target_database": "prod_db",
"sql_content": "DELETE FROM users WHERE id = 1",
"reason": "测试未授权操作权限",
"priority": "low",
},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
assert "权限" in resp.json()["detail"]
# ── 3. 审批流对接测试 ──────────────────────────────────────────────────
class TestWorkflowIntegration:
async def test_no_workflow_status_pending(self, client, admin_token, test_instance):
"""无默认审批流 → 状态为 pending"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "简单测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'test' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
async def test_with_workflow_status_reviewing(self, client, admin_token, test_instance, default_workflow):
"""有默认审批流 → 状态为 reviewing"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "审批流测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'test' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "reviewing"
ticket_id = data["id"]
resp2 = await client.get(
f"/api/v1/tickets/{ticket_id}/approvals",
headers=auth_header(admin_token),
)
assert resp2.status_code == 200
approvals = resp2.json()
assert len(approvals) == 2
assert approvals[0]["step_number"] == 1
assert approvals[0]["approver_role"] == "dba"
assert approvals[0]["action"] == "pending"
assert approvals[1]["step_number"] == 2
assert approvals[1]["approver_role"] == "admin"
async def test_multi_step_first_approve_still_reviewing(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 第一步通过 → reviewing"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "多级审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET age = 25 WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "DBA审核通过"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "reviewing"
async def test_multi_step_all_approve_becomes_approved(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 全部通过 → approved"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "完整审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET status = 'active' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "medium",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "DBA通过"},
headers=auth_header(dba_token),
)
resp3 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "管理员确认通过"},
headers=auth_header(admin_token),
)
assert resp3.status_code == 200
assert resp3.json()["status"] == "approved"
async def test_reject_at_any_step(self, client, admin_token, dba_token, test_instance, default_workflow):
"""多级审批: 拒绝 → rejected"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "被拒绝的测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "DELETE FROM logs WHERE id < 1000",
"reason": "清理过期日志数据",
"priority": "high",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "reject", "comment": "风险太高"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "rejected"
async def test_no_workflow_single_step_approve(self, client, admin_token, dba_token, test_instance):
"""无审批流: 传统单步审批直接 approved"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "单步审批测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET name = 'x' WHERE id = 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
resp2 = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "通过"},
headers=auth_header(dba_token),
)
assert resp2.status_code == 200
assert resp2.json()["status"] == "approved"
async def test_approvals_endpoint_empty(self, client, admin_token, test_instance):
"""无审批流 → approvals 返回空列表"""
resp = await client.post(
"/api/v1/tickets",
json={
"title": "无审批流测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT 1",
"reason": "测试审批流功能",
"priority": "low",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
resp2 = await client.get(
f"/api/v1/tickets/{ticket_id}/approvals",
headers=auth_header(admin_token),
)
assert resp2.status_code == 200
assert resp2.json() == []