"""权限组 + 审批流 全链路测试。 覆盖: 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 pytestmark = pytest.mark.skip(reason="Skip: awaiting Go backend alignment") 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() == []