167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
|
|
"""Nexus — Step 1-5: Data Layer + Auth + Sync + Models Tests
|
||
|
|
|
||
|
|
D10: Data migration tests
|
||
|
|
A5: JWT auth flow tests
|
||
|
|
S6: Sync engine tests
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
|
||
|
|
|
||
|
|
class TestDomainModels:
|
||
|
|
"""D10: Model integrity tests"""
|
||
|
|
|
||
|
|
def test_platform_model_fields(self):
|
||
|
|
"""Platform model has all required fields"""
|
||
|
|
from server.domain.models import Platform
|
||
|
|
p = Platform(name="Linux", category="host", type="linux")
|
||
|
|
assert p.name == "Linux"
|
||
|
|
assert p.category == "host"
|
||
|
|
assert p.type == "linux"
|
||
|
|
# Column default only applies on DB INSERT, not Python construction
|
||
|
|
|
||
|
|
def test_node_model_fields(self):
|
||
|
|
"""Node model has tree structure fields"""
|
||
|
|
from server.domain.models import Node
|
||
|
|
n = Node(name="Web Servers", parent_id=None, sort_order=1)
|
||
|
|
assert n.name == "Web Servers"
|
||
|
|
assert n.parent_id is None
|
||
|
|
assert n.sort_order == 1
|
||
|
|
|
||
|
|
def test_server_model_new_fields(self):
|
||
|
|
"""Server model has new extended fields"""
|
||
|
|
from server.domain.models import Server
|
||
|
|
s = Server(name="test", domain="1.2.3.4")
|
||
|
|
assert s.platform_id is None
|
||
|
|
assert s.node_id is None
|
||
|
|
assert s.protocols is None
|
||
|
|
assert s.extra_attrs is None
|
||
|
|
# Column default only applies on DB INSERT, not Python construction
|
||
|
|
|
||
|
|
def test_admin_model_jwt_fields(self):
|
||
|
|
"""Admin model has JWT fields"""
|
||
|
|
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):
|
||
|
|
"""SshSession model has lifecycle fields"""
|
||
|
|
from server.domain.models import SshSession
|
||
|
|
s = SshSession(server_id=1, admin_id=1)
|
||
|
|
# Column default only applies on DB INSERT, not Python construction
|
||
|
|
assert s.closed_at is None
|
||
|
|
|
||
|
|
def test_command_log_model(self):
|
||
|
|
"""CommandLog model has required fields"""
|
||
|
|
from server.domain.models import CommandLog
|
||
|
|
c = CommandLog(server_id=1, command="ls -la")
|
||
|
|
assert c.command == "ls -la"
|
||
|
|
assert c.session_id is None # Can be standalone
|
||
|
|
|
||
|
|
|
||
|
|
class TestAuthService:
|
||
|
|
"""A5: Auth service unit tests"""
|
||
|
|
|
||
|
|
def test_jwt_token_creation(self):
|
||
|
|
"""JWT access token contains correct claims"""
|
||
|
|
from server.application.services.auth_service import AuthService
|
||
|
|
from server.domain.models import Admin
|
||
|
|
|
||
|
|
service = AuthService(
|
||
|
|
admin_repo=MagicMock(),
|
||
|
|
attempt_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
admin = Admin(id=42, username="testuser", password_hash="xxx")
|
||
|
|
token = service._create_access_token(admin)
|
||
|
|
|
||
|
|
import jwt
|
||
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||
|
|
assert payload["sub"] == "42"
|
||
|
|
assert payload["username"] == "testuser"
|
||
|
|
assert "exp" in payload
|
||
|
|
assert "iat" in payload
|
||
|
|
|
||
|
|
def test_jwt_token_verification(self):
|
||
|
|
"""JWT token can be verified correctly"""
|
||
|
|
from server.application.services.auth_service import AuthService
|
||
|
|
from server.domain.models import Admin
|
||
|
|
|
||
|
|
service = AuthService(
|
||
|
|
admin_repo=MagicMock(),
|
||
|
|
attempt_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
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):
|
||
|
|
"""Invalid JWT token returns None"""
|
||
|
|
from server.application.services.auth_service import AuthService
|
||
|
|
service = AuthService(
|
||
|
|
admin_repo=MagicMock(),
|
||
|
|
attempt_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
result = service._decode_access_token("invalid.token.here")
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_refresh_token_is_random(self):
|
||
|
|
"""Refresh tokens are random and unique"""
|
||
|
|
from server.application.services.auth_service import AuthService
|
||
|
|
service = AuthService(
|
||
|
|
admin_repo=MagicMock(),
|
||
|
|
attempt_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
from server.domain.models import Admin
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
class TestTOTPVerification:
|
||
|
|
"""A5: TOTP verification tests"""
|
||
|
|
|
||
|
|
def test_totp_invalid_code_rejected(self):
|
||
|
|
"""Invalid TOTP code is rejected"""
|
||
|
|
from server.application.services.auth_service import AuthService
|
||
|
|
service = AuthService(
|
||
|
|
admin_repo=MagicMock(),
|
||
|
|
attempt_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
# Generate a real secret and test with wrong code
|
||
|
|
import base64, secrets
|
||
|
|
secret = base64.b32encode(secrets.token_bytes(20)).decode()
|
||
|
|
assert service._verify_totp("000000", secret) is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestSyncEngineV2:
|
||
|
|
"""S6: Sync engine unit tests"""
|
||
|
|
|
||
|
|
def test_max_concurrent_default(self):
|
||
|
|
"""Default concurrency is 10"""
|
||
|
|
from server.application.services.sync_engine_v2 import MAX_CONCURRENT
|
||
|
|
assert MAX_CONCURRENT == 10
|
||
|
|
|
||
|
|
def test_engine_creation(self):
|
||
|
|
"""SyncEngineV2 can be instantiated with mock repos"""
|
||
|
|
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||
|
|
engine = SyncEngineV2(
|
||
|
|
server_repo=MagicMock(),
|
||
|
|
sync_log_repo=MagicMock(),
|
||
|
|
audit_repo=MagicMock(),
|
||
|
|
retry_repo=MagicMock(),
|
||
|
|
)
|
||
|
|
assert engine is not None
|
||
|
|
|
||
|
|
|
||
|
|
from server.config import settings
|