Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Alex Blank
2026-05-29 10:35:47 +02:00
46 changed files with 6599 additions and 1380 deletions
+268
View File
@@ -0,0 +1,268 @@
"""Integration tests for SSE endpoint and lifecycle event flow."""
import asyncio
import uuid
from collections.abc import Generator
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api import events as events_module
from src.auth.session import decode_session_cookie
from src.config import Settings
from src.models.git_repository import GitRepository
from src.models.instance_event import InstanceEvent
from src.models.project import Project
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
@pytest.fixture
def event_bus() -> Generator[InstanceEventBus, None, None]:
bus = InstanceEventBus()
bus._reset_for_testing()
yield bus
bus._reset_for_testing()
@pytest.fixture
def sample_payload() -> InstanceEventPayload:
return {
"event": "instance.started",
"instance_id": str(uuid.uuid4()),
"status": "starting",
"message": "Container starting...",
"metadata": {},
"timestamp": "2026-05-28T12:00:00Z",
"correlation_id": str(uuid.uuid4()),
}
def _get_user_id_from_client(client: TestClient) -> uuid.UUID | None:
settings = Settings()
cookie = client.cookies.get("session")
if not cookie:
return None
session = decode_session_cookie(settings=settings, cookie_value=cookie)
if session and "user_id" in session:
return uuid.UUID(session["user_id"])
return None
@pytest.mark.integration
def test_sse_requires_auth(test_client: TestClient) -> None:
response = test_client.get("/events/stream")
assert response.status_code == 401
@pytest.mark.integration
def test_sse_enforces_connection_limit(authenticated_client: TestClient) -> None:
user_id = _get_user_id_from_client(authenticated_client)
assert user_id is not None
events_module._connection_counts[user_id] = events_module.MAX_CONNECTIONS_PER_USER
try:
response = authenticated_client.get("/events/stream")
assert response.status_code == 429
finally:
events_module._connection_counts.pop(user_id, None)
@pytest.mark.integration
def test_sse_event_generator_format() -> None:
"""Test the SSE endpoint is registered."""
from src.api.events import router
route_paths = [getattr(r, "path", "") for r in router.routes]
assert any("/stream" in str(p) for p in route_paths)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_lifecycle_hook_publishes_event_and_persists(
authenticated_client: TestClient,
db_session: AsyncSession,
event_bus: InstanceEventBus,
) -> None:
"""Test that the lifecycle hook publishes an event and persists an audit row."""
user_id = _get_user_id_from_client(authenticated_client)
assert user_id is not None
project = Project(
id=uuid.uuid4(),
name="test-project",
description="Test",
owner_id=user_id,
)
repo = GitRepository(
id=uuid.uuid4(),
name="test-repo",
path="/tmp/test-repo",
project_id=project.id,
owner_id=user_id,
remote_url="https://github.com/test/repo.git",
)
tool_type = ToolType(
id=uuid.uuid4(),
name="test-tool",
display_name="Test Tool",
category="other",
interface_type="web",
requires_port=True,
default_port=8080,
definition_type="legacy",
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
)
db_session.add_all([project, repo, tool_type])
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="test-instance",
display_name="Test Instance",
tool_type_id=tool_type.id,
repository_id=repo.id,
project_id=project.id,
owner_id=user_id,
status="pending",
compose_path="/tmp/test-compose.yml",
port=8080,
)
db_session.add(instance)
await db_session.commit()
received: list[Any] = []
def subscriber(payload: InstanceEventPayload) -> None:
received.append(payload)
event_bus.subscribe("instance.created", subscriber)
from src.services.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
session=db_session,
instance=instance,
event_type="instance.created",
created_by=user_id,
status="pending",
message="Instance created",
)
assert len(received) == 1
assert received[0]["event"] == "instance.created"
result = await db_session.execute(
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].event_type == "created"
assert rows[0].created_by == user_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_lifecycle_event_persists_audit_row(
authenticated_client: TestClient,
db_session: AsyncSession,
event_bus: InstanceEventBus,
) -> None:
"""Test that publishing a lifecycle event persists an audit row."""
user_id = _get_user_id_from_client(authenticated_client)
assert user_id is not None
project = Project(
id=uuid.uuid4(),
name="test-project",
description="Test",
owner_id=user_id,
)
repo = GitRepository(
id=uuid.uuid4(),
name="test-repo",
path="/tmp/test-repo",
project_id=project.id,
owner_id=user_id,
remote_url="https://github.com/test/repo.git",
)
tool_type = ToolType(
id=uuid.uuid4(),
name="test-tool-2",
display_name="Test Tool 2",
category="other",
interface_type="web",
requires_port=True,
default_port=8080,
definition_type="legacy",
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
)
db_session.add_all([project, repo, tool_type])
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="test-instance",
display_name="Test Instance",
tool_type_id=tool_type.id,
repository_id=repo.id,
project_id=project.id,
owner_id=user_id,
status="running",
compose_path="/tmp/test-compose.yml",
port=8080,
)
db_session.add(instance)
await db_session.commit()
from src.services.lifecycle_hooks import publish_lifecycle_event
await publish_lifecycle_event(
event_bus=event_bus,
session=db_session,
instance=instance,
event_type="instance.stopped",
created_by=user_id,
status="stopped",
message="Instance stopped",
)
result = await db_session.execute(
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].event_type == "stopped"
assert rows[0].status == "stopped"
assert rows[0].created_by == user_id
@pytest.mark.integration
def test_event_bus_pubsub(event_bus: InstanceEventBus) -> None:
"""Test that the event bus delivers events to subscribers."""
received: list[InstanceEventPayload] = []
def handler(payload: InstanceEventPayload) -> None:
received.append(payload)
event_bus.subscribe("test.event", handler)
payload: InstanceEventPayload = {
"event": "test.event",
"instance_id": str(uuid.uuid4()),
"status": "running",
"message": "Test",
"metadata": {},
"timestamp": "2026-05-28T12:00:00Z",
"correlation_id": str(uuid.uuid4()),
}
asyncio.run(event_bus.publish("test.event", payload))
assert len(received) == 1
assert received[0]["event"] == "test.event"
+148
View File
@@ -0,0 +1,148 @@
"""Unit tests for InstanceEventBus."""
import asyncio
import uuid
from typing import Any
import pytest
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
@pytest.fixture
def event_bus() -> InstanceEventBus:
"""Provide a fresh EventBus instance with reset singleton state."""
bus = InstanceEventBus()
bus._reset_for_testing()
return bus
@pytest.fixture
def sample_payload() -> InstanceEventPayload:
"""Provide a sample event payload."""
return {
"event": "instance.started",
"instance_id": str(uuid.uuid4()),
"status": "starting",
"message": "Container starting...",
"metadata": {},
"timestamp": "2026-05-28T12:00:00Z",
"correlation_id": str(uuid.uuid4()),
}
@pytest.mark.unit
async def test_publish_delivers_to_all_subscribers(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""All subscribed callbacks should receive the published payload."""
received: list[Any] = []
def callback_1(payload: InstanceEventPayload) -> None:
received.append(("callback_1", payload))
def callback_2(payload: InstanceEventPayload) -> None:
received.append(("callback_2", payload))
def callback_3(payload: InstanceEventPayload) -> None:
received.append(("callback_3", payload))
event_bus.subscribe("instance.started", callback_1)
event_bus.subscribe("instance.started", callback_2)
event_bus.subscribe("instance.started", callback_3)
await event_bus.publish("instance.started", sample_payload)
assert len(received) == 3
assert received[0][0] == "callback_1"
assert received[1][0] == "callback_2"
assert received[2][0] == "callback_3"
@pytest.mark.unit
async def test_subscriber_exception_isolation(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""If one subscriber raises, others should still receive the event."""
received: list[str] = []
def bad_callback(_payload: InstanceEventPayload) -> None:
raise RuntimeError("boom")
def good_callback(_payload: InstanceEventPayload) -> None:
received.append("good_callback")
event_bus.subscribe("instance.started", bad_callback)
event_bus.subscribe("instance.started", good_callback)
# Should not raise
await event_bus.publish("instance.started", sample_payload)
assert received == ["good_callback"]
@pytest.mark.unit
async def test_unsubscribe_removes_callback(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""After unsubscribing, the callback should not be called."""
received: list[str] = []
def callback(_payload: InstanceEventPayload) -> None:
received.append("callback")
unsubscribe = event_bus.subscribe("instance.started", callback)
unsubscribe()
await event_bus.publish("instance.started", sample_payload)
assert received == []
@pytest.mark.unit
async def test_publish_to_empty_subscriber_list(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""Publishing to an event type with no subscribers should not raise."""
await event_bus.publish("instance.started", sample_payload)
@pytest.mark.unit
async def test_async_subscriber_supported(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""Async callbacks should be awaited correctly."""
received: list[str] = []
async def async_callback(_payload: InstanceEventPayload) -> None:
await asyncio.sleep(0)
received.append("async_callback")
event_bus.subscribe("instance.started", async_callback)
await event_bus.publish("instance.started", sample_payload)
assert received == ["async_callback"]
@pytest.mark.unit
async def test_unsubscribe_all_clears_subscribers(
event_bus: InstanceEventBus,
sample_payload: InstanceEventPayload,
) -> None:
"""unsubscribe_all should remove all callbacks for an event type."""
received: list[str] = []
def callback(_payload: InstanceEventPayload) -> None:
received.append("callback")
event_bus.subscribe("instance.started", callback)
event_bus.unsubscribe_all("instance.started")
await event_bus.publish("instance.started", sample_payload)
assert received == []
+292
View File
@@ -0,0 +1,292 @@
"""Unit tests for HealthMonitor state-transition logic."""
import asyncio
import uuid
from contextlib import suppress
from unittest.mock import patch
import pytest
from sqlalchemy import select
from src.models.health_check import HealthCheck
from src.models.tool_instance import ToolInstance
from src.models.user import User
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
from src.services.health_monitor import HealthMonitor, HealthSnapshot
@pytest.fixture
def event_bus() -> InstanceEventBus:
"""Provide a fresh EventBus instance."""
bus = InstanceEventBus()
bus._reset_for_testing()
return bus
@pytest.fixture
def health_monitor(event_bus: InstanceEventBus) -> HealthMonitor:
"""Provide a HealthMonitor with a short poll interval for testing."""
monitor = HealthMonitor(event_bus)
monitor.POLL_INTERVAL_SECONDS = 0.1
return monitor
async def _create_running_instance(db_session) -> ToolInstance:
"""Helper to create a user and a running tool instance."""
user = User(
id=uuid.uuid4(),
email="hm@example.com",
name="HM Test",
authentik_id="auth-hm",
)
db_session.add(user)
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="hm-test-instance",
display_name="HM Test Instance",
tool_type_id=uuid.uuid4(),
repository_id=uuid.uuid4(),
project_id=uuid.uuid4(),
owner_id=user.id,
status="running",
container_id="container123",
public_url="https://example.trycloudflare.com",
)
db_session.add(instance)
await db_session.commit()
return instance
@pytest.mark.unit
async def test_detects_container_crash(
db_session,
event_bus: InstanceEventBus,
health_monitor: HealthMonitor,
) -> None:
"""Monitor should detect exited container and publish error event."""
instance = await _create_running_instance(db_session)
events_captured: list[InstanceEventPayload] = []
def capture_event(payload: InstanceEventPayload) -> None:
events_captured.append(payload)
event_bus.subscribe("instance.error", capture_event)
with (
patch(
"src.services.health_monitor.get_container_status",
return_value={"status": "exited", "exit_code": 137, "health": None},
),
patch(
"src.services.health_monitor.check_tunnel_health",
return_value={"healthy": False, "tunnel_status": "not_applicable"},
),
):
await health_monitor._check_instance(db_session, instance)
# Refresh instance from DB
await db_session.refresh(instance)
assert instance.status == "error"
# Event published
assert len(events_captured) == 1
assert events_captured[0]["event"] == "instance.error"
assert events_captured[0]["status"] == "error"
assert events_captured[0]["metadata"]["exit_code"] == 137
# Health check row inserted
result = await db_session.execute(
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
)
check = result.scalar_one()
assert check.container_status == "exited"
assert check.exit_code == 137
@pytest.mark.unit
async def test_detects_tunnel_failure(
db_session,
event_bus: InstanceEventBus,
health_monitor: HealthMonitor,
) -> None:
"""Monitor should detect tunnel failure and mark unhealthy."""
instance = await _create_running_instance(db_session)
events_captured: list[InstanceEventPayload] = []
def capture_event(payload: InstanceEventPayload) -> None:
events_captured.append(payload)
event_bus.subscribe("instance.health_changed", capture_event)
with (
patch(
"src.services.health_monitor.get_container_status",
return_value={"status": "running", "exit_code": None, "health": "healthy"},
),
patch(
"src.services.health_monitor.check_tunnel_health",
return_value={
"healthy": False,
"tunnel_status": "error_response",
"status_code": 502,
},
),
):
await health_monitor._check_instance(db_session, instance)
await db_session.refresh(instance)
assert instance.status == "unhealthy"
assert len(events_captured) == 1
assert events_captured[0]["event"] == "instance.health_changed"
assert events_captured[0]["status"] == "unhealthy"
assert events_captured[0]["metadata"]["previous_status"] == "running"
result = await db_session.execute(
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
)
check = result.scalar_one()
assert check.tunnel_healthy is False
@pytest.mark.unit
async def test_detects_recovery(
db_session,
event_bus: InstanceEventBus,
health_monitor: HealthMonitor,
) -> None:
"""Monitor should detect recovery from unhealthy to running."""
instance = await _create_running_instance(db_session)
instance.status = "unhealthy"
await db_session.commit()
# Seed last known state as unhealthy
health_monitor._last_known_state[instance.id] = HealthSnapshot(
container_status="running",
container_healthy=None,
tunnel_healthy=False,
exit_code=None,
)
events_captured: list[InstanceEventPayload] = []
def capture_event(payload: InstanceEventPayload) -> None:
events_captured.append(payload)
event_bus.subscribe("instance.health_changed", capture_event)
with (
patch(
"src.services.health_monitor.get_container_status",
return_value={"status": "running", "exit_code": None, "health": None},
),
patch(
"src.services.health_monitor.check_tunnel_health",
return_value={
"healthy": True,
"tunnel_status": "healthy",
"status_code": 200,
},
),
):
await health_monitor._check_instance(db_session, instance)
await db_session.refresh(instance)
assert instance.status == "running"
assert len(events_captured) == 1
assert events_captured[0]["status"] == "running"
assert events_captured[0]["metadata"]["previous_status"] == "unhealthy"
result = await db_session.execute(
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
)
check = result.scalar_one()
assert check.tunnel_healthy is True
@pytest.mark.unit
async def test_skips_writes_when_no_state_change(
db_session,
event_bus: InstanceEventBus,
health_monitor: HealthMonitor,
) -> None:
"""Two identical polls should result in only one health_checks row."""
instance = await _create_running_instance(db_session)
with (
patch(
"src.services.health_monitor.get_container_status",
return_value={"status": "running", "exit_code": None, "health": None},
),
patch(
"src.services.health_monitor.check_tunnel_health",
return_value={
"healthy": True,
"tunnel_status": "healthy",
"status_code": 200,
},
),
):
await health_monitor._check_instance(db_session, instance)
await health_monitor._check_instance(db_session, instance)
result = await db_session.execute(
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
)
assert len(result.scalars().all()) == 1
@pytest.mark.unit
async def test_docker_exception_resilience(
db_session,
event_bus: InstanceEventBus,
health_monitor: HealthMonitor,
) -> None:
"""Docker exception should be caught and not propagate."""
instance = await _create_running_instance(db_session)
events_captured: list[InstanceEventPayload] = []
def capture_event(payload: InstanceEventPayload) -> None:
events_captured.append(payload)
event_bus.subscribe("instance.error", capture_event)
event_bus.subscribe("instance.health_changed", capture_event)
with patch(
"src.services.health_monitor.get_container_status",
side_effect=RuntimeError("docker exploded"),
):
# Should not raise
await health_monitor._check_instance(db_session, instance)
# No DB writes
result = await db_session.execute(
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
)
assert result.scalar_one_or_none() is None
# No events published
assert events_captured == []
@pytest.mark.unit
async def test_monitor_start_stop(health_monitor: HealthMonitor) -> None:
"""Start and stop should manage the background task."""
health_monitor.start()
task = health_monitor._task
assert task is not None
assert not task.done()
health_monitor.stop()
if task is not None and not task.done():
with suppress(asyncio.CancelledError):
await task
assert task is not None
assert task.cancelled() or task.done()
assert health_monitor._last_known_state == {}
@@ -0,0 +1,143 @@
"""Unit tests for monitoring models and migration compatibility."""
import uuid
from datetime import datetime
import pytest
from sqlalchemy import select
from src.models.health_check import HealthCheck
from src.models.instance_event import InstanceEvent
from src.models.tool_instance import ToolInstance
from src.models.user import User
@pytest.mark.unit
async def test_instance_event_creation(db_session) -> None:
"""InstanceEvent model can be created and persisted."""
user = User(
id=uuid.uuid4(),
email="test@example.com",
name="Test",
authentik_id="auth-1",
)
db_session.add(user)
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="test-instance",
display_name="Test Instance",
tool_type_id=uuid.uuid4(),
repository_id=uuid.uuid4(),
project_id=uuid.uuid4(),
owner_id=user.id,
status="pending",
)
db_session.add(instance)
await db_session.commit()
event = InstanceEvent(
instance_id=instance.id,
event_type="started",
status="starting",
message="Container starting...",
created_by=user.id,
event_metadata={"previous_status": "pending"},
)
db_session.add(event)
await db_session.commit()
await db_session.refresh(event)
assert event.id is not None
assert event.instance_id == instance.id
assert event.event_type == "started"
assert event.status == "starting"
assert event.created_by == user.id
assert event.event_metadata == {"previous_status": "pending"}
assert isinstance(event.created_at, datetime)
@pytest.mark.unit
async def test_health_check_creation(db_session) -> None:
"""HealthCheck model can be created and persisted."""
user = User(
id=uuid.uuid4(),
email="test2@example.com",
name="Test2",
authentik_id="auth-2",
)
db_session.add(user)
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="test-instance-2",
display_name="Test Instance 2",
tool_type_id=uuid.uuid4(),
repository_id=uuid.uuid4(),
project_id=uuid.uuid4(),
owner_id=user.id,
status="running",
)
db_session.add(instance)
await db_session.commit()
check = HealthCheck(
instance_id=instance.id,
container_status="running",
container_healthy=True,
tunnel_healthy=True,
exit_code=None,
probe_status="passed",
probe_output="OK",
)
db_session.add(check)
await db_session.commit()
await db_session.refresh(check)
assert check.id is not None
assert check.instance_id == instance.id
assert check.container_status == "running"
assert check.container_healthy is True
assert check.tunnel_healthy is True
assert isinstance(check.checked_at, datetime)
@pytest.mark.unit
async def test_instance_event_query_by_instance(db_session) -> None:
"""InstanceEvent rows can be queried by instance_id."""
user = User(
id=uuid.uuid4(),
email="test3@example.com",
name="Test3",
authentik_id="auth-3",
)
db_session.add(user)
await db_session.commit()
instance = ToolInstance(
id=uuid.uuid4(),
name="test-instance-3",
display_name="Test Instance 3",
tool_type_id=uuid.uuid4(),
repository_id=uuid.uuid4(),
project_id=uuid.uuid4(),
owner_id=user.id,
status="pending",
)
db_session.add(instance)
await db_session.commit()
event = InstanceEvent(
instance_id=instance.id,
event_type="created",
status="pending",
)
db_session.add(event)
await db_session.commit()
result = await db_session.execute(
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
)
assert result.scalar_one() is not None