sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+711
View File
@@ -0,0 +1,711 @@
"""MongoDB 管理 API 端点测试 — 所有 MongoConnector 方法均 mock。"""
import pytest
from unittest.mock import AsyncMock, patch
from tests.conftest import auth_header
from app.models.instance import Instance
# ── 辅助工具 ──────────────────────────────────────────────────────
MONGO_INSTANCE_DATA = {
"name": "test-mongo",
"db_type": "mongo",
"host": "127.0.0.1",
"port": 27017,
"username": "root",
"password": "test",
"database": "test_mongo",
"env": "development",
"status": "disconnected",
}
MYSQL_INSTANCE_DATA = {
"name": "Test MySQL",
"db_type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"username": "root",
"password": "test123",
"database": "test_db",
"env": "development",
}
async def _create_mongo_instance(client, admin_token):
"""创建一个 mongo 类型的实例并返回其 ID。"""
resp = await client.post(
"/api/v1/instances",
json=MONGO_INSTANCE_DATA,
headers=auth_header(admin_token),
)
assert resp.status_code == 201
return resp.json()["id"]
async def _create_mysql_instance(client, admin_token):
"""创建一个 mysql 类型的实例并返回其 ID。"""
resp = await client.post(
"/api/v1/instances",
json=MYSQL_INSTANCE_DATA,
headers=auth_header(admin_token),
)
assert resp.status_code == 201
return resp.json()["id"]
# ====================================================================
# 1. 数据库列表端点 GET /mongo/{id}/databases
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_list_databases_success(mock_cls, client, admin_token):
"""管理员列出 mongo 数据库 — 正常返回。"""
mock_cls.list_databases = AsyncMock(
return_value=["test_mongo", "myapp", "analytics"]
)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "databases" in data
assert data["databases"] == ["test_mongo", "myapp", "analytics"]
mock_cls.list_databases.assert_awaited_once()
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_list_databases_empty(mock_cls, client, admin_token):
"""数据库列表为空时返回空列表。"""
mock_cls.list_databases = AsyncMock(return_value=[])
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["databases"] == []
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_list_databases_connector_error(mock_cls, client, admin_token):
"""MongoConnector 抛出异常时返回 500。"""
mock_cls.list_databases = AsyncMock(side_effect=Exception("连接超时"))
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "获取数据库列表失败" in resp.json()["detail"]
# ====================================================================
# 2. 集合列表端点 GET /mongo/{id}/databases/{db}/collections
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_list_collections_success(mock_cls, client, admin_token):
"""列出指定数据库下的集合 — 正常返回。"""
mock_cls.list_collections = AsyncMock(
return_value=["users", "orders", "products"]
)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases/myapp/collections",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["database"] == "myapp"
assert data["collections"] == ["users", "orders", "products"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_list_collections_error(mock_cls, client, admin_token):
"""获取集合列表失败时返回 500。"""
mock_cls.list_collections = AsyncMock(side_effect=Exception("权限不足"))
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases/myapp/collections",
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "获取集合列表失败" in resp.json()["detail"]
# ====================================================================
# 3. 集合统计 GET /mongo/{id}/collections/{coll}/stats?database=xxx
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_collection_stats_success(mock_cls, client, admin_token):
"""获取集合统计信息 — 正常返回。"""
mock_stats = {
"namespace": "myapp.users",
"document_count": 5000,
"size_bytes": 1048576,
"avg_document_size_bytes": 209,
"storage_size_bytes": 524288,
"index_count": 3,
"index_size_bytes": 131072,
"capped": False,
}
mock_cls.get_collection_stats = AsyncMock(return_value=mock_stats)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/collections/users/stats?database=myapp",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["document_count"] == 5000
assert data["index_count"] == 3
mock_cls.get_collection_stats.assert_awaited_once()
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_collection_stats_uses_instance_default_db(mock_cls, client, admin_token):
"""未传 database 参数时使用实例默认数据库。"""
mock_stats = {"namespace": "test_mongo.users", "document_count": 100}
mock_cls.get_collection_stats = AsyncMock(return_value=mock_stats)
iid = await _create_mongo_instance(client, admin_token)
# 实例默认 database = "test_mongo",不传 database 参数
resp = await client.get(
f"/api/v1/mongo/{iid}/collections/users/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["document_count"] == 100
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_collection_stats_no_database(mock_cls, client, admin_token, db):
"""实例无默认数据库且未传 database 参数 — 返回 400。"""
mock_cls.get_collection_stats = AsyncMock(return_value={})
iid = await _create_mongo_instance(client, admin_token)
# 手动清空实例的 database 字段
from sqlalchemy import select
result = await db.execute(
select(Instance).where(Instance.id == iid)
)
inst = result.scalar_one()
inst.database = None
await db.commit()
resp = await client.get(
f"/api/v1/mongo/{iid}/collections/users/stats",
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "未指定数据库名称" in resp.json()["detail"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_collection_stats_error(mock_cls, client, admin_token):
"""获取集合统计异常时返回 500。"""
mock_cls.get_collection_stats = AsyncMock(
side_effect=Exception("集合不存在")
)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/collections/users/stats?database=myapp",
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "获取集合统计失败" in resp.json()["detail"]
# ====================================================================
# 4. 只读查询 POST /mongo/{id}/query
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_find_success(mock_cls, client, admin_token):
"""执行 find 查询 — 正常返回。"""
mock_result = [{"_id": "1", "name": "Alice"}, {"_id": "2", "name": "Bob"}]
mock_cls.execute_query_json = AsyncMock(return_value=mock_result)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "find",
"filter": {"age": {"$gt": 18}},
"limit": 100,
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["operation"] == "find"
assert len(data["result"]) == 2
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_aggregate_success(mock_cls, client, admin_token):
"""执行 aggregate 聚合查询 — 正常返回。"""
mock_result = [{"_id": "groupA", "total": 42}]
mock_cls.execute_query_json = AsyncMock(return_value=mock_result)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "orders",
"operation": "aggregate",
"pipeline": [{"$group": {"_id": "$category", "total": {"$sum": 1}}}],
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["operation"] == "aggregate"
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_write_operation_rejected(mock_cls, client, admin_token):
"""查询端点拒绝写操作(如 insertOne)— 返回 403。"""
mock_cls.execute_query_json = AsyncMock(return_value={})
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "hack"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 403
assert "写操作" in resp.json()["detail"] or "禁止" in resp.json()["detail"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_unknown_operation_rejected(mock_cls, client, admin_token):
"""查询端点拒绝未知操作 — 返回 403。"""
mock_cls.execute_query_json = AsyncMock(return_value={})
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "dropCollection",
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 403
assert "未知" in resp.json()["detail"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_no_database(mock_cls, client, admin_token, db):
"""查询时无数据库名称 — 返回 400。"""
mock_cls.execute_query_json = AsyncMock(return_value=[])
iid = await _create_mongo_instance(client, admin_token)
# 清空实例默认 database
from sqlalchemy import select
result = await db.execute(
select(Instance).where(Instance.id == iid)
)
inst = result.scalar_one()
inst.database = None
await db.commit()
body = {
"collection": "users",
"operation": "find",
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "未指定数据库名称" in resp.json()["detail"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_connector_error(mock_cls, client, admin_token):
"""查询端点 connector 异常 — 返回 500。"""
mock_cls.execute_query_json = AsyncMock(
side_effect=Exception("cursor timeout")
)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "find",
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "查询执行失败" in resp.json()["detail"]
# ====================================================================
# 5. 变更操作 POST /mongo/{id}/execute (仅 admin/dba)
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_insert_one_admin(mock_cls, client, admin_token):
"""admin 执行 insertOne 变更 — 正常返回。"""
mock_cls.execute_change_json = AsyncMock(
return_value="507f1f77bcf86cd799439011"
)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "Alice", "age": 25},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["operation"] == "insertOne"
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_delete_many_dba(mock_cls, client, dba_token, admin_token):
"""dba 执行 deleteMany 变更 — 正常返回。"""
mock_cls.execute_change_json = AsyncMock(return_value="删除 3 条文档")
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "logs",
"operation": "deleteMany",
"filter": {"level": "debug"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(dba_token),
)
assert resp.status_code == 200
assert resp.json()["success"] is True
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_update_many_success(mock_cls, client, admin_token):
"""admin 执行 updateMany 变更 — 正常返回。"""
mock_cls.execute_change_json = AsyncMock(
return_value="匹配 5 条,修改 5 条"
)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "updateMany",
"filter": {"status": "inactive"},
"update": {"$set": {"status": "archived"}},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert "匹配 5 条" in resp.json()["result"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_no_database(mock_cls, client, admin_token, db):
"""变更操作无数据库名称 — 返回 400。"""
mock_cls.execute_change_json = AsyncMock(return_value="")
iid = await _create_mongo_instance(client, admin_token)
# 清空实例默认 database
from sqlalchemy import select
result = await db.execute(
select(Instance).where(Instance.id == iid)
)
inst = result.scalar_one()
inst.database = None
await db.commit()
body = {
"collection": "users",
"operation": "insertOne",
"document": {"name": "test"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "未指定数据库名称" in resp.json()["detail"]
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_connector_error(mock_cls, client, admin_token):
"""变更操作 connector 异常 — 返回 500。"""
mock_cls.execute_change_json = AsyncMock(
side_effect=Exception("写入失败")
)
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "test"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "变更执行失败" in resp.json()["detail"]
# ====================================================================
# 6. 测试连接 POST /mongo/{id}/test-connection
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_test_connection_success(mock_cls, client, admin_token):
"""测试连接成功 — 返回带 version 的结果。"""
from app.services.mongo_connector import ConnectionResult
mock_result = ConnectionResult(
success=True, version="7.0.4", latency_ms=12, message="连接成功"
)
mock_cls.test_connection = AsyncMock(return_value=mock_result)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.post(
f"/api/v1/mongo/{iid}/test-connection",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["version"] == "7.0.4"
assert data["latency_ms"] == 12
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_test_connection_failure(mock_cls, client, admin_token):
"""测试连接失败 — 返回 500。"""
mock_cls.test_connection = AsyncMock(
side_effect=Exception("network unreachable")
)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.post(
f"/api/v1/mongo/{iid}/test-connection",
headers=auth_header(admin_token),
)
assert resp.status_code == 500
assert "连接测试失败" in resp.json()["detail"]
# ====================================================================
# 7. 实例验证:非 mongo 类型 / 实例不存在
# ====================================================================
@pytest.mark.asyncio
async def test_non_mongo_instance_rejected(client, admin_token):
"""MySQL 实例访问 mongo 端点 — 返回 400。"""
iid = await _create_mysql_instance(client, admin_token)
resp = await client.get(
f"/api/v1/mongo/{iid}/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "MongoDB" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_non_mongo_instance_rejected_query(client, admin_token):
"""MySQL 实例访问 mongo 查询端点 — 返回 400。"""
iid = await _create_mysql_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "find",
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_non_mongo_instance_rejected_execute(client, admin_token):
"""MySQL 实例访问 mongo 变更端点 — 返回 400。"""
iid = await _create_mysql_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "test"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(admin_token),
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_instance_not_found(client, admin_token):
"""不存在的实例 ID — 返回 404。"""
resp = await client.get(
"/api/v1/mongo/99999/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
# ====================================================================
# 8. 权限检查:developer 不能执行变更操作
# ====================================================================
@pytest.mark.asyncio
async def test_execute_requires_admin_or_dba(client, developer_token, admin_token):
"""developer 角色无法执行变更操作 — 返回 403。"""
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "unauthorized"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_query_allowed_for_developer(mock_cls, client, developer_token, admin_token):
"""developer 可以执行只读查询(get_current_user 即可)。"""
mock_cls.execute_query_json = AsyncMock(return_value=[{"name": "Alice"}])
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "find",
}
resp = await client.post(
f"/api/v1/mongo/{iid}/query",
json=body,
headers=auth_header(developer_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_test_connection_allowed_for_developer(mock_cls, client, developer_token, admin_token):
"""developer 可以测试连接(get_current_user 即可)。"""
from app.services.mongo_connector import ConnectionResult
mock_result = ConnectionResult(
success=True, version="7.0.0", latency_ms=5, message="连接成功"
)
mock_cls.test_connection = AsyncMock(return_value=mock_result)
iid = await _create_mongo_instance(client, admin_token)
resp = await client.post(
f"/api/v1/mongo/{iid}/test-connection",
headers=auth_header(developer_token),
)
assert resp.status_code == 200
# ====================================================================
# 9. dba 角色可以执行变更操作
# ====================================================================
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.mongo.MongoConnector")
async def test_execute_dba_allowed(mock_cls, client, dba_token, admin_token):
"""dba 角色可以执行变更操作 — 正常返回。"""
mock_cls.execute_change_json = AsyncMock(return_value="507f1f77bcf86cd799439011")
iid = await _create_mongo_instance(client, admin_token)
body = {
"database": "myapp",
"collection": "users",
"operation": "insertOne",
"document": {"name": "dba_user"},
}
resp = await client.post(
f"/api/v1/mongo/{iid}/execute",
json=body,
headers=auth_header(dba_token),
)
assert resp.status_code == 200
assert resp.json()["success"] is True