- 新增 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 依赖
445 lines
20 KiB
Python
445 lines
20 KiB
Python
"""
|
|
集成测试: 工单全流程 (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
|