feat: comprehensive health check with DB, scheduler, cache

- Add /api/v1/health endpoint with component-level checks
- Verify DB connectivity, fail2ban socket, scheduler, session cache
- Add SQLite WAL cleanup on startup (orphan crash files)
- Migration 8: import_log.timestamp → INTEGER UNIX epoch
- Align import_log timestamps with history_archive (already UNIX int)
- Add unit tests for DB cleanup and health router

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 23:03:57 +02:00
parent b631c1c546
commit 1285bc8571
12 changed files with 472 additions and 241 deletions

View File

@@ -62,12 +62,19 @@ async def client(test_settings: Settings) -> AsyncClient: # type: ignore[misc]
Yields:
An :class:`httpx.AsyncClient` with ``base_url="http://test"``.
"""
from unittest.mock import MagicMock
app = create_app(settings=test_settings)
# Ensure fail2ban is reported as online for tests (mock socket is not
# actually connected so we need to set the cached status manually).
app.state.server_status = ServerStatus(online=True)
# Mock scheduler for health check tests (lifespan not run in ASGITransport tests)
mock_scheduler = MagicMock()
mock_scheduler.running = True
app.state.scheduler = mock_scheduler
# Bootstrap the database schema before making requests. ASGITransport
# does not run the application lifespan, so we create the test SQLite file
# directly rather than relying on startup logic.

View File

@@ -7,6 +7,7 @@ import pytest
from app.db import (
_apply_migration,
_cleanup_wal_files,
_parse_migration_statements,
init_db,
open_db,
@@ -241,3 +242,32 @@ async def test_init_db_idempotent(tmp_path: Path) -> None:
finally:
await db.close()
async def test_cleanup_wal_files_removes_orphaned_files(tmp_path: Path) -> None:
"""Test that _cleanup_wal_files removes orphaned WAL and SHM files."""
db_path = str(tmp_path / "test_wal.db")
wal_path = Path(db_path + "-wal")
shm_path = Path(db_path + "-shm")
# Create the orphaned files
wal_path.write_text("orphan")
shm_path.write_text("orphan")
assert wal_path.exists()
assert shm_path.exists()
# Run cleanup
await _cleanup_wal_files(db_path)
# Both files should be removed
assert not wal_path.exists()
assert not shm_path.exists()
async def test_cleanup_wal_files_handles_missing_files(tmp_path: Path) -> None:
"""Test that _cleanup_wal_files handles non-existent files gracefully."""
db_path = str(tmp_path / "nonexistent.db")
# Should not raise
await _cleanup_wal_files(db_path)

View File

@@ -8,15 +8,14 @@ from app.models.server import ServerStatus
@pytest.mark.asyncio
async def test_health_check_returns_200_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 200 when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
"""``GET /api/v1/health`` must return HTTP 200 when fail2ban is online."""
response = await client.get("/api/v1/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_health_check_returns_503_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 503 when fail2ban is offline."""
"""``GET /api/v1/health`` must return HTTP 503 when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/v1/health")
assert response.status_code == 503
@@ -24,27 +23,84 @@ async def test_health_check_returns_503_when_offline(client: AsyncClient) -> Non
@pytest.mark.asyncio
async def test_health_check_returns_ok_status_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: ok`` when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
"""``GET /api/v1/health`` must contain ``status: ok`` when fail2ban is online."""
response = await client.get("/api/v1/health")
data: dict[str, str] = response.json()
data: dict[str, object] = response.json()
assert data["status"] == "ok"
assert data["fail2ban"] == "online"
@pytest.mark.asyncio
async def test_health_check_returns_unavailable_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: unavailable`` when fail2ban is offline."""
"""``GET /api/v1/health`` must contain ``status: unavailable`` when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/v1/health")
data: dict[str, str] = response.json()
data: dict[str, object] = response.json()
assert data["status"] == "unavailable"
assert data["fail2ban"] == "offline"
@pytest.mark.asyncio
async def test_health_check_content_type_is_json(client: AsyncClient) -> None:
"""``GET /api/health`` must set the ``Content-Type`` header to JSON."""
"""``GET /api/v1/health`` must set the ``Content-Type`` header to JSON."""
response = await client.get("/api/v1/health")
assert "application/json" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_health_check_includes_database_status(client: AsyncClient) -> None:
"""``GET /api/v1/health`` must include database status field."""
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert "database" in data
assert data["database"] in ("ok", "error")
@pytest.mark.asyncio
async def test_health_check_includes_scheduler_status(client: AsyncClient) -> None:
"""``GET /api/v1/health`` must include scheduler status field."""
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert "scheduler" in data
assert data["scheduler"] in ("running", "stopped", "unknown")
@pytest.mark.asyncio
async def test_health_check_includes_cache_status(client: AsyncClient) -> None:
"""``GET /api/v1/health`` must include cache status field."""
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert "cache" in data
assert data["cache"] in ("initialised", "uninitialised")
@pytest.mark.asyncio
async def test_health_check_includes_components_list(client: AsyncClient) -> None:
"""``GET /api/v1/health`` must include components list."""
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert "components" in data
assert isinstance(data["components"], list)
@pytest.mark.asyncio
async def test_health_check_offline_adds_fail2ban_to_components(
client: AsyncClient,
) -> None:
"""When fail2ban is offline, it must appear in the components list."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert data["status"] == "unavailable"
components: list[dict[str, object]] = data["components"] # type: ignore[assignment]
assert any(c.get("name") == "fail2ban" and c.get("healthy") is False for c in components)
@pytest.mark.asyncio
async def test_health_check_online_returns_empty_components(client: AsyncClient) -> None:
"""When all components are healthy, components list must be empty."""
response = await client.get("/api/v1/health")
data: dict[str, object] = response.json()
assert data["status"] == "ok"
assert data["components"] == []