"""审核规则管理端点全面测试。 覆盖所有 9 个端点的正常/异常路径,包括: - 权限校验(admin 可读写, dba 只读, developer/guest 禁止访问) - 列表过滤(rule_type, severity, db_type, is_enabled) - 创建/更新/删除/切换启用状态 - 统计摘要 - 种子数据(V1 seed/{db_type} 和 V2 seed-v2) """ import pytest from tests.conftest import auth_header # ── 通用测试载荷 ────────────────────────────────────────────── VALID_RULE_PAYLOAD = { "name": "禁止SELECT星号", "description": "禁止使用 SELECT * 查询", "rule_type": "performance", "severity": "warning", "db_type": "mysql", "pattern": r"SELECT\s+\*", "value": None, "is_enabled": True, "message": "禁止使用 SELECT *,请明确指定字段", } PREFIX = "/api/v1/audit-rules" # ══════════════════════════════════════════════════════════════ # 一、列表端点 GET /api/v1/audit-rules # ══════════════════════════════════════════════════════════════ class TestListAuditRules: """GET /audit-rules — 列表 & 过滤""" @pytest.mark.asyncio async def test_admin列表返回200(self, client, admin_token): """admin 角色可正常获取列表""" resp = await client.get(PREFIX, headers=auth_header(admin_token)) assert resp.status_code == 200 assert isinstance(resp.json(), list) @pytest.mark.asyncio async def test_dba列表返回200(self, client, dba_token): """dba 角色可正常获取列表(只读权限)""" resp = await client.get(PREFIX, headers=auth_header(dba_token)) assert resp.status_code == 200 @pytest.mark.asyncio async def test_developer无权限返回403(self, client, developer_token): """developer 角色无权限""" resp = await client.get(PREFIX, headers=auth_header(developer_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test_guest无权限返回403(self, client, guest_token): """guest 角色无权限""" resp = await client.get(PREFIX, headers=auth_header(guest_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test无token返回401(self, client): """未携带 JWT 应返回 401""" resp = await client.get(PREFIX) assert resp.status_code == 401 @pytest.mark.asyncio async def test按rule_type过滤(self, client, admin_token): """通过 rule_type 查询参数过滤""" # 创建一条 performance 规则 await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "perf-rule"}, headers=auth_header(admin_token), ) resp = await client.get( f"{PREFIX}?rule_type=performance", headers=auth_header(admin_token) ) assert resp.status_code == 200 for rule in resp.json(): assert rule["rule_type"] == "performance" @pytest.mark.asyncio async def test按severity过滤(self, client, admin_token): """通过 severity 查询参数过滤""" await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "sev-rule", "severity": "error"}, headers=auth_header(admin_token), ) resp = await client.get( f"{PREFIX}?severity=error", headers=auth_header(admin_token) ) assert resp.status_code == 200 for rule in resp.json(): assert rule["severity"] == "error" @pytest.mark.asyncio async def test按db_type过滤(self, client, admin_token): """通过 db_type 过滤,应同时包含 db_type='all' 的规则""" await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "mysql-rule", "db_type": "mysql"}, headers=auth_header(admin_token), ) resp = await client.get( f"{PREFIX}?db_type=mysql", headers=auth_header(admin_token) ) assert resp.status_code == 200 for rule in resp.json(): assert rule["db_type"] in ("mysql", "all") @pytest.mark.asyncio async def test按is_enabled过滤(self, client, admin_token): """通过 is_enabled 过滤已启用/已禁用规则""" # 创建一条规则后禁用 create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "disabled-rule"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] await client.post( f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) ) # 只查已禁用的 resp = await client.get( f"{PREFIX}?is_enabled=false", headers=auth_header(admin_token) ) assert resp.status_code == 200 for rule in resp.json(): assert rule["is_enabled"] is False @pytest.mark.asyncio async def test多条件联合过滤(self, client, admin_token): """同时传入多个过滤参数""" await client.post( PREFIX, json={ **VALID_RULE_PAYLOAD, "name": "multi-filter", "rule_type": "DDL", "severity": "error", }, headers=auth_header(admin_token), ) resp = await client.get( f"{PREFIX}?rule_type=DDL&severity=error&is_enabled=true", headers=auth_header(admin_token), ) assert resp.status_code == 200 for rule in resp.json(): assert rule["rule_type"] == "DDL" assert rule["severity"] == "error" assert rule["is_enabled"] is True @pytest.mark.asyncio async def test空数据库返回空列表(self, client, admin_token): """无数据时返回空列表""" resp = await client.get(PREFIX, headers=auth_header(admin_token)) assert resp.status_code == 200 assert resp.json() == [] # ══════════════════════════════════════════════════════════════ # 二、创建端点 POST /api/v1/audit-rules # ══════════════════════════════════════════════════════════════ class TestCreateAuditRule: """POST /audit-rules — 创建规则""" @pytest.mark.asyncio async def test创建成功返回201(self, client, admin_token): """正常创建,返回 201 + 完整对象""" resp = await client.post( PREFIX, json=VALID_RULE_PAYLOAD, headers=auth_header(admin_token) ) assert resp.status_code == 201 data = resp.json() assert data["id"] is not None assert data["name"] == VALID_RULE_PAYLOAD["name"] assert data["rule_type"] == "performance" assert data["severity"] == "warning" assert data["is_enabled"] is True assert "created_at" in data assert "updated_at" in data @pytest.mark.asyncio async def test重复名称返回400(self, client, admin_token): """同名规则不允许重复创建""" payload = {**VALID_RULE_PAYLOAD, "name": "dup-name"} await client.post(PREFIX, json=payload, headers=auth_header(admin_token)) resp = await client.post(PREFIX, json=payload, headers=auth_header(admin_token)) assert resp.status_code == 400 assert "已存在" in resp.json()["detail"] @pytest.mark.asyncio async def test_dba创建返回403(self, client, dba_token): """dba 只有只读权限,不能创建""" resp = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dba-create"}, headers=auth_header(dba_token), ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_developer创建返回403(self, client, developer_token): """developer 无权限""" resp = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dev-create"}, headers=auth_header(developer_token), ) assert resp.status_code == 403 @pytest.mark.asyncio async def test创建时仅必填字段(self, client, admin_token): """只提供 name/rule_type/message 三个必填字段也可成功""" resp = await client.post( PREFIX, json={ "name": "minimal-rule", "rule_type": "syntax", "message": "语法检查", }, headers=auth_header(admin_token), ) assert resp.status_code == 201 data = resp.json() assert data["severity"] == "warning" # 默认值 assert data["db_type"] == "mysql" # 默认值 assert data["is_enabled"] is True # 默认值 @pytest.mark.asyncio async def test创建全部rule_type合法(self, client, admin_token): """所有合法 rule_type 枚举均可创建""" for rt in ("syntax", "performance", "safety", "naming", "DDL", "DML"): resp = await client.post( PREFIX, json={ "name": f"rt-{rt}", "rule_type": rt, "message": f"类型{rt}规则", }, headers=auth_header(admin_token), ) assert resp.status_code == 201, f"rule_type={rt} 创建失败" @pytest.mark.asyncio async def test创建全部severity合法(self, client, admin_token): """所有合法 severity 枚举均可创建""" for sev in ("error", "warning", "info"): resp = await client.post( PREFIX, json={ "name": f"sev-{sev}", "rule_type": "syntax", "severity": sev, "message": f"严重度{sev}", }, headers=auth_header(admin_token), ) assert resp.status_code == 201, f"severity={sev} 创建失败" @pytest.mark.asyncio async def test非法rule_type返回422(self, client, admin_token): """非法 rule_type 应触发 Pydantic 校验错误""" resp = await client.post( PREFIX, json={ "name": "bad-type", "rule_type": "invalid_type", "message": "测试", }, headers=auth_header(admin_token), ) assert resp.status_code == 422 @pytest.mark.asyncio async def test缺少必填字段返回422(self, client, admin_token): """缺少 name 字段""" resp = await client.post( PREFIX, json={"rule_type": "syntax", "message": "测试"}, headers=auth_header(admin_token), ) assert resp.status_code == 422 @pytest.mark.asyncio async def test创建带value和pattern(self, client, admin_token): """附带 pattern 和 value 的规则可正常创建""" resp = await client.post( PREFIX, json={ "name": "pattern-rule", "rule_type": "safety", "pattern": r"DROP\s+TABLE", "value": "100", "message": "禁止删表", }, headers=auth_header(admin_token), ) assert resp.status_code == 201 assert resp.json()["pattern"] == r"DROP\s+TABLE" assert resp.json()["value"] == "100" # ══════════════════════════════════════════════════════════════ # 三、统计摘要 GET /api/v1/audit-rules/stats/summary # ══════════════════════════════════════════════════════════════ class TestRuleStats: """GET /audit-rules/stats/summary — 统计摘要""" @pytest.mark.asyncio async def test空数据库统计(self, client, admin_token): """无数据时 total=0""" resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(admin_token)) assert resp.status_code == 200 data = resp.json() assert data["total"] == 0 assert data["enabled"] == 0 assert data["disabled"] == 0 @pytest.mark.asyncio async def test统计数据正确(self, client, admin_token): """创建规则后统计数据应更新""" # 创建2条启用 await client.post( PREFIX, json={"name": "stat1", "rule_type": "DDL", "message": "t1"}, headers=auth_header(admin_token), ) await client.post( PREFIX, json={"name": "stat2", "rule_type": "DML", "message": "t2"}, headers=auth_header(admin_token), ) # 创建1条后禁用 create = await client.post( PREFIX, json={"name": "stat3", "rule_type": "DDL", "message": "t3"}, headers=auth_header(admin_token), ) rid = create.json()["id"] await client.post(f"{PREFIX}/{rid}/toggle", headers=auth_header(admin_token)) resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(admin_token)) assert resp.status_code == 200 data = resp.json() assert data["total"] == 3 assert data["enabled"] == 2 assert data["disabled"] == 1 assert data["by_type"]["DDL"] == 2 assert data["by_type"]["DML"] == 1 @pytest.mark.asyncio async def test_dba可查看统计(self, client, dba_token): """dba 有只读权限""" resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(dba_token)) assert resp.status_code == 200 @pytest.mark.asyncio async def test_developer不可查看统计(self, client, developer_token): """developer 无权限""" resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(developer_token)) assert resp.status_code == 403 # ══════════════════════════════════════════════════════════════ # 四、种子数据 V1 POST /api/v1/audit-rules/seed/{db_type} # ══════════════════════════════════════════════════════════════ class TestSeedAuditRules: """POST /audit-rules/seed/{db_type} — 批量种子数据""" @pytest.mark.asyncio async def test_mysql种子数据创建成功(self, client, admin_token): """MySQL 种子数据可正常导入""" resp = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) assert resp.status_code == 200 assert isinstance(resp.json(), list) assert len(resp.json()) > 0 @pytest.mark.asyncio async def test_postgres种子数据创建成功(self, client, admin_token): """PostgreSQL 种子数据可正常导入""" resp = await client.post(f"{PREFIX}/seed/postgres", headers=auth_header(admin_token)) assert resp.status_code == 200 assert len(resp.json()) > 0 @pytest.mark.asyncio async def test_redis种子数据创建成功(self, client, admin_token): """Redis 种子数据可正常导入""" resp = await client.post(f"{PREFIX}/seed/redis", headers=auth_header(admin_token)) assert resp.status_code == 200 assert len(resp.json()) > 0 @pytest.mark.asyncio async def test不支持的db_type返回400(self, client, admin_token): """不支持的数据库类型应返回 400""" resp = await client.post(f"{PREFIX}/seed/oracle", headers=auth_header(admin_token)) assert resp.status_code == 400 assert "不支持的数据库类型" in resp.json()["detail"] @pytest.mark.asyncio async def test_dba无种子写入权限(self, client, dba_token): """dba 无写权限""" resp = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(dba_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test重复种子数据跳过已存在(self, client, admin_token): """第二次导入相同种子时,已存在的规则会被跳过""" r1 = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) r2 = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) assert r1.status_code == 200 assert r2.status_code == 200 # 第二次不应创建新规则(列表应为空) assert len(r2.json()) == 0 # ══════════════════════════════════════════════════════════════ # 五、获取单条 GET /api/v1/audit-rules/{rule_id} # ══════════════════════════════════════════════════════════════ class TestGetAuditRule: """GET /audit-rules/{rule_id} — 获取单条""" @pytest.mark.asyncio async def test获取存在的规则(self, client, admin_token): """正常获取已创建的规则""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "get-one"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.get( f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) ) assert resp.status_code == 200 assert resp.json()["name"] == "get-one" @pytest.mark.asyncio async def test获取不存在的规则返回404(self, client, admin_token): """不存在的 ID 返回 404""" resp = await client.get(f"{PREFIX}/99999", headers=auth_header(admin_token)) assert resp.status_code == 404 assert "不存在" in resp.json()["detail"] @pytest.mark.asyncio async def test_dba可获取规则(self, client, admin_token, dba_token): """dba 有只读权限""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dba-get"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.get( f"{PREFIX}/{rule_id}", headers=auth_header(dba_token) ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_developer不可获取规则(self, client, admin_token, developer_token): """developer 无权限""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dev-get"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.get( f"{PREFIX}/{rule_id}", headers=auth_header(developer_token) ) assert resp.status_code == 403 # ══════════════════════════════════════════════════════════════ # 六、更新 PUT /api/v1/audit-rules/{rule_id} # ══════════════════════════════════════════════════════════════ class TestUpdateAuditRule: """PUT /audit-rules/{rule_id} — 更新规则""" @pytest.mark.asyncio async def test更新部分字段成功(self, client, admin_token): """只更新 severity 和 message""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "update-partial"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.put( f"{PREFIX}/{rule_id}", json={"severity": "error", "message": "更新后的消息"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["severity"] == "error" assert data["message"] == "更新后的消息" # 未修改的字段保持不变 assert data["name"] == "update-partial" assert data["rule_type"] == "performance" @pytest.mark.asyncio async def test更新不存在的规则返回404(self, client, admin_token): """更新不存在的 ID""" resp = await client.put( f"{PREFIX}/99999", json={"severity": "info"}, headers=auth_header(admin_token), ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_dba无更新权限返回403(self, client, admin_token, dba_token): """dba 无写权限""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dba-update"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.put( f"{PREFIX}/{rule_id}", json={"severity": "info"}, headers=auth_header(dba_token), ) assert resp.status_code == 403 @pytest.mark.asyncio async def test更新全部字段(self, client, admin_token): """一次更新所有可修改字段""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "full-update"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.put( f"{PREFIX}/{rule_id}", json={ "name": "full-update-v2", "description": "新描述", "rule_type": "safety", "severity": "error", "db_type": "postgres", "pattern": r"ALTER\s+TABLE", "value": "200", "is_enabled": False, "message": "全新的消息", }, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "full-update-v2" assert data["description"] == "新描述" assert data["rule_type"] == "safety" assert data["severity"] == "error" assert data["db_type"] == "postgres" assert data["pattern"] == r"ALTER\s+TABLE" assert data["value"] == "200" assert data["is_enabled"] is False assert data["message"] == "全新的消息" @pytest.mark.asyncio async def test更新is_enabled字段(self, client, admin_token): """通过 PUT 单独更新 is_enabled""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "toggle-via-put"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] assert create.json()["is_enabled"] is True resp = await client.put( f"{PREFIX}/{rule_id}", json={"is_enabled": False}, headers=auth_header(admin_token), ) assert resp.status_code == 200 assert resp.json()["is_enabled"] is False # ══════════════════════════════════════════════════════════════ # 七、删除 DELETE /api/v1/audit-rules/{rule_id} # ══════════════════════════════════════════════════════════════ class TestDeleteAuditRule: """DELETE /audit-rules/{rule_id} — 删除规则""" @pytest.mark.asyncio async def test删除成功(self, client, admin_token): """删除后 GET 应返回 404""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "del-me"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.delete( f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) ) assert resp.status_code == 200 assert resp.json()["message"] == "规则已删除" # 确认已删除 get = await client.get( f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) ) assert get.status_code == 404 @pytest.mark.asyncio async def test删除不存在的规则返回404(self, client, admin_token): """删除不存在的 ID""" resp = await client.delete( f"{PREFIX}/99999", headers=auth_header(admin_token) ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_dba无删除权限返回403(self, client, admin_token, dba_token): """dba 无删除权限""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dba-del"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.delete( f"{PREFIX}/{rule_id}", headers=auth_header(dba_token) ) assert resp.status_code == 403 # ══════════════════════════════════════════════════════════════ # 八、切换启用 POST /api/v1/audit-rules/{rule_id}/toggle # ══════════════════════════════════════════════════════════════ class TestToggleAuditRule: """POST /audit-rules/{rule_id}/toggle — 切换启用/禁用""" @pytest.mark.asyncio async def test切换启用到禁用再恢复(self, client, admin_token): """连续 toggle 两次:启用→禁用→启用""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "toggle-twice"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] assert create.json()["is_enabled"] is True # 第一次 toggle:禁用 r1 = await client.post( f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) ) assert r1.status_code == 200 assert r1.json()["is_enabled"] is False # 第二次 toggle:恢复启用 r2 = await client.post( f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) ) assert r2.status_code == 200 assert r2.json()["is_enabled"] is True @pytest.mark.asyncio async def test切换不存在的规则返回404(self, client, admin_token): """不存在的 ID 返回 404""" resp = await client.post( f"{PREFIX}/99999/toggle", headers=auth_header(admin_token) ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_dba可切换规则(self, client, admin_token, dba_token): """dba 有 toggle 权限(只读类操作中 toggle 是特殊写操作,需确认)""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dba-toggle"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.post( f"{PREFIX}/{rule_id}/toggle", headers=auth_header(dba_token) ) # 端点 require_role("admin", "dba") — dba 有权限 assert resp.status_code == 200 assert resp.json()["is_enabled"] is False @pytest.mark.asyncio async def test_developer不可切换(self, client, admin_token, developer_token): """developer 无权限""" create = await client.post( PREFIX, json={**VALID_RULE_PAYLOAD, "name": "dev-toggle"}, headers=auth_header(admin_token), ) rule_id = create.json()["id"] resp = await client.post( f"{PREFIX}/{rule_id}/toggle", headers=auth_header(developer_token) ) assert resp.status_code == 403 # ══════════════════════════════════════════════════════════════ # 九、V2 种子数据 POST /api/v1/audit-rules/seed-v2 # ══════════════════════════════════════════════════════════════ class TestSeedV2AuditRules: """POST /audit-rules/seed-v2 — V2 种子数据""" @pytest.mark.asyncio async def test_v2种子数据创建成功(self, client, admin_token): """V2 种子数据可正常导入""" resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) assert resp.status_code == 200 assert isinstance(resp.json(), list) assert len(resp.json()) > 0 @pytest.mark.asyncio async def test_v2种子数据去重(self, client, admin_token): """重复导入 V2 种子时应跳过已存在的规则""" await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) assert resp.status_code == 200 # 第二次不会创建新规则 assert len(resp.json()) == 0 @pytest.mark.asyncio async def test_dba无v2种子写入权限(self, client, dba_token): """dba 无写权限""" resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(dba_token)) assert resp.status_code == 403 @pytest.mark.asyncio async def test_developer无v2种子权限(self, client, developer_token): """developer 无权限""" resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(developer_token)) assert resp.status_code == 403