Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
564 lines
20 KiB
Python
564 lines
20 KiB
Python
"""
|
|
Tests for terminal session management.
|
|
|
|
Tests cover:
|
|
- Session reuse for same user/host/mode
|
|
- Session limit with rich error response
|
|
- Idempotent close
|
|
- Heartbeat updates last_seen_at
|
|
- GC cleans up expired/idle sessions
|
|
- Close beacon endpoint
|
|
"""
|
|
import pytest
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from app.models.terminal_session import (
|
|
TerminalSession,
|
|
SESSION_STATUS_ACTIVE,
|
|
SESSION_STATUS_CLOSED,
|
|
SESSION_STATUS_EXPIRED,
|
|
CLOSE_REASON_USER,
|
|
CLOSE_REASON_TTL,
|
|
CLOSE_REASON_IDLE,
|
|
CLOSE_REASON_CLIENT_LOST,
|
|
)
|
|
from app.crud.terminal_session import TerminalSessionRepository
|
|
from app.schemas.terminal import (
|
|
SessionLimitError,
|
|
HeartbeatResponse,
|
|
ActiveSessionInfo,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def mock_session():
|
|
"""Create a mock terminal session."""
|
|
now = datetime.now(timezone.utc)
|
|
return TerminalSession(
|
|
id="test-session-123",
|
|
host_id="host-1",
|
|
host_name="test-host",
|
|
host_ip="192.168.1.100",
|
|
user_id="user-1",
|
|
username="testuser",
|
|
token_hash="hash123",
|
|
ttyd_port=7680,
|
|
ttyd_pid=12345,
|
|
mode="embedded",
|
|
status=SESSION_STATUS_ACTIVE,
|
|
created_at=now,
|
|
last_seen_at=now,
|
|
expires_at=now + timedelta(minutes=30),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db_session():
|
|
"""Create a mock database session."""
|
|
session = AsyncMock()
|
|
session.execute = AsyncMock()
|
|
session.flush = AsyncMock()
|
|
session.commit = AsyncMock()
|
|
return session
|
|
|
|
|
|
# ============================================================================
|
|
# Session Reuse Tests
|
|
# ============================================================================
|
|
|
|
class TestSessionReuse:
|
|
"""Tests for session reuse functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_reusable_session_returns_matching_session(self, mock_db_session, mock_session):
|
|
"""Should find and return an existing active session for same user/host/mode."""
|
|
# Setup mock to return the session
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.find_reusable_session(
|
|
user_id="user-1",
|
|
host_id="host-1",
|
|
mode="embedded",
|
|
idle_timeout_seconds=120
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.id == mock_session.id
|
|
assert result.host_id == "host-1"
|
|
assert result.user_id == "user-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_reusable_session_returns_none_for_different_mode(self, mock_db_session):
|
|
"""Should not find session if mode is different."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.find_reusable_session(
|
|
user_id="user-1",
|
|
host_id="host-1",
|
|
mode="popout", # Different mode
|
|
idle_timeout_seconds=120
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_reusable_session_excludes_idle_sessions(self, mock_db_session):
|
|
"""Should not return sessions that are idle (last_seen too old)."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
# With a very short idle timeout, session should be considered idle
|
|
result = await repo.find_reusable_session(
|
|
user_id="user-1",
|
|
host_id="host-1",
|
|
mode="embedded",
|
|
idle_timeout_seconds=1 # Very short timeout
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
# ============================================================================
|
|
# Session Limit Tests
|
|
# ============================================================================
|
|
|
|
class TestSessionLimit:
|
|
"""Tests for session limit handling."""
|
|
|
|
def test_session_limit_error_schema(self):
|
|
"""SessionLimitError schema should contain all required fields."""
|
|
error = SessionLimitError(
|
|
message="Maximum sessions reached",
|
|
max_active=3,
|
|
current_count=3,
|
|
active_sessions=[
|
|
ActiveSessionInfo(
|
|
session_id="sess-1",
|
|
host_id="host-1",
|
|
host_name="test-host",
|
|
mode="embedded",
|
|
age_seconds=300,
|
|
last_seen_seconds=10,
|
|
)
|
|
],
|
|
suggested_actions=["close_oldest", "close_session:sess-1"],
|
|
can_reuse=False,
|
|
)
|
|
|
|
assert error.error == "SESSION_LIMIT"
|
|
assert error.max_active == 3
|
|
assert error.current_count == 3
|
|
assert len(error.active_sessions) == 1
|
|
assert "close_oldest" in error.suggested_actions
|
|
|
|
def test_session_limit_error_with_reusable_session(self):
|
|
"""SessionLimitError should indicate when a session can be reused."""
|
|
error = SessionLimitError(
|
|
message="Maximum sessions reached",
|
|
max_active=3,
|
|
current_count=3,
|
|
active_sessions=[],
|
|
suggested_actions=["reuse_existing", "close_oldest"],
|
|
can_reuse=True,
|
|
reusable_session_id="sess-reusable",
|
|
)
|
|
|
|
assert error.can_reuse is True
|
|
assert error.reusable_session_id == "sess-reusable"
|
|
assert "reuse_existing" in error.suggested_actions
|
|
|
|
|
|
# ============================================================================
|
|
# Idempotent Close Tests
|
|
# ============================================================================
|
|
|
|
class TestIdempotentClose:
|
|
"""Tests for idempotent session close."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_already_closed_session(self, mock_db_session, mock_session):
|
|
"""Closing an already closed session should succeed (idempotent)."""
|
|
mock_session.status = SESSION_STATUS_CLOSED
|
|
mock_session.closed_at = datetime.now(timezone.utc)
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
# Close should not raise, even if already closed
|
|
result = await repo.close_session(mock_session.id)
|
|
|
|
# Status should remain closed
|
|
assert result.status == SESSION_STATUS_CLOSED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_nonexistent_session(self, mock_db_session):
|
|
"""Closing a nonexistent session should return None."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.close_session("nonexistent-session")
|
|
|
|
assert result is None
|
|
|
|
|
|
# ============================================================================
|
|
# Heartbeat Tests
|
|
# ============================================================================
|
|
|
|
class TestHeartbeat:
|
|
"""Tests for heartbeat functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_updates_last_seen(self, mock_db_session, mock_session):
|
|
"""Heartbeat should update last_seen_at timestamp."""
|
|
original_last_seen = mock_session.last_seen_at
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.update_last_seen(mock_session.id)
|
|
|
|
assert result is not None
|
|
assert result.last_seen_at >= original_last_seen
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_on_closed_session_does_nothing(self, mock_db_session, mock_session):
|
|
"""Heartbeat on closed session should not update last_seen."""
|
|
mock_session.status = SESSION_STATUS_CLOSED
|
|
original_last_seen = mock_session.last_seen_at
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.update_last_seen(mock_session.id)
|
|
|
|
# last_seen should not be updated for closed sessions
|
|
assert result.last_seen_at == original_last_seen
|
|
|
|
def test_heartbeat_response_schema(self):
|
|
"""HeartbeatResponse schema should contain all required fields."""
|
|
response = HeartbeatResponse(
|
|
session_id="test-session",
|
|
status=SESSION_STATUS_ACTIVE,
|
|
last_seen_at=datetime.now(timezone.utc),
|
|
remaining_seconds=1500,
|
|
healthy=True,
|
|
)
|
|
|
|
assert response.session_id == "test-session"
|
|
assert response.status == SESSION_STATUS_ACTIVE
|
|
assert response.healthy is True
|
|
assert response.remaining_seconds == 1500
|
|
|
|
|
|
# ============================================================================
|
|
# GC (Garbage Collection) Tests
|
|
# ============================================================================
|
|
|
|
class TestGarbageCollection:
|
|
"""Tests for session garbage collection."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_expired_sessions(self, mock_db_session):
|
|
"""Should list sessions past their TTL."""
|
|
now = datetime.now(timezone.utc)
|
|
expired_session = TerminalSession(
|
|
id="expired-1",
|
|
host_id="host-1",
|
|
host_name="test-host",
|
|
host_ip="192.168.1.100",
|
|
user_id="user-1",
|
|
token_hash="hash",
|
|
ttyd_port=7680,
|
|
mode="embedded",
|
|
status=SESSION_STATUS_ACTIVE,
|
|
created_at=now - timedelta(hours=1),
|
|
last_seen_at=now - timedelta(minutes=5),
|
|
expires_at=now - timedelta(minutes=10), # Expired
|
|
)
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [expired_session]
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
expired = await repo.list_expired()
|
|
|
|
assert len(expired) == 1
|
|
assert expired[0].id == "expired-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_idle_sessions(self, mock_db_session):
|
|
"""Should list sessions without recent heartbeat."""
|
|
now = datetime.now(timezone.utc)
|
|
idle_session = TerminalSession(
|
|
id="idle-1",
|
|
host_id="host-1",
|
|
host_name="test-host",
|
|
host_ip="192.168.1.100",
|
|
user_id="user-1",
|
|
token_hash="hash",
|
|
ttyd_port=7680,
|
|
mode="embedded",
|
|
status=SESSION_STATUS_ACTIVE,
|
|
created_at=now - timedelta(hours=1),
|
|
last_seen_at=now - timedelta(minutes=5), # Idle for 5 minutes
|
|
expires_at=now + timedelta(minutes=25), # Not expired
|
|
)
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [idle_session]
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
# With 120 second idle timeout, 5 minutes idle should be detected
|
|
idle = await repo.list_idle(idle_timeout_seconds=120)
|
|
|
|
assert len(idle) == 1
|
|
assert idle[0].id == "idle-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_stale_sessions_combines_expired_and_idle(self, mock_db_session):
|
|
"""Should list both expired and idle sessions."""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = []
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
stale = await repo.list_stale_sessions(
|
|
ttl_seconds=1800,
|
|
idle_timeout_seconds=120
|
|
)
|
|
|
|
# Query should be executed
|
|
mock_db_session.execute.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_expired_sets_correct_reason(self, mock_db_session, mock_session):
|
|
"""mark_expired should set reason_closed to TTL."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.mark_expired(mock_session.id)
|
|
|
|
assert result.status == SESSION_STATUS_EXPIRED
|
|
assert result.reason_closed == CLOSE_REASON_TTL
|
|
assert result.closed_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_idle_sets_correct_reason(self, mock_db_session, mock_session):
|
|
"""mark_idle should set reason_closed to IDLE."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.mark_idle(mock_session.id)
|
|
|
|
assert result.status == SESSION_STATUS_EXPIRED
|
|
assert result.reason_closed == CLOSE_REASON_IDLE
|
|
assert result.closed_at is not None
|
|
|
|
|
|
# ============================================================================
|
|
# Close Beacon Tests
|
|
# ============================================================================
|
|
|
|
class TestCloseBeacon:
|
|
"""Tests for close beacon endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_client_lost_sets_correct_reason(self, mock_db_session, mock_session):
|
|
"""mark_client_lost should set reason_closed to CLIENT_LOST."""
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_session
|
|
mock_db_session.execute.return_value = mock_result
|
|
|
|
repo = TerminalSessionRepository(mock_db_session)
|
|
|
|
result = await repo.mark_client_lost(mock_session.id)
|
|
|
|
assert result.status == SESSION_STATUS_CLOSED
|
|
assert result.reason_closed == CLOSE_REASON_CLIENT_LOST
|
|
assert result.closed_at is not None
|
|
|
|
|
|
# ============================================================================
|
|
# Terminal Service Tests
|
|
# ============================================================================
|
|
|
|
class TestTerminalService:
|
|
"""Tests for terminal service functionality."""
|
|
|
|
def test_metrics_tracking(self):
|
|
"""Service should track metrics for observability."""
|
|
from app.services.terminal_service import TerminalService
|
|
|
|
service = TerminalService()
|
|
|
|
# Record some metrics
|
|
service.record_session_created(reused=False)
|
|
service.record_session_created(reused=True)
|
|
service.record_session_limit_hit()
|
|
|
|
metrics = service.get_metrics()
|
|
|
|
assert metrics["sessions_created"] == 1
|
|
assert metrics["sessions_reused"] == 1
|
|
assert metrics["session_limit_hits"] == 1
|
|
assert "active_processes" in metrics
|
|
assert "gc_running" in metrics
|
|
|
|
def test_token_generation_and_verification(self):
|
|
"""Service should generate and verify tokens correctly."""
|
|
from app.services.terminal_service import TerminalService
|
|
|
|
service = TerminalService()
|
|
|
|
token, token_hash = service.generate_session_token()
|
|
|
|
assert token is not None
|
|
assert token_hash is not None
|
|
assert len(token) > 32
|
|
assert len(token_hash) == 64 # SHA256 hex
|
|
|
|
# Verify correct token
|
|
assert service.verify_token(token, token_hash) is True
|
|
|
|
# Verify incorrect token
|
|
assert service.verify_token("wrong-token", token_hash) is False
|
|
|
|
def test_session_id_generation(self):
|
|
"""Service should generate unique session IDs."""
|
|
from app.services.terminal_service import TerminalService
|
|
|
|
service = TerminalService()
|
|
|
|
id1 = service.generate_session_id()
|
|
id2 = service.generate_session_id()
|
|
|
|
assert id1 != id2
|
|
assert len(id1) == 64 # 32 bytes hex
|
|
|
|
def test_to_utc_aware_handles_naive_datetime(self):
|
|
"""GC should not crash when SQLite returns naive datetimes."""
|
|
from app.services.terminal_service import TerminalService
|
|
|
|
service = TerminalService()
|
|
naive = datetime(2025, 1, 1, 12, 0, 0)
|
|
aware = service._to_utc_aware(naive)
|
|
|
|
assert aware is not None
|
|
assert aware.tzinfo is not None
|
|
assert aware.utcoffset() == timedelta(0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gc_cycle_expired_comparison_with_naive_expires_at(self, mock_db_session):
|
|
"""_run_gc_cycle should not raise when expires_at is naive."""
|
|
from app.services.terminal_service import TerminalService
|
|
from app.crud import terminal_session as terminal_session_module
|
|
|
|
service = TerminalService()
|
|
|
|
now = datetime.now(timezone.utc)
|
|
session = TerminalSession(
|
|
id="naive-exp-1",
|
|
host_id="host-1",
|
|
host_name="test-host",
|
|
host_ip="192.168.1.100",
|
|
user_id="user-1",
|
|
token_hash="hash",
|
|
ttyd_port=7680,
|
|
mode="embedded",
|
|
status=SESSION_STATUS_ACTIVE,
|
|
created_at=now - timedelta(minutes=10),
|
|
last_seen_at=now - timedelta(minutes=1),
|
|
expires_at=datetime.now() - timedelta(minutes=1),
|
|
)
|
|
|
|
# Fake repo that returns a session with naive expires_at
|
|
fake_repo = AsyncMock()
|
|
fake_repo.list_stale_sessions.return_value = [session]
|
|
fake_repo.mark_expired.return_value = session
|
|
fake_repo.mark_idle.return_value = session
|
|
|
|
# Fake async session factory
|
|
class _Factory:
|
|
async def __aenter__(self_inner):
|
|
return mock_db_session
|
|
async def __aexit__(self_inner, exc_type, exc, tb):
|
|
return False
|
|
|
|
service._db_session_factory = lambda: _Factory()
|
|
|
|
with patch.object(terminal_session_module, "TerminalSessionRepository", return_value=fake_repo):
|
|
service.terminate_session = AsyncMock()
|
|
service.release_port = AsyncMock()
|
|
await service._run_gc_cycle()
|
|
|
|
|
|
# ============================================================================
|
|
# Model Tests
|
|
# ============================================================================
|
|
|
|
class TestTerminalSessionModel:
|
|
"""Tests for TerminalSession model."""
|
|
|
|
def test_status_constants(self):
|
|
"""Status constants should be defined correctly."""
|
|
assert SESSION_STATUS_ACTIVE == "active"
|
|
assert SESSION_STATUS_CLOSED == "closed"
|
|
assert SESSION_STATUS_EXPIRED == "expired"
|
|
|
|
def test_close_reason_constants(self):
|
|
"""Close reason constants should be defined correctly."""
|
|
assert CLOSE_REASON_USER == "user_close"
|
|
assert CLOSE_REASON_TTL == "ttl"
|
|
assert CLOSE_REASON_IDLE == "idle"
|
|
assert CLOSE_REASON_CLIENT_LOST == "client_lost"
|
|
|
|
def test_session_repr(self, mock_session):
|
|
"""Session repr should be informative."""
|
|
repr_str = repr(mock_session)
|
|
|
|
assert "TerminalSession" in repr_str
|
|
assert mock_session.host_name in repr_str
|
|
assert mock_session.status in repr_str
|