feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)

This commit is contained in:
logan committed 2026-05-22 13:35:35 +08:00
1 parent e45fbf3586
commit ff0a330e8a
23 files changed
+1404 -159

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ from app.models.user import User
from app.models.instance import Instance
from app.models.ticket import Ticket
from app.models.audit_log import AuditLog
from app.models.audit_rule import AuditRule
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
+165
View File
@@ -0,0 +1,165 @@
"""Tests for Audit Rule CRUD API (Phase 4.2)."""
import pytest
from tests.conftest import auth_header
class TestListAuditRules:
@pytest.mark.asyncio
async def test_list_rules_admin(self, client, admin_token):
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
assert resp.status_code == 200
assert isinstance(resp.json(), list)
@pytest.mark.asyncio
async def test_list_rules_dba(self, client, dba_token):
resp = await client.get("/api/v1/audit-rules", headers=auth_header(dba_token))
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_list_rules_developer_forbidden(self, client, developer_token):
resp = await client.get("/api/v1/audit-rules", headers=auth_header(developer_token))
assert resp.status_code == 403
class TestCreateAuditRule:
@pytest.mark.asyncio
async def test_create_rule_success(self, client, admin_token):
resp = await client.post(
"/api/v1/audit-rules",
json={
"name": "no_select_star",
"description": "禁止 SELECT *",
"rule_type": "performance",
"severity": "warning",
"db_type": "mysql",
"pattern": r"SELECT\s+\*",
"message": "禁止使用 SELECT *,请明确指定字段",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "no_select_star"
assert data["rule_type"] == "performance"
assert data["is_enabled"] is True
@pytest.mark.asyncio
async def test_create_duplicate_name(self, client, admin_token):
payload = {
"name": "dup_rule",
"rule_type": "safety",
"severity": "error",
"message": "test",
}
await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token))
resp = await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token))
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_create_rule_non_admin(self, client, dba_token):
resp = await client.post(
"/api/v1/audit-rules",
json={"name": "dba_rule", "rule_type": "syntax", "severity": "info", "message": "test"},
headers=auth_header(dba_token),
)
assert resp.status_code == 403
class TestGetAuditRule:
@pytest.mark.asyncio
async def test_get_rule(self, client, admin_token):
create = await client.post(
"/api/v1/audit-rules",
json={"name": "get_test", "rule_type": "naming", "severity": "info", "message": "test"},
headers=auth_header(admin_token),
)
rule_id = create.json()["id"]
resp = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["name"] == "get_test"
@pytest.mark.asyncio
async def test_get_nonexistent_rule(self, client, admin_token):
resp = await client.get("/api/v1/audit-rules/99999", headers=auth_header(admin_token))
assert resp.status_code == 404
class TestUpdateAuditRule:
@pytest.mark.asyncio
async def test_update_rule(self, client, admin_token):
create = await client.post(
"/api/v1/audit-rules",
json={"name": "update_test", "rule_type": "safety", "severity": "warning", "message": "old msg"},
headers=auth_header(admin_token),
)
rule_id = create.json()["id"]
resp = await client.put(
f"/api/v1/audit-rules/{rule_id}",
json={"severity": "error", "message": "new msg"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["severity"] == "error"
assert resp.json()["message"] == "new msg"
class TestDeleteAuditRule:
@pytest.mark.asyncio
async def test_delete_rule(self, client, admin_token):
create = await client.post(
"/api/v1/audit-rules",
json={"name": "delete_test", "rule_type": "syntax", "severity": "info", "message": "test"},
headers=auth_header(admin_token),
)
rule_id = create.json()["id"]
resp = await client.delete(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
# Verify deleted
get = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
assert get.status_code == 404
class TestToggleAuditRule:
@pytest.mark.asyncio
async def test_toggle_rule(self, client, admin_token):
create = await client.post(
"/api/v1/audit-rules",
json={"name": "toggle_test", "rule_type": "safety", "severity": "warning", "message": "test"},
headers=auth_header(admin_token),
)
rule_id = create.json()["id"]
assert create.json()["is_enabled"] is True
# Toggle off
resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["is_enabled"] is False
# Toggle back on
resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["is_enabled"] is True
class TestRuleStats:
@pytest.mark.asyncio
async def test_rule_stats(self, client, admin_token):
# Create some rules
await client.post(
"/api/v1/audit-rules",
json={"name": "stats_rule_1", "rule_type": "safety", "severity": "error", "message": "test"},
headers=auth_header(admin_token),
)
await client.post(
"/api/v1/audit-rules",
json={"name": "stats_rule_2", "rule_type": "performance", "severity": "warning", "message": "test"},
headers=auth_header(admin_token),
)
resp = await client.get("/api/v1/audit-rules/stats/summary", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 2
assert data["enabled"] >= 2
assert "by_type" in data
+157
View File
@@ -0,0 +1,157 @@
"""Tests for SQL AST Analyzer (Phase 4.1)."""
import pytest
from app.services.sql_analyzer import SQLAnalyzer, SQLType, RiskLevel
analyzer = SQLAnalyzer()
class TestSQLTypeDetection:
def test_select(self):
result = analyzer.analyze("SELECT id, name FROM users WHERE id = 1;")
assert result.sql_type == SQLType.SELECT
def test_insert(self):
result = analyzer.analyze("INSERT INTO users (name) VALUES ('test');")
assert result.sql_type == SQLType.INSERT
def test_update(self):
result = analyzer.analyze("UPDATE users SET name = 'test' WHERE id = 1;")
assert result.sql_type == SQLType.UPDATE
def test_delete(self):
result = analyzer.analyze("DELETE FROM users WHERE id = 1;")
assert result.sql_type == SQLType.DELETE
def test_drop_table(self):
result = analyzer.analyze("DROP TABLE users;")
assert result.sql_type == SQLType.DROP
def test_truncate(self):
result = analyzer.analyze("TRUNCATE TABLE logs;")
assert result.sql_type == SQLType.TRUNCATE
def test_alter_table(self):
result = analyzer.analyze("ALTER TABLE users ADD COLUMN age INT;")
assert result.sql_type == SQLType.ALTER
def test_create_table(self):
result = analyzer.analyze("CREATE TABLE test (id INT PRIMARY KEY);")
assert result.sql_type == SQLType.CREATE
def test_cte_select(self):
result = analyzer.analyze("WITH cte AS (SELECT 1) SELECT * FROM cte;")
assert result.sql_type == SQLType.SELECT
def test_explain(self):
result = analyzer.analyze("EXPLAIN SELECT * FROM users;")
assert result.sql_type == SQLType.SELECT
class TestReadonlyValidation:
def test_select_is_safe(self):
safe, msg = analyzer.validate_readonly("SELECT id FROM users LIMIT 10;")
assert safe is True
def test_insert_not_safe(self):
safe, msg = analyzer.validate_readonly("INSERT INTO users (name) VALUES ('test');")
assert safe is False
assert "INSERT" in msg
def test_update_not_safe(self):
safe, msg = analyzer.validate_readonly("UPDATE users SET name = 'test' WHERE id = 1;")
assert safe is False
def test_delete_not_safe(self):
safe, msg = analyzer.validate_readonly("DELETE FROM users WHERE id = 1;")
assert safe is False
def test_drop_not_safe(self):
safe, msg = analyzer.validate_readonly("DROP TABLE users;")
assert safe is False
def test_truncate_not_safe(self):
safe, msg = analyzer.validate_readonly("TRUNCATE TABLE logs;")
assert safe is False
def test_alter_not_safe(self):
safe, msg = analyzer.validate_readonly("ALTER TABLE users ADD COLUMN age INT;")
assert safe is False
def test_create_not_safe(self):
safe, msg = analyzer.validate_readonly("CREATE TABLE test (id INT);")
assert safe is False
class TestRiskLevels:
def test_select_is_safe_risk(self):
result = analyzer.analyze("SELECT * FROM users;")
assert result.risk_level == RiskLevel.SAFE
def test_dml_with_where_is_medium(self):
result = analyzer.analyze("UPDATE users SET name = 'a' WHERE id = 1;")
assert result.risk_level == RiskLevel.MEDIUM
def test_dml_without_where_is_high(self):
result = analyzer.analyze("UPDATE users SET name = 'a';")
assert result.risk_level == RiskLevel.HIGH
def test_ddl_is_dangerous(self):
result = analyzer.analyze("DROP TABLE users;")
assert result.risk_level == RiskLevel.DANGEROUS
class TestSecurityChecks:
def test_multi_statement_rejected(self):
safe, msg = analyzer.validate_readonly("SELECT 1; DROP TABLE users;")
assert safe is False
assert "多条" in msg or "分号" in msg
def test_into_outfile_rejected(self):
safe, msg = analyzer.validate_readonly("SELECT * INTO OUTFILE '/tmp/data.txt' FROM users;")
assert safe is False
assert "OUTFILE" in msg
def test_subquery_dml_rejected(self):
safe, msg = analyzer.validate_readonly("SELECT * FROM (DELETE FROM users) AS t;")
assert safe is False
def test_normal_subquery_ok(self):
safe, msg = analyzer.validate_readonly("SELECT * FROM (SELECT id FROM users) AS t;")
assert safe is True
def test_semicolon_in_string_ok(self):
safe, msg = analyzer.validate_readonly("SELECT 'hello;world' FROM users;")
assert safe is True
class TestAnalysis:
def test_has_where(self):
result = analyzer.analyze("SELECT * FROM users WHERE id = 1;")
assert result.has_where is True
def test_no_where(self):
result = analyzer.analyze("SELECT * FROM users;")
assert result.has_where is False
def test_to_dict(self):
result = analyzer.analyze("SELECT id FROM users;")
d = result.to_dict()
assert "sql_type" in d
assert "risk_level" in d
assert "is_readonly" in d
assert d["sql_type"] == "SELECT"
assert d["is_readonly"] is True
def test_complex_select(self):
result = analyzer.analyze("""
SELECT u.id, u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
ORDER BY o.total DESC
LIMIT 50;
""")
assert result.sql_type == SQLType.SELECT
assert result.is_readonly is True
assert result.has_where is True