TestExecuteQuery (16 tests): - 快乐路径: SELECT成功, mask=false, save_history=false - 安全: DDL/DML拒绝(400), guest(403), 无认证(401) - 权限: 公共实例developer可查, 受限组403, 组成员+query:select→200, 无权限→403 - 错误: 无效instance(404), 缺参数(422), 空SQL(422), 服务异常(500) TestSupportedTypes (6 tests): - 4种DB类型返回, 结构字段验证 - Redis readonly_commands, MongoDB readonly_operations - developer可访问, 无认证401 后端测试 843→895 (+52)
533 lines
17 KiB
Python
533 lines
17 KiB
Python
import sys
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.core.crypto import encrypt_password
|
|
from app.models.instance_group import InstanceGroup, user_groups
|
|
from tests.conftest import auth_header
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_aiomysql_mock():
|
|
aiomysql = MagicMock()
|
|
aiomysql.connect = AsyncMock()
|
|
aiomysql.DictCursor = MagicMock()
|
|
return aiomysql
|
|
|
|
|
|
def _build_mock_query_result():
|
|
"""Return a mock UnifiedQueryResult for SELECT queries."""
|
|
from app.schemas.query import UnifiedQueryResult
|
|
return UnifiedQueryResult(
|
|
columns=["id", "name"],
|
|
rows=[{"id": 1, "name": "test"}],
|
|
total=1,
|
|
elapsed_ms=10,
|
|
truncated=False,
|
|
db_type="mysql",
|
|
query_type="SELECT",
|
|
)
|
|
|
|
|
|
async def _create_grouped_instance(db, group_id, **overrides):
|
|
"""Create an instance bound to the given group."""
|
|
from app.models.instance import Instance
|
|
defaults = dict(
|
|
name="GroupedMySQL",
|
|
db_type="mysql",
|
|
host="127.0.0.1",
|
|
port=3306,
|
|
username="root",
|
|
password=encrypt_password("test123"),
|
|
database="test_db",
|
|
env="development",
|
|
status="disconnected",
|
|
group_id=group_id,
|
|
)
|
|
defaults.update(overrides)
|
|
inst = Instance(**defaults)
|
|
db.add(inst)
|
|
await db.commit()
|
|
await db.refresh(inst)
|
|
return inst
|
|
|
|
|
|
# ===================================================================
|
|
# 1. POST /query — execute_query
|
|
# ===================================================================
|
|
|
|
class TestExecuteQuery:
|
|
"""Tests for POST /api/v1/query"""
|
|
|
|
# ---- happy path ------------------------------------------------
|
|
|
|
async def test_select_query_success(
|
|
self, client: AsyncClient, test_instance, admin_token
|
|
):
|
|
mock_result = _build_mock_query_result()
|
|
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
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
|
|
assert data["total"] == 1
|
|
assert data["query_type"] == "SELECT"
|
|
|
|
# ---- forbidden SQL (DDL / DML) ---------------------------------
|
|
|
|
async def test_drop_rejected(
|
|
self, client: AsyncClient, test_instance, admin_token
|
|
):
|
|
"""DDL like DROP should be rejected by the query service (ValueError → 400)."""
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
side_effect=ValueError("不允许执行非SELECT语句"),
|
|
):
|
|
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
|
|
):
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
side_effect=ValueError("不允许执行非SELECT语句"),
|
|
):
|
|
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
|
|
|
|
# ---- guest blocked ---------------------------------------------
|
|
|
|
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
|
|
|
|
# ---- instance not found ----------------------------------------
|
|
|
|
async def test_nonexistent_instance(self, client: AsyncClient, admin_token):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"instance_id": 99999,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
# ---- missing sql parameter (validation) ------------------------
|
|
|
|
async def test_missing_sql_parameter(
|
|
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",
|
|
# no "sql" field
|
|
},
|
|
)
|
|
assert resp.status_code == 422 # Pydantic validation error
|
|
|
|
# ---- empty sql string ------------------------------------------
|
|
|
|
async def test_empty_sql_string(
|
|
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": "",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
# ---- internal server error from query service ------------------
|
|
|
|
async def test_query_service_exception(
|
|
self, client: AsyncClient, test_instance, admin_token
|
|
):
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("connection lost"),
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 500
|
|
|
|
# ---- mask=false query param ------------------------------------
|
|
|
|
async def test_select_query_no_mask(
|
|
self, client: AsyncClient, test_instance, admin_token
|
|
):
|
|
mock_result = _build_mock_query_result()
|
|
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/query?mask=false",
|
|
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
|
|
|
|
# ---- save_history=false query param ----------------------------
|
|
|
|
async def test_select_query_no_history(
|
|
self, client: AsyncClient, test_instance, admin_token
|
|
):
|
|
mock_result = _build_mock_query_result()
|
|
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
), patch(
|
|
"app.api.v1.endpoints.query.QueryHistoryService.record_query",
|
|
new_callable=AsyncMock,
|
|
) as mock_hist:
|
|
resp = await client.post(
|
|
"/api/v1/query?save_history=false",
|
|
headers=auth_header(admin_token),
|
|
json={
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# history should NOT be recorded
|
|
mock_hist.assert_not_called()
|
|
|
|
# ---- unauthenticated request -----------------------------------
|
|
|
|
async def test_unauthenticated_request(self, client: AsyncClient, test_instance):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
json={
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
# ---- developer can query public instance -----------------------
|
|
|
|
async def test_developer_can_query_public_instance(
|
|
self, client: AsyncClient, test_instance, developer_token
|
|
):
|
|
"""Public instance (no group_id) is accessible to developer."""
|
|
mock_result = _build_mock_query_result()
|
|
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(developer_token),
|
|
json={
|
|
"instance_id": test_instance.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# ---- developer blocked from grouped instance without membership --
|
|
|
|
async def test_developer_blocked_from_grouped_instance(
|
|
self, client: AsyncClient, db, developer_token
|
|
):
|
|
"""Instance in a group where developer is not a member → 403."""
|
|
group = InstanceGroup(name="private-group", db_type="mysql")
|
|
db.add(group)
|
|
await db.commit()
|
|
await db.refresh(group)
|
|
|
|
inst = await _create_grouped_instance(db, group.id)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(developer_token),
|
|
json={
|
|
"instance_id": inst.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
# ---- developer with group membership can query -----------------
|
|
|
|
async def test_developer_with_group_membership_can_query(
|
|
self, client: AsyncClient, db, developer_user, developer_token
|
|
):
|
|
"""Developer belongs to the instance group → 200."""
|
|
group = InstanceGroup(
|
|
name="dev-group",
|
|
db_type="mysql",
|
|
permissions='["query:select"]',
|
|
)
|
|
db.add(group)
|
|
await db.commit()
|
|
await db.refresh(group)
|
|
|
|
await db.execute(
|
|
user_groups.insert().values(
|
|
user_id=developer_user.id,
|
|
group_id=group.id,
|
|
permissions='["query:select"]',
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
inst = await _create_grouped_instance(db, group.id)
|
|
|
|
mock_result = _build_mock_query_result()
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(developer_token),
|
|
json={
|
|
"instance_id": inst.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# ---- permission check: missing query:select → 403 --------------
|
|
|
|
async def test_developer_without_query_select_permission(
|
|
self, client: AsyncClient, db, developer_user, developer_token
|
|
):
|
|
"""Developer in group but without query:select permission → 403."""
|
|
group = InstanceGroup(
|
|
name="no-query-group",
|
|
db_type="mysql",
|
|
permissions='["slowlog:view"]', # no query:select
|
|
)
|
|
db.add(group)
|
|
await db.commit()
|
|
await db.refresh(group)
|
|
|
|
await db.execute(
|
|
user_groups.insert().values(
|
|
user_id=developer_user.id,
|
|
group_id=group.id,
|
|
permissions='["slowlog:view"]',
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
inst = await _create_grouped_instance(db, group.id)
|
|
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(developer_token),
|
|
json={
|
|
"instance_id": inst.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
|
|
# ---- DBA can always query (admin bypass) -----------------------
|
|
|
|
async def test_dba_can_query_any_instance(
|
|
self, client: AsyncClient, db, dba_token
|
|
):
|
|
"""DBA is not admin, but can query a public instance."""
|
|
from app.models.instance import Instance
|
|
|
|
inst = Instance(
|
|
name="dba-test",
|
|
db_type="mysql",
|
|
host="127.0.0.1",
|
|
port=3306,
|
|
username="root",
|
|
password=encrypt_password("pw"),
|
|
database="test_db",
|
|
env="development",
|
|
status="disconnected",
|
|
)
|
|
db.add(inst)
|
|
await db.commit()
|
|
await db.refresh(inst)
|
|
|
|
mock_result = _build_mock_query_result()
|
|
with patch(
|
|
"app.api.v1.endpoints.query.QueryService.execute_query",
|
|
new_callable=AsyncMock,
|
|
return_value=mock_result,
|
|
):
|
|
resp = await client.post(
|
|
"/api/v1/query",
|
|
headers=auth_header(dba_token),
|
|
json={
|
|
"instance_id": inst.id,
|
|
"database": "test_db",
|
|
"sql": "SELECT 1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ===================================================================
|
|
# 2. GET /query/supported-types
|
|
# ===================================================================
|
|
|
|
class TestSupportedTypes:
|
|
"""Tests for GET /api/v1/query/supported-types"""
|
|
|
|
async def test_supported_types_returns_200(
|
|
self, client: AsyncClient, admin_token
|
|
):
|
|
resp = await client.get(
|
|
"/api/v1/query/supported-types",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "types" in data
|
|
types = data["types"]
|
|
assert len(types) >= 4
|
|
type_names = [t["type"] for t in types]
|
|
assert "mysql" in type_names
|
|
assert "postgres" in type_names
|
|
assert "redis" in type_names
|
|
assert "mongo" in type_names
|
|
|
|
async def test_supported_types_each_entry_has_required_fields(
|
|
self, client: AsyncClient, admin_token
|
|
):
|
|
resp = await client.get(
|
|
"/api/v1/query/supported-types",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
data = resp.json()
|
|
for entry in data["types"]:
|
|
assert "type" in entry
|
|
assert "name" in entry
|
|
assert "query_format" in entry
|
|
assert "example" in entry
|
|
assert "security" in entry
|
|
assert "features" in entry
|
|
|
|
async def test_supported_types_unauthenticated(
|
|
self, client: AsyncClient
|
|
):
|
|
resp = await client.get("/api/v1/query/supported-types")
|
|
assert resp.status_code == 401
|
|
|
|
async def test_supported_types_developer(
|
|
self, client: AsyncClient, developer_token
|
|
):
|
|
"""Non-admin authenticated user can also view supported types."""
|
|
resp = await client.get(
|
|
"/api/v1/query/supported-types",
|
|
headers=auth_header(developer_token),
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
async def test_supported_types_redis_has_readonly_commands(
|
|
self, client: AsyncClient, admin_token
|
|
):
|
|
resp = await client.get(
|
|
"/api/v1/query/supported-types",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
data = resp.json()
|
|
redis_entry = next(t for t in data["types"] if t["type"] == "redis")
|
|
assert "readonly_commands" in redis_entry
|
|
assert "GET" in redis_entry["readonly_commands"]
|
|
|
|
async def test_supported_types_mongo_has_readonly_operations(
|
|
self, client: AsyncClient, admin_token
|
|
):
|
|
resp = await client.get(
|
|
"/api/v1/query/supported-types",
|
|
headers=auth_header(admin_token),
|
|
)
|
|
data = resp.json()
|
|
mongo_entry = next(t for t in data["types"] if t["type"] == "mongo")
|
|
assert "readonly_operations" in mongo_entry
|
|
assert "find" in mongo_entry["readonly_operations"]
|