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 依赖
This commit is contained in:
1 parent
622923821b
commit
88e5f4e7ee
71 files changed
+20390
-64
No files matched your search
@@ -0,0 +1,486 @@
|
||||
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"])
|
||||
Reference in new issue
Block a user