"""MongoDB 管理 API 端点测试 — 所有 MongoConnector 方法均 mock。""" import pytest from unittest.mock import AsyncMock, patch from tests.conftest import auth_header pytestmark = pytest.mark.skip(reason="Requires Python app module (app.models.instance) not available in Go backend tests") # ── 辅助工具 ────────────────────────────────────────────────────── 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