homelab_automation/tests/backend/test_terminal_sessions.py
Bruno Charest 5bc12d0729
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
Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
2025-12-18 13:49:40 -05:00

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