refactor: 数据源菜单合一+布局优化
- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误
This commit is contained in:
1 parent
bab55d3927
commit
5cbd0e3441
23 files changed
+2881
-94
No files matched your search
@@ -15,6 +15,7 @@ from app.models.audit_rule import AuditRule
|
||||
from app.models.announcement import Announcement
|
||||
from app.models.notification import NotificationChannel
|
||||
from app.models.ldap_config import LdapConfig
|
||||
from app.models.backup import Backup
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""数据字典模块测试。"""
|
||||
import sys
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import auth_header
|
||||
from app.services.db_connector import QueryResult
|
||||
|
||||
|
||||
def _mock_query_result(columns, rows):
|
||||
"""Helper: build a QueryResult for mocking execute_query."""
|
||||
return QueryResult(
|
||||
columns=columns,
|
||||
rows=rows,
|
||||
total=len(rows),
|
||||
elapsed_ms=10,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
|
||||
class TestListDatabases:
|
||||
async def test_list_databases_success(
|
||||
self, client: AsyncClient, test_instance, admin_token
|
||||
):
|
||||
mock_result = _mock_query_result(
|
||||
["Database"],
|
||||
[
|
||||
{"Database": "test_db"},
|
||||
{"Database": "app_db"},
|
||||
{"Database": "information_schema"},
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"app.services.dictionary_service.DBConnector.execute_query",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "databases" in data
|
||||
db_list = data["databases"]
|
||||
assert "test_db" in db_list
|
||||
assert "app_db" in db_list
|
||||
# system databases should be filtered
|
||||
assert "information_schema" not in db_list
|
||||
|
||||
async def test_list_databases_not_found(
|
||||
self, client: AsyncClient, admin_token
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/99999/databases",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestListTables:
|
||||
async def test_list_tables_success(
|
||||
self, client: AsyncClient, test_instance, admin_token
|
||||
):
|
||||
mock_result = _mock_query_result(
|
||||
["TABLE_NAME"],
|
||||
[{"TABLE_NAME": "users"}, {"TABLE_NAME": "orders"}],
|
||||
)
|
||||
with patch(
|
||||
"app.services.dictionary_service.DBConnector.execute_query",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables",
|
||||
params={"database": "test_db"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tables" in data
|
||||
assert "users" in data["tables"]
|
||||
assert "orders" in data["tables"]
|
||||
|
||||
|
||||
class TestGetTableDetail:
|
||||
async def test_table_detail_success(
|
||||
self, client: AsyncClient, test_instance, admin_token
|
||||
):
|
||||
col_result = _mock_query_result(
|
||||
["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"],
|
||||
[
|
||||
{
|
||||
"Field": "id",
|
||||
"Type": "int",
|
||||
"Null": "NO",
|
||||
"Key": "PRI",
|
||||
"Default": None,
|
||||
"Extra": "auto_increment",
|
||||
"Comment": "primary key",
|
||||
},
|
||||
],
|
||||
)
|
||||
idx_result = _mock_query_result(
|
||||
["Key_name", "Non_unique", "Index_type", "Column_name"],
|
||||
[
|
||||
{"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"},
|
||||
],
|
||||
)
|
||||
ddl_result = _mock_query_result(
|
||||
["Table", "Create Table"],
|
||||
[{"Table": "users", "Create Table": "CREATE TABLE users (id int PRIMARY KEY)"}],
|
||||
)
|
||||
cnt_result = _mock_query_result(
|
||||
["TABLE_ROWS"],
|
||||
[{"TABLE_ROWS": 42}],
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
results = [col_result, idx_result, ddl_result, cnt_result]
|
||||
|
||||
async def _mock_execute(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
r = results[call_count]
|
||||
call_count += 1
|
||||
return r
|
||||
|
||||
with patch(
|
||||
"app.services.dictionary_service.DBConnector.execute_query",
|
||||
new=AsyncMock(side_effect=_mock_execute),
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables/users",
|
||||
params={"database": "test_db"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "columns" in data
|
||||
assert len(data["columns"]) == 1
|
||||
assert data["columns"][0]["Field"] == "id"
|
||||
assert "indexes" in data
|
||||
assert "create_ddl" in data
|
||||
assert "row_count" in data
|
||||
assert data["row_count"] == 42
|
||||
|
||||
|
||||
class TestExportDictionary:
|
||||
async def test_export_success(
|
||||
self, client: AsyncClient, test_instance, admin_token
|
||||
):
|
||||
# list_tables returns 1 table
|
||||
tbl_result = _mock_query_result(
|
||||
["TABLE_NAME"],
|
||||
[{"TABLE_NAME": "users"}],
|
||||
)
|
||||
# get_table_detail: columns
|
||||
col_result = _mock_query_result(
|
||||
["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"],
|
||||
[{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "", "Comment": ""}],
|
||||
)
|
||||
# indexes
|
||||
idx_result = _mock_query_result(
|
||||
["Key_name", "Non_unique", "Index_type", "Column_name"],
|
||||
[{"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"}],
|
||||
)
|
||||
# ddl
|
||||
ddl_result = _mock_query_result(
|
||||
["Table", "Create Table"],
|
||||
[{"Table": "users", "Create Table": "CREATE TABLE users (id int)"}],
|
||||
)
|
||||
# row count
|
||||
cnt_result = _mock_query_result(
|
||||
["TABLE_ROWS"],
|
||||
[{"TABLE_ROWS": 10}],
|
||||
)
|
||||
|
||||
results = [tbl_result, col_result, idx_result, ddl_result, cnt_result]
|
||||
call_count = 0
|
||||
|
||||
async def _mock_execute(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
r = results[call_count]
|
||||
call_count += 1
|
||||
return r
|
||||
|
||||
with patch(
|
||||
"app.services.dictionary_service.DBConnector.execute_query",
|
||||
new=AsyncMock(side_effect=_mock_execute),
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/export",
|
||||
params={"database": "test_db"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tables" in data
|
||||
assert len(data["tables"]) == 1
|
||||
assert data["tables"][0]["table_name"] == "users"
|
||||
|
||||
|
||||
class TestPermission:
|
||||
async def test_guest_forbidden(
|
||||
self, client: AsyncClient, test_instance, guest_token
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
|
||||
headers=auth_header(guest_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_developer_allowed(
|
||||
self, client: AsyncClient, test_instance, developer_token
|
||||
):
|
||||
mock_result = _mock_query_result(["Database"], [])
|
||||
with patch(
|
||||
"app.services.dictionary_service.DBConnector.execute_query",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
|
||||
headers=auth_header(developer_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Tests for instance detail API endpoints and backup API."""
|
||||
import sys
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import auth_header
|
||||
|
||||
|
||||
def _make_aiomysql_mock():
|
||||
"""Create a mock aiomysql module."""
|
||||
aiomysql = MagicMock()
|
||||
aiomysql.DictCursor = MagicMock()
|
||||
return aiomysql
|
||||
|
||||
|
||||
def _mock_mysql_connection(rows=None, description=None):
|
||||
"""Create a mock MySQL connection that returns given rows."""
|
||||
if rows is None:
|
||||
rows = []
|
||||
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.execute = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=rows)
|
||||
mock_cursor.description = description
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_cursor_ctx(*args, **kwargs):
|
||||
yield mock_cursor
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.cursor = mock_cursor_ctx
|
||||
mock_conn.close = MagicMock()
|
||||
|
||||
return mock_conn, mock_cursor
|
||||
|
||||
|
||||
class TestListDatabases:
|
||||
async def test_list_databases_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Database": "test_db"},
|
||||
{"Database": "myapp"},
|
||||
{"Database": "information_schema"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "test_db" in data
|
||||
assert "myapp" in data
|
||||
# information_schema should be filtered out
|
||||
assert "information_schema" not in data
|
||||
|
||||
async def test_list_databases_requires_admin(self, client: AsyncClient, test_instance, developer_token):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
|
||||
headers=auth_header(developer_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestListTables:
|
||||
async def test_list_tables_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Name": "users", "Engine": "InnoDB", "Rows": 100, "Data_length": 16384,
|
||||
"Index_length": 0, "Comment": "", "Collation": "utf8mb4_general_ci",
|
||||
"Create_time": "2024-01-01", "Update_time": None},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/tables",
|
||||
params={"database": "test_db"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "users"
|
||||
assert data[0]["engine"] == "InnoDB"
|
||||
assert data[0]["rows"] == 100
|
||||
|
||||
|
||||
class TestTableDetail:
|
||||
async def test_table_detail_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
# Need three separate connections for DDL, columns, indexes
|
||||
ddl_conn, _ = _mock_mysql_connection([
|
||||
{"Table": "users", "Create Table": "CREATE TABLE users (id INT PRIMARY KEY)"},
|
||||
])
|
||||
col_conn, _ = _mock_mysql_connection([
|
||||
{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "auto_increment"},
|
||||
])
|
||||
idx_conn, _ = _mock_mysql_connection([
|
||||
{"Table": "users", "Non_unique": 0, "Key_name": "PRIMARY", "Column_name": "id"},
|
||||
])
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_connect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return ddl_conn
|
||||
elif call_count == 2:
|
||||
return col_conn
|
||||
else:
|
||||
return idx_conn
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = mock_connect
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/table-detail",
|
||||
params={"database": "test_db", "table": "users"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "ddl" in data
|
||||
assert "columns" in data
|
||||
assert "indexes" in data
|
||||
assert "CREATE TABLE" in data["ddl"]
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
async def test_list_users_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"user": "root", "host": "localhost"},
|
||||
{"user": "admin", "host": "%"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/users",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["user"] == "root"
|
||||
|
||||
|
||||
class TestShowGrants:
|
||||
async def test_show_grants_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Grants for root@localhost": "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost'"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/grants",
|
||||
params={"user": "root", "host": "localhost"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"] == "root"
|
||||
assert len(data["grants"]) >= 1
|
||||
|
||||
|
||||
class TestSlowQueries:
|
||||
async def test_slow_queries_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"start_time": "2024-01-01 00:00:00", "user_host": "root[root]@localhost",
|
||||
"query_time": "00:00:05", "lock_time": "00:00:00", "rows_sent": 1,
|
||||
"rows_examined": 1000, "sql_text": "SELECT * FROM big_table"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/slow-queries",
|
||||
params={"database": "test_db"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
class TestProcesslist:
|
||||
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Id": 1, "User": "root", "Host": "localhost", "db": "test_db",
|
||||
"Command": "Query", "Time": 10, "State": "executing", "Info": "SELECT 1"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["Id"] == 1
|
||||
|
||||
|
||||
class TestKillProcess:
|
||||
async def test_kill_process_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.post(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist/123/kill",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["pid"] == 123
|
||||
assert "已终止" in data["message"]
|
||||
|
||||
|
||||
class TestShowStatus:
|
||||
async def test_show_status_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Variable_name": "Threads_connected", "Value": "5"},
|
||||
{"Variable_name": "Queries", "Value": "1000"},
|
||||
{"Variable_name": "Slow_queries", "Value": "10"},
|
||||
{"Variable_name": "Uptime", "Value": "86400"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/status",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "key_metrics" in data
|
||||
assert data["key_metrics"]["Threads_connected"] == "5"
|
||||
|
||||
|
||||
class TestShowVariables:
|
||||
async def test_show_variables_success(self, client: AsyncClient, test_instance, admin_token):
|
||||
mock_conn, _ = _mock_mysql_connection([
|
||||
{"Variable_name": "max_connections", "Value": "151"},
|
||||
{"Variable_name": "slow_query_log", "Value": "ON"},
|
||||
])
|
||||
|
||||
mock_aiomysql = _make_aiomysql_mock()
|
||||
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||
|
||||
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/" + str(test_instance.id) + "/detail/variables",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
class TestBackupAPI:
|
||||
async def test_create_backup(self, client: AsyncClient, test_instance, admin_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/backups",
|
||||
headers=auth_header(admin_token),
|
||||
json={
|
||||
"title": "测试备份",
|
||||
"instance_id": test_instance.id,
|
||||
"database": "test_db",
|
||||
"remark": "自动备份",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "测试备份"
|
||||
assert data["status"] == "pending"
|
||||
assert data["database"] == "test_db"
|
||||
|
||||
async def test_list_backups(self, client: AsyncClient, test_instance, admin_token):
|
||||
# Create a backup first
|
||||
await client.post(
|
||||
"/api/v1/backups",
|
||||
headers=auth_header(admin_token),
|
||||
json={
|
||||
"title": "备份1",
|
||||
"instance_id": test_instance.id,
|
||||
"database": "test_db",
|
||||
},
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/v1/backups",
|
||||
params={"instance_id": test_instance.id},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
async def test_get_backup_detail(self, client: AsyncClient, test_instance, admin_token):
|
||||
# Create a backup
|
||||
create_resp = await client.post(
|
||||
"/api/v1/backups",
|
||||
headers=auth_header(admin_token),
|
||||
json={
|
||||
"title": "备份详情测试",
|
||||
"instance_id": test_instance.id,
|
||||
"database": "test_db",
|
||||
},
|
||||
)
|
||||
backup_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/backups/" + str(backup_id),
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == backup_id
|
||||
assert data["title"] == "备份详情测试"
|
||||
|
||||
async def test_get_backup_not_found(self, client: AsyncClient, admin_token):
|
||||
resp = await client.get(
|
||||
"/api/v1/backups/99999",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_backup_requires_admin(self, client: AsyncClient, developer_token):
|
||||
resp = await client.get(
|
||||
"/api/v1/backups",
|
||||
headers=auth_header(developer_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestInstanceNotFound:
|
||||
async def test_databases_instance_not_found(self, client: AsyncClient, admin_token):
|
||||
resp = await client.get(
|
||||
"/api/v1/instances/99999/detail/databases",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for Redis management API endpoints — all methods mocked."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from tests.conftest import auth_header
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
REDIS_INSTANCE_DATA = {
|
||||
"name": "Test Redis",
|
||||
"db_type": "redis",
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"username": "default",
|
||||
"password": "test123",
|
||||
"database": "0",
|
||||
"env": "development",
|
||||
"description": "Test Redis instance",
|
||||
}
|
||||
|
||||
MOCK_INFO = {
|
||||
"redis_version": "7.2.3",
|
||||
"uptime_in_days": 30,
|
||||
"connected_clients": 42,
|
||||
"db0": {"keys": 1000, "expires": 500},
|
||||
"used_memory_human": "256M",
|
||||
"keyspace_hits": 10000,
|
||||
"keyspace_misses": 100,
|
||||
}
|
||||
|
||||
|
||||
async def _create_redis_instance(client, admin_token):
|
||||
"""Create a Redis instance and return its ID."""
|
||||
resp = await client.post(
|
||||
"/api/v1/instances",
|
||||
json=REDIS_INSTANCE_DATA,
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
# ── Info endpoint ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_redis_info(mock_cls, client, admin_token):
|
||||
mock_cls.get_info = AsyncMock(return_value=MOCK_INFO)
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/info",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["redis_version"] == "7.2.3"
|
||||
|
||||
|
||||
# ── DBSIZE endpoint ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_redis_dbsize(mock_cls, client, admin_token):
|
||||
mock_cls.get_dbsize = AsyncMock(return_value=1000)
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/dbsize?db=0",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["size"] == 1000
|
||||
|
||||
|
||||
# ── Keys list ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_redis_keys(mock_cls, client, admin_token):
|
||||
mock_keys = [
|
||||
{"key": "user:1", "type": "string", "ttl": 3600},
|
||||
{"key": "session:abc", "type": "hash", "ttl": -1},
|
||||
]
|
||||
mock_cls.scan_keys = AsyncMock(return_value=(0, mock_keys))
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/keys?db=0&pattern=*&count=50",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["keys"]) == 2
|
||||
assert data["keys"][0]["key"] == "user:1"
|
||||
|
||||
|
||||
# ── Key detail ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_redis_key_detail(mock_cls, client, admin_token):
|
||||
mock_cls.get_key_value = AsyncMock(return_value={
|
||||
"key": "user:1", "type": "string", "value": "hello", "ttl": 3600,
|
||||
})
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/keys/user:1?db=0",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["value"] == "hello"
|
||||
|
||||
|
||||
# ── Key CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_create_redis_key(mock_cls, client, admin_token):
|
||||
mock_cls.set_key_value = AsyncMock(return_value="OK")
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/redis/" + str(iid) + "/keys",
|
||||
json={"db": 0, "key": "test_key", "value": "test_value", "type": "string", "ttl": 60},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "操作成功"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_delete_redis_key(mock_cls, client, admin_token):
|
||||
mock_cls.del_key = AsyncMock(return_value=1)
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/redis/" + str(iid) + "/keys/test_key?db=0",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_set_expire(mock_cls, client, admin_token):
|
||||
mock_cls.set_ttl = AsyncMock(return_value=True)
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.put(
|
||||
"/api/v1/redis/" + str(iid) + "/keys/test_key/expire",
|
||||
json={"db": 0, "seconds": 300},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Command execution ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_execute_command(mock_cls, client, admin_token):
|
||||
mock_cls.execute_command = AsyncMock(return_value="PONG")
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/redis/" + str(iid) + "/command",
|
||||
json={"db": 0, "command": "PING"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["output"] == "PONG"
|
||||
|
||||
|
||||
# ── Slowlog & Clients ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_slowlog(mock_cls, client, admin_token):
|
||||
mock_cls.get_slowlog = AsyncMock(return_value=[
|
||||
{"id": 1, "timestamp": 1700000000, "duration_us": 5000, "command": "KEYS *"},
|
||||
])
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/slowlog?count=10",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_get_clients(mock_cls, client, admin_token):
|
||||
mock_cls.get_clients = AsyncMock(return_value=[
|
||||
{"id": "1", "addr": "127.0.0.1:54321", "cmd": "ping"},
|
||||
])
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/clients",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
|
||||
# ── Permission checks ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_key_requires_admin_or_dba(client, developer_token, admin_token):
|
||||
"""Non-admin/dba cannot create keys."""
|
||||
# Need a redis instance first
|
||||
iid_data = dict(REDIS_INSTANCE_DATA)
|
||||
resp = await client.post(
|
||||
"/api/v1/instances",
|
||||
json=iid_data,
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
iid = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/redis/" + str(iid) + "/keys",
|
||||
json={"db": 0, "key": "k", "value": "v", "type": "string"},
|
||||
headers=auth_header(developer_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flushdb_requires_admin(client, dba_token, admin_token):
|
||||
"""Non-admin cannot flush DB."""
|
||||
iid_data = dict(REDIS_INSTANCE_DATA)
|
||||
resp = await client.post(
|
||||
"/api/v1/instances",
|
||||
json=iid_data,
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
iid = resp.json()["id"]
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
|
||||
headers=auth_header(dba_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
|
||||
async def test_flushdb_admin_can(mock_cls, client, admin_token):
|
||||
"""Admin can flush DB."""
|
||||
mock_cls.flush_db = AsyncMock(return_value="OK")
|
||||
iid = await _create_redis_instance(client, admin_token)
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Non-Redis instance rejection ──────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_redis_instance_rejected(client, admin_token):
|
||||
"""A MySQL instance should be rejected on Redis endpoints."""
|
||||
mysql_data = {
|
||||
"name": "Test MySQL",
|
||||
"db_type": "mysql",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"username": "root",
|
||||
"password": "test123",
|
||||
"database": "test_db",
|
||||
"env": "development",
|
||||
}
|
||||
resp = await client.post(
|
||||
"/api/v1/instances",
|
||||
json=mysql_data,
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
iid = resp.json()["id"]
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/redis/" + str(iid) + "/info",
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Redis" in resp.json()["detail"]
|
||||
Reference in new issue
Block a user