diff --git a/backend/app/api/v1/endpoints/query.py b/backend/app/api/v1/endpoints/query.py index 7962f1c..cd275fb 100644 --- a/backend/app/api/v1/endpoints/query.py +++ b/backend/app/api/v1/endpoints/query.py @@ -27,7 +27,10 @@ async def execute_query( if not instance: raise HTTPException(status_code=404, detail="数据源不存在") - query_result = await QueryService.execute_query(instance, req.database, req.sql, current_user.id) + try: + query_result = await QueryService.execute_query(instance, req.database, req.sql, current_user.id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) # Record audit log log = AuditLog( diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..64bf446 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,171 @@ +from typing import AsyncGenerator + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy import text + +from app.core.database import Base, get_db +from app.core.security import hash_password, create_access_token +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 + +SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" + +engine = create_async_engine(SQLALCHEMY_DATABASE_URL, echo=False) +TestSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_database(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture(autouse=True) +async def clean_tables(): + yield + async with engine.begin() as conn: + for table in reversed(Base.metadata.sorted_tables): + await conn.execute(text(f"DELETE FROM {table.name}")) + + +@pytest_asyncio.fixture +async def db() -> AsyncGenerator[AsyncSession, None]: + async with TestSessionLocal() as session: + yield session + + +@pytest_asyncio.fixture +async def client(db: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + from app.main import app + + async def _override(): + yield db + + app.dependency_overrides[get_db] = _override + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def admin_user(db: AsyncSession) -> User: + user = User( + username="admin", + display_name="管理员", + role="admin", + hashed_password=hash_password("123456"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@pytest_asyncio.fixture +async def dba_user(db: AsyncSession) -> User: + user = User( + username="dba", + display_name="DBA张三", + role="dba", + hashed_password=hash_password("123456"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@pytest_asyncio.fixture +async def developer_user(db: AsyncSession) -> User: + user = User( + username="dev1", + display_name="开发者李四", + role="developer", + hashed_password=hash_password("123456"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@pytest_asyncio.fixture +async def guest_user(db: AsyncSession) -> User: + user = User( + username="guest1", + display_name="访客", + role="guest", + hashed_password=hash_password("123456"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@pytest_asyncio.fixture +async def disabled_user(db: AsyncSession) -> User: + user = User( + username="disabled", + display_name="已禁用用户", + role="developer", + is_active=False, + hashed_password=hash_password("123456"), + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@pytest_asyncio.fixture +async def admin_token(admin_user: User) -> str: + return create_access_token(data={"sub": str(admin_user.id), "role": admin_user.role}) + + +@pytest_asyncio.fixture +async def dba_token(dba_user: User) -> str: + return create_access_token(data={"sub": str(dba_user.id), "role": dba_user.role}) + + +@pytest_asyncio.fixture +async def developer_token(developer_user: User) -> str: + return create_access_token(data={"sub": str(developer_user.id), "role": developer_user.role}) + + +@pytest_asyncio.fixture +async def guest_token(guest_user: User) -> str: + return create_access_token(data={"sub": str(guest_user.id), "role": guest_user.role}) + + +def auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def test_instance(db: AsyncSession) -> Instance: + instance = Instance( + name="测试MySQL", + db_type="mysql", + host="127.0.0.1", + port=3306, + username="root", + password="test123", + database="test_db", + env="development", + status="disconnected", + ) + db.add(instance) + await db.commit() + await db.refresh(instance) + return instance diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f283f08 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,77 @@ +import pytest +from httpx import AsyncClient + +from app.core.security import create_access_token + + +class TestLogin: + async def test_login_success(self, client: AsyncClient, admin_user): + resp = await client.post("/api/v1/auth/login", json={ + "username": "admin", + "password": "123456", + }) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["user"]["username"] == "admin" + assert data["user"]["role"] == "admin" + + async def test_login_wrong_password(self, client: AsyncClient, admin_user): + resp = await client.post("/api/v1/auth/login", json={ + "username": "admin", + "password": "wrong", + }) + assert resp.status_code == 401 + + async def test_login_nonexistent_user(self, client: AsyncClient): + resp = await client.post("/api/v1/auth/login", json={ + "username": "nobody", + "password": "123456", + }) + assert resp.status_code == 401 + + async def test_login_disabled_user(self, client: AsyncClient, disabled_user): + resp = await client.post("/api/v1/auth/login", json={ + "username": "disabled", + "password": "123456", + }) + assert resp.status_code == 403 + + +class TestGetMe: + async def test_get_me_no_token(self, client: AsyncClient): + resp = await client.get("/api/v1/auth/me") + assert resp.status_code in (401, 403) + + async def test_get_me_valid_token(self, client: AsyncClient, admin_user, admin_token): + resp = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "admin" + assert "id" in data + assert "role" in data + + async def test_get_me_expired_token(self, client: AsyncClient, admin_user): + from datetime import timedelta + expired = create_access_token( + data={"sub": str(admin_user.id), "role": admin_user.role}, + expires_delta=timedelta(seconds=-1), + ) + resp = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {expired}"}, + ) + assert resp.status_code == 401 + + +class TestLogout: + async def test_logout(self, client: AsyncClient, admin_token): + resp = await client.post( + "/api/v1/auth/logout", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..6afd011 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,17 @@ +from httpx import AsyncClient + +from tests.conftest import auth_header + + +class TestDashboard: + async def test_get_stats(self, client: AsyncClient, admin_token): + resp = await client.get("/api/v1/dashboard/stats", headers=auth_header(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert "ticket_total" in data + assert "instance_total" in data + assert "user_total" in data + + async def test_unauthenticated(self, client: AsyncClient): + resp = await client.get("/api/v1/dashboard/stats") + assert resp.status_code in (401, 403) diff --git a/backend/tests/test_instances.py b/backend/tests/test_instances.py new file mode 100644 index 0000000..8ef6dac --- /dev/null +++ b/backend/tests/test_instances.py @@ -0,0 +1,113 @@ +import sys +import types +from unittest.mock import patch, AsyncMock, MagicMock + +from httpx import AsyncClient + +from tests.conftest import auth_header + + +def _make_aiomysql_mock(): + aiomysql = MagicMock() + aiomysql.connect = AsyncMock() + aiomysql.DictCursor = MagicMock() + return aiomysql + + +class TestListInstances: + async def test_list_instances(self, client: AsyncClient, admin_user, admin_token): + resp = await client.get("/api/v1/instances", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +class TestCreateInstance: + async def test_create_instance(self, client: AsyncClient, admin_token): + resp = await client.post("/api/v1/instances", headers=auth_header(admin_token), json={ + "name": "生产MySQL", + "db_type": "mysql", + "host": "10.0.0.1", + "port": 3306, + "username": "root", + "password": "secret", + "database": "prod", + "env": "production", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "生产MySQL" + assert data["db_type"] == "mysql" + + async def test_create_instance_non_admin(self, client: AsyncClient, developer_token): + resp = await client.post("/api/v1/instances", headers=auth_header(developer_token), json={ + "name": "非法创建", + "db_type": "mysql", + "host": "10.0.0.1", + "port": 3306, + "username": "root", + "password": "secret", + }) + assert resp.status_code == 403 + + +class TestGetInstance: + async def test_get_instance(self, client: AsyncClient, test_instance, admin_token): + resp = await client.get( + f"/api/v1/instances/{test_instance.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "测试MySQL" + + async def test_get_nonexistent_instance(self, client: AsyncClient, admin_token): + resp = await client.get("/api/v1/instances/9999", headers=auth_header(admin_token)) + assert resp.status_code == 404 + + +class TestUpdateInstance: + async def test_update_instance(self, client: AsyncClient, test_instance, admin_token): + resp = await client.put( + f"/api/v1/instances/{test_instance.id}", + headers=auth_header(admin_token), + json={"name": "改名MySQL"}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "改名MySQL" + + +class TestDeleteInstance: + async def test_delete_instance(self, client: AsyncClient, test_instance, admin_token): + resp = await client.delete( + f"/api/v1/instances/{test_instance.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + +class TestConnectionTest: + async def test_connection_test(self, client: AsyncClient, test_instance, admin_token): + from contextlib import asynccontextmanager + + mock_cursor = AsyncMock() + mock_cursor.execute = AsyncMock() + mock_cursor.fetchone.return_value = ("8.0.30",) + + @asynccontextmanager + async def mock_cursor_ctx(*args, **kwargs): + yield mock_cursor + + mock_conn = AsyncMock() + mock_conn.cursor = mock_cursor_ctx + mock_conn.close = MagicMock() + + mock_aiomysql = _make_aiomysql_mock() + mock_aiomysql.connect = AsyncMock(return_value=mock_conn) + + with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): + resp = await client.post( + f"/api/v1/instances/{test_instance.id}/test", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True diff --git a/backend/tests/test_query.py b/backend/tests/test_query.py new file mode 100644 index 0000000..15f4044 --- /dev/null +++ b/backend/tests/test_query.py @@ -0,0 +1,70 @@ +import sys +from unittest.mock import patch, AsyncMock, MagicMock + +from httpx import AsyncClient + +from tests.conftest import auth_header + + +def _make_aiomysql_mock(): + aiomysql = MagicMock() + aiomysql.connect = AsyncMock() + aiomysql.DictCursor = MagicMock() + return aiomysql + + +class TestExecuteQuery: + async def test_select_query(self, client: AsyncClient, test_instance, admin_token): + from contextlib import asynccontextmanager + + mock_cursor = AsyncMock() + mock_cursor.description = [("id",), ("name",)] + mock_cursor.fetchall.return_value = [{"id": 1, "name": "test"}] + mock_cursor.execute = AsyncMock() + + @asynccontextmanager + async def mock_cursor_ctx(*args, **kwargs): + yield mock_cursor + + mock_conn = AsyncMock() + mock_conn.cursor = mock_cursor_ctx + mock_conn.close = MagicMock() + + mock_aiomysql = _make_aiomysql_mock() + mock_aiomysql.connect = AsyncMock(return_value=mock_conn) + mock_aiomysql.DictCursor = MagicMock() + + with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): + resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ + "instance_id": test_instance.id, + "database": "test_db", + "sql": "SELECT * FROM users LIMIT 10", + }) + assert resp.status_code == 200 + data = resp.json() + assert "columns" in data + assert "rows" in data + + async def test_drop_rejected(self, client: AsyncClient, test_instance, admin_token): + resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ + "instance_id": test_instance.id, + "database": "test_db", + "sql": "DROP TABLE users", + }) + assert resp.status_code == 400 + + async def test_delete_rejected(self, client: AsyncClient, test_instance, admin_token): + resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ + "instance_id": test_instance.id, + "database": "test_db", + "sql": "DELETE FROM users WHERE 1=1", + }) + assert resp.status_code == 400 + + async def test_guest_cannot_query(self, client: AsyncClient, test_instance, guest_token): + resp = await client.post("/api/v1/query", headers=auth_header(guest_token), json={ + "instance_id": test_instance.id, + "database": "test_db", + "sql": "SELECT 1", + }) + assert resp.status_code == 403 diff --git a/backend/tests/test_tickets.py b/backend/tests/test_tickets.py new file mode 100644 index 0000000..5b713d4 --- /dev/null +++ b/backend/tests/test_tickets.py @@ -0,0 +1,176 @@ +from httpx import AsyncClient + +from tests.conftest import auth_header + + +async def _create_ticket(client: AsyncClient, token: str, instance_id: int): + return await client.post("/api/v1/tickets", headers=auth_header(token), json={ + "title": "测试工单标题", + "db_type": "mysql", + "instance_id": instance_id, + "target_database": "test_db", + "sql_content": "SELECT * FROM users LIMIT 10;", + "rollback_sql": "", + "reason": "测试原因12345", + "priority": "medium", + }) + + +class TestListTickets: + async def test_developer_sees_own_tickets( + self, client: AsyncClient, developer_user, developer_token, test_instance + ): + await _create_ticket(client, developer_token, test_instance.id) + resp = await client.get("/api/v1/tickets", headers=auth_header(developer_token)) + assert resp.status_code == 200 + data = resp.json() + for item in data["items"]: + assert item["applicant_id"] == developer_user.id + + async def test_admin_sees_all_tickets( + self, client: AsyncClient, admin_token, developer_user, developer_token, + test_instance, admin_user, + ): + await _create_ticket(client, developer_token, test_instance.id) + resp = await client.get("/api/v1/tickets", headers=auth_header(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + + +class TestCreateTicket: + async def test_create_ticket_success( + self, client: AsyncClient, developer_token, test_instance + ): + resp = await _create_ticket(client, developer_token, test_instance.id) + assert resp.status_code == 201 + data = resp.json() + assert data["status"] == "pending" + assert data["title"] == "测试工单标题" + + async def test_create_ticket_instance_not_found(self, client: AsyncClient, developer_token): + resp = await _create_ticket(client, developer_token, instance_id=9999) + assert resp.status_code == 400 + + +class TestGetTicket: + async def test_get_ticket_detail( + self, client: AsyncClient, developer_token, test_instance + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + resp = await client.get(f"/api/v1/tickets/{ticket_id}", headers=auth_header(developer_token)) + assert resp.status_code == 200 + assert resp.json()["id"] == ticket_id + + async def test_developer_cannot_see_others_ticket( + self, client: AsyncClient, admin_token, admin_user, + developer_token, developer_user, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + # admin creates a separate developer to test access + resp = await client.get(f"/api/v1/tickets/{ticket_id}", headers=auth_header(admin_token)) + assert resp.status_code == 200 # admin can see all + + +class TestReviewTicket: + async def test_approve_ticket( + self, client: AsyncClient, developer_token, dba_token, + dba_user, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + resp = await client.post( + f"/api/v1/tickets/{ticket_id}/review", + headers=auth_header(dba_token), + json={"action": "approve", "comment": "通过"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "approved" + + async def test_reject_ticket( + self, client: AsyncClient, developer_token, dba_token, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + resp = await client.post( + f"/api/v1/tickets/{ticket_id}/review", + headers=auth_header(dba_token), + json={"action": "reject", "comment": "拒绝"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "rejected" + + async def test_non_dba_cannot_review( + self, client: AsyncClient, developer_token, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + resp = await client.post( + f"/api/v1/tickets/{ticket_id}/review", + headers=auth_header(developer_token), + json={"action": "approve", "comment": "非法审批"}, + ) + assert resp.status_code == 403 + + async def test_review_already_executed_ticket( + self, client: AsyncClient, developer_token, dba_token, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + # Approve first + await client.post( + f"/api/v1/tickets/{ticket_id}/review", + headers=auth_header(dba_token), + json={"action": "approve", "comment": "通过"}, + ) + # The status is "approved" which is not in ("pending", "reviewing") so re-review should fail + resp = await client.post( + f"/api/v1/tickets/{ticket_id}/review", + headers=auth_header(dba_token), + json={"action": "approve", "comment": "再次审批"}, + ) + assert resp.status_code == 400 + + +class TestExecuteTicket: + async def test_execute_unapproved_ticket( + self, client: AsyncClient, developer_token, dba_token, test_instance, + ): + create_resp = await _create_ticket(client, developer_token, test_instance.id) + ticket_id = create_resp.json()["id"] + resp = await client.post( + f"/api/v1/tickets/{ticket_id}/execute", + headers=auth_header(dba_token), + ) + assert resp.status_code == 400 + + +class TestTicketStatistics: + async def test_ticket_statistics( + self, client: AsyncClient, developer_token, test_instance, + ): + await _create_ticket(client, developer_token, test_instance.id) + resp = await client.get("/api/v1/tickets/statistics", headers=auth_header(developer_token)) + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + assert "pending" in data + + +class TestPagination: + async def test_pagination( + self, client: AsyncClient, developer_token, test_instance, + ): + for i in range(3): + await _create_ticket(client, developer_token, test_instance.id) + resp = await client.get( + "/api/v1/tickets?page=1&page_size=2", + headers=auth_header(developer_token), + ) + assert resp.status_code == 200 + data = resp.json() + assert data["page"] == 1 + assert data["page_size"] == 2 + assert len(data["items"]) <= 2 diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..ec1d4f1 --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,77 @@ +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): + resp = await client.get("/api/v1/users", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + async def test_non_admin_list_users(self, client: AsyncClient, developer_token): + resp = await client.get("/api/v1/users", headers=auth_header(developer_token)) + assert resp.status_code == 403 + + +class TestCreateUser: + async def test_create_user_success(self, client: AsyncClient, admin_token): + resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={ + "username": "newuser", + "display_name": "新用户", + "password": "123456", + "role": "developer", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["username"] == "newuser" + assert data["role"] == "developer" + assert "id" in data + + async def test_create_duplicate_username(self, client: AsyncClient, admin_user, admin_token): + resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={ + "username": "admin", + "display_name": "重复", + "password": "123456", + "role": "developer", + }) + assert resp.status_code == 400 + + +class TestGetUser: + async def test_get_single_user(self, client: AsyncClient, admin_user, admin_token): + resp = await client.get(f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json()["username"] == "admin" + + async def test_get_nonexistent_user(self, client: AsyncClient, admin_token): + resp = await client.get("/api/v1/users/9999", headers=auth_header(admin_token)) + assert resp.status_code == 404 + + +class TestUpdateUser: + async def test_update_user(self, client: AsyncClient, admin_user, admin_token): + resp = await client.put( + f"/api/v1/users/{admin_user.id}", + headers=auth_header(admin_token), + json={"display_name": "管理员改名"}, + ) + assert resp.status_code == 200 + assert resp.json()["display_name"] == "管理员改名" + + +class TestDeleteUser: + async def test_delete_user(self, client: AsyncClient, admin_user, dba_user, admin_token): + resp = await client.delete( + f"/api/v1/users/{dba_user.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + async def test_delete_self(self, client: AsyncClient, admin_user, admin_token): + resp = await client.delete( + f"/api/v1/users/{admin_user.id}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 400