- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误
297 lines
10 KiB
Python
297 lines
10 KiB
Python
"""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"]
|