feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)
This commit is contained in:
1 parent
e45fbf3586
commit
ff0a330e8a
23 files changed
+1404
-159
No files matched your search
@@ -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://"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user