Remove Node.js cache files containing npm vulnerability data for vitest and vite packages
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
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
This commit is contained in:
@@ -33,6 +33,12 @@ from app.core.dependencies import get_db, verify_api_key, get_current_user
|
||||
from app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key_header() -> dict:
|
||||
"""Header containing the test API key."""
|
||||
return {"X-API-Key": os.environ["API_KEY"]}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE FIXTURES
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for Docker service functionality."""
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models.docker_container import DockerContainer
|
||||
from app.models.docker_image import DockerImage
|
||||
from app.models.docker_volume import DockerVolume
|
||||
from app.models.docker_alert import DockerAlert
|
||||
from app.crud.docker_container import DockerContainerRepository
|
||||
from app.crud.docker_image import DockerImageRepository
|
||||
from app.crud.docker_volume import DockerVolumeRepository
|
||||
from app.crud.docker_alert import DockerAlertRepository
|
||||
|
||||
|
||||
class TestDockerContainerRepository:
|
||||
"""Tests for DockerContainerRepository."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_creates_new_container(self, db_session):
|
||||
"""Test that upsert creates a new container when it doesn't exist."""
|
||||
repo = DockerContainerRepository(db_session)
|
||||
|
||||
container = await repo.upsert(
|
||||
host_id="test-host-1",
|
||||
container_id="abc123def456",
|
||||
name="nginx",
|
||||
image="nginx:latest",
|
||||
state="running",
|
||||
status="Up 2 hours"
|
||||
)
|
||||
|
||||
assert container.name == "nginx"
|
||||
assert container.state == "running"
|
||||
assert container.container_id == "abc123def456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_updates_existing_container(self, db_session):
|
||||
"""Test that upsert updates an existing container."""
|
||||
repo = DockerContainerRepository(db_session)
|
||||
|
||||
# Create first
|
||||
await repo.upsert(
|
||||
host_id="test-host-1",
|
||||
container_id="abc123def456",
|
||||
name="nginx",
|
||||
state="running"
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Update
|
||||
container = await repo.upsert(
|
||||
host_id="test-host-1",
|
||||
container_id="abc123def456",
|
||||
name="nginx",
|
||||
state="exited"
|
||||
)
|
||||
|
||||
assert container.state == "exited"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_by_host(self, db_session):
|
||||
"""Test listing containers by host."""
|
||||
repo = DockerContainerRepository(db_session)
|
||||
|
||||
await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running")
|
||||
await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="exited")
|
||||
await repo.upsert(host_id="host-2", container_id="c3", name="container3", state="running")
|
||||
await db_session.commit()
|
||||
|
||||
host1_containers = await repo.list_by_host("host-1")
|
||||
assert len(host1_containers) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_host(self, db_session):
|
||||
"""Test counting containers by host."""
|
||||
repo = DockerContainerRepository(db_session)
|
||||
|
||||
await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running")
|
||||
await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="running")
|
||||
await repo.upsert(host_id="host-1", container_id="c3", name="container3", state="exited")
|
||||
await db_session.commit()
|
||||
|
||||
counts = await repo.count_by_host("host-1")
|
||||
assert counts["total"] == 3
|
||||
assert counts["running"] == 2
|
||||
|
||||
|
||||
class TestDockerAlertRepository:
|
||||
"""Tests for DockerAlertRepository."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_alert(self, db_session):
|
||||
"""Test creating a new alert."""
|
||||
repo = DockerAlertRepository(db_session)
|
||||
|
||||
alert = await repo.create(
|
||||
host_id="test-host-1",
|
||||
container_name="nginx",
|
||||
severity="error",
|
||||
message="Container is down"
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert alert.id is not None
|
||||
assert alert.state == "open"
|
||||
assert alert.severity == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_open_alert(self, db_session):
|
||||
"""Test getting an open alert for a container."""
|
||||
repo = DockerAlertRepository(db_session)
|
||||
|
||||
await repo.create(
|
||||
host_id="test-host-1",
|
||||
container_name="nginx",
|
||||
severity="error",
|
||||
message="Container is down"
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
alert = await repo.get_open_alert("test-host-1", "nginx")
|
||||
assert alert is not None
|
||||
assert alert.container_name == "nginx"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alert(self, db_session):
|
||||
"""Test acknowledging an alert."""
|
||||
repo = DockerAlertRepository(db_session)
|
||||
|
||||
alert = await repo.create(
|
||||
host_id="test-host-1",
|
||||
container_name="nginx",
|
||||
severity="error",
|
||||
message="Container is down"
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
acknowledged = await repo.acknowledge(alert.id, "admin")
|
||||
assert acknowledged is not None
|
||||
assert acknowledged.state == "acknowledged"
|
||||
assert acknowledged.acknowledged_by == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_alert(self, db_session):
|
||||
"""Test closing an alert."""
|
||||
repo = DockerAlertRepository(db_session)
|
||||
|
||||
alert = await repo.create(
|
||||
host_id="test-host-1",
|
||||
container_name="nginx",
|
||||
severity="error",
|
||||
message="Container is down"
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
closed = await repo.close(alert.id)
|
||||
assert closed is not None
|
||||
assert closed.state == "closed"
|
||||
assert closed.closed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_for_container(self, db_session):
|
||||
"""Test closing all alerts for a container."""
|
||||
repo = DockerAlertRepository(db_session)
|
||||
|
||||
await repo.create(host_id="host-1", container_name="nginx", severity="error", message="Down")
|
||||
await repo.create(host_id="host-1", container_name="nginx", severity="warning", message="Health check")
|
||||
await db_session.commit()
|
||||
|
||||
count = await repo.close_for_container("host-1", "nginx")
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestDockerAlertDetection:
|
||||
"""Tests for Docker alert detection logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_container_down(self):
|
||||
"""Test detection of container that should be running but is stopped."""
|
||||
container = MagicMock()
|
||||
container.state = "exited"
|
||||
container.health = None
|
||||
container.labels = {"homelab.monitor": "true", "homelab.desired": "running"}
|
||||
container.name = "nginx"
|
||||
container.host_id = "host-1"
|
||||
|
||||
# Container is exited but should be running -> should trigger alert
|
||||
expected_state = container.labels.get("homelab.desired", "running")
|
||||
should_alert = expected_state == "running" and container.state != "running"
|
||||
|
||||
assert should_alert is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_unhealthy_container(self):
|
||||
"""Test detection of unhealthy container."""
|
||||
container = MagicMock()
|
||||
container.state = "running"
|
||||
container.health = "unhealthy"
|
||||
container.labels = {"homelab.monitor": "true"}
|
||||
container.name = "nginx"
|
||||
|
||||
# Container is unhealthy -> should trigger warning
|
||||
should_alert = container.health == "unhealthy"
|
||||
|
||||
assert should_alert is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_alert_for_healthy_running_container(self):
|
||||
"""Test that healthy running containers don't trigger alerts."""
|
||||
container = MagicMock()
|
||||
container.state = "running"
|
||||
container.health = "healthy"
|
||||
container.labels = {"homelab.monitor": "true", "homelab.desired": "running"}
|
||||
|
||||
expected_state = container.labels.get("homelab.desired", "running")
|
||||
should_close_alert = container.state == "running" and container.health in ("healthy", None)
|
||||
|
||||
assert should_close_alert is True
|
||||
|
||||
|
||||
class TestDockerServiceSSH:
|
||||
"""Tests for Docker service SSH functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_docker_json_lines(self):
|
||||
"""Test parsing Docker JSON output."""
|
||||
from app.services.docker_service import DockerService
|
||||
|
||||
service = DockerService()
|
||||
|
||||
output = '{"ID":"abc123","Names":"nginx","State":"running"}\n{"ID":"def456","Names":"redis","State":"exited"}'
|
||||
|
||||
result = await service._parse_docker_json_lines(output)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["ID"] == "abc123"
|
||||
assert result[1]["State"] == "exited"
|
||||
|
||||
def test_parse_size(self):
|
||||
"""Test parsing Docker size strings."""
|
||||
from app.services.docker_service import DockerService
|
||||
|
||||
service = DockerService()
|
||||
|
||||
assert service._parse_size("100MB") == 100 * 1024 * 1024
|
||||
assert service._parse_size("1.5GB") == int(1.5 * 1024 * 1024 * 1024)
|
||||
assert service._parse_size("500KB") == 500 * 1024
|
||||
assert service._parse_size("") == 0
|
||||
assert service._parse_size("invalid") == 0
|
||||
|
||||
|
||||
class TestDockerServiceHostFiltering:
|
||||
"""Tests for Docker host listing and filtering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_docker_hosts_filters_role_docker(self):
|
||||
"""Only hosts in inventory group role_docker must be visible in Docker section."""
|
||||
from app.services.docker_service import docker_service
|
||||
|
||||
host1 = MagicMock()
|
||||
host1.id = "host-1"
|
||||
host1.name = "docker-1"
|
||||
host1.ip_address = "10.0.0.1"
|
||||
host1.deleted_at = None
|
||||
host1.docker_enabled = True
|
||||
host1.docker_version = None
|
||||
host1.docker_status = None
|
||||
host1.docker_last_collect_at = None
|
||||
|
||||
host2 = MagicMock()
|
||||
host2.id = "host-2"
|
||||
host2.name = "docker-2"
|
||||
host2.ip_address = "10.0.0.2"
|
||||
host2.deleted_at = None
|
||||
host2.docker_enabled = False
|
||||
host2.docker_version = None
|
||||
host2.docker_status = None
|
||||
host2.docker_last_collect_at = None
|
||||
|
||||
host3 = MagicMock()
|
||||
host3.id = "host-3"
|
||||
host3.name = "not-docker"
|
||||
host3.ip_address = "10.0.0.3"
|
||||
host3.deleted_at = None
|
||||
host3.docker_enabled = True
|
||||
host3.docker_version = None
|
||||
host3.docker_status = None
|
||||
host3.docker_last_collect_at = None
|
||||
|
||||
inv_h1 = MagicMock()
|
||||
inv_h1.name = "docker-1"
|
||||
inv_h1.groups = ["role_docker"]
|
||||
|
||||
inv_h2 = MagicMock()
|
||||
inv_h2.name = "docker-2"
|
||||
inv_h2.groups = ["role_docker"]
|
||||
|
||||
inv_h3 = MagicMock()
|
||||
inv_h3.name = "not-docker"
|
||||
inv_h3.groups = ["role_web"]
|
||||
|
||||
@asynccontextmanager
|
||||
async def _dummy_session_ctx():
|
||||
yield MagicMock()
|
||||
|
||||
host_repo_instance = MagicMock()
|
||||
host_repo_instance.list = AsyncMock(return_value=[host1, host2, host3])
|
||||
|
||||
container_repo_instance = MagicMock()
|
||||
container_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "running": 0})
|
||||
|
||||
image_repo_instance = MagicMock()
|
||||
image_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "total_size": 0})
|
||||
|
||||
volume_repo_instance = MagicMock()
|
||||
volume_repo_instance.count_by_host = AsyncMock(return_value=0)
|
||||
|
||||
alert_repo_instance = MagicMock()
|
||||
alert_repo_instance.count_open_by_host = AsyncMock(return_value=0)
|
||||
|
||||
with patch("app.services.docker_service.async_session_maker", new=_dummy_session_ctx):
|
||||
with patch("app.services.docker_service.ansible_service.get_hosts_from_inventory", return_value=[inv_h1, inv_h2, inv_h3]):
|
||||
with patch("app.services.docker_service.HostRepository", return_value=host_repo_instance):
|
||||
with patch("app.services.docker_service.DockerContainerRepository", return_value=container_repo_instance):
|
||||
with patch("app.services.docker_service.DockerImageRepository", return_value=image_repo_instance):
|
||||
with patch("app.services.docker_service.DockerVolumeRepository", return_value=volume_repo_instance):
|
||||
with patch("app.services.docker_service.DockerAlertRepository", return_value=alert_repo_instance):
|
||||
hosts = await docker_service.get_docker_hosts()
|
||||
|
||||
assert [h["host_name"] for h in hosts] == ["docker-1", "docker-2"]
|
||||
by_id = {h["host_id"]: h for h in hosts}
|
||||
assert by_id["host-1"]["docker_enabled"] is True
|
||||
assert by_id["host-2"]["docker_enabled"] is False
|
||||
|
||||
|
||||
class TestDockerActions:
|
||||
"""Tests for Docker container actions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_container_success(self):
|
||||
"""Test successful container start."""
|
||||
from app.services.docker_actions import DockerActionsService
|
||||
|
||||
service = DockerActionsService()
|
||||
|
||||
with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host:
|
||||
mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"}
|
||||
|
||||
with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect:
|
||||
mock_conn = AsyncMock()
|
||||
mock_connect.return_value = mock_conn
|
||||
|
||||
with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = {
|
||||
"success": True,
|
||||
"stdout": "abc123",
|
||||
"stderr": "",
|
||||
"exit_code": 0
|
||||
}
|
||||
|
||||
result = await service.start_container("host-1", "abc123")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "start"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_container_success(self):
|
||||
"""Test successful container stop."""
|
||||
from app.services.docker_actions import DockerActionsService
|
||||
|
||||
service = DockerActionsService()
|
||||
|
||||
with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host:
|
||||
mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"}
|
||||
|
||||
with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect:
|
||||
mock_conn = AsyncMock()
|
||||
mock_connect.return_value = mock_conn
|
||||
|
||||
with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = {
|
||||
"success": True,
|
||||
"stdout": "abc123",
|
||||
"stderr": "",
|
||||
"exit_code": 0
|
||||
}
|
||||
|
||||
result = await service.stop_container("host-1", "abc123")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "stop"
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests for Docker API routes."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestDockerHostsRoutes:
|
||||
"""Tests for Docker hosts endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_docker_hosts(self, client: AsyncClient, api_key_header):
|
||||
"""Test listing Docker hosts."""
|
||||
with patch('app.services.docker_service.docker_service.get_docker_hosts', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = [
|
||||
{
|
||||
"host_id": "host-1",
|
||||
"host_name": "server1",
|
||||
"host_ip": "192.168.1.10",
|
||||
"docker_enabled": True,
|
||||
"docker_version": "24.0.7",
|
||||
"docker_status": "online",
|
||||
"docker_last_collect_at": None,
|
||||
"containers_total": 5,
|
||||
"containers_running": 3,
|
||||
"images_total": 10,
|
||||
"volumes_total": 2,
|
||||
"open_alerts": 0
|
||||
},
|
||||
{
|
||||
"host_id": "host-2",
|
||||
"host_name": "server2",
|
||||
"host_ip": "192.168.1.11",
|
||||
"docker_enabled": False,
|
||||
"docker_version": None,
|
||||
"docker_status": None,
|
||||
"docker_last_collect_at": None,
|
||||
"containers_total": 0,
|
||||
"containers_running": 0,
|
||||
"images_total": 0,
|
||||
"volumes_total": 0,
|
||||
"open_alerts": 0
|
||||
}
|
||||
]
|
||||
|
||||
response = await client.get("/api/docker/hosts", headers=api_key_header)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "hosts" in data
|
||||
assert len(data["hosts"]) == 2
|
||||
assert data["hosts"][0]["host_name"] == "server1"
|
||||
|
||||
hosts_by_id = {h["host_id"]: h for h in data["hosts"]}
|
||||
assert hosts_by_id["host-1"]["docker_enabled"] is True
|
||||
assert hosts_by_id["host-2"]["docker_enabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_docker_monitoring(self, client: AsyncClient, api_key_header):
|
||||
"""Test enabling Docker monitoring on a host."""
|
||||
with patch('app.services.docker_service.docker_service.enable_docker_monitoring', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = True
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/hosts/host-1/enable",
|
||||
headers=api_key_header,
|
||||
json={"enabled": True}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_docker_now(self, client: AsyncClient, api_key_header):
|
||||
"""Test forcing Docker collection on a host."""
|
||||
with patch('app.services.docker_service.docker_service.collect_docker_host', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"success": True,
|
||||
"host_id": "host-1",
|
||||
"host_name": "server1",
|
||||
"docker_version": "24.0.7",
|
||||
"containers_count": 5,
|
||||
"images_count": 10,
|
||||
"volumes_count": 2,
|
||||
"duration_ms": 1500,
|
||||
"error": None
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/hosts/host-1/collect",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["containers_count"] == 5
|
||||
|
||||
|
||||
class TestDockerContainersRoutes:
|
||||
"""Tests for Docker containers endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_containers(self, client: AsyncClient, api_key_header, db_session):
|
||||
"""Test listing containers for a host."""
|
||||
# This test would require setting up container data in the database
|
||||
# For now, test that the endpoint returns correctly structured data
|
||||
response = await client.get(
|
||||
"/api/docker/hosts/host-1/containers",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "containers" in data
|
||||
assert "total" in data
|
||||
assert "running" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_container(self, client: AsyncClient, api_key_header):
|
||||
"""Test starting a container."""
|
||||
with patch('app.services.docker_actions.docker_actions.start_container', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"success": True,
|
||||
"message": "Container started successfully",
|
||||
"container_id": "abc123",
|
||||
"action": "start",
|
||||
"output": "abc123",
|
||||
"error": None
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/containers/host-1/abc123/start",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["action"] == "start"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_container(self, client: AsyncClient, api_key_header):
|
||||
"""Test stopping a container."""
|
||||
with patch('app.services.docker_actions.docker_actions.stop_container', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"success": True,
|
||||
"message": "Container stopped successfully",
|
||||
"container_id": "abc123",
|
||||
"action": "stop",
|
||||
"output": "abc123",
|
||||
"error": None
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/containers/host-1/abc123/stop",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["action"] == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_container(self, client: AsyncClient, api_key_header):
|
||||
"""Test restarting a container."""
|
||||
with patch('app.services.docker_actions.docker_actions.restart_container', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"success": True,
|
||||
"message": "Container restarted successfully",
|
||||
"container_id": "abc123",
|
||||
"action": "restart",
|
||||
"output": "abc123",
|
||||
"error": None
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/containers/host-1/abc123/restart",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["action"] == "restart"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_container_logs(self, client: AsyncClient, api_key_header):
|
||||
"""Test getting container logs."""
|
||||
with patch('app.services.docker_actions.docker_actions.get_container_logs', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"container_id": "abc123",
|
||||
"container_name": "nginx",
|
||||
"logs": "2024-01-15 10:00:00 Starting nginx...\n2024-01-15 10:00:01 Ready",
|
||||
"lines": 2
|
||||
}
|
||||
|
||||
response = await client.get(
|
||||
"/api/docker/containers/host-1/abc123/logs?tail=100",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
assert data["lines"] == 2
|
||||
|
||||
|
||||
class TestDockerAlertsRoutes:
|
||||
"""Tests for Docker alerts endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_alerts(self, client: AsyncClient, api_key_header):
|
||||
"""Test listing Docker alerts."""
|
||||
with patch('app.services.docker_alerts.docker_alerts_service.get_alerts', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"alerts": [
|
||||
{
|
||||
"id": 1,
|
||||
"host_id": "host-1",
|
||||
"host_name": "server1",
|
||||
"container_name": "nginx",
|
||||
"severity": "error",
|
||||
"state": "open",
|
||||
"message": "Container is down",
|
||||
"opened_at": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"open_count": 1,
|
||||
"acknowledged_count": 0
|
||||
}
|
||||
|
||||
response = await client.get(
|
||||
"/api/docker/alerts",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "alerts" in data
|
||||
assert data["total"] == 1
|
||||
assert data["open_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alert(self, client: AsyncClient, api_key_header):
|
||||
"""Test acknowledging an alert."""
|
||||
with patch('app.services.docker_alerts.docker_alerts_service.acknowledge_alert', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"id": 1,
|
||||
"state": "acknowledged",
|
||||
"acknowledged_by": "admin",
|
||||
"acknowledged_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/alerts/1/acknowledge",
|
||||
headers=api_key_header,
|
||||
json={"note": "Investigating"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "alert" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_alert(self, client: AsyncClient, api_key_header):
|
||||
"""Test closing an alert."""
|
||||
with patch('app.services.docker_alerts.docker_alerts_service.close_alert', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"id": 1,
|
||||
"state": "closed",
|
||||
"closed_at": "2024-01-15T11:00:00Z"
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/docker/alerts/1/close",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "alert" in data
|
||||
|
||||
|
||||
class TestDockerStatsRoute:
|
||||
"""Tests for Docker stats endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_docker_stats(self, client: AsyncClient, api_key_header):
|
||||
"""Test getting global Docker statistics."""
|
||||
with patch('app.services.docker_alerts.docker_alerts_service.get_stats', new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {
|
||||
"total_hosts": 5,
|
||||
"enabled_hosts": 3,
|
||||
"online_hosts": 2,
|
||||
"total_containers": 25,
|
||||
"running_containers": 20,
|
||||
"total_images": 50,
|
||||
"total_volumes": 10,
|
||||
"open_alerts": 2,
|
||||
"last_collection": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
|
||||
response = await client.get(
|
||||
"/api/docker/stats",
|
||||
headers=api_key_header
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_hosts"] == 5
|
||||
assert data["running_containers"] == 20
|
||||
assert data["open_alerts"] == 2
|
||||
@@ -3,6 +3,7 @@ Tests pour les routes metrics.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import re
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -54,6 +55,11 @@ class TestGetAllHostsMetrics:
|
||||
response = await client.get("/api/metrics/all-hosts")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert host.id in data
|
||||
assert data[host.id]["last_collected"] is not None
|
||||
assert data[host.id]["last_collected"] == data[host.id]["collected_at"]
|
||||
assert re.search(r"[+-]\d{2}:\d{2}$", data[host.id]["last_collected"]) is not None
|
||||
|
||||
|
||||
class TestGetCollectionSchedule:
|
||||
@@ -157,6 +163,9 @@ class TestGetHostMetrics:
|
||||
data = response.json()
|
||||
assert data["host_id"] == host.id
|
||||
assert data["cpu_usage_percent"] == 45.5
|
||||
assert data["last_collected"] is not None
|
||||
assert data["last_collected"] == data["collected_at"]
|
||||
assert re.search(r"[+-]\d{2}:\d{2}$", data["last_collected"]) is not None
|
||||
|
||||
|
||||
class TestMetricsWithErrors:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Tests for Docker Section JavaScript
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const createTestDom = () => {
|
||||
const dom = new JSDOM(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="docker-hosts-grid"></div>
|
||||
<div id="docker-stat-hosts">0</div>
|
||||
<div id="docker-stat-containers">0</div>
|
||||
<div id="docker-stat-images">0</div>
|
||||
<div id="docker-stat-alerts">0</div>
|
||||
<div id="docker-alerts-badge" class="hidden">0</div>
|
||||
<input id="docker-search" type="text" />
|
||||
<div id="docker-detail-modal" class="hidden"></div>
|
||||
<div id="docker-containers-list"></div>
|
||||
<div id="docker-images-list"></div>
|
||||
<div id="docker-volumes-list"></div>
|
||||
<div id="docker-host-alerts-list"></div>
|
||||
<div id="docker-logs-modal" class="hidden"></div>
|
||||
<div id="docker-logs-content"></div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Import docker section after DOM setup
|
||||
const dockerSection = {
|
||||
hosts: [],
|
||||
stats: {},
|
||||
|
||||
formatRelativeTime(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
return `Il y a ${diffDays}j`;
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
renderHostCard(host) {
|
||||
const statusColor = host.docker_status === 'online' ? 'green' :
|
||||
host.docker_status === 'offline' ? 'red' : 'yellow';
|
||||
return `<div class="glass-card" data-host-id="${host.host_id}">
|
||||
<h3>${host.host_name}</h3>
|
||||
<span class="status-${statusColor}">${host.docker_status}</span>
|
||||
<div>${host.containers_running}/${host.containers_total} containers</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
renderHostsGrid() {
|
||||
const grid = document.getElementById('docker-hosts-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (this.hosts.length === 0) {
|
||||
grid.innerHTML = '<div class="empty">Aucun host Docker</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = this.hosts.map(host => this.renderHostCard(host)).join('');
|
||||
},
|
||||
|
||||
updateStatsDisplay() {
|
||||
const el = (id, value) => {
|
||||
const elem = document.getElementById(id);
|
||||
if (elem) elem.textContent = value;
|
||||
};
|
||||
|
||||
el('docker-stat-hosts', this.stats.enabled_hosts || 0);
|
||||
el('docker-stat-containers', this.stats.running_containers || 0);
|
||||
el('docker-stat-images', this.stats.total_images || 0);
|
||||
el('docker-stat-alerts', this.stats.open_alerts || 0);
|
||||
},
|
||||
|
||||
filterHosts(query) {
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
cards.forEach(card => {
|
||||
const hostId = card.dataset.hostId;
|
||||
const host = this.hosts.find(h => h.host_id === hostId);
|
||||
if (host) {
|
||||
const matches = host.host_name.toLowerCase().includes(lowerQuery) ||
|
||||
host.host_ip.toLowerCase().includes(lowerQuery);
|
||||
card.style.display = matches ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('Docker Section', () => {
|
||||
beforeEach(() => {
|
||||
createTestDom();
|
||||
vi.clearAllMocks();
|
||||
dockerSection.hosts = [];
|
||||
dockerSection.stats = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup between tests
|
||||
if (global.document) {
|
||||
global.document.body.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('returns N/A for null input', () => {
|
||||
expect(dockerSection.formatRelativeTime(null)).toBe('N/A');
|
||||
});
|
||||
|
||||
it('returns "À l\'instant" for recent times', () => {
|
||||
const now = new Date().toISOString();
|
||||
expect(dockerSection.formatRelativeTime(now)).toBe('À l\'instant');
|
||||
});
|
||||
|
||||
it('returns minutes for times less than an hour ago', () => {
|
||||
const thirtyMinsAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
||||
expect(dockerSection.formatRelativeTime(thirtyMinsAgo)).toBe('Il y a 30min');
|
||||
});
|
||||
|
||||
it('returns hours for times less than a day ago', () => {
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString();
|
||||
expect(dockerSection.formatRelativeTime(fiveHoursAgo)).toBe('Il y a 5h');
|
||||
});
|
||||
|
||||
it('returns days for older times', () => {
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(dockerSection.formatRelativeTime(twoDaysAgo)).toBe('Il y a 2j');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('returns "0 B" for falsy input', () => {
|
||||
expect(dockerSection.formatBytes(0)).toBe('0 B');
|
||||
expect(dockerSection.formatBytes(null)).toBe('0 B');
|
||||
expect(dockerSection.formatBytes(undefined)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes correctly', () => {
|
||||
expect(dockerSection.formatBytes(500)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes correctly', () => {
|
||||
expect(dockerSection.formatBytes(1024)).toBe('1 KB');
|
||||
expect(dockerSection.formatBytes(2048)).toBe('2 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes correctly', () => {
|
||||
expect(dockerSection.formatBytes(1024 * 1024)).toBe('1 MB');
|
||||
expect(dockerSection.formatBytes(150 * 1024 * 1024)).toBe('150 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes correctly', () => {
|
||||
expect(dockerSection.formatBytes(1024 * 1024 * 1024)).toBe('1 GB');
|
||||
expect(dockerSection.formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderHostsGrid', () => {
|
||||
it('shows empty message when no hosts', () => {
|
||||
dockerSection.hosts = [];
|
||||
dockerSection.renderHostsGrid();
|
||||
|
||||
const grid = document.getElementById('docker-hosts-grid');
|
||||
expect(grid.innerHTML).toContain('Aucun host Docker');
|
||||
});
|
||||
|
||||
it('renders host cards for each host', () => {
|
||||
dockerSection.hosts = [
|
||||
{ host_id: 'h1', host_name: 'server1', host_ip: '192.168.1.10', docker_status: 'online', containers_total: 5, containers_running: 3 },
|
||||
{ host_id: 'h2', host_name: 'server2', host_ip: '192.168.1.11', docker_status: 'offline', containers_total: 2, containers_running: 0 }
|
||||
];
|
||||
dockerSection.renderHostsGrid();
|
||||
|
||||
const grid = document.getElementById('docker-hosts-grid');
|
||||
expect(grid.querySelectorAll('[data-host-id]').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderHostCard', () => {
|
||||
it('includes host name', () => {
|
||||
const host = { host_id: 'h1', host_name: 'myserver', host_ip: '192.168.1.10', docker_status: 'online', containers_total: 5, containers_running: 3 };
|
||||
const html = dockerSection.renderHostCard(host);
|
||||
expect(html).toContain('myserver');
|
||||
});
|
||||
|
||||
it('shows green status for online hosts', () => {
|
||||
const host = { host_id: 'h1', host_name: 'myserver', host_ip: '192.168.1.10', docker_status: 'online', containers_total: 5, containers_running: 3 };
|
||||
const html = dockerSection.renderHostCard(host);
|
||||
expect(html).toContain('status-green');
|
||||
});
|
||||
|
||||
it('shows red status for offline hosts', () => {
|
||||
const host = { host_id: 'h1', host_name: 'myserver', host_ip: '192.168.1.10', docker_status: 'offline', containers_total: 5, containers_running: 0 };
|
||||
const html = dockerSection.renderHostCard(host);
|
||||
expect(html).toContain('status-red');
|
||||
});
|
||||
|
||||
it('shows container counts', () => {
|
||||
const host = { host_id: 'h1', host_name: 'myserver', host_ip: '192.168.1.10', docker_status: 'online', containers_total: 10, containers_running: 7 };
|
||||
const html = dockerSection.renderHostCard(host);
|
||||
expect(html).toContain('7/10 containers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatsDisplay', () => {
|
||||
it('updates stat elements with values', () => {
|
||||
dockerSection.stats = {
|
||||
enabled_hosts: 5,
|
||||
running_containers: 25,
|
||||
total_images: 100,
|
||||
open_alerts: 3
|
||||
};
|
||||
dockerSection.updateStatsDisplay();
|
||||
|
||||
expect(document.getElementById('docker-stat-hosts').textContent).toBe('5');
|
||||
expect(document.getElementById('docker-stat-containers').textContent).toBe('25');
|
||||
expect(document.getElementById('docker-stat-images').textContent).toBe('100');
|
||||
expect(document.getElementById('docker-stat-alerts').textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('shows 0 for missing stats', () => {
|
||||
dockerSection.stats = {};
|
||||
dockerSection.updateStatsDisplay();
|
||||
|
||||
expect(document.getElementById('docker-stat-hosts').textContent).toBe('0');
|
||||
expect(document.getElementById('docker-stat-containers').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterHosts', () => {
|
||||
beforeEach(() => {
|
||||
dockerSection.hosts = [
|
||||
{ host_id: 'h1', host_name: 'production-server', host_ip: '192.168.1.10', docker_status: 'online', containers_total: 5, containers_running: 3 },
|
||||
{ host_id: 'h2', host_name: 'staging-server', host_ip: '192.168.1.11', docker_status: 'online', containers_total: 2, containers_running: 2 },
|
||||
{ host_id: 'h3', host_name: 'dev-server', host_ip: '10.0.0.5', docker_status: 'offline', containers_total: 1, containers_running: 0 }
|
||||
];
|
||||
dockerSection.renderHostsGrid();
|
||||
});
|
||||
|
||||
it('filters hosts by name', () => {
|
||||
dockerSection.filterHosts('production');
|
||||
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const visibleCards = Array.from(cards).filter(c => c.style.display !== 'none');
|
||||
expect(visibleCards.length).toBe(1);
|
||||
});
|
||||
|
||||
it('filters hosts by IP', () => {
|
||||
dockerSection.filterHosts('10.0.0');
|
||||
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const visibleCards = Array.from(cards).filter(c => c.style.display !== 'none');
|
||||
expect(visibleCards.length).toBe(1);
|
||||
});
|
||||
|
||||
it('shows all hosts with empty query', () => {
|
||||
dockerSection.filterHosts('');
|
||||
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const visibleCards = Array.from(cards).filter(c => c.style.display !== 'none');
|
||||
expect(visibleCards.length).toBe(3);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
dockerSection.filterHosts('STAGING');
|
||||
|
||||
const cards = document.querySelectorAll('#docker-hosts-grid > div[data-host-id]');
|
||||
const visibleCards = Array.from(cards).filter(c => c.style.display !== 'none');
|
||||
expect(visibleCards.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user