refactor: tests目录精简重构 + 脏数据清理脚本

- 删除16个旧Python测试文件(970行→400行)
- 重写conftest.py适配真实种子用户(admin/test123/test234/testtest/logan)
- 5个精简测试文件覆盖核心API:
  - test_auth.py: 登录/鉴权/种子用户验证 (6 tests)
  - test_instances.py: 实例列表/详情/连接测试 (6 tests)
  - test_tickets.py: 工单CRUD/只读SQL拦截/auditor权限 (7 tests)
  - test_workflow.py: E2E完整审批流/拒绝/撤销 (3 tests)
  - test_permissions.py: 角色权限/执行权限/workflow权限 (6 tests)
- 新增 cleanup_test_data.py 清理测试脏数据(支持--dry-run)
- 全量29测试3.27s通过
This commit is contained in:
logan committed 2026-06-03 02:24:40 +08:00
1 parent bec2cafec2
commit 2984f11181
22 files changed
+408 -978

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Cleanup test data from SQLite database.
Removes tickets, approvals, and test-created tables, preserving seed data.
Usage:
python tests/cleanup_test_data.py # full cleanup
python tests/cleanup_test_data.py --dry-run # preview only
"""
import sqlite3
import sys
import os
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'backend', 'sqlite3.db')
# Tables in remote MySQL that were created by tests (DROP IF EXISTS)
TEST_MYSQL_TABLES = [
"_test_tbl", "_e2e_test", "_reject_test", "_cancel_test", "_perm_test",
"orders", "_ghost_test_del", "flow_test", "ghost_test",
]
def cleanup(dry_run=False):
db = sqlite3.connect(DB_PATH)
cur = db.cursor()
actions = []
# 1. Count and delete ticket-related data
for table in ["ticket_sql_items", "ticket_approvals", "tickets"]:
cur.execute(f"SELECT COUNT(*) FROM {table}")
count = cur.fetchone()[0]
if count > 0:
actions.append(f"DELETE {count} rows FROM {table}")
if not dry_run:
cur.execute(f"DELETE FROM {table}")
# 2. Count query history
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='query_history'")
if cur.fetchone():
cur.execute("SELECT COUNT(*) FROM query_history")
count = cur.fetchone()[0]
if count > 0:
actions.append(f"DELETE {count} rows FROM query_history")
if not dry_run:
cur.execute("DELETE FROM query_history")
# 3. Reset auto-increment counters
if not dry_run:
for table in ["ticket_sql_items", "ticket_approvals", "tickets"]:
cur.execute(f"DELETE FROM sqlite_sequence WHERE name='{table}'")
if not dry_run:
db.commit()
db.close()
return actions
def main():
dry_run = "--dry-run" in sys.argv
if dry_run:
print("🔍 DRY RUN — preview only\n")
actions = cleanup(dry_run)
if not actions:
print("✅ 数据库干净,无需清理")
return
for a in actions:
prefix = " WOULD " if dry_run else " ✅ "
print(f"{prefix}{a}")
if dry_run:
print(f"\n共 {len(actions)} 项待清理 (使用不带 --dry-run 执行)")
else:
print(f"\n✅ 已清理 {len(actions)} 项")
# Print note about MySQL remote tables
print(f"\n⚠️ 注意: 远程 MySQL 测试表需手动清理:")
for t in TEST_MYSQL_TABLES:
print(f" DROP TABLE IF EXISTS opsxsql_test.{t};")
if __name__ == "__main__":
main()
+53 -173
View File
@@ -1,212 +1,92 @@
"""Integration test conftest — connects to real Go backend at localhost:9000"""
from typing import AsyncGenerator
"""Integration test conftest — Go/Gin backend at localhost:9000"""
import pytest
import pytest_asyncio
from httpx import AsyncClient
BASE_URL = "http://localhost:9000"
class DictObject(dict):
"""Dict that supports dot-notation access: obj.key == obj['key']."""
def __getattr__(self, key):
try:
return self[key]
except KeyError:
raise AttributeError(f"'{type(self).__name__}' has no attribute '{key}'")
def __setattr__(self, key, value):
self[key] = value
# Token cache — avoid re-login for every test
_token_cache: dict[str, str] = {}
# ═══════════════════════════════════════════════
# Real test users (seeded by main.go seed_data)
# ═══════════════════════════════════════════════
USERS = {
"admin": {"password": "admin123", "role": "admin"},
"test123": {"password": "test123", "role": "developer"},
"test234": {"password": "test234", "role": "developer"},
"testtest": {"password": "testtest", "role": "developer"},
"logan": {"password": "zxc987", "role": "dba"},
}
async def _get_token(client: AsyncClient, username: str, password: str = "admin123") -> str:
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
async def _login(client: AsyncClient, username: str, password: str) -> str:
resp = await client.post("/api/v1/auth/login", json={
"username": username,
"password": password,
"username": username, "password": password,
})
if resp.status_code == 200:
return resp.json()["access_token"]
return ""
assert resp.status_code == 200, f"Login failed for {username}: {resp.status_code} {resp.text}"
return resp.json()["access_token"]
async def _ensure_admin_exists(client: AsyncClient):
"""Ensure admin and dba users exist — recreate if deleted by tests."""
# Try login first
admin_token = await _get_token(client, "admin")
if admin_token:
return admin_token
# Admin doesn't exist — use dev1 to create it
dev_token = await _get_token(client, "dev1")
if not dev_token:
return ""
# Create admin user via API
resp = await client.post("/api/v1/users", json={
"username": "admin",
"display_name": "超级管理员",
"role": "admin",
"password": "admin123",
}, headers={"Authorization": f"Bearer {dev_token}"})
if resp.status_code in (200, 201):
pass # created
elif resp.status_code == 400:
# Username exists but password wrong — can't fix via API
pass
# Try login again
return await _get_token(client, "admin")
# ═══════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════
@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Function-scoped client — fresh for each test, no event loop issues."""
async def client():
async with AsyncClient(base_url=BASE_URL, timeout=30.0) as c:
yield c
@pytest_asyncio.fixture
async def admin_token(client: AsyncClient) -> str:
token = await _get_token(client, "admin")
assert token, "Admin login failed"
return token
async def admin_token(client):
return await _login(client, "admin", "admin123")
@pytest_asyncio.fixture
async def dba_token(client: AsyncClient) -> str:
token = await _get_token(client, "dba")
if token:
return token
# Fallback
return await _get_token(client, "admin")
async def dba_token(client):
return await _login(client, "logan", "zxc987")
@pytest_asyncio.fixture
async def developer_token(client: AsyncClient) -> str:
token = await _get_token(client, "dev1")
assert token, "Developer login failed"
return token
async def developer_token(client):
return await _login(client, "testtest", "testtest")
@pytest_asyncio.fixture
async def guest_token(client: AsyncClient) -> str:
token = await _get_token(client, "auditor")
if token:
return token
pytest.skip("guest/auditor user not available")
return ""
# ══════════════════════════════════════════════════════════════
# Helper fixtures for non-integration test files
# ══════════════════════════════════════════════════════════════
@pytest_asyncio.fixture
async def admin_user(client: AsyncClient, admin_token: str) -> dict:
resp = await client.get("/api/v1/auth/me", headers=auth_header(admin_token))
if resp.status_code == 200:
return resp.json()
return {"id": 1, "username": "admin", "role": "admin"}
async def approver1_token(client):
"""test123 — 初审人"""
return await _login(client, "test123", "test123")
@pytest_asyncio.fixture
async def disabled_user(client: AsyncClient, admin_token: str) -> dict:
return DictObject({"id": 999, "username": "disabled_test_user", "role": "developer", "is_active": False})
async def approver2_token(client):
"""test234 — 复审人"""
return await _login(client, "test234", "test234")
@pytest_asyncio.fixture
async def test_mysql_instance(client: AsyncClient, admin_token: str) -> dict:
async def mysql_instance(client, admin_token):
"""获取第一个 MySQL 实例"""
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
if resp.status_code == 200:
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "mysql":
return inst
return DictObject({"id": 3, "name": "测试MySQL", "db_type": "mysql", "host": "127.0.0.1", "port": 3306})
assert resp.status_code == 200
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "mysql":
return inst
pytest.skip("No MySQL instance found")
@pytest_asyncio.fixture
async def test_redis_instance(client: AsyncClient, admin_token: str) -> dict:
async def redis_instance(client, admin_token):
"""获取第一个 Redis 实例"""
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
if resp.status_code == 200:
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "redis":
return inst
return DictObject({"id": 2, "name": "测试Redis", "db_type": "redis", "host": "127.0.0.1", "port": 6379})
@pytest_asyncio.fixture
async def test_ticket(client: AsyncClient, admin_token: str, test_mysql_instance: dict) -> dict:
resp = await client.post("/api/v1/tickets", json={
"title": "conftest测试工单", "db_type": "mysql",
"instance_id": test_mysql_instance["id"],
"target_database": "test",
"sql_content": "SELECT 1", "reason": "conftest测试",
}, headers=auth_header(admin_token))
if resp.status_code in (200, 201):
return resp.json()
return DictObject({"id": 1, "title": "conftest测试工单", "status": "pending"})
@pytest_asyncio.fixture
async def test_instance(client: AsyncClient, admin_token: str) -> dict:
"""Get first MySQL test instance."""
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
if resp.status_code == 200:
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "mysql":
return DictObject(inst)
return DictObject({"id": 3, "name": "测试MySQL", "db_type": "mysql", "host": "127.0.0.1", "port": 3306})
@pytest_asyncio.fixture
async def db(client: AsyncClient, admin_token: str) -> dict:
"""Database fixture — get first MySQL instance."""
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
if resp.status_code == 200:
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "mysql":
return DictObject(inst)
return DictObject({"id": 3, "name": "测试MySQL", "db_type": "mysql", "host": "127.0.0.1", "port": 3306})
@pytest_asyncio.fixture
async def dba_user(client: AsyncClient, dba_token: str) -> dict:
"""Get DBA user info."""
resp = await client.get("/api/v1/auth/me", headers=auth_header(dba_token))
if resp.status_code == 200:
return resp.json()
return DictObject({"id": 2, "username": "dba", "role": "dba"})
@pytest_asyncio.fixture
async def developer_user(client: AsyncClient, developer_token: str) -> dict:
"""Get developer user info."""
resp = await client.get("/api/v1/auth/me", headers=auth_header(developer_token))
if resp.status_code == 200:
return resp.json()
return DictObject({"id": 3, "username": "dev1", "role": "developer"})
@pytest_asyncio.fixture
async def guest_user(client: AsyncClient, guest_token: str) -> dict:
"""Get guest/auditor user info."""
resp = await client.get("/api/v1/auth/me", headers=auth_header(guest_token))
if resp.status_code == 200:
return resp.json()
return DictObject({"id": 5, "username": "auditor", "role": "auditor"})
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
assert resp.status_code == 200
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
for inst in items:
if inst.get("db_type") == "redis":
return inst
pytest.skip("No Redis instance found")
-16
View File
@@ -1,16 +0,0 @@
"""Announcement list tests — Go/Gin backend (read-only endpoint)."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestAnnouncements:
async def test_list_announcements(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/announcements", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/announcements")
assert resp.status_code == 401
-53
View File
@@ -1,53 +0,0 @@
"""Audit rules read-only tests — Go/Gin backend.
Go backend only has GET /audit-rules (list), no CRUD."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestListAuditRules:
async def test_list_rules(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) > 0
async def test_list_rules_has_required_fields(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
data = resp.json()
if data:
rule = data[0]
assert "id" in rule
assert "name" in rule
assert "rule_type" in rule
assert "severity" in rule
assert "db_type" in rule
assert "is_enabled" in rule
async def test_filter_db_type(self, client: AsyncClient, admin_token: str):
"""Go backend does not support server-side filter, verify data has db_type field."""
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
mysql_rules = [r for r in data if r["db_type"] == "mysql"]
assert len(mysql_rules) > 0, "Should have at least one mysql rule"
async def test_filter_rule_type(self, client: AsyncClient, admin_token: str):
"""Go backend does not support server-side filter, verify rule_type field exists."""
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
ddl_rules = [r for r in data if r["rule_type"] == "DDL"]
assert len(ddl_rules) > 0, "Should have at least one DDL rule"
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/audit-rules")
assert resp.status_code == 401
async def test_all_users_can_list(self, client: AsyncClient, admin_token: str, developer_token: str):
"""audit-rules is under auth group (no role restriction), all authenticated users can access."""
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
assert resp.status_code == 200
resp2 = await client.get("/api/v1/audit-rules", headers=auth_header(developer_token))
assert resp2.status_code == 200
+22 -10
View File
@@ -1,39 +1,51 @@
"""Auth endpoint tests — Go/Gin backend."""
"""Auth API tests"""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestLogin:
async def test_admin_login(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "admin", "password": "admin123"
"username": "admin", "password": "admin123",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "access_token" in resp.json()
async def test_all_seed_users_login(self, client: AsyncClient):
"""验证所有种子用户都能登录"""
users = {
"admin": "admin123", "test123": "test123",
"test234": "test234", "testtest": "testtest", "logan": "zxc987",
}
for username, password in users.items():
resp = await client.post("/api/v1/auth/login", json={
"username": username, "password": password,
})
assert resp.status_code == 200, f"{username} login failed"
assert "access_token" in resp.json()
async def test_wrong_password(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "admin", "password": "wrong"
"username": "admin", "password": "wrong",
})
assert resp.status_code == 401
async def test_nonexistent_user(self, client: AsyncClient):
resp = await client.post("/api/v1/auth/login", json={
"username": "nobody", "password": "test"
"username": "nobody", "password": "xxx",
})
assert resp.status_code == 401
class TestGetMe:
async def test_get_me(self, client: AsyncClient, admin_token: str):
async def test_get_me(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/auth/me", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "admin"
assert "role" in data
assert "id" in data
assert data["role"] == "admin"
async def test_no_token(self, client: AsyncClient):
resp = await client.get("/api/v1/auth/me")
-42
View File
@@ -1,42 +0,0 @@
"""Instance detail endpoint tests — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestInstanceDetail:
async def test_detail_databases(self, client: AsyncClient, admin_token: str, test_mysql_instance):
inst = test_mysql_instance
resp = await client.get(
f"/api/v1/instances/{inst['id']}/detail/databases",
headers=auth_header(admin_token),
)
assert resp.status_code in (200, 500) # 500 if MySQL unreachable
async def test_detail_processlist(self, client: AsyncClient, admin_token: str, test_mysql_instance):
inst = test_mysql_instance
resp = await client.get(
f"/api/v1/instances/{inst['id']}/detail/processlist",
headers=auth_header(admin_token),
)
assert resp.status_code in (200, 500) # 500 if MySQL unreachable
async def test_detail_status(self, client: AsyncClient, admin_token: str, test_mysql_instance):
inst = test_mysql_instance
resp = await client.get(
f"/api/v1/instances/{inst['id']}/detail/status",
headers=auth_header(admin_token),
)
assert resp.status_code in (200, 500) # 500 if MySQL unreachable
async def test_detail_users(self, client: AsyncClient, admin_token: str, test_mysql_instance):
inst = test_mysql_instance
resp = await client.get(
f"/api/v1/instances/{inst['id']}/detail/users",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/instances/4/detail/databases")
assert resp.status_code == 401
-33
View File
@@ -1,33 +0,0 @@
"""Instance fields (database/table listing) tests — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestInstanceFields:
async def test_list_databases(self, client: AsyncClient, admin_token: str, test_mysql_instance):
resp = await client.get(
f"/api/v1/instances/{test_mysql_instance['id']}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_list_tables(self, client: AsyncClient, admin_token: str, test_mysql_instance):
# First get a database name
db_resp = await client.get(
f"/api/v1/instances/{test_mysql_instance['id']}/databases",
headers=auth_header(admin_token),
)
dbs = db_resp.json()
if not dbs:
pytest.skip("No databases available")
db_name = dbs[0] if isinstance(dbs[0], str) else dbs[0].get("name", "test")
resp = await client.get(
f"/api/v1/instances/{test_mysql_instance['id']}/databases/{db_name}/tables",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/instances/4/databases")
assert resp.status_code == 401
+28 -83
View File
@@ -1,107 +1,52 @@
"""Instance CRUD + listing tests — Go/Gin backend."""
"""Instance API tests"""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestListInstances:
async def test_list_instances(self, client: AsyncClient, admin_token: str):
async def test_admin_sees_all(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
items = data.get("items", data) if isinstance(data, dict) else data
assert len(items) >= 3 # MySQL + 2 Redis
async def test_list_filter_db_type(self, client: AsyncClient, admin_token: str):
"""Go backend does not support ?db_type= filter, filter client-side."""
resp = await client.get("/api/v1/instances", headers=auth_header(admin_token))
async def test_developer_sees_group_instances(self, client: AsyncClient, developer_token):
"""testtest 在 test 权限组,应看到绑定的实例"""
resp = await client.get("/api/v1/instances", headers=auth_header(developer_token))
assert resp.status_code == 200
data = resp.json()
mysql_instances = [i for i in data if i["db_type"] == "mysql"]
if not mysql_instances:
pytest.skip("No mysql instances")
items = data.get("items", data) if isinstance(data, dict) else data
assert len(items) >= 1
async def test_unauthenticated(self, client: AsyncClient):
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/instances")
assert resp.status_code == 401
class TestGetInstance:
async def test_get_existing(self, client: AsyncClient, admin_token: str):
# List first to get a valid id
lst = await client.get("/api/v1/instances", headers=auth_header(admin_token))
instances = lst.json()
if not instances:
pytest.skip("No instances in database")
inst_id = instances[0]["id"]
resp = await client.get(f"/api/v1/instances/{inst_id}", headers=auth_header(admin_token))
async def test_get_mysql_instance(self, client: AsyncClient, admin_token, mysql_instance):
resp = await client.get(f"/api/v1/instances/{mysql_instance['id']}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["id"] == inst_id
async def test_get_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/instances/99999", headers=auth_header(admin_token))
assert resp.status_code in (404, 400)
assert resp.json()["db_type"] == "mysql"
class TestCreateInstance:
async def test_create_instance(self, client: AsyncClient, admin_token: str):
import uuid
resp = await client.post("/api/v1/instances", headers=auth_header(admin_token), json={
"name": f"test-{uuid.uuid4().hex[:6]}",
"db_type": "mysql",
"host": "10.0.0.1",
"port": 3306,
"username": "root",
})
assert resp.status_code == 201
class TestTestConnection:
async def test_mysql_connection(self, client: AsyncClient, admin_token, mysql_instance):
resp = await client.post(
f"/api/v1/instances/{mysql_instance['id']}/test-connection",
headers=auth_header(admin_token), json={},
)
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert data.get("success") is True
async def test_developer_cannot_create(self, client: AsyncClient, developer_token: str):
resp = await client.post("/api/v1/instances", headers=auth_header(developer_token), json={
"name": "nope", "db_type": "mysql", "host": "10.0.0.1",
"port": 3306, "username": "root",
})
assert resp.status_code == 403
class TestUpdateInstance:
async def test_update_instance(self, client: AsyncClient, admin_token: str):
lst = await client.get("/api/v1/instances", headers=auth_header(admin_token))
instances = lst.json()
if not instances:
pytest.skip("No instances")
inst_id = instances[0]["id"]
resp = await client.put(
f"/api/v1/instances/{inst_id}",
headers=auth_header(admin_token),
json={"name": "updated-test"},
async def test_redis_connection(self, client: AsyncClient, admin_token, redis_instance):
resp = await client.post(
f"/api/v1/instances/{redis_instance['id']}/test-connection",
headers=auth_header(admin_token), json={},
)
assert resp.status_code == 200
async def test_update_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.put(
"/api/v1/instances/99999",
headers=auth_header(admin_token),
json={"name": "x"},
)
assert resp.status_code in (404, 400)
class TestDeleteInstance:
async def test_delete_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.delete("/api/v1/instances/99999", headers=auth_header(admin_token))
assert resp.status_code in (200, 404)
class TestInstanceConnections:
async def test_list_databases(self, client: AsyncClient, admin_token: str, test_mysql_instance):
inst = test_mysql_instance
resp = await client.get(
f"/api/v1/instances/{inst['id']}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_accessible_instances(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/instances/accessible", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json().get("success") is True
-92
View File
@@ -1,92 +0,0 @@
"""Integration: API CRUD smoke test — Go/Gin backend.
Validates core API flow: auth → list → create → read → update → delete."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestInstanceFlow:
"""Full instance lifecycle test."""
async def test_instance_crud_flow(self, client: AsyncClient, admin_token: str):
import uuid
suffix = uuid.uuid4().hex[:6]
# Create
resp = await client.post("/api/v1/instances", headers=auth_header(admin_token), json={
"name": f"crud-test-{suffix}",
"db_type": "mysql",
"host": "10.0.0.99",
"port": 3306,
"username": "root",
})
assert resp.status_code == 201
inst = resp.json()
inst_id = inst["id"]
# Read
resp = await client.get(f"/api/v1/instances/{inst_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["name"] == f"crud-test-{suffix}"
# Update
resp = await client.put(
f"/api/v1/instances/{inst_id}",
headers=auth_header(admin_token),
json={"name": f"updated-{suffix}"},
)
assert resp.status_code == 200
# Delete
resp = await client.delete(f"/api/v1/instances/{inst_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
# Verify deleted
resp = await client.get(f"/api/v1/instances/{inst_id}", headers=auth_header(admin_token))
assert resp.status_code in (404, 400)
class TestWorkflowFlow:
"""Full workflow lifecycle test."""
async def test_workflow_crud_flow(self, client: AsyncClient, admin_token: str):
import uuid
suffix = uuid.uuid4().hex[:6]
# Create
resp = await client.post("/api/v1/workflows", headers=auth_header(admin_token), json={
"name": f"crud-wf-{suffix}",
"description": "CRUD test",
"is_active": True,
})
assert resp.status_code == 201
wf_id = resp.json()["id"]
# Read
resp = await client.get(f"/api/v1/workflows/{wf_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["name"] == f"crud-wf-{suffix}"
# Update
resp = await client.put(
f"/api/v1/workflows/{wf_id}",
headers=auth_header(admin_token),
json={"name": f"updated-wf-{suffix}", "description": "Updated"},
)
assert resp.status_code == 200
# Delete
resp = await client.delete(f"/api/v1/workflows/{wf_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
class TestDashboardEndpoints:
"""Dashboard endpoint smoke tests."""
async def test_dashboard_stats(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/dashboard/stats", headers=auth_header(admin_token))
assert resp.status_code == 200
async def test_my_tickets(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/dashboard/my-tickets", headers=auth_header(admin_token))
assert resp.status_code == 200
-20
View File
@@ -1,20 +0,0 @@
"""Integration: audit policy — Go/Gin backend.
Tests SQL audit via ticket audit endpoint."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestAuditPolicy:
async def test_audit_sql(self, client: AsyncClient, admin_token: str):
resp = await client.post("/api/v1/tickets/audit", headers=auth_header(admin_token), json={
"sql_content": "SELECT * FROM users",
"db_type": "mysql",
})
assert resp.status_code == 200
async def test_audit_sql_no_auth(self, client: AsyncClient):
resp = await client.post("/api/v1/tickets/audit", json={
"sql": "SELECT 1", "db_type": "mysql",
})
assert resp.status_code == 401
-35
View File
@@ -1,35 +0,0 @@
"""Integration: notification config and test — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestNotificationConfig:
async def test_full_notification_flow(self, client: AsyncClient, admin_token: str):
import uuid
suffix = uuid.uuid4().hex[:6]
# Create
resp = await client.post("/api/v1/notifications", headers=auth_header(admin_token), json={
"name": f"test-ch-{suffix}",
"channel_type": "webhook_feishu",
"config": {"webhook_url": "https://example.com/test"},
"is_enabled": False,
})
assert resp.status_code == 200
ch_id = resp.json()["id"]
# Update
resp = await client.put(
f"/api/v1/notifications/{ch_id}",
headers=auth_header(admin_token),
json={"name": f"updated-ch-{suffix}"},
)
assert resp.status_code == 200
# Delete
resp = await client.delete(
f"/api/v1/notifications/{ch_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
-23
View File
@@ -1,23 +0,0 @@
"""Integration: query execution test — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestQueryExecute:
async def test_execute_select(self, client: AsyncClient, admin_token: str, test_mysql_instance):
resp = await client.post("/api/v1/query/execute", headers=auth_header(admin_token), json={
"instance_id": test_mysql_instance["id"],
"database": "test",
"sql": "SELECT 1 AS val",
})
assert resp.status_code == 200
data = resp.json()
# Should have columns and rows
assert "columns" in data or "rows" in data or "data" in data
async def test_no_auth(self, client: AsyncClient):
resp = await client.post("/api/v1/query/execute", json={
"instance_id": 1, "database": "test", "sql": "SELECT 1",
})
assert resp.status_code == 401
-35
View File
@@ -1,35 +0,0 @@
"""Integration: ticket flow test — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestTicketFlow:
async def test_create_and_get_ticket(self, client: AsyncClient, admin_token: str, test_mysql_instance):
import uuid
suffix = uuid.uuid4().hex[:6]
# Create
resp = await client.post("/api/v1/tickets", headers=auth_header(admin_token), json={
"title": f"flow-test-{suffix}",
"db_type": "mysql",
"instance_id": test_mysql_instance["id"],
"target_database": "test",
"sql_content": "SELECT 1",
"reason": "Integration test",
})
assert resp.status_code == 201
ticket_id = resp.json()["id"]
# Get
resp = await client.get(f"/api/v1/tickets/{ticket_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["id"] == ticket_id
async def test_ticket_timeline(self, client: AsyncClient, admin_token: str):
lst = await client.get("/api/v1/tickets", headers=auth_header(admin_token))
items = lst.json().get("items", [])
if not items:
pytest.skip("No tickets")
tid = items[0]["id"]
resp = await client.get(f"/api/v1/tickets/{tid}/timeline", headers=auth_header(admin_token))
assert resp.status_code == 200
-32
View File
@@ -1,32 +0,0 @@
"""LDAP config tests — Go/Gin backend (admin only)."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestLDAPConfig:
async def test_get_config(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/ldap-config", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert "ldap_host" in data
assert "ldap_port" in data
async def test_update_config(self, client: AsyncClient, admin_token: str):
resp = await client.put("/api/v1/ldap-config", headers=auth_header(admin_token), json={
"ldap_host": "8.217.205.92",
"ldap_port": 389,
"admin_dn": "cn=admin,dc=opsx,dc=vip",
"admin_password": "admin123",
"base_dn": "dc=opsx,dc=vip",
"use_ldaps": False,
})
assert resp.status_code == 200
async def test_developer_forbidden(self, client: AsyncClient, developer_token: str):
resp = await client.get("/api/v1/ldap-config", headers=auth_header(developer_token))
assert resp.status_code == 403
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/ldap-config")
assert resp.status_code == 401
-38
View File
@@ -1,38 +0,0 @@
"""Notification channel tests — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestListNotifications:
async def test_list(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/notifications", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/notifications")
assert resp.status_code == 401
class TestCreateNotification:
async def test_create_feishu(self, client: AsyncClient, admin_token: str):
import uuid
resp = await client.post("/api/v1/notifications", headers=auth_header(admin_token), json={
"name": f"test-feishu-{uuid.uuid4().hex[:6]}",
"channel_type": "webhook_feishu",
"config": {"webhook_url": "https://example.com/feishu"},
"is_enabled": False,
})
assert resp.status_code == 200
async def test_create_dingtalk(self, client: AsyncClient, admin_token: str):
import uuid
resp = await client.post("/api/v1/notifications", headers=auth_header(admin_token), json={
"name": f"test-ding-{uuid.uuid4().hex[:6]}",
"channel_type": "dingtalk",
"config": {"webhook_url": "https://example.com/dingtalk"},
"is_enabled": False,
})
assert resp.status_code == 200
+63
View File
@@ -0,0 +1,63 @@
"""Permission tests — verify role-based access control"""
import pytest
from httpx import AsyncClient
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestTicketPermissions:
"""验证各角色对工单操作的权限"""
async def test_developer_can_see_approver_tickets(self, client: AsyncClient,
developer_token, approver1_token):
"""test123(developer) 作为审批人能看到待审工单"""
# test123 查看工单列表
resp = await client.get("/api/v1/tickets", headers=auth_header(approver1_token))
assert resp.status_code == 200
data = resp.json()
# 应能看到工单(自己的 + 作为审批人的)
assert "items" in data
async def test_only_admin_dba_can_execute(self, client: AsyncClient,
developer_token, admin_token, mysql_instance):
"""developer 不能执行工单"""
# 创建工单
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "权限测试-执行",
"db_type": "mysql",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE IF NOT EXISTS _perm_test (id INT)",
"workflow_id": 4,
})
assert resp.status_code in (200, 201)
tid = resp.json()["id"]
# developer 尝试执行 → 应被拒绝
resp = await client.post(f"/api/v1/tickets/{tid}/execute", headers=auth_header(developer_token), json={})
assert resp.status_code in (403, 400)
class TestWorkflowPermissions:
"""审批流程管理权限"""
async def test_admin_can_list_workflows(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/workflows", headers=auth_header(admin_token))
assert resp.status_code == 200
async def test_developer_can_list_workflows(self, client: AsyncClient, developer_token):
"""developer 应该能看到 workflow 列表(前端提交时需要选 workflow)"""
resp = await client.get("/api/v1/workflows", headers=auth_header(developer_token))
assert resp.status_code == 200
class TestDashboardPermissions:
"""Dashboard 统计数据权限"""
async def test_admin_statistics(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/tickets/statistics", headers=auth_header(admin_token))
assert resp.status_code == 200
async def test_developer_statistics(self, client: AsyncClient, developer_token):
resp = await client.get("/api/v1/tickets/statistics", headers=auth_header(developer_token))
assert resp.status_code == 200
-38
View File
@@ -1,38 +0,0 @@
"""Redis instance endpoint tests — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestRedisInfo:
async def test_redis_info(self, client: AsyncClient, admin_token: str, test_redis_instance):
resp = await client.get(
f"/api/v1/redis/{test_redis_instance['id']}/info",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_redis_dbsize(self, client: AsyncClient, admin_token: str, test_redis_instance):
resp = await client.get(
f"/api/v1/redis/{test_redis_instance['id']}/dbsize",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_redis_slowlog(self, client: AsyncClient, admin_token: str, test_redis_instance):
resp = await client.get(
f"/api/v1/redis/{test_redis_instance['id']}/slowlog",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_redis_clients(self, client: AsyncClient, admin_token: str, test_redis_instance):
resp = await client.get(
f"/api/v1/redis/{test_redis_instance['id']}/clients",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/redis/2/info")
assert resp.status_code == 401
-19
View File
@@ -1,19 +0,0 @@
"""Schema drift tests — Go backend does not have schema-drift endpoints yet."""
import pytest
pytestmark = pytest.mark.skip(reason="Schema drift endpoints not implemented in Go backend")
class TestSchemaDriftCapture:
async def test_capture_success(self, client):
pass
class TestSchemaDriftCheck:
async def test_check_success(self, client):
pass
class TestSchemaDriftHistory:
async def test_history(self, client):
pass
-23
View File
@@ -1,23 +0,0 @@
"""Settings endpoint tests — Go/Gin backend."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestListSettings:
async def test_list_settings(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/settings", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
# Should have grouped settings
assert len(data) > 0
class TestUpdateSettings:
async def test_update_setting(self, client: AsyncClient, admin_token: str):
# Go backend expects array of {key, value} objects
resp = await client.put("/api/v1/settings", headers=auth_header(admin_token), json=[
{"key": "data_masking_enabled", "value": "true"},
])
assert resp.status_code == 200
+63 -72
View File
@@ -1,92 +1,83 @@
"""Ticket CRUD + workflow tests — Go/Gin backend."""
"""Ticket API tests — CRUD + status filter"""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestListTickets:
async def test_list_tickets(self, client: AsyncClient, admin_token: str):
async def test_admin_sees_all(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/tickets", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert isinstance(data["items"], list)
async def test_list_with_pagination(self, client: AsyncClient, admin_token: str):
resp = await client.get(
"/api/v1/tickets?page=1&page_size=5",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/tickets")
assert resp.status_code == 401
async def test_developer_sees_own_and_approver(self, client: AsyncClient, developer_token):
"""developer 能看到自己提交的 + 自己作为审批人的工单"""
resp = await client.get("/api/v1/tickets", headers=auth_header(developer_token))
assert resp.status_code == 200
async def test_filter_by_status(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/tickets?status=pending", headers=auth_header(admin_token))
assert resp.status_code == 200
items = resp.json().get("items", [])
for t in items:
assert t["status"] == "pending"
async def test_pagination(self, client: AsyncClient, admin_token):
resp = await client.get("/api/v1/tickets?page=1&page_size=5", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert data["page"] == 1
assert data["page_size"] == 5
class TestCreateTicket:
async def test_create_ticket(self, client: AsyncClient, admin_token: str, test_mysql_instance):
resp = await client.post("/api/v1/tickets", headers=auth_header(admin_token), json={
"title": "Test ticket",
async def test_create_mysql_ticket(self, client: AsyncClient, developer_token, mysql_instance):
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "测试工单-CREATE TABLE",
"db_type": "mysql",
"instance_id": test_mysql_instance["id"],
"target_database": "test",
"sql_content": "SELECT 1",
"reason": "Test",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE IF NOT EXISTS _test_tbl (id INT)",
"workflow_id": 4,
})
assert resp.status_code == 201
assert resp.status_code in (200, 201)
data = resp.json()
assert "id" in data
assert data["status"] == "pending"
assert "ticket_no" in data
return data
async def test_reject_readonly_sql(self, client: AsyncClient, developer_token, mysql_instance):
"""SELECT/SHOW 等只读 SQL 应被拦截"""
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "只读SQL",
"db_type": "mysql",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "SELECT * FROM orders",
"workflow_id": 4,
})
assert resp.status_code == 400
async def test_auditor_cannot_create(self, client: AsyncClient):
"""auditor 角色不能提交工单"""
resp = await client.post("/api/v1/auth/login", json={
"username": "wangwu", "password": "admin123",
})
if resp.status_code != 200:
pytest.skip("auditor user not available")
token = resp.json()["access_token"]
resp2 = await client.post("/api/v1/tickets", headers=auth_header(token), json={
"title": "auditor提交",
"db_type": "mysql", "instance_id": 5,
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE _t(x INT)",
})
assert resp2.status_code in (403, 401)
async def test_no_auth(self, client: AsyncClient):
resp = await client.post("/api/v1/tickets", json={
"title": "No auth", "db_type": "mysql",
"instance_id": 1, "target_database": "test",
"sql_content": "SELECT 1", "reason": "test",
})
resp = await client.post("/api/v1/tickets", json={"title": "x"})
assert resp.status_code == 401
class TestGetTicket:
async def test_get_ticket(self, client: AsyncClient, admin_token: str):
# List first
lst = await client.get("/api/v1/tickets", headers=auth_header(admin_token))
items = lst.json().get("items", [])
if not items:
pytest.skip("No tickets in database")
tid = items[0]["id"]
resp = await client.get(f"/api/v1/tickets/{tid}", headers=auth_header(admin_token))
assert resp.status_code == 200
assert resp.json()["id"] == tid
class TestReviewTicket:
async def test_approve_ticket(self, client: AsyncClient, admin_token: str):
# Find a pending ticket
lst = await client.get(
"/api/v1/tickets?status=pending&page=1&page_size=5",
headers=auth_header(admin_token),
)
items = lst.json().get("items", [])
pending = [t for t in items if t.get("status") == "pending"]
if not pending:
pytest.skip("No pending tickets to approve")
tid = pending[0]["id"]
resp = await client.post(
f"/api/v1/tickets/{tid}/approve",
headers=auth_header(admin_token),
json={"action": "approve", "comment": "Test approve"},
)
assert resp.status_code in (200, 400) # 400 if already approved
async def test_developer_cannot_approve(self, client: AsyncClient, developer_token: str):
resp = await client.post(
"/api/v1/tickets/1/approve",
headers=auth_header(developer_token),
json={"action": "approve"},
)
# May be 403 or 400 depending on ticket state
assert resp.status_code in (200, 400, 403, 404)
-76
View File
@@ -1,76 +0,0 @@
"""User CRUD tests — Go/Gin backend (admin only)."""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
class TestListUsers:
async def test_admin_list_users(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/users", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) > 0
assert "id" in data[0]
assert "username" in data[0]
assert "role" in data[0]
async def test_developer_forbidden(self, client: AsyncClient, developer_token: str):
resp = await client.get("/api/v1/users", headers=auth_header(developer_token))
assert resp.status_code == 403
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/users")
assert resp.status_code == 401
class TestCreateUser:
async def test_create_user_success(self, client: AsyncClient, admin_token: str):
import uuid
uname = f"test_{uuid.uuid4().hex[:8]}"
resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={
"username": uname,
"password": "test123456",
"role": "developer",
"display_name": "Test User",
})
assert resp.status_code == 201
data = resp.json()
assert data["username"] == uname
assert data["role"] == "developer"
async def test_create_duplicate_username(self, client: AsyncClient, admin_token: str):
resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={
"username": "admin",
"password": "test123456",
"role": "developer",
"display_name": "Duplicate",
})
# Should fail with 400 or 409
assert resp.status_code in (400, 409)
async def test_developer_cannot_create(self, client: AsyncClient, developer_token: str):
resp = await client.post("/api/v1/users", headers=auth_header(developer_token), json={
"username": "nope", "password": "test123456", "role": "developer",
})
assert resp.status_code == 403
class TestUpdateUser:
async def test_update_display_name(self, client: AsyncClient, admin_token: str):
# Get admin user id
me = await client.get("/api/v1/auth/me", headers=auth_header(admin_token))
uid = me.json()["id"]
resp = await client.put(
f"/api/v1/users/{uid}",
headers=auth_header(admin_token),
json={"display_name": "Updated Name"},
)
assert resp.status_code == 200
class TestDeleteUser:
async def test_delete_nonexistent(self, client: AsyncClient, admin_token: str):
resp = await client.delete("/api/v1/users/99999", headers=auth_header(admin_token))
# May return 404 or 200 with message
assert resp.status_code in (200, 404)
+92 -65
View File
@@ -1,78 +1,105 @@
"""Workflow CRUD tests — Go/Gin backend."""
"""Full E2E ticket workflow test — submit→approve→approve→execute"""
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestListWorkflows:
async def test_list_workflows(self, client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/workflows", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
class TestE2EWorkflow:
"""完整审批流程: testtest提交 → test123初审 → test234复审 → admin执行"""
async def test_no_auth(self, client: AsyncClient):
resp = await client.get("/api/v1/workflows")
assert resp.status_code == 401
class TestCreateWorkflow:
async def test_create_success(self, client: AsyncClient, admin_token: str):
import uuid
resp = await client.post("/api/v1/workflows", headers=auth_header(admin_token), json={
"name": f"wf-test-{uuid.uuid4().hex[:6]}",
"description": "Test workflow",
"is_active": True,
async def test_full_approval_flow(self, client: AsyncClient,
developer_token, approver1_token,
approver2_token, admin_token,
mysql_instance):
# Step 1: developer 提交工单
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "E2E测试-完整审批流",
"db_type": "mysql",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE IF NOT EXISTS _e2e_test (id INT PRIMARY KEY, val VARCHAR(50))",
"workflow_id": 4,
})
assert resp.status_code == 201
data = resp.json()
assert "id" in data
assert "message" in data
assert resp.status_code in (200, 201)
ticket = resp.json()
tid = ticket["id"]
assert ticket["status"] == "pending"
async def test_create_with_category(self, client: AsyncClient, admin_token: str):
import uuid
resp = await client.post("/api/v1/workflows", headers=auth_header(admin_token), json={
"name": f"wf-cat-{uuid.uuid4().hex[:6]}",
"description": "With category",
"category": "ticket",
"is_active": True,
# Step 1.5: 验证审批记录已生成
resp = await client.get(f"/api/v1/tickets/{tid}/approvals", headers=auth_header(admin_token))
assert resp.status_code == 200
approvals = resp.json()
assert len(approvals) >= 2 # 至少有初审+复审+执行
# Step 2: test123 初审通过
resp = await client.post(f"/api/v1/tickets/{tid}/review", headers=auth_header(approver1_token), json={
"action": "approve", "comment": "初审通过",
})
assert resp.status_code == 201
class TestGetWorkflow:
async def test_get_existing(self, client: AsyncClient, admin_token: str):
# List first
lst = await client.get("/api/v1/workflows", headers=auth_header(admin_token))
wfs = lst.json()
if not wfs:
pytest.skip("No workflows")
wf_id = wfs[0]["id"]
resp = await client.get(f"/api/v1/workflows/{wf_id}", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert data["id"] == wf_id
assert "name" in data
assert "steps" in data
assert "is_active" in data
assert resp.json()["ticket"]["status"] == "reviewing"
class TestUpdateWorkflow:
async def test_update_name(self, client: AsyncClient, admin_token: str):
lst = await client.get("/api/v1/workflows", headers=auth_header(admin_token))
wfs = lst.json()
if not wfs:
pytest.skip("No workflows")
wf_id = wfs[0]["id"]
resp = await client.put(
f"/api/v1/workflows/{wf_id}",
headers=auth_header(admin_token),
json={"name": "updated-name", "description": "updated desc"},
)
# Step 3: test234 复审通过
resp = await client.post(f"/api/v1/tickets/{tid}/review", headers=auth_header(approver2_token), json={
"action": "approve", "comment": "复审通过",
})
assert resp.status_code == 200
assert resp.json()["ticket"]["status"] == "approved"
# Step 4: admin 执行
resp = await client.post(f"/api/v1/tickets/{tid}/execute", headers=auth_header(admin_token), json={})
assert resp.status_code in (200, 201)
ticket = resp.json()["ticket"]
assert ticket["status"] == "success"
class TestDeleteWorkflow:
async def test_delete_not_found(self, client: AsyncClient, admin_token: str):
resp = await client.delete("/api/v1/workflows/99999", headers=auth_header(admin_token))
assert resp.status_code in (200, 404)
# Step 5: 验证执行结果是结构化 JSON
import json
exec_result = json.loads(ticket["execute_result"])
assert exec_result["status"] == "success"
assert "items" in exec_result
assert len(exec_result["items"]) >= 1
item = exec_result["items"][0]
assert item["status"] == "success"
assert "duration_ms" in item
async def test_reject_flow(self, client: AsyncClient,
developer_token, approver1_token, mysql_instance):
"""审批拒绝流程"""
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "E2E测试-拒绝",
"db_type": "mysql",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE IF NOT EXISTS _reject_test (id INT)",
"workflow_id": 4,
})
assert resp.status_code in (200, 201)
tid = resp.json()["id"]
# 初审拒绝
resp = await client.post(f"/api/v1/tickets/{tid}/review", headers=auth_header(approver1_token), json={
"action": "reject", "comment": "不通过",
})
assert resp.status_code == 200
assert resp.json()["ticket"]["status"] == "rejected"
async def test_cancel_flow(self, client: AsyncClient,
developer_token, mysql_instance):
"""提交人撤销工单"""
resp = await client.post("/api/v1/tickets", headers=auth_header(developer_token), json={
"title": "E2E测试-撤销",
"db_type": "mysql",
"instance_id": mysql_instance["id"],
"target_database": "opsxsql_test",
"sql_content": "CREATE TABLE IF NOT EXISTS _cancel_test (id INT)",
"workflow_id": 4,
})
assert resp.status_code in (200, 201)
tid = resp.json()["id"]
# 提交人撤销
resp = await client.post(f"/api/v1/tickets/{tid}/cancel", headers=auth_header(developer_token), json={
"reason": "不想执行了",
})
assert resp.status_code == 200
assert resp.json()["ticket"]["status"] == "cancelled"