refactor: 权限组从一组一类型改为一组同时包含readonly+write实例

- 新增 instance_group_members 中间表关联权限组和实例
- InstanceGroup 模型移除旧的 instances relationship,改为通过中间表查询
- Instance 模型保留 group_id FK(兼容)但移除 group relationship
- Schema 新增 readonly_instance_ids / write_instance_ids 字段
- InstanceGroupResponse 改为 readonly_instance_count + write_instance_count
- InstanceGroupDetailResponse 返回 readonly_instances + write_instances
- InstanceAssignRequest 新增 access_type 参数
- API create/update 支持同步写入 instance_group_members
- role_permissions 服务更新为使用中间表查询
- 新增迁移 SQL 并应用到 sqlite3.db
- 测试全部通过 (20/20)
This commit is contained in:
logan committed 2026-05-24 20:03:55 +08:00
1 parent 40868c61bf
commit 39d9600e82
8 files changed
+418 -97

No files matched your search

+200 -5
View File
@@ -5,7 +5,7 @@ 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
from app.models.instance_group import InstanceGroup, instance_group_members
from tests.conftest import auth_header
@@ -159,7 +159,7 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str,
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
json={"instance_ids": [inst.id], "access_type": "readonly"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@@ -170,8 +170,75 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str,
headers=auth_header(admin_token),
)
data = detail_resp.json()
assert data["instance_count"] >= 1
assert any(i["id"] == inst.id for i in data["instances"])
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
@@ -195,17 +262,110 @@ async def test_remove_instance_from_group(client: AsyncClient, admin_token: str,
# Assign first
await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
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(
@@ -289,3 +449,38 @@ async def test_group_db_type_validation(client: AsyncClient, admin_token: str):
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"])