e5e2582743
收紧 PUBLIC 路径误匹配、强制 health/detail 与壁纸 sync 鉴权、默认关闭 OpenAPI; 安装锁定后 /api/install/* 统一 404;附探测脚本、测试与审计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
"""Nexus — Step 0-5: Unit Tests (no external dependencies)
|
|
|
|
All tests use mocks — no MySQL, Redis, or SSH connections required.
|
|
Run with: python -m pytest tests/test_nexus_v6.py -v
|
|
"""
|
|
|
|
import pytest
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
# ── Model Tests (no DB required) ──
|
|
|
|
class TestDomainModels:
|
|
"""D10: Model field validation"""
|
|
|
|
def test_platform_model(self):
|
|
from server.domain.models import Platform
|
|
p = Platform(name="Linux", category="host", type="linux")
|
|
assert p.name == "Linux"
|
|
assert p.category == "host"
|
|
# Note: Column default only applies on INSERT, not on object construction
|
|
|
|
def test_node_model(self):
|
|
from server.domain.models import Node
|
|
n = Node(name="Web", sort_order=1)
|
|
assert n.name == "Web"
|
|
assert n.parent_id is None
|
|
|
|
def test_server_model_new_fields(self):
|
|
from server.domain.models import Server
|
|
s = Server(name="test", domain="1.2.3.4")
|
|
# New fields exist and are nullable (Column default only on INSERT)
|
|
assert s.platform_id is None
|
|
assert s.node_id is None
|
|
|
|
def test_admin_jwt_fields(self):
|
|
from server.domain.models import Admin
|
|
a = Admin(username="admin", password_hash="xxx")
|
|
assert (a.token_version or 0) == 0
|
|
|
|
def test_ssh_session_model(self):
|
|
from server.domain.models import SshSession
|
|
s = SshSession(server_id=1)
|
|
# Note: Column default only applies on INSERT
|
|
assert s.closed_at is None
|
|
|
|
def test_command_log_model(self):
|
|
from server.domain.models import CommandLog
|
|
c = CommandLog(server_id=1, command="ls")
|
|
assert c.command == "ls"
|
|
|
|
|
|
# ── JWT Auth Tests ──
|
|
|
|
class TestJWTAuth:
|
|
"""A5: JWT authentication logic"""
|
|
|
|
def test_public_path_detection(self):
|
|
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
|
|
assert _is_public_path("/docs") is False
|
|
assert _is_public_path("/health/detail") is False
|
|
|
|
def test_protected_path_detection(self):
|
|
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
|
|
|
|
|
|
# ── AuthService Tests (no DB) ──
|
|
|
|
class TestAuthService:
|
|
"""A5: Auth service JWT/TOTP unit tests"""
|
|
|
|
def _make_service(self):
|
|
from server.application.services.auth_service import AuthService
|
|
return AuthService(
|
|
admin_repo=MagicMock(),
|
|
attempt_repo=MagicMock(),
|
|
audit_repo=MagicMock(),
|
|
)
|
|
|
|
def test_jwt_token_creation_and_decode(self):
|
|
from server.domain.models import Admin
|
|
from server.config import settings
|
|
import jwt
|
|
|
|
service = self._make_service()
|
|
admin = Admin(id=42, username="testuser", password_hash="xxx")
|
|
token = service._create_access_token(admin)
|
|
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
assert payload["sub"] == "42"
|
|
assert payload["username"] == "testuser"
|
|
assert "exp" in payload
|
|
|
|
def test_jwt_token_roundtrip(self):
|
|
service = self._make_service()
|
|
from server.domain.models import Admin
|
|
admin = Admin(id=42, username="testuser", password_hash="xxx")
|
|
token = service._create_access_token(admin)
|
|
payload = service._decode_access_token(token)
|
|
assert payload is not None
|
|
assert payload["sub"] == "42"
|
|
|
|
def test_invalid_token_returns_none(self):
|
|
service = self._make_service()
|
|
assert service._decode_access_token("invalid.token.here") is None
|
|
|
|
def test_refresh_token_unique(self):
|
|
from server.domain.models import Admin
|
|
service = self._make_service()
|
|
admin = Admin(id=1, username="admin", password_hash="xxx", token_version=0)
|
|
t1 = service._create_refresh_token(admin)
|
|
t2 = service._create_refresh_token(admin)
|
|
assert t1 != t2
|
|
assert len(t1) > 20
|
|
|
|
def test_totp_invalid_code(self):
|
|
service = self._make_service()
|
|
import base64, secrets
|
|
secret = base64.b32encode(secrets.token_bytes(20)).decode()
|
|
assert service._verify_totp("000000", secret) is False
|
|
|
|
|
|
# ── WebSocket Manager Tests ──
|
|
|
|
class TestWebSocketManager:
|
|
"""R8: WebSocket ConnectionManager"""
|
|
|
|
def test_initial_state(self):
|
|
from server.api.websocket import ConnectionManager
|
|
m = ConnectionManager()
|
|
assert m.client_count == 0
|
|
|
|
def test_disconnect_nonexistent(self):
|
|
from server.api.websocket import ConnectionManager
|
|
m = ConnectionManager()
|
|
m.disconnect("nonexistent")
|
|
assert m.client_count == 0
|
|
|
|
def test_update_pong(self):
|
|
from server.api.websocket import ConnectionManager
|
|
import time
|
|
m = ConnectionManager()
|
|
m._connections["test"] = {"websocket": MagicMock(), "last_pong": time.monotonic() - 10}
|
|
before = m._connections["test"]["last_pong"]
|
|
m.update_pong("test")
|
|
assert m._connections["test"]["last_pong"] > before
|
|
|
|
|
|
# ── AsyncSSH Pool Tests ──
|
|
|
|
class TestAsyncSSHPool:
|
|
"""W7: asyncssh connection pool"""
|
|
|
|
def test_initial_state(self):
|
|
from server.infrastructure.ssh.asyncssh_pool import AsyncSSHPool
|
|
p = AsyncSSHPool()
|
|
assert p.stats == {"total": 0, "active": 0, "idle": 0}
|
|
|
|
def test_config_values(self):
|
|
from server.infrastructure.ssh.asyncssh_pool import AsyncSSHPool
|
|
p = AsyncSSHPool()
|
|
assert p.MAX_CONNECTIONS == 100
|
|
assert p.IDLE_TIMEOUT == 300
|
|
assert p.CONNECT_TIMEOUT == 10
|
|
|
|
|
|
# ── Sync Engine Tests ──
|
|
|
|
class TestSyncEngineV2:
|
|
"""S6: Sync engine"""
|
|
|
|
def test_max_concurrent(self):
|
|
from server.application.services.sync_engine_v2 import MAX_CONCURRENT
|
|
assert MAX_CONCURRENT == 10
|
|
|
|
def test_engine_creation(self):
|
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
|
e = SyncEngineV2(
|
|
server_repo=MagicMock(),
|
|
sync_log_repo=MagicMock(),
|
|
audit_repo=MagicMock(),
|
|
retry_repo=MagicMock(),
|
|
)
|
|
assert e is not None
|
|
|
|
|
|
# ── Redis Client Tests (mocked) ──
|
|
|
|
class TestRedisClient:
|
|
"""R8: Redis client logic"""
|
|
|
|
def test_get_redis_raises_before_init(self):
|
|
from server.infrastructure.redis.client import get_redis
|
|
import server.infrastructure.redis.client as mod
|
|
mod._redis = None
|
|
with pytest.raises(RuntimeError, match="Redis not initialized"):
|
|
get_redis()
|
|
|
|
def test_get_redis_sync_returns_none(self):
|
|
from server.infrastructure.redis.client import get_redis_sync
|
|
import server.infrastructure.redis.client as mod
|
|
mod._redis = None
|
|
assert get_redis_sync() is None |