161 lines
5.6 KiB
Python
161 lines
5.6 KiB
Python
"""Nexus — Step 0: Infrastructure Tests (R8: Redis + WebSocket + Session leak)
|
|
|
|
Tests:
|
|
- R8: Redis BlockingConnectionPool configuration
|
|
- R8: WebSocket ConnectionManager
|
|
- D7: DB Session middleware
|
|
"""
|
|
|
|
import pytest
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
class TestRedisClient:
|
|
"""R8: Redis client tests"""
|
|
|
|
def test_get_redis_raises_before_init(self):
|
|
"""get_redis() raises RuntimeError if called before init_redis()"""
|
|
from server.infrastructure.redis.client import get_redis
|
|
# Reset module state
|
|
import server.infrastructure.redis.client as redis_mod
|
|
redis_mod._redis = None
|
|
|
|
with pytest.raises(RuntimeError, match="Redis not initialized"):
|
|
get_redis()
|
|
|
|
def test_get_redis_sync_returns_none_before_init(self):
|
|
"""get_redis_sync() returns None if Redis not initialized"""
|
|
import server.infrastructure.redis.client as redis_mod
|
|
redis_mod._redis = None
|
|
|
|
from server.infrastructure.redis.client import get_redis_sync
|
|
assert get_redis_sync() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_redis_exits_on_empty_url(self):
|
|
"""init_redis() raises SystemExit if REDIS_URL is empty"""
|
|
from server.infrastructure.redis.client import init_redis
|
|
import server.config as config_mod
|
|
|
|
original_url = config_mod.settings.REDIS_URL
|
|
config_mod.settings.REDIS_URL = ""
|
|
|
|
with pytest.raises(SystemExit, match="REDIS_URL must be set"):
|
|
await init_redis(MagicMock())
|
|
|
|
config_mod.settings.REDIS_URL = original_url
|
|
|
|
|
|
class TestWebSocketManager:
|
|
"""R8: WebSocket ConnectionManager tests"""
|
|
|
|
def test_manager_initial_state(self):
|
|
"""ConnectionManager starts with zero connections"""
|
|
from server.api.websocket import ConnectionManager
|
|
manager = ConnectionManager()
|
|
assert manager.client_count == 0
|
|
|
|
def test_manager_disconnect_nonexistent(self):
|
|
"""Disconnecting a non-existent client is a no-op"""
|
|
from server.api.websocket import ConnectionManager
|
|
manager = ConnectionManager()
|
|
manager.disconnect("nonexistent") # Should not raise
|
|
assert manager.client_count == 0
|
|
|
|
def test_manager_update_pong(self):
|
|
"""update_pong updates last_pong_time for existing connection"""
|
|
from server.api.websocket import ConnectionManager
|
|
import time
|
|
manager = ConnectionManager()
|
|
ws_mock = MagicMock()
|
|
manager._connections["test_client"] = {
|
|
"websocket": ws_mock,
|
|
"last_pong": time.monotonic() - 10,
|
|
}
|
|
|
|
before = manager._connections["test_client"]["last_pong"]
|
|
manager.update_pong("test_client")
|
|
after = manager._connections["test_client"]["last_pong"]
|
|
|
|
assert after > before
|
|
|
|
|
|
class TestDbSessionMiddleware:
|
|
"""D7: DB Session middleware tests"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_middleware_skips_non_api_routes(self):
|
|
"""Middleware skips non-API routes (WebSocket, health, static)"""
|
|
from server.main import DbSessionMiddleware
|
|
from starlette.responses import Response
|
|
|
|
inner = AsyncMock()
|
|
middleware = DbSessionMiddleware(app=inner)
|
|
|
|
# Create a proper ASGI scope for a non-API path
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/favicon.ico",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
}
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": b"", "more_body": False}
|
|
|
|
sent = []
|
|
|
|
async def send(message):
|
|
sent.append(message)
|
|
|
|
await middleware(scope, receive, send)
|
|
inner.assert_called_once()
|
|
|
|
|
|
class TestJWTAuth:
|
|
"""A5: JWT authentication tests"""
|
|
|
|
def test_public_path_detection(self):
|
|
"""Public paths are correctly identified"""
|
|
from server.api.auth_jwt import _is_public_path
|
|
|
|
assert _is_public_path("/api/auth/login") is True
|
|
assert _is_public_path("/api/auth/refresh") is True
|
|
assert _is_public_path("/api/agent/heartbeat") is True
|
|
assert _is_public_path("/health") is True
|
|
assert _is_public_path("/ws/alerts") is True
|
|
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
|
|
|
|
def test_protected_path_detection(self):
|
|
"""Protected paths are correctly identified"""
|
|
from server.api.auth_jwt import _is_public_path
|
|
|
|
assert _is_public_path("/api/servers/") is False
|
|
assert _is_public_path("/api/settings/") is False
|
|
assert _is_public_path("/api/sync/files") is False
|
|
assert _is_public_path("/api/assets/platforms") is False
|
|
assert _is_public_path("/api/auth/me") is False
|
|
assert _is_public_path("/api/auth/totp/setup") is False
|
|
assert _is_public_path("/health/detail") is False
|
|
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
|
|
assert _is_public_path("/openapi.json") is False
|
|
|
|
|
|
class TestAsyncSSHPool:
|
|
"""W7: asyncssh pool tests"""
|
|
|
|
def test_pool_initial_state(self):
|
|
"""Pool starts with zero connections"""
|
|
from server.infrastructure.ssh.asyncssh_pool import AsyncSSHPool
|
|
pool = AsyncSSHPool()
|
|
assert pool.stats == {"total": 0, "active": 0, "idle": 0}
|
|
|
|
def test_pool_config(self):
|
|
"""Pool configuration is reasonable"""
|
|
from server.infrastructure.ssh.asyncssh_pool import AsyncSSHPool
|
|
pool = AsyncSSHPool()
|
|
assert pool.MAX_CONNECTIONS == 100
|
|
assert pool.IDLE_TIMEOUT == 300
|
|
assert pool.CONNECT_TIMEOUT == 10 |