"""Tests for SQL Audit Engine V2 — 可配置规则引擎 (40+ 规则覆盖测试). 测试覆盖: - DDL 规则 (12条): 主键、注释、索引、类型等 - DML 规则 (8条): WHERE、LIMIT、INSERT、REPLACE 等 - DQL 规则 (5条): SELECT *、LIMIT、函数、排序等 - 命名规范 (5条): 表名、字段名、索引名等 - 性能规则 (8条): 全表扫描、IN、LIKE、JOIN 等 - 安全规则 (5条): 敏感字段、密码、UNION、注释等 """ import pytest from app.services.audit_engine_v2 import ( SQLAuditEngineV2, AuditReportV2, AuditFindingV2, Severity, RuleCategory, AuditRuleV2, ) # ────────────────────────────────────────────── # 引擎基础测试 # ────────────────────────────────────────────── class TestSQLAuditEngineV2: """SQLAuditEngineV2 引擎核心功能测试。""" @pytest.fixture def engine(self): """返回不带数据库的同步引擎实例。""" return SQLAuditEngineV2(db_session=None, env="development") @pytest.mark.asyncio async def test_audit_returns_report(self, engine): """audit() 应返回 AuditReportV2 实例。""" report = await engine.audit("SELECT id, name FROM users LIMIT 10;") assert isinstance(report, AuditReportV2) @pytest.mark.asyncio async def test_clean_sql_passes(self, engine): """规范 SQL 应通过审核且评分为100。""" report = await engine.audit("SELECT id, name FROM users WHERE id = 1 LIMIT 10;") assert report.passed assert report.score == 100 @pytest.mark.asyncio async def test_report_has_summary(self, engine): """审核报告应包含摘要。""" report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1;") assert report.summary assert isinstance(report.summary, str) @pytest.mark.asyncio async def test_report_to_dict_backward_compat(self, engine): """to_dict() 应返回向后兼容的字典格式。""" report = await engine.audit("SELECT * FROM users;") d = report.to_dict() assert "passed" in d assert "score" in d assert "findings" in d assert "summary" in d assert isinstance(d["findings"], list) @pytest.mark.asyncio async def test_error_reduces_score(self, engine): """ERROR 级别发现项应扣10分。""" report = await engine.audit("UPDATE users SET status = 1;") assert report.score < 100 assert not report.passed @pytest.mark.asyncio async def test_db_type_filtering(self, engine): """规则应按 db_type 过滤。""" report_mysql = await engine.audit("SELECT id FROM t LIMIT 10;", db_type="mysql") report_postgres = await engine.audit("SELECT id FROM t LIMIT 10;", db_type="postgres") # 两者都应通过(基本 SELECT) assert report_mysql.passed assert report_postgres.passed @pytest.mark.asyncio async def test_sync_audit(self, engine): """同步审核应正常工作。""" report = engine.audit_sync("SELECT id FROM users WHERE id = 1 LIMIT 10;") assert report.passed assert report.score == 100 @pytest.mark.asyncio async def test_get_rule_list(self, engine): """获取规则列表应返回非空列表。""" rules = await engine.get_rule_list() assert len(rules) >= 40 # 至少40条规则 # ────────────────────────────────────────────── # DDL 规则测试 # ────────────────────────────────────────────── class TestDDLRules: """DDL 规则测试 (DDL-001 ~ DDL-012)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # DDL-001: 建表必须包含主键 @pytest.mark.asyncio async def test_ddl_001_no_primary_key(self, engine): report = await engine.audit("CREATE TABLE users (name VARCHAR(50));") findings = [f for f in report.findings if f.rule_code == "DDL-001"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR @pytest.mark.asyncio async def test_ddl_001_has_primary_key(self, engine): report = await engine.audit("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50));") findings = [f for f in report.findings if f.rule_code == "DDL-001"] assert len(findings) == 0 # DDL-002: 建表必须添加表注释 @pytest.mark.asyncio async def test_ddl_002_no_comment(self, engine): report = await engine.audit("CREATE TABLE users (id INT PRIMARY KEY);") findings = [f for f in report.findings if f.rule_code == "DDL-002"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_ddl_002_has_comment(self, engine): report = await engine.audit( "CREATE TABLE users (id INT PRIMARY KEY) COMMENT='用户表';" ) findings = [f for f in report.findings if f.rule_code == "DDL-002"] assert len(findings) == 0 # DDL-004: 禁止 DROP TABLE @pytest.mark.asyncio async def test_ddl_004_drop_table(self, engine): report = await engine.audit("DROP TABLE users;") findings = [f for f in report.findings if f.rule_code == "DDL-004"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # DDL-005: 禁止 TRUNCATE TABLE @pytest.mark.asyncio async def test_ddl_005_truncate(self, engine): report = await engine.audit("TRUNCATE TABLE users;") findings = [f for f in report.findings if f.rule_code == "DDL-005"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # DDL-006: 单表索引数量不得超过12个 @pytest.mark.asyncio async def test_ddl_006_too_many_indexes(self, engine): # 创建15个索引 idx_defs = ", ".join([f"KEY idx_{i} (col{i})" for i in range(15)]) sql = f"CREATE TABLE t (id INT PRIMARY KEY, {', '.join([f'col{i} INT' for i in range(15)])}, {idx_defs});" report = await engine.audit(sql) findings = [f for f in report.findings if f.rule_code == "DDL-006"] assert len(findings) >= 1 # DDL-007: 单索引字段数不得超过8个 @pytest.mark.asyncio async def test_ddl_007_too_many_index_columns(self, engine): cols = ", ".join([f"col{i}" for i in range(10)]) sql = f"CREATE TABLE t (id INT PRIMARY KEY, KEY idx_big ({cols}));" report = await engine.audit(sql) findings = [f for f in report.findings if f.rule_code == "DDL-007"] assert len(findings) >= 1 # DDL-008: 禁止使用 FLOAT/DOUBLE @pytest.mark.asyncio async def test_ddl_008_float_type(self, engine): report = await engine.audit( "CREATE TABLE t (id INT PRIMARY KEY, price FLOAT);" ) findings = [f for f in report.findings if f.rule_code == "DDL-008"] assert len(findings) >= 1 # DDL-009: CHAR 类型长度不得超过10 @pytest.mark.asyncio async def test_ddl_009_char_too_long(self, engine): report = await engine.audit( "CREATE TABLE t (id INT PRIMARY KEY, code CHAR(20));" ) findings = [f for f in report.findings if f.rule_code == "DDL-009"] assert len(findings) >= 1 # DDL-010: 表名长度不得超过45字符 @pytest.mark.asyncio async def test_ddl_010_long_table_name(self, engine): long_name = "a" * 50 report = await engine.audit( f"CREATE TABLE {long_name} (id INT PRIMARY KEY);" ) findings = [f for f in report.findings if f.rule_code == "DDL-010"] assert len(findings) >= 1 # DDL-011: 禁止外键约束 @pytest.mark.asyncio async def test_ddl_011_foreign_key(self, engine): sql = """CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, FOREIGN KEY (user_id) REFERENCES users(id) );""" report = await engine.audit(sql) findings = [f for f in report.findings if f.rule_code == "DDL-011"] assert len(findings) >= 1 # DDL-012: 必须包含 create_time/update_time @pytest.mark.asyncio async def test_ddl_012_missing_audit_fields(self, engine): report = await engine.audit( "CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(50));" ) findings = [f for f in report.findings if f.rule_code == "DDL-012"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_ddl_012_has_audit_fields(self, engine): report = await engine.audit( "CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(50), create_time DATETIME, update_time DATETIME);" ) findings = [f for f in report.findings if f.rule_code == "DDL-012"] assert len(findings) == 0 # ────────────────────────────────────────────── # DML 规则测试 # ────────────────────────────────────────────── class TestDMLRules: """DML 规则测试 (DML-001 ~ DML-008)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # DML-001: UPDATE/DELETE 必须包含 WHERE @pytest.mark.asyncio async def test_dml_001_update_no_where(self, engine): report = await engine.audit("UPDATE users SET name = 'test';") findings = [f for f in report.findings if f.rule_code == "DML-001"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR @pytest.mark.asyncio async def test_dml_001_delete_no_where(self, engine): report = await engine.audit("DELETE FROM users;") findings = [f for f in report.findings if f.rule_code == "DML-001"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR @pytest.mark.asyncio async def test_dml_001_update_with_where(self, engine): report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1;") findings = [f for f in report.findings if f.rule_code == "DML-001"] assert len(findings) == 0 # DML-003: UPDATE/DELETE 禁止带 LIMIT @pytest.mark.asyncio async def test_dml_003_update_with_limit(self, engine): report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1 LIMIT 10;") findings = [f for f in report.findings if f.rule_code == "DML-003"] assert len(findings) >= 1 # DML-007: 禁止 REPLACE INTO @pytest.mark.asyncio async def test_dml_007_replace_into(self, engine): report = await engine.audit("REPLACE INTO users (id, name) VALUES (1, 'test');") findings = [f for f in report.findings if f.rule_code == "DML-007"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # DML-008: INSERT 必须指定字段列表 @pytest.mark.asyncio async def test_dml_008_insert_no_columns(self, engine): report = await engine.audit("INSERT INTO users VALUES (1, 'test');") findings = [f for f in report.findings if f.rule_code == "DML-008"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_dml_008_insert_with_columns(self, engine): report = await engine.audit("INSERT INTO users (id, name) VALUES (1, 'test');") findings = [f for f in report.findings if f.rule_code == "DML-008"] assert len(findings) == 0 # ────────────────────────────────────────────── # DQL 规则测试 # ────────────────────────────────────────────── class TestDQLRules: """DQL 规则测试 (DQL-001 ~ DQL-005)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # DQL-001: 禁止 SELECT * @pytest.mark.asyncio async def test_dql_001_select_star(self, engine): report = await engine.audit("SELECT * FROM users;") findings = [f for f in report.findings if f.rule_code == "DQL-001"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_dql_001_select_columns(self, engine): report = await engine.audit("SELECT id, name FROM users;") findings = [f for f in report.findings if f.rule_code == "DQL-001"] assert len(findings) == 0 # DQL-002: SELECT 必须有 LIMIT @pytest.mark.asyncio async def test_dql_002_no_limit(self, engine): report = await engine.audit("SELECT id, name FROM users;") findings = [f for f in report.findings if f.rule_code == "DQL-002"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_dql_002_has_limit(self, engine): report = await engine.audit("SELECT id, name FROM users LIMIT 10;") findings = [f for f in report.findings if f.rule_code == "DQL-002"] assert len(findings) == 0 # DQL-003: 禁止在 WHERE 中使用函数 @pytest.mark.asyncio async def test_dql_003_where_function(self, engine): report = await engine.audit("SELECT id FROM users WHERE YEAR(created_at) = 2024;") findings = [f for f in report.findings if f.rule_code == "DQL-003"] # 只要检测到 WHERE + 函数模式 assert len(findings) >= 1 # DQL-005: ORDER BY 必须使用索引字段 @pytest.mark.asyncio async def test_dql_005_order_by(self, engine): report = await engine.audit("SELECT id FROM users ORDER BY name;") findings = [f for f in report.findings if f.rule_code == "DQL-005"] assert len(findings) >= 1 # ────────────────────────────────────────────── # 命名规范测试 # ────────────────────────────────────────────── class TestNamingRules: """命名规范测试 (NAME-001 ~ NAME-005)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # NAME-001: 表名必须小写下划线命名 @pytest.mark.asyncio async def test_name_001_uppercase_table(self, engine): report = await engine.audit("CREATE TABLE UserInfo (id INT PRIMARY KEY);") findings = [f for f in report.findings if f.rule_code == "NAME-001"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_name_001_snake_case_table(self, engine): report = await engine.audit("CREATE TABLE user_info (id INT PRIMARY KEY);") findings = [f for f in report.findings if f.rule_code == "NAME-001"] assert len(findings) == 0 # NAME-002: 字段名必须小写下划线命名 @pytest.mark.asyncio async def test_name_002_uppercase_column(self, engine): report = await engine.audit( "CREATE TABLE t (id INT PRIMARY KEY, UserName VARCHAR(50));" ) findings = [f for f in report.findings if f.rule_code == "NAME-002"] assert len(findings) >= 1 # NAME-004: 主键名必须为 id 或 pk_ 开头 @pytest.mark.asyncio async def test_name_004_bad_pk_name(self, engine): report = await engine.audit( "CREATE TABLE t (user_id INT PRIMARY KEY, name VARCHAR(50));" ) findings = [f for f in report.findings if f.rule_code == "NAME-004"] assert len(findings) >= 1 # ────────────────────────────────────────────── # 性能规则测试 # ────────────────────────────────────────────── class TestPerfRules: """性能规则测试 (PERF-001 ~ PERF-008)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # PERF-002: IN 子句值数量不得超过100个 @pytest.mark.asyncio async def test_perf_002_large_in(self, engine): values = ", ".join([str(i) for i in range(150)]) sql = f"SELECT id FROM users WHERE id IN ({values});" report = await engine.audit(sql) findings = [f for f in report.findings if f.rule_code == "PERF-002"] assert len(findings) >= 1 # PERF-003: 禁止 LIKE '%xxx%' @pytest.mark.asyncio async def test_perf_003_full_like(self, engine): report = await engine.audit("SELECT id FROM users WHERE name LIKE '%test%';") findings = [f for f in report.findings if f.rule_code == "PERF-003"] assert len(findings) >= 1 # PERF-005: 禁止 ORDER BY RAND() @pytest.mark.asyncio async def test_perf_005_order_by_rand(self, engine): report = await engine.audit("SELECT id FROM users ORDER BY RAND() LIMIT 10;") findings = [f for f in report.findings if f.rule_code == "PERF-005"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # PERF-007: 大表禁止 OFFSET 分页 @pytest.mark.asyncio async def test_perf_007_offset(self, engine): report = await engine.audit("SELECT id FROM users LIMIT 10 OFFSET 1000;") findings = [f for f in report.findings if f.rule_code == "PERF-007"] assert len(findings) >= 1 # PERF-008: 禁止 SELECT DISTINCT @pytest.mark.asyncio async def test_perf_008_distinct(self, engine): report = await engine.audit("SELECT DISTINCT name FROM users;") findings = [f for f in report.findings if f.rule_code == "PERF-008"] assert len(findings) >= 1 # ────────────────────────────────────────────── # 安全规则测试 # ────────────────────────────────────────────── class TestSecurityRules: """安全规则测试 (SEC-001 ~ SEC-005)。""" @pytest.fixture def engine(self): return SQLAuditEngineV2(db_session=None, env="development") # SEC-001: 禁止查询敏感字段 @pytest.mark.asyncio async def test_sec_001_sensitive_fields(self, engine): report = await engine.audit("SELECT id, password, secret FROM users;") findings = [f for f in report.findings if f.rule_code == "SEC-001"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # SEC-003: 禁止 UNION @pytest.mark.asyncio async def test_sec_003_union(self, engine): report = await engine.audit("SELECT id FROM users UNION SELECT id FROM orders;") findings = [f for f in report.findings if f.rule_code == "SEC-003"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # SEC-004: 禁止注释符 @pytest.mark.asyncio async def test_sec_004_comment_dash(self, engine): report = await engine.audit("SELECT id FROM users WHERE id = 1; -- comment") findings = [f for f in report.findings if f.rule_code == "SEC-004"] assert len(findings) >= 1 @pytest.mark.asyncio async def test_sec_004_comment_block(self, engine): report = await engine.audit("SELECT id /* comment */ FROM users;") findings = [f for f in report.findings if f.rule_code == "SEC-004"] assert len(findings) >= 1 # SEC-005: 禁止多语句 @pytest.mark.asyncio async def test_sec_005_multi_statement(self, engine): report = await engine.audit("UPDATE users SET name = 'a'; DELETE FROM logs;") findings = [f for f in report.findings if f.rule_code == "SEC-005"] assert len(findings) >= 1 assert findings[0].level == Severity.ERROR # ────────────────────────────────────────────── # 数据类测试 # ────────────────────────────────────────────── class TestAuditDataClasses: """数据类单元测试。""" def test_audit_rule_v2_dataclass(self): """AuditRuleV2 数据类应正常工作。""" rule = AuditRuleV2( code="DDL-001", name="测试规则", category=RuleCategory.DDL, severity=Severity.ERROR, ) assert rule.code == "DDL-001" assert rule.compiled_pattern is None def test_audit_rule_v2_pattern_compile(self): """AuditRuleV2 应能编译正则表达式。""" rule = AuditRuleV2( code="TEST-001", name="测试规则", category=RuleCategory.DDL, severity=Severity.WARNING, pattern=r"^test_", ) assert rule.compiled_pattern is not None assert rule.compiled_pattern.match("test_table") def test_audit_finding_v2(self): """AuditFindingV2 数据类应正常工作。""" finding = AuditFindingV2( rule_code="DDL-001", rule_name="测试规则", level=Severity.ERROR, message="测试消息", position=10, ) assert finding.rule_code == "DDL-001" assert finding.position == 10 def test_audit_report_v2_score_calculation(self): """AuditReportV2 评分计算应正确。""" report = AuditReportV2(sql="SELECT * FROM t;") report.findings = [ AuditFindingV2("DQL-001", "禁止SELECT *", Severity.INFO, "msg1"), AuditFindingV2("DDL-004", "禁止DROP TABLE", Severity.ERROR, "msg2"), ] assert report.score == 88 # 100 - 10(ERROR) - 2(INFO) = 88 assert not report.passed def test_audit_report_v2_empty_findings(self): """无发现项时评分应为100且通过。""" report = AuditReportV2(sql="SELECT id FROM t WHERE id = 1;") assert report.score == 100 assert report.passed assert report.summary == "审核通过,未发现违规项" def test_audit_report_backward_compat(self): """AuditReportV2 应向后兼容 AuditReport 格式。""" report = AuditReportV2(sql="UPDATE t SET a = 1;") report.findings.append( AuditFindingV2("DML-001", "缺少WHERE", Severity.ERROR, "msg") ) d = report.to_dict() assert "passed" in d assert "score" in d assert "findings" in d # 检查旧版格式兼容性 assert d["findings"][0]["rule"] == "DML-001" assert d["findings"][0]["level"] == "error" # ────────────────────────────────────────────── # API 集成测试 # ────────────────────────────────────────────── class TestAuditEngineV2Integration: """V2 引擎与 API 集成测试。""" @pytest.mark.asyncio async def test_ticket_creation_uses_v2_engine(self, client, db, admin_token, test_instance): """创建工单时应使用 V2 引擎进行审核。""" from tests.conftest import auth_header resp = await client.post( "/api/v1/tickets", json={ "title": "V2引擎测试工单", "db_type": "mysql", "instance_id": test_instance.id, "target_database": "test_db", "sql_content": "SELECT * FROM users WHERE id = 1;", "reason": "测试V2审核引擎", "priority": "medium", }, headers=auth_header(admin_token), ) assert resp.status_code == 201 data = resp.json() assert "audit_report" in data report = data["audit_report"] assert "score" in report assert "findings" in report assert "summary" in report # SELECT * 应触发 DQL-001 (INFO) rules = [f["rule"] for f in report["findings"]] assert "DQL-001" in rules or "DQL-002" in rules @pytest.mark.asyncio async def test_audit_endpoint_returns_v2_format(self, client, db, admin_token, test_instance): """审核接口应返回 V2 格式报告。""" from tests.conftest import auth_header resp = await client.post( "/api/v1/tickets/audit", json={ "sql_content": "UPDATE users SET status = 1 WHERE id = 1;", "instance_id": test_instance.id, "database": "test_db", }, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert "score" in data assert "passed" in data assert "summary" in data assert "findings" in data @pytest.mark.asyncio async def test_audit_endpoint_detects_dangerous_sql(self, client, db, admin_token, test_instance): """审核接口应能检测危险 SQL。""" from tests.conftest import auth_header resp = await client.post( "/api/v1/tickets/audit", json={ "sql_content": "DROP TABLE users;", "instance_id": test_instance.id, "database": "test_db", }, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["passed"] is False rules = [f["rule"] for f in data["findings"]] assert "DDL-004" in rules @pytest.mark.asyncio async def test_seed_v2_endpoint(self, client, db, admin_token): """seed-v2 接口应能初始化所有 V2 规则。""" from tests.conftest import auth_header resp = await client.post( "/api/v1/audit-rules/seed-v2", headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) >= 40 # 应创建至少40条规则 # 再次调用应跳过已存在的规则 resp2 = await client.post( "/api/v1/audit-rules/seed-v2", headers=auth_header(admin_token), ) assert resp2.status_code == 200 data2 = resp2.json() assert len(data2) == 0 # 都已存在,不再创建 class TestAllRulesRegistered: """验证所有要求的规则都已注册。""" def test_ddl_rules_registered(self): """DDL 规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 13): assert f"DDL-{i:03d}" in codes, f"DDL-{i:03d} 未注册" def test_dml_rules_registered(self): """DML 规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 9): assert f"DML-{i:03d}" in codes, f"DML-{i:03d} 未注册" def test_dql_rules_registered(self): """DQL 规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 6): assert f"DQL-{i:03d}" in codes, f"DQL-{i:03d} 未注册" def test_naming_rules_registered(self): """命名规范规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 6): assert f"NAME-{i:03d}" in codes, f"NAME-{i:03d} 未注册" def test_perf_rules_registered(self): """性能规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 9): assert f"PERF-{i:03d}" in codes, f"PERF-{i:03d} 未注册" def test_security_rules_registered(self): """安全规则应全部注册。""" from app.services.audit_engine_v2 import get_all_registered_rules rules = get_all_registered_rules() codes = [r.code for r in rules] for i in range(1, 6): assert f"SEC-{i:03d}" in codes, f"SEC-{i:03d} 未注册"