Backend (tasks 1.1, 1.5–1.8): - pyproject.toml with FastAPI, Pydantic v2, aiosqlite, APScheduler 3.x, structlog, bcrypt; ruff + mypy strict configured - Pydantic Settings (BANGUI_ prefix env vars, fail-fast validation) - SQLite schema: settings, sessions, blocklist_sources, import_log; WAL mode + foreign keys; idempotent init_db() - FastAPI app factory with lifespan (DB, aiohttp session, scheduler), CORS, unhandled-exception handler, GET /api/health - Fail2BanClient: async Unix-socket wrapper using run_in_executor, custom error types, async context manager - Utility modules: ip_utils, time_utils, constants - 47 tests; ruff 0 errors; mypy --strict 0 errors Frontend (tasks 1.2–1.4): - Vite + React 18 + TypeScript strict; Fluent UI v9; ESLint + Prettier - Custom brand theme (#0F6CBD, WCAG AA contrast) with light/dark variants - Typed fetch API client (ApiError, get/post/put/del) + endpoints constants - tsc --noEmit 0 errors
88 lines
3.2 KiB
Python
88 lines
3.2 KiB
Python
"""Tests for app.utils.fail2ban_client."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.utils.fail2ban_client import (
|
|
Fail2BanClient,
|
|
Fail2BanConnectionError,
|
|
Fail2BanProtocolError,
|
|
_send_command_sync,
|
|
)
|
|
|
|
|
|
class TestFail2BanClientPing:
|
|
"""Tests for :meth:`Fail2BanClient.ping`."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping_returns_true_when_daemon_responds(self) -> None:
|
|
"""``ping()`` must return ``True`` when fail2ban responds with 1."""
|
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
|
with patch.object(client, "send", new_callable=AsyncMock, return_value=1):
|
|
result = await client.ping()
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping_returns_false_on_connection_error(self) -> None:
|
|
"""``ping()`` must return ``False`` when the daemon is unreachable."""
|
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
|
with patch.object(
|
|
client,
|
|
"send",
|
|
new_callable=AsyncMock,
|
|
side_effect=Fail2BanConnectionError("refused", "/fake/fail2ban.sock"),
|
|
):
|
|
result = await client.ping()
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping_returns_false_on_protocol_error(self) -> None:
|
|
"""``ping()`` must return ``False`` if the response cannot be parsed."""
|
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
|
with patch.object(
|
|
client,
|
|
"send",
|
|
new_callable=AsyncMock,
|
|
side_effect=Fail2BanProtocolError("bad pickle"),
|
|
):
|
|
result = await client.ping()
|
|
assert result is False
|
|
|
|
|
|
class TestFail2BanClientContextManager:
|
|
"""Tests for the async context manager protocol."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_returns_self(self) -> None:
|
|
"""``async with Fail2BanClient(...)`` must yield the client itself."""
|
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
|
async with client as ctx:
|
|
assert ctx is client
|
|
|
|
|
|
class TestSendCommandSync:
|
|
"""Tests for the synchronous :func:`_send_command_sync` helper."""
|
|
|
|
def test_send_command_sync_raises_connection_error_when_socket_absent(self) -> None:
|
|
"""Must raise :class:`Fail2BanConnectionError` if the socket does not exist."""
|
|
with pytest.raises(Fail2BanConnectionError):
|
|
_send_command_sync(
|
|
socket_path="/nonexistent/fail2ban.sock",
|
|
command=["ping"],
|
|
timeout=1.0,
|
|
)
|
|
|
|
def test_send_command_sync_raises_connection_error_on_oserror(self) -> None:
|
|
"""Must translate :class:`OSError` into :class:`Fail2BanConnectionError`."""
|
|
with patch("socket.socket") as mock_socket_cls:
|
|
mock_sock = MagicMock()
|
|
mock_sock.connect.side_effect = OSError("connection refused")
|
|
mock_socket_cls.return_value = mock_sock
|
|
with pytest.raises(Fail2BanConnectionError):
|
|
_send_command_sync(
|
|
socket_path="/fake/fail2ban.sock",
|
|
command=["status"],
|
|
timeout=1.0,
|
|
)
|