refactor: 数据源菜单合一+布局优化

- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」
- InstancePage 改为统一入口,顶部Tab切换类型过滤
- 添加数据源改为弹框Modal(默认MySQL)
- 去掉左下角用户区,退出登录移到右上角header
- 侧边栏收窄220px,整体布局更紧凑
- 后端202 tests passing
- 前端构建零错误
This commit is contained in:
logan committed 2026-05-22 22:53:48 +08:00
1 parent bab55d3927
commit 5cbd0e3441
23 files changed
+2881 -94

No files matched your search

+1
View File
@@ -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://"
+227
View File
@@ -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
+365
View File
@@ -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
+296
View File
@@ -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"]