import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient 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.instance_group import InstanceGroup, instance_group_members from tests.conftest import auth_header @pytest.mark.asyncio async def test_create_instance_group(client: AsyncClient, admin_token: str): resp = await client.post( "/api/v1/instance-groups", json={ "name": "生产MySQL组", "description": "生产环境MySQL实例", "db_type": "mysql", "permissions": ["query:select", "query:insert", "ddl:create_table"], "is_active": True, }, headers=auth_header(admin_token), ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "生产MySQL组" assert data["db_type"] == "mysql" assert "query:select" in data["permissions"] @pytest.mark.asyncio async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str): payload = {"name": "重复组", "db_type": "mysql"} resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) assert resp1.status_code == 201 resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) assert resp2.status_code == 400 @pytest.mark.asyncio async def test_list_instance_groups(client: AsyncClient, admin_token: str): # Create two groups await client.post( "/api/v1/instance-groups", json={"name": "组A", "db_type": "mysql"}, headers=auth_header(admin_token), ) await client.post( "/api/v1/instance-groups", json={"name": "组B", "db_type": "redis"}, headers=auth_header(admin_token), ) resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token)) assert resp.status_code == 200 data = resp.json() assert len(data) >= 2 @pytest.mark.asyncio async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str): await client.post( "/api/v1/instance-groups", json={"name": "过滤MySQL", "db_type": "mysql"}, headers=auth_header(admin_token), ) resp = await client.get( "/api/v1/instance-groups", params={"db_type": "mysql"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert all(g["db_type"] == "mysql" for g in data) @pytest.mark.asyncio async def test_get_instance_group(client: AsyncClient, admin_token: str): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] resp = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "详情组" assert data["permissions"] == ["query:select"] @pytest.mark.asyncio async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str): resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token)) assert resp.status_code == 404 @pytest.mark.asyncio async def test_update_instance_group(client: AsyncClient, admin_token: str): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "更新前", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] resp = await client.put( f"/api/v1/instance-groups/{group_id}", json={"name": "更新后", "description": "已更新"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "更新后" assert data["description"] == "已更新" @pytest.mark.asyncio async def test_delete_instance_group(client: AsyncClient, admin_token: str): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "删除组", "db_type": "redis"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] resp = await client.delete( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) assert resp.status_code == 200 # Verify deleted get_resp = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) assert get_resp.status_code == 404 @pytest.mark.asyncio async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db): # Create group create_resp = await client.post( "/api/v1/instance-groups", json={"name": "实例关联组", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] # Create instance directly inst = Instance( name="测试实例1", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) db.add(inst) await db.commit() await db.refresh(inst) resp = await client.post( f"/api/v1/instance-groups/{group_id}/instances", json={"instance_ids": [inst.id], "access_type": "readonly"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 # Verify via group detail detail_resp = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) data = detail_resp.json() assert data["readonly_instance_count"] >= 1 assert any(i["id"] == inst.id for i in data["readonly_instances"]) @pytest.mark.asyncio async def test_assign_write_instances_to_group(client: AsyncClient, admin_token: str, db): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "写入实例组", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] inst = Instance( name="写入实例", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) db.add(inst) await db.commit() await db.refresh(inst) resp = await client.post( f"/api/v1/instance-groups/{group_id}/instances", json={"instance_ids": [inst.id], "access_type": "write"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 detail_resp = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) data = detail_resp.json() assert data["write_instance_count"] >= 1 assert any(i["id"] == inst.id for i in data["write_instances"]) @pytest.mark.asyncio async def test_assign_instances_default_readonly(client: AsyncClient, admin_token: str, db): """Test that access_type defaults to readonly when not specified.""" create_resp = await client.post( "/api/v1/instance-groups", json={"name": "默认只读组", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] inst = Instance( name="默认只读实例", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) db.add(inst) await db.commit() await db.refresh(inst) # Send without access_type — should default to readonly resp = await client.post( f"/api/v1/instance-groups/{group_id}/instances", json={"instance_ids": [inst.id]}, headers=auth_header(admin_token), ) assert resp.status_code == 200 detail_resp = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) data = detail_resp.json() assert data["readonly_instance_count"] >= 1 @pytest.mark.asyncio async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db): # Create group + instance create_resp = await client.post( "/api/v1/instance-groups", json={"name": "移除实例组", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] inst = Instance( name="移除实例", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) db.add(inst) await db.commit() await db.refresh(inst) # Assign first await client.post( f"/api/v1/instance-groups/{group_id}/instances", json={"instance_ids": [inst.id], "access_type": "readonly"}, headers=auth_header(admin_token), ) # Remove resp = await client.delete( f"/api/v1/instance-groups/{group_id}/instances/{inst.id}", params={"access_type": "readonly"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_assign_instances_on_create(client: AsyncClient, admin_token: str, db): """Test creating a group with readonly_instance_ids and write_instance_ids.""" inst1 = Instance( name="创建时只读实例", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) inst2 = Instance( name="创建时写入实例", db_type="mysql", host="127.0.0.1", port=3307, username="root", password="test", database="test", ) db.add(inst1) db.add(inst2) await db.commit() await db.refresh(inst1) await db.refresh(inst2) resp = await client.post( "/api/v1/instance-groups", json={ "name": "创建时关联实例组", "db_type": "mysql", "readonly_instance_ids": [inst1.id], "write_instance_ids": [inst2.id], }, headers=auth_header(admin_token), ) assert resp.status_code == 201 data = resp.json() assert data["readonly_instance_count"] == 1 assert data["write_instance_count"] == 1 # Verify detail detail = await client.get( f"/api/v1/instance-groups/{data['id']}", headers=auth_header(admin_token), ) detail_data = detail.json() assert len(detail_data["readonly_instances"]) == 1 assert len(detail_data["write_instances"]) == 1 @pytest.mark.asyncio async def test_update_group_members(client: AsyncClient, admin_token: str, db): """Test updating a group's instance members.""" inst1 = Instance( name="更新成员实例1", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) inst2 = Instance( name="更新成员实例2", db_type="mysql", host="127.0.0.1", port=3307, username="root", password="test", database="test", ) db.add(inst1) db.add(inst2) await db.commit() await db.refresh(inst1) await db.refresh(inst2) create_resp = await client.post( "/api/v1/instance-groups", json={ "name": "更新成员组", "db_type": "mysql", "readonly_instance_ids": [inst1.id], }, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] # Update to replace members resp = await client.put( f"/api/v1/instance-groups/{group_id}", json={ "readonly_instance_ids": [inst2.id], "write_instance_ids": [inst1.id], }, headers=auth_header(admin_token), ) assert resp.status_code == 200 detail = await client.get( f"/api/v1/instance-groups/{group_id}", headers=auth_header(admin_token), ) data = detail.json() assert data["readonly_instance_count"] == 1 assert data["write_instance_count"] == 1 assert data["readonly_instances"][0]["id"] == inst2.id assert data["write_instances"][0]["id"] == inst1.id @pytest.mark.asyncio async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "用户关联组", "db_type": "all"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] resp = await client.post( f"/api/v1/instance-groups/{group_id}/users", json={"user_ids": [admin_user.id], "permissions": ["query:select"]}, headers=auth_header(admin_token), ) assert resp.status_code == 200 # Verify via group users users_resp = await client.get( f"/api/v1/instance-groups/{group_id}/users", headers=auth_header(admin_token), ) assert users_resp.status_code == 200 users_data = users_resp.json() assert any(u["id"] == admin_user.id for u in users_data) @pytest.mark.asyncio async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user): # Create two groups g1 = await client.post( "/api/v1/instance-groups", json={"name": "用户组1", "db_type": "mysql"}, headers=auth_header(admin_token), ) g2 = await client.post( "/api/v1/instance-groups", json={"name": "用户组2", "db_type": "redis"}, headers=auth_header(admin_token), ) g1_id = g1.json()["id"] g2_id = g2.json()["id"] resp = await client.put( f"/api/v1/instance-groups/users/{admin_user.id}/groups", json={"group_ids": [g1_id, g2_id]}, headers=auth_header(admin_token), ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str): resp = await client.post( "/api/v1/instance-groups", json={"name": "非管理员组", "db_type": "mysql"}, headers=auth_header(developer_token), ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str): create_resp = await client.post( "/api/v1/instance-groups", json={"name": "禁止删除组", "db_type": "mysql"}, headers=auth_header(admin_token), ) group_id = create_resp.json()["id"] resp = await client.delete( f"/api/v1/instance-groups/{group_id}", headers=auth_header(developer_token), ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_group_db_type_validation(client: AsyncClient, admin_token: str): resp = await client.post( "/api/v1/instance-groups", json={"name": "无效类型", "db_type": "oracle"}, headers=auth_header(admin_token), ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_same_instance_readonly_and_write(client: AsyncClient, admin_token: str, db): """Test that the same instance can be both readonly and write in the same group.""" inst = Instance( name="双重实例", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="test", database="test", ) db.add(inst) await db.commit() await db.refresh(inst) create_resp = await client.post( "/api/v1/instance-groups", json={ "name": "双重权限组", "db_type": "mysql", "readonly_instance_ids": [inst.id], "write_instance_ids": [inst.id], }, headers=auth_header(admin_token), ) assert create_resp.status_code == 201 data = create_resp.json() assert data["readonly_instance_count"] == 1 assert data["write_instance_count"] == 1 detail = await client.get( f"/api/v1/instance-groups/{data['id']}", headers=auth_header(admin_token), ) detail_data = detail.json() assert any(i["id"] == inst.id for i in detail_data["readonly_instances"]) assert any(i["id"] == inst.id for i in detail_data["write_instances"])