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 依赖
This commit is contained in:
1 parent
622923821b
commit
88e5f4e7ee
71 files changed
+20390
-64
No files matched your search
@@ -0,0 +1,467 @@
|
||||
"""权限组 + 审批流 全链路测试。
|
||||
|
||||
覆盖:
|
||||
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() == []
|
||||
Reference in new issue
Block a user