""" 集成测试: 工单全流程 (MySQL/Redis 审批流) 覆盖: 创建→审核→审批→执行→取消→回滚→时间线→统计→权限 """ import pytest import pytest_asyncio from httpx import AsyncClient from app.core.security import hash_password, create_access_token from app.models.user import User from app.models.instance import Instance from app.models.workflow import WorkflowDefinition, WorkflowStep def _auth(token: str) -> dict: return {"Authorization": f"Bearer {token}"} # ── helpers ────────────────────────────────────────────────── async def _create_workflow(client: AsyncClient, admin_token: str, name: str = "默认审批流"): """创建一个简单审批流: developer提交 → dba审批""" resp = await client.post("/api/v1/workflows", json={ "name": name, "description": "测试审批流", "is_default": True, "is_active": True, "steps": [ {"step_order": 1, "step_name": "DBA审批", "step_type": "role", "required_role": "dba", "approval_mode": "any"}, ] }, headers=_auth(admin_token)) return resp async def _create_mysql_instance(client: AsyncClient, admin_token: str, env: str = "development"): resp = await client.post("/api/v1/instances", json={ "name": f"测试MySQL-{env}", "db_type": "mysql", "host": "127.0.0.1", "port": 3306, "username": "root", "password": "test123", "database": "test_db", "env": env, }, headers=_auth(admin_token)) return resp async def _create_redis_instance(client: AsyncClient, admin_token: str): resp = await client.post("/api/v1/instances", json={ "name": "测试Redis", "db_type": "redis", "host": "127.0.0.1", "port": 6379, "username": "default", "password": "test123", "database": "0", "env": "development", }, headers=_auth(admin_token)) return resp async def _create_ticket(client, token, instance_id, sql, db_type="mysql", target_db="test_db", title="测试工单", reason="日常变更"): resp = await client.post("/api/v1/tickets", json={ "title": title, "db_type": db_type, "instance_id": instance_id, "target_database": target_db, "sql_content": sql, "reason": reason, }, headers=_auth(token)) return resp # ══════════════════════════════════════════════════════════════ # 1. MySQL DML 工单全流程 # ══════════════════════════════════════════════════════════════ class TestMySQLDMLFullFlow: """developer创建 → 审核引擎审核 → dba审批通过 → 执行""" async def test_dml_full_lifecycle(self, client, admin_token, dba_token, developer_token): # 准备: 创建实例 + 工作流 inst = await _create_mysql_instance(client, admin_token) assert inst.status_code == 201 instance_id = inst.json()["id"] wf = await _create_workflow(client, admin_token) assert wf.status_code == 201 # developer 创建 DML 工单 ticket = await _create_ticket(client, developer_token, instance_id, sql="UPDATE users SET name='test' WHERE id=1", title="更新用户名-测试工单") assert ticket.status_code == 201 data = ticket.json() tid = data["id"] assert data["status"] in ("pending", "reviewing") assert data["applicant_id"] is not None # dba 审批通过 review = await client.post(f"/api/v1/tickets/{tid}/review", json={ "action": "approve", "comment": "同意执行", }, headers=_auth(dba_token)) assert review.status_code == 200 assert review.json()["status"] in ("approved", "reviewing", "success") # 验证 timeline 存在 timeline = await client.get(f"/api/v1/tickets/{tid}/timeline", headers=_auth(developer_token)) assert timeline.status_code == 200 events = timeline.json() assert len(events) >= 1 # 至少有创建事件 # ══════════════════════════════════════════════════════════════ # 2. MySQL DDL 工单 — 高风险需要更高级别审批 # ══════════════════════════════════════════════════════════════ class TestMySQLDDLFlow: async def test_ddl_high_risk_review(self, client, admin_token, dba_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) # DDL: ALTER TABLE (高风险) ticket = await _create_ticket(client, developer_token, instance_id, sql="ALTER TABLE users ADD COLUMN age INT", title="DDL变更-添加字段") assert ticket.status_code == 201 data = ticket.json() # DDL 审核后 risk_level 应该是 medium 或 high assert data["risk_level"] in ("low", "medium", "high", "critical") async def test_drop_table_critical(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token, env="production") instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="DROP TABLE users", title="删表-高危操作") assert ticket.status_code == 201 data = ticket.json() # DROP TABLE 应该被标记为 critical 或 high assert data["risk_level"] in ("high", "critical", "medium") # ══════════════════════════════════════════════════════════════ # 3. 高危工单拒绝流程 # ══════════════════════════════════════════════════════════════ class TestTicketRejection: async def test_dba_reject_ticket(self, client, admin_token, dba_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="UPDATE users SET status=1", title="全表更新-无WHERE") assert ticket.status_code == 201 tid = ticket.json()["id"] # dba 拒绝 review = await client.post(f"/api/v1/tickets/{tid}/review", json={ "action": "reject", "comment": "缺少WHERE条件,拒绝", }, headers=_auth(dba_token)) assert review.status_code == 200 # 验证状态变为 rejected detail = await client.get(f"/api/v1/tickets/{tid}", headers=_auth(developer_token)) assert detail.status_code == 200 assert detail.json()["status"] == "rejected" # ══════════════════════════════════════════════════════════════ # 4. Redis 工单 # ══════════════════════════════════════════════════════════════ class TestRedisTicketFlow: async def test_redis_set_command(self, client, admin_token, dba_token, developer_token): inst = await _create_redis_instance(client, admin_token) assert inst.status_code == 201 instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="SET mykey myvalue", db_type="redis", target_db="db0", title="Redis-SET操作") assert ticket.status_code == 201 tid = ticket.json()["id"] # dba 审批 review = await client.post(f"/api/v1/tickets/{tid}/review", json={ "action": "approve", "comment": "OK", }, headers=_auth(dba_token)) assert review.status_code == 200 async def test_redis_flushall_critical(self, client, admin_token, developer_token): inst = await _create_redis_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="FLUSHALL", db_type="redis", target_db="db0", title="Redis-清空全部") assert ticket.status_code == 201 # FLUSHALL 应被标记 critical data = ticket.json() assert data["risk_level"] in ("high", "critical") # ══════════════════════════════════════════════════════════════ # 5. 工单取消 # ══════════════════════════════════════════════════════════════ class TestTicketCancel: async def test_cancel_pending_ticket(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="待取消工单") tid = ticket.json()["id"] cancel = await client.post(f"/api/v1/tickets/{tid}/cancel", headers=_auth(developer_token)) assert cancel.status_code == 200 detail = await client.get(f"/api/v1/tickets/{tid}", headers=_auth(developer_token)) assert detail.json()["status"] == "cancelled" # ══════════════════════════════════════════════════════════════ # 6. 工单回滚 # ══════════════════════════════════════════════════════════════ class TestTicketRollback: async def test_rollback_with_rollback_sql(self, client, admin_token, dba_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) # 创建带回滚SQL的工单 ticket = await client.post("/api/v1/tickets", json={ "title": "回滚测试工单", "db_type": "mysql", "instance_id": instance_id, "target_database": "test_db", "sql_content": "UPDATE users SET name='test' WHERE id=1", "rollback_sql": "UPDATE users SET name='original' WHERE id=1", "reason": "测试回滚功能", }, headers=_auth(developer_token)) assert ticket.status_code == 201 tid = ticket.json()["id"] assert ticket.json()["rollback_sql"] is not None # ══════════════════════════════════════════════════════════════ # 7. 工单 Timeline # ══════════════════════════════════════════════════════════════ class TestTicketTimeline: async def test_timeline_records_events(self, client, admin_token, dba_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="INSERT INTO logs (msg) VALUES ('test')", title="Timeline测试") tid = ticket.json()["id"] # 审批 await client.post(f"/api/v1/tickets/{tid}/review", json={ "action": "approve", "comment": "OK", }, headers=_auth(dba_token)) timeline = await client.get(f"/api/v1/tickets/{tid}/timeline", headers=_auth(developer_token)) assert timeline.status_code == 200 events = timeline.json() assert isinstance(events, list) # 至少有: 创建事件 assert len(events) >= 1 # ══════════════════════════════════════════════════════════════ # 8. 工单统计 # ══════════════════════════════════════════════════════════════ class TestTicketStatistics: async def test_statistics_empty(self, client, admin_token): stats = await client.get("/api/v1/tickets/statistics", headers=_auth(admin_token)) assert stats.status_code == 200 data = stats.json() assert isinstance(data, dict) async def test_statistics_after_create(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="统计测试") stats = await client.get("/api/v1/tickets/statistics", headers=_auth(admin_token)) assert stats.status_code == 200 # ══════════════════════════════════════════════════════════════ # 9. 权限控制 # ══════════════════════════════════════════════════════════════ class TestTicketPermissions: async def test_developer_cannot_approve(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) ticket = await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="权限测试") tid = ticket.json()["id"] # developer 尝试审批 → 应该403 review = await client.post(f"/api/v1/tickets/{tid}/review", json={ "action": "approve", "comment": "自己审批自己", }, headers=_auth(developer_token)) assert review.status_code in (403, 400) # 403权限不足或400状态不对 async def test_unauthenticated_cannot_create(self, client, admin_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] ticket = await client.post("/api/v1/tickets", json={ "title": "未认证工单", "db_type": "mysql", "instance_id": instance_id, "target_database": "test_db", "sql_content": "SELECT 1", "reason": "测试未认证", }) assert ticket.status_code == 401 async def test_my_tickets_filter(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) # developer 创建工单 await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="我的工单") # 查看我的工单 my = await client.get("/api/v1/tickets/my-tickets", headers=_auth(developer_token)) assert my.status_code == 200 items = my.json() if isinstance(my.json(), list) else my.json().get("items", []) assert len(items) >= 1 # ══════════════════════════════════════════════════════════════ # 10. SQL 审核 API # ══════════════════════════════════════════════════════════════ class TestSQLAuditAPI: async def test_audit_clean_sql(self, client, admin_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] resp = await client.post("/api/v1/tickets/audit", json={ "sql_content": "SELECT id, name FROM users WHERE id=1", "instance_id": instance_id, "database": "test_db", }, headers=_auth(admin_token)) assert resp.status_code == 200 data = resp.json() assert data["score"] >= 80 # 干净SQL高分 async def test_audit_dangerous_sql(self, client, admin_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] resp = await client.post("/api/v1/tickets/audit", json={ "sql_content": "DELETE FROM users", "instance_id": instance_id, "database": "test_db", }, headers=_auth(admin_token)) assert resp.status_code == 200 data = resp.json() assert data["score"] < 80 # 危险SQL低分 async def test_audit_redis_command(self, client, admin_token): inst = await _create_redis_instance(client, admin_token) instance_id = inst.json()["id"] resp = await client.post("/api/v1/tickets/audit", json={ "sql_content": "FLUSHALL", "instance_id": instance_id, "database": "db0", }, headers=_auth(admin_token)) assert resp.status_code == 200 # ══════════════════════════════════════════════════════════════ # 11. 工单列表过滤 # ══════════════════════════════════════════════════════════════ class TestTicketListFilter: async def test_filter_by_status(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="过滤测试") resp = await client.get("/api/v1/tickets?status=pending", headers=_auth(admin_token)) assert resp.status_code == 200 async def test_filter_by_db_type(self, client, admin_token, developer_token): inst = await _create_mysql_instance(client, admin_token) instance_id = inst.json()["id"] await _create_workflow(client, admin_token) await _create_ticket(client, developer_token, instance_id, sql="SELECT 1", title="类型过滤") resp = await client.get("/api/v1/tickets?db_type=mysql", headers=_auth(admin_token)) assert resp.status_code == 200