- 新增 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 依赖
602 lines
21 KiB
Python
602 lines
21 KiB
Python
"""TicketExecutor 多数据库工单执行引擎测试。
|
|
|
|
覆盖:
|
|
1. ExecutionReport 序列化/反序列化
|
|
2. MySQL 语法预校验
|
|
3. PostgreSQL 语法预校验
|
|
4. Redis 命令解析和校验
|
|
5. MongoDB 操作解析和校验
|
|
6. SQL 工具方法: 表名提取、WHERE 子句提取、语句分割
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
from app.services.ticket_executor import (
|
|
TicketExecutor,
|
|
ExecutionReport,
|
|
SyntaxValidationError,
|
|
REDIS_WRITE_COMMANDS,
|
|
MONGO_SUPPORTED_OPERATIONS,
|
|
)
|
|
from app.models.instance import Instance
|
|
from app.models.ticket import Ticket
|
|
|
|
|
|
# ===== ExecutionReport 测试 =====
|
|
|
|
|
|
class TestExecutionReport:
|
|
"""ExecutionReport 数据类测试。"""
|
|
|
|
def test_default_values(self):
|
|
"""默认值应为 success。"""
|
|
report = ExecutionReport()
|
|
assert report.status == "success"
|
|
assert report.affected_rows == 0
|
|
assert report.duration_ms == 0
|
|
assert report.validation_passed is True
|
|
assert report.warnings == []
|
|
assert report.errors == []
|
|
|
|
def test_to_json(self):
|
|
"""应正确序列化为 JSON。"""
|
|
report = ExecutionReport(
|
|
status="success",
|
|
affected_rows=10,
|
|
duration_ms=150,
|
|
before_snapshot="10 rows",
|
|
after_snapshot="15 rows",
|
|
validation_passed=True,
|
|
warnings=["UPDATE 缺少 WHERE"],
|
|
errors=[],
|
|
raw_output="UPDATE 10 rows",
|
|
)
|
|
j = report.to_json()
|
|
data = json.loads(j)
|
|
assert data["status"] == "success"
|
|
assert data["affected_rows"] == 10
|
|
assert data["duration_ms"] == 150
|
|
assert data["warnings"] == ["UPDATE 缺少 WHERE"]
|
|
|
|
def test_from_exception(self):
|
|
"""从异常创建失败的报告。"""
|
|
exc = RuntimeError("连接超时")
|
|
report = ExecutionReport.from_exception(exc, "执行")
|
|
assert report.status == "failed"
|
|
assert report.validation_passed is False
|
|
assert len(report.errors) == 1
|
|
assert "连接超时" in report.errors[0]
|
|
|
|
def test_to_json_chinese(self):
|
|
"""JSON 序列化应保留中文字符。"""
|
|
report = ExecutionReport(raw_output="更新了3行数据")
|
|
j = report.to_json()
|
|
assert "更新了3行数据" in j
|
|
|
|
|
|
# ===== TicketExecutor 初始化 =====
|
|
|
|
|
|
class TestTicketExecutorInit:
|
|
"""TicketExecutor 初始化测试。"""
|
|
|
|
def test_init_creates_analyzer(self):
|
|
"""初始化应创建 SQLAnalyzer。"""
|
|
executor = TicketExecutor()
|
|
assert executor._analyzer is not None
|
|
|
|
|
|
# ===== MySQL 语法预校验 =====
|
|
|
|
|
|
class TestMySQLSyntaxValidation:
|
|
"""MySQL 语法预校验测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_empty_sql_rejected(self):
|
|
"""空 SQL 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="不能为空"):
|
|
self.executor._validate_mysql_syntax("")
|
|
|
|
def test_whitespace_sql_rejected(self):
|
|
"""纯空格 SQL 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="不能为空"):
|
|
self.executor._validate_mysql_syntax(" ")
|
|
|
|
def test_insert_pass(self):
|
|
"""INSERT 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("INSERT INTO users (name) VALUES ('test')")
|
|
|
|
def test_update_pass(self):
|
|
"""UPDATE 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("UPDATE users SET name='x' WHERE id=1")
|
|
|
|
def test_delete_pass(self):
|
|
"""DELETE 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("DELETE FROM users WHERE id=1")
|
|
|
|
def test_drop_pass(self):
|
|
"""DROP 语句应通过校验(DDL 通过工单审批执行)。"""
|
|
self.executor._validate_mysql_syntax("DROP TABLE users")
|
|
|
|
def test_create_pass(self):
|
|
"""CREATE 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("CREATE TABLE test (id INT)")
|
|
|
|
def test_alter_pass(self):
|
|
"""ALTER 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("ALTER TABLE users ADD COLUMN age INT")
|
|
|
|
def test_truncate_pass(self):
|
|
"""TRUNCATE 语句应通过校验。"""
|
|
self.executor._validate_mysql_syntax("TRUNCATE TABLE users")
|
|
|
|
|
|
# ===== PostgreSQL 语法预校验 =====
|
|
|
|
|
|
class TestPostgresSyntaxValidation:
|
|
"""PostgreSQL 语法预校验测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_insert_pass(self):
|
|
"""INSERT 语句应通过校验。"""
|
|
self.executor._validate_postgres_syntax("INSERT INTO users (name) VALUES ('test')")
|
|
|
|
def test_update_pass(self):
|
|
"""UPDATE 语句应通过校验。"""
|
|
self.executor._validate_postgres_syntax("UPDATE users SET name='x' WHERE id=1")
|
|
|
|
def test_delete_pass(self):
|
|
"""DELETE 语句应通过校验。"""
|
|
self.executor._validate_postgres_syntax("DELETE FROM users WHERE id=1")
|
|
|
|
def test_drop_pass(self):
|
|
"""DROP 语句应通过校验(DDL 通过工单审批执行)。"""
|
|
self.executor._validate_postgres_syntax("DROP TABLE users")
|
|
|
|
|
|
# ===== Redis 命令解析 =====
|
|
|
|
|
|
class TestRedisCommandParsing:
|
|
"""Redis 命令解析测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_parse_json_commands(self):
|
|
"""应解析 JSON 格式的命令列表。"""
|
|
content = json.dumps({
|
|
"commands": [
|
|
{"command": "SET key1 value1", "db": 0},
|
|
{"command": "SET key2 value2", "db": 1},
|
|
]
|
|
})
|
|
commands = self.executor._parse_redis_commands(content)
|
|
assert len(commands) == 2
|
|
assert commands[0]["command"] == "SET key1 value1"
|
|
assert commands[0]["db"] == 0
|
|
assert commands[1]["db"] == 1
|
|
|
|
def test_parse_json_single_command(self):
|
|
"""应解析单条 JSON 命令。"""
|
|
content = json.dumps({"command": "SET key1 value1", "db": 2})
|
|
commands = self.executor._parse_redis_commands(content)
|
|
assert len(commands) == 1
|
|
assert commands[0]["command"] == "SET key1 value1"
|
|
assert commands[0]["db"] == 2
|
|
|
|
def test_parse_text_commands(self):
|
|
"""应解析文本格式命令(每行一个)。"""
|
|
content = "SET key1 value1\nSET key2 value2\n# this is a comment"
|
|
commands = self.executor._parse_redis_commands(content)
|
|
assert len(commands) == 2
|
|
assert commands[0]["command"] == "SET key1 value1"
|
|
assert commands[1]["command"] == "SET key2 value2"
|
|
|
|
def test_parse_empty_content(self):
|
|
"""空内容应返回空列表。"""
|
|
commands = self.executor._parse_redis_commands("")
|
|
assert commands == []
|
|
|
|
def test_parse_invalid_json_as_text(self):
|
|
"""无效 JSON 应作为文本解析。"""
|
|
content = "{not valid json"
|
|
commands = self.executor._parse_redis_commands(content)
|
|
# Should fall through to text parsing
|
|
assert len(commands) == 1
|
|
assert commands[0]["command"] == content
|
|
|
|
|
|
# ===== Redis 命令校验 =====
|
|
|
|
|
|
class TestRedisCommandValidation:
|
|
"""Redis 命令格式校验测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_valid_set_command(self):
|
|
"""合法的 SET 命令应通过校验。"""
|
|
self.executor._validate_redis_commands([{"command": "SET key1 value1", "db": 0}])
|
|
|
|
def test_valid_hset_command(self):
|
|
"""合法的 HSET 命令应通过校验。"""
|
|
self.executor._validate_redis_commands([{"command": "HSET myhash field value", "db": 0}])
|
|
|
|
def test_empty_command_rejected(self):
|
|
"""空命令应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="不能为空"):
|
|
self.executor._validate_redis_commands([{"command": "", "db": 0}])
|
|
|
|
def test_no_args_command_with_ping(self):
|
|
"""PING 等无参数命令应通过。"""
|
|
self.executor._validate_redis_commands([{"command": "PING", "db": 0}])
|
|
|
|
def test_no_args_command_without_special(self):
|
|
"""需要参数的命令缺少参数应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="缺少参数"):
|
|
self.executor._validate_redis_commands([{"command": "GET", "db": 0}])
|
|
|
|
def test_invalid_command_name_rejected(self):
|
|
"""非法命令名应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="非法"):
|
|
self.executor._validate_redis_commands([{"command": "123BAD key", "db": 0}])
|
|
|
|
|
|
# ===== MongoDB 操作解析 =====
|
|
|
|
|
|
class TestMongoOperationParsing:
|
|
"""MongoDB 操作解析测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_parse_valid_insert(self):
|
|
"""应解析有效的 insertOne 操作。"""
|
|
content = json.dumps({
|
|
"collection": "users",
|
|
"operation": "insertOne",
|
|
"document": {"name": "test", "age": 25},
|
|
})
|
|
result = self.executor._parse_mongo_operation(content)
|
|
assert result["collection"] == "users"
|
|
assert result["operation"] == "insertOne"
|
|
assert result["document"]["name"] == "test"
|
|
|
|
def test_parse_valid_update(self):
|
|
"""应解析有效的 updateMany 操作。"""
|
|
content = json.dumps({
|
|
"collection": "users",
|
|
"operation": "updateMany",
|
|
"filter": {"status": "inactive"},
|
|
"update": {"$set": {"status": "active"}},
|
|
})
|
|
result = self.executor._parse_mongo_operation(content)
|
|
assert result["operation"] == "updateMany"
|
|
|
|
def test_parse_empty_content_rejected(self):
|
|
"""空内容应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="不能为空"):
|
|
self.executor._parse_mongo_operation("")
|
|
|
|
def test_parse_invalid_json_rejected(self):
|
|
"""无效 JSON 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="JSON"):
|
|
self.executor._parse_mongo_operation("not json")
|
|
|
|
def test_parse_non_dict_rejected(self):
|
|
"""非 JSON 对象应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="JSON 对象"):
|
|
self.executor._parse_mongo_operation("[1, 2, 3]")
|
|
|
|
|
|
# ===== MongoDB 操作校验 =====
|
|
|
|
|
|
class TestMongoOperationValidation:
|
|
"""MongoDB 操作格式校验测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_insertone_valid(self):
|
|
"""insertOne 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "insertOne",
|
|
"document": {"name": "test"},
|
|
})
|
|
|
|
def test_insertmany_valid(self):
|
|
"""insertMany 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "insertMany",
|
|
"documents": [{"name": "a"}, {"name": "b"}],
|
|
})
|
|
|
|
def test_updateone_valid(self):
|
|
"""updateOne 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "updateOne",
|
|
"filter": {"_id": 1},
|
|
"update": {"$set": {"name": "x"}},
|
|
})
|
|
|
|
def test_updatemany_valid(self):
|
|
"""updateMany 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "updateMany",
|
|
"filter": {"status": "old"},
|
|
"update": {"$set": {"status": "new"}},
|
|
})
|
|
|
|
def test_deleteone_valid(self):
|
|
"""deleteOne 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "deleteOne",
|
|
"filter": {"_id": 1},
|
|
})
|
|
|
|
def test_deletemany_valid(self):
|
|
"""deleteMany 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "deleteMany",
|
|
"filter": {"status": "inactive"},
|
|
})
|
|
|
|
def test_replaceone_valid(self):
|
|
"""replaceOne 应通过校验。"""
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "replaceOne",
|
|
"filter": {"_id": 1},
|
|
"document": {"name": "new"},
|
|
})
|
|
|
|
def test_missing_collection_rejected(self):
|
|
"""缺少 collection 字段应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="collection"):
|
|
self.executor._validate_mongo_operation({
|
|
"operation": "insertOne",
|
|
"document": {"name": "test"},
|
|
})
|
|
|
|
def test_missing_operation_rejected(self):
|
|
"""缺少 operation 字段应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="操作类型"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
})
|
|
|
|
def test_unsupported_operation_rejected(self):
|
|
"""不支持的操作应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="不支持"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "createIndex",
|
|
"keys": {"name": 1},
|
|
})
|
|
|
|
def test_insertone_missing_document_rejected(self):
|
|
"""insertOne 缺少 document 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="document"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "insertOne",
|
|
})
|
|
|
|
def test_insertmany_missing_documents_rejected(self):
|
|
"""insertMany 缺少 documents 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="documents"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "insertMany",
|
|
})
|
|
|
|
def test_updateone_missing_filter_rejected(self):
|
|
"""updateOne 缺少 filter 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="filter"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "updateOne",
|
|
"update": {"$set": {"x": 1}},
|
|
})
|
|
|
|
def test_updateone_missing_update_rejected(self):
|
|
"""updateOne 缺少 update 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="update"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "updateOne",
|
|
"filter": {"_id": 1},
|
|
})
|
|
|
|
def test_replaceone_missing_document_rejected(self):
|
|
"""replaceOne 缺少 document 应被拒绝。"""
|
|
with pytest.raises(SyntaxValidationError, match="document"):
|
|
self.executor._validate_mongo_operation({
|
|
"collection": "users",
|
|
"operation": "replaceOne",
|
|
"filter": {"_id": 1},
|
|
})
|
|
|
|
|
|
# ===== SQL 工具方法 =====
|
|
|
|
|
|
class TestSQLUtilities:
|
|
"""SQL 工具方法测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def test_split_statements_basic(self):
|
|
"""应按分号分割语句。"""
|
|
sql = "INSERT INTO t1 VALUES (1); INSERT INTO t2 VALUES (2);"
|
|
stmts = self.executor._split_statements(sql)
|
|
assert len(stmts) == 2
|
|
|
|
def test_split_statements_single(self):
|
|
"""单条语句无分号应返回一条。"""
|
|
sql = "INSERT INTO t1 VALUES (1)"
|
|
stmts = self.executor._split_statements(sql)
|
|
assert len(stmts) == 1
|
|
|
|
def test_split_statements_empty(self):
|
|
"""空内容应返回空列表。"""
|
|
stmts = self.executor._split_statements("")
|
|
assert stmts == []
|
|
|
|
def test_extract_table_name_insert(self):
|
|
"""应从 INSERT 语句中提取表名。"""
|
|
from app.services.sql_analyzer import SQLType
|
|
name = self.executor._extract_table_name(
|
|
"INSERT INTO users (name) VALUES ('test')", SQLType.INSERT
|
|
)
|
|
assert name == "USERS"
|
|
|
|
def test_extract_table_name_update(self):
|
|
"""应从 UPDATE 语句中提取表名。"""
|
|
from app.services.sql_analyzer import SQLType
|
|
name = self.executor._extract_table_name(
|
|
"UPDATE users SET name='x' WHERE id=1", SQLType.UPDATE
|
|
)
|
|
assert name == "USERS"
|
|
|
|
def test_extract_table_name_delete(self):
|
|
"""应从 DELETE 语句中提取表名。"""
|
|
from app.services.sql_analyzer import SQLType
|
|
name = self.executor._extract_table_name(
|
|
"DELETE FROM users WHERE id=1", SQLType.DELETE
|
|
)
|
|
assert name == "USERS"
|
|
|
|
def test_extract_where_clause_found(self):
|
|
"""应从带 WHERE 的 SQL 中提取条件。"""
|
|
where = self.executor._extract_where_clause_text(
|
|
"UPDATE users SET name='x' WHERE id=1 AND status='active'"
|
|
)
|
|
assert where is not None
|
|
assert "id=1" in where
|
|
|
|
def test_extract_where_clause_not_found(self):
|
|
"""没有 WHERE 的 SQL 应返回 None。"""
|
|
where = self.executor._extract_where_clause_text(
|
|
"INSERT INTO users (name) VALUES ('test')"
|
|
)
|
|
assert where is None
|
|
|
|
def test_format_redis_result_nil(self):
|
|
"""None 应格式化为 (nil)。"""
|
|
result = self.executor._format_redis_result(None)
|
|
assert result == "(nil)"
|
|
|
|
def test_format_redis_result_bool(self):
|
|
"""布尔值应格式化为 1/0。"""
|
|
assert self.executor._format_redis_result(True) == "1"
|
|
assert self.executor._format_redis_result(False) == "0"
|
|
|
|
def test_format_redis_result_list(self):
|
|
"""列表应格式化为方括号。"""
|
|
result = self.executor._format_redis_result([b"a", b"b", b"c"])
|
|
assert "[" in result
|
|
assert "a" in result
|
|
|
|
def test_format_redis_result_string(self):
|
|
"""字符串应原样返回。"""
|
|
result = self.executor._format_redis_result("OK")
|
|
assert result == "OK"
|
|
|
|
|
|
# ===== 路由分发 =====
|
|
|
|
|
|
class TestTicketExecutorRouting:
|
|
"""TicketExecutor 路由分发测试。"""
|
|
|
|
def setup_method(self):
|
|
self.executor = TicketExecutor()
|
|
|
|
def _make_ticket(self, sql_content, db_type="mysql", database="test_db"):
|
|
"""创建测试用 Ticket 对象。"""
|
|
ticket = MagicMock(spec=Ticket)
|
|
ticket.sql_content = sql_content
|
|
ticket.target_database = database
|
|
return ticket
|
|
|
|
def _make_instance(self, db_type="mysql"):
|
|
"""创建测试用 Instance 对象。"""
|
|
instance = MagicMock(spec=Instance)
|
|
instance.db_type = db_type
|
|
instance.host = "127.0.0.1"
|
|
instance.port = 3306
|
|
instance.username = "root"
|
|
instance.password = "test123"
|
|
instance.database = "test_db"
|
|
return instance
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsupported_db_type_raises(self):
|
|
"""不支持的数据库类型应抛出 ValueError。"""
|
|
ticket = self._make_ticket("SELECT 1")
|
|
instance = self._make_instance("oracle")
|
|
with pytest.raises(ValueError, match="不支持的执行类型"):
|
|
await self.executor.execute(ticket, instance)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mysql_drop_rejected(self):
|
|
"""MySQL DROP 语句应被语法预校验拒绝。"""
|
|
ticket = self._make_ticket("DROP TABLE users")
|
|
instance = self._make_instance("mysql")
|
|
report = await self.executor.execute(ticket, instance)
|
|
assert report.status == "failed"
|
|
assert report.validation_passed is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_drop_rejected(self):
|
|
"""PostgreSQL DROP 语句应被语法预校验拒绝。"""
|
|
ticket = self._make_ticket("DROP TABLE users")
|
|
instance = self._make_instance("postgres")
|
|
report = await self.executor.execute(ticket, instance)
|
|
assert report.status == "failed"
|
|
assert report.validation_passed is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redis_empty_commands_rejected(self):
|
|
"""Redis 空命令应被拒绝。"""
|
|
ticket = self._make_ticket("")
|
|
instance = self._make_instance("redis")
|
|
report = await self.executor.execute(ticket, instance)
|
|
assert report.status == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mongo_invalid_json_rejected(self):
|
|
"""MongoDB 无效 JSON 应被拒绝。"""
|
|
ticket = self._make_ticket("not json")
|
|
instance = self._make_instance("mongo")
|
|
report = await self.executor.execute(ticket, instance)
|
|
assert report.status == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mongo_missing_collection_rejected(self):
|
|
"""MongoDB 缺少 collection 应被拒绝。"""
|
|
ticket = self._make_ticket(json.dumps({"operation": "insertOne"}))
|
|
instance = self._make_instance("mongo")
|
|
report = await self.executor.execute(ticket, instance)
|
|
assert report.status == "failed"
|