Files
opsxsql/tests/test_mongo.py
T
logan 88e5f4e7ee feat: Go后端LDAP真实对接 — AES-256-GCM密码加解密 + 登录LDAP fallback
- 新增 crypto.go: AES-256-GCM 加解密,与 Python backend 兼容
- auth.go: Login 添加 LDAP fallback(本地优先→LDAP绑定→自动创建本地用户)
- ldap.go: 实现真实 LDAP 连接/用户搜索/密码验证/OU列表/用户列表
- 所有 LDAP 操作解密 admin_password 后使用
- 新增/更新密码存储时加密
- router.go: 补充 delete-user 路由
- 添加 go-ldap/v3 依赖
2026-05-25 23:32:15 +08:00

712 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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