- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
396 lines
15 KiB
Python
396 lines
15 KiB
Python
"""备份管理 API 端点测试。
|
||
|
||
测试范围:
|
||
- GET /api/v1/backups — 列表(支持 instance_id 过滤)
|
||
- POST /api/v1/backups — 创建备份(201, 自动生成 filename)
|
||
- GET /api/v1/backups/{id} — 备份详情(404 不存在)
|
||
|
||
覆盖要点:
|
||
- CRUD 基本流程
|
||
- 权限控制(admin/dba 可访问, developer/guest → 403)
|
||
- instance_id 过滤
|
||
- filename 自动生成(database_instance_id.sql)
|
||
- 404 不存在的备份
|
||
"""
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
from tests.conftest import auth_header
|
||
|
||
BASE = "/api/v1/backups"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 权限测试 — developer / guest → 403
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBackupPermissions:
|
||
"""备份路由权限控制:仅 admin / dba 可访问。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_developer_forbidden(
|
||
self, client: AsyncClient, developer_token: str
|
||
):
|
||
"""developer 访问列表 → 403。"""
|
||
resp = await client.get(BASE, headers=auth_header(developer_token))
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_guest_forbidden(
|
||
self, client: AsyncClient, guest_token: str
|
||
):
|
||
"""guest 访问列表 → 403。"""
|
||
resp = await client.get(BASE, headers=auth_header(guest_token))
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_developer_forbidden(
|
||
self, client: AsyncClient, developer_token: str
|
||
):
|
||
"""developer 创建备份 → 403。"""
|
||
resp = await client.post(
|
||
BASE,
|
||
json={"title": "t", "instance_id": 1, "database": "db"},
|
||
headers=auth_header(developer_token),
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_guest_forbidden(
|
||
self, client: AsyncClient, guest_token: str
|
||
):
|
||
"""guest 创建备份 → 403。"""
|
||
resp = await client.post(
|
||
BASE,
|
||
json={"title": "t", "instance_id": 1, "database": "db"},
|
||
headers=auth_header(guest_token),
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_backup_developer_forbidden(
|
||
self, client: AsyncClient, developer_token: str
|
||
):
|
||
"""developer 获取详情 → 403。"""
|
||
resp = await client.get(f"{BASE}/1", headers=auth_header(developer_token))
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_backup_guest_forbidden(
|
||
self, client: AsyncClient, guest_token: str
|
||
):
|
||
"""guest 获取详情 → 403。"""
|
||
resp = await client.get(f"{BASE}/1", headers=auth_header(guest_token))
|
||
assert resp.status_code == 403
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unauthenticated_access(self, client: AsyncClient):
|
||
"""未认证 → 401。"""
|
||
resp = await client.get(BASE)
|
||
assert resp.status_code == 401
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 创建备份 — POST /api/v1/backups
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestCreateBackup:
|
||
"""创建备份测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_admin(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""admin 创建备份 → 201,字段完整。"""
|
||
payload = {
|
||
"title": "每日全量备份",
|
||
"instance_id": test_instance.id,
|
||
"database": "production_db",
|
||
"remark": "定时任务触发",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
|
||
assert resp.status_code == 201
|
||
|
||
data = resp.json()
|
||
assert data["id"] is not None
|
||
assert data["title"] == "每日全量备份"
|
||
assert data["instance_id"] == test_instance.id
|
||
assert data["database"] == "production_db"
|
||
assert data["status"] == "pending"
|
||
assert data["size_bytes"] == 0
|
||
assert data["remark"] == "定时任务触发"
|
||
# created_by 应为 admin 用户 id
|
||
assert data["created_by"] is not None
|
||
# created_at 应有值
|
||
assert data["created_at"] is not None
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_filename_auto_generated(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""filename 自动生成为 {database}_{instance_id}.sql。"""
|
||
payload = {
|
||
"title": "测试文件名",
|
||
"instance_id": test_instance.id,
|
||
"database": "mydb",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
|
||
assert resp.status_code == 201
|
||
|
||
data = resp.json()
|
||
expected_filename = f"mydb_{test_instance.id}.sql"
|
||
assert data["filename"] == expected_filename
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_dba(
|
||
self, client: AsyncClient, dba_token: str, test_instance
|
||
):
|
||
"""dba 创建备份 → 201。"""
|
||
payload = {
|
||
"title": "DBA手动备份",
|
||
"instance_id": test_instance.id,
|
||
"database": "orders",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(dba_token))
|
||
assert resp.status_code == 201
|
||
assert resp.json()["title"] == "DBA手动备份"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_without_remark(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""不传 remark → remark 为 None。"""
|
||
payload = {
|
||
"title": "无备注备份",
|
||
"instance_id": test_instance.id,
|
||
"database": "test_db",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
|
||
assert resp.status_code == 201
|
||
assert resp.json()["remark"] is None
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_sets_created_by(
|
||
self, client: AsyncClient, dba_token: str, dba_user, test_instance
|
||
):
|
||
"""created_by 应为当前登录用户 id。"""
|
||
payload = {
|
||
"title": "验证created_by",
|
||
"instance_id": test_instance.id,
|
||
"database": "hr_db",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(dba_token))
|
||
assert resp.status_code == 201
|
||
assert resp.json()["created_by"] == dba_user.id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_backup_nonexistent_instance_id(
|
||
self, client: AsyncClient, admin_token: str
|
||
):
|
||
"""instance_id 可以是不存在的(无外键约束),仍然创建成功。"""
|
||
payload = {
|
||
"title": "幽灵实例备份",
|
||
"instance_id": 99999,
|
||
"database": "ghost_db",
|
||
}
|
||
resp = await client.post(BASE, json=payload, headers=auth_header(admin_token))
|
||
assert resp.status_code == 201
|
||
assert resp.json()["instance_id"] == 99999
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 获取备份详情 — GET /api/v1/backups/{backup_id}
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestGetBackup:
|
||
"""获取备份详情测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_backup_by_id(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""创建后通过 id 获取 → 数据一致。"""
|
||
# 先创建
|
||
payload = {
|
||
"title": "查询测试",
|
||
"instance_id": test_instance.id,
|
||
"database": "inventory",
|
||
"remark": "验证查询",
|
||
}
|
||
create_resp = await client.post(
|
||
BASE, json=payload, headers=auth_header(admin_token)
|
||
)
|
||
assert create_resp.status_code == 201
|
||
backup_id = create_resp.json()["id"]
|
||
|
||
# 再查询
|
||
get_resp = await client.get(
|
||
f"{BASE}/{backup_id}", headers=auth_header(admin_token)
|
||
)
|
||
assert get_resp.status_code == 200
|
||
data = get_resp.json()
|
||
assert data["id"] == backup_id
|
||
assert data["title"] == "查询测试"
|
||
assert data["database"] == "inventory"
|
||
assert data["remark"] == "验证查询"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_backup_not_found(self, client: AsyncClient, admin_token: str):
|
||
"""获取不存在的 backup_id → 404。"""
|
||
resp = await client.get(f"{BASE}/99999", headers=auth_header(admin_token))
|
||
assert resp.status_code == 404
|
||
assert "不存在" in resp.json()["detail"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_backup_dba(
|
||
self, client: AsyncClient, dba_token: str, admin_token: str, test_instance
|
||
):
|
||
"""dba 可以获取 admin 创建的备份。"""
|
||
payload = {
|
||
"title": "跨角色查看",
|
||
"instance_id": test_instance.id,
|
||
"database": "shared_db",
|
||
}
|
||
create_resp = await client.post(
|
||
BASE, json=payload, headers=auth_header(admin_token)
|
||
)
|
||
backup_id = create_resp.json()["id"]
|
||
|
||
get_resp = await client.get(
|
||
f"{BASE}/{backup_id}", headers=auth_header(dba_token)
|
||
)
|
||
assert get_resp.status_code == 200
|
||
assert get_resp.json()["id"] == backup_id
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════
|
||
# 列表查询 — GET /api/v1/backups
|
||
# ══════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestListBackups:
|
||
"""备份列表查询测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_empty(self, client: AsyncClient, admin_token: str):
|
||
"""无备份时返回空列表。"""
|
||
resp = await client.get(BASE, headers=auth_header(admin_token))
|
||
assert resp.status_code == 200
|
||
assert resp.json() == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_returns_created(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""创建后列表包含该备份。"""
|
||
payload = {
|
||
"title": "列表验证",
|
||
"instance_id": test_instance.id,
|
||
"database": "catalog",
|
||
}
|
||
await client.post(BASE, json=payload, headers=auth_header(admin_token))
|
||
|
||
resp = await client.get(BASE, headers=auth_header(admin_token))
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert len(data) >= 1
|
||
assert any(b["title"] == "列表验证" for b in data)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_filter_by_instance_id(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""instance_id 过滤:只返回对应实例的备份。"""
|
||
# 创建两个不同 instance_id 的备份
|
||
await client.post(
|
||
BASE,
|
||
json={"title": "目标实例", "instance_id": test_instance.id, "database": "a"},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
await client.post(
|
||
BASE,
|
||
json={"title": "其他实例", "instance_id": 99999, "database": "b"},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
|
||
resp = await client.get(
|
||
BASE,
|
||
params={"instance_id": test_instance.id},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert len(data) == 1
|
||
assert data[0]["title"] == "目标实例"
|
||
assert data[0]["instance_id"] == test_instance.id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_filter_nonexistent_instance(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""过滤不存在的 instance_id → 空列表。"""
|
||
# 先创建一个备份确保数据库不为空
|
||
await client.post(
|
||
BASE,
|
||
json={
|
||
"title": "占位",
|
||
"instance_id": test_instance.id,
|
||
"database": "x",
|
||
},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
|
||
resp = await client.get(
|
||
BASE,
|
||
params={"instance_id": 88888},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json() == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_dba(
|
||
self, client: AsyncClient, dba_token: str, admin_token: str, test_instance
|
||
):
|
||
"""dba 可以查看备份列表。"""
|
||
await client.post(
|
||
BASE,
|
||
json={
|
||
"title": "DBA列表",
|
||
"instance_id": test_instance.id,
|
||
"database": "dba_db",
|
||
},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
|
||
resp = await client.get(BASE, headers=auth_header(dba_token))
|
||
assert resp.status_code == 200
|
||
assert len(resp.json()) >= 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backups_multiple_ordered_desc(
|
||
self, client: AsyncClient, admin_token: str, test_instance
|
||
):
|
||
"""多条备份按 id 降序排列。"""
|
||
for i in range(3):
|
||
await client.post(
|
||
BASE,
|
||
json={
|
||
"title": f"备份{i}",
|
||
"instance_id": test_instance.id,
|
||
"database": f"db{i}",
|
||
},
|
||
headers=auth_header(admin_token),
|
||
)
|
||
|
||
resp = await client.get(BASE, headers=auth_header(admin_token))
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert len(data) == 3
|
||
# 按 id 降序
|
||
ids = [b["id"] for b in data]
|
||
assert ids == sorted(ids, reverse=True)
|