feat: Stage 1 — backend and frontend scaffolding

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
This commit is contained in:
2026-02-28 21:15:01 +01:00
parent 460d877339
commit 7392c930d6
59 changed files with 7601 additions and 17 deletions

View File

@@ -0,0 +1 @@
"""Tests package."""

64
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,64 @@
"""Shared pytest fixtures for the BanGUI backend test suite.
All fixtures are async-compatible via pytest-asyncio. External dependencies
(fail2ban socket, HTTP APIs) are always mocked so tests never touch real
infrastructure.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure the bundled fail2ban package is importable.
_FAIL2BAN_MASTER: Path = Path(__file__).resolve().parents[2] / "fail2ban-master"
if str(_FAIL2BAN_MASTER) not in sys.path:
sys.path.insert(0, str(_FAIL2BAN_MASTER))
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.main import create_app
@pytest.fixture
def test_settings(tmp_path: Path) -> Settings:
"""Return a ``Settings`` instance configured for testing.
Uses a temporary directory for the database so tests are isolated from
each other and from the development database.
Args:
tmp_path: Pytest-provided temporary directory (unique per test).
Returns:
A :class:`~app.config.Settings` instance with overridden paths.
"""
return Settings(
database_path=str(tmp_path / "test_bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
@pytest.fixture
async def client(test_settings: Settings) -> AsyncClient:
"""Provide an ``AsyncClient`` wired to a test instance of the BanGUI app.
The client sends requests directly to the ASGI application (no network).
A fresh database is created for each test.
Args:
test_settings: Injected test settings fixture.
Yields:
An :class:`httpx.AsyncClient` with ``base_url="http://test"``.
"""
app = create_app(settings=test_settings)
transport: ASGITransport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac

View File

@@ -0,0 +1 @@
"""Repository test package."""

View File

@@ -0,0 +1,69 @@
"""Tests for app.db — database schema initialisation."""
from pathlib import Path
import aiosqlite
import pytest
from app.db import init_db
@pytest.mark.asyncio
async def test_init_db_creates_settings_table(tmp_path: Path) -> None:
"""``init_db`` must create the ``settings`` table."""
db_path = str(tmp_path / "test.db")
async with aiosqlite.connect(db_path) as db:
await init_db(db)
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='settings';"
) as cursor:
row = await cursor.fetchone()
assert row is not None
@pytest.mark.asyncio
async def test_init_db_creates_sessions_table(tmp_path: Path) -> None:
"""``init_db`` must create the ``sessions`` table."""
db_path = str(tmp_path / "test.db")
async with aiosqlite.connect(db_path) as db:
await init_db(db)
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions';"
) as cursor:
row = await cursor.fetchone()
assert row is not None
@pytest.mark.asyncio
async def test_init_db_creates_blocklist_sources_table(tmp_path: Path) -> None:
"""``init_db`` must create the ``blocklist_sources`` table."""
db_path = str(tmp_path / "test.db")
async with aiosqlite.connect(db_path) as db:
await init_db(db)
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='blocklist_sources';"
) as cursor:
row = await cursor.fetchone()
assert row is not None
@pytest.mark.asyncio
async def test_init_db_creates_import_log_table(tmp_path: Path) -> None:
"""``init_db`` must create the ``import_log`` table."""
db_path = str(tmp_path / "test.db")
async with aiosqlite.connect(db_path) as db:
await init_db(db)
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='import_log';"
) as cursor:
row = await cursor.fetchone()
assert row is not None
@pytest.mark.asyncio
async def test_init_db_is_idempotent(tmp_path: Path) -> None:
"""Calling ``init_db`` twice on the same database must not raise."""
db_path = str(tmp_path / "test.db")
async with aiosqlite.connect(db_path) as db:
await init_db(db)
await init_db(db) # Second call must be a no-op.

View File

@@ -0,0 +1 @@
"""Router test package."""

View File

@@ -0,0 +1,26 @@
"""Tests for the health check router."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_health_check_returns_200(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 200."""
response = await client.get("/api/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_health_check_returns_ok_status(client: AsyncClient) -> None:
"""``GET /api/health`` must return ``{"status": "ok"}``."""
response = await client.get("/api/health")
data: dict[str, str] = response.json()
assert data == {"status": "ok"}
@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."""
response = await client.get("/api/health")
assert "application/json" in response.headers.get("content-type", "")

View File

@@ -0,0 +1 @@
"""Service test package."""

View File

@@ -0,0 +1,87 @@
"""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,
)

View File

@@ -0,0 +1,106 @@
"""Tests for app.utils.ip_utils."""
import pytest
from app.utils.ip_utils import (
ip_version,
is_valid_ip,
is_valid_ip_or_network,
is_valid_network,
normalise_ip,
normalise_network,
)
class TestIsValidIp:
"""Tests for :func:`is_valid_ip`."""
def test_is_valid_ip_with_valid_ipv4_returns_true(self) -> None:
assert is_valid_ip("192.168.1.1") is True
def test_is_valid_ip_with_valid_ipv6_returns_true(self) -> None:
assert is_valid_ip("2001:db8::1") is True
def test_is_valid_ip_with_cidr_returns_false(self) -> None:
assert is_valid_ip("10.0.0.0/8") is False
def test_is_valid_ip_with_empty_string_returns_false(self) -> None:
assert is_valid_ip("") is False
def test_is_valid_ip_with_hostname_returns_false(self) -> None:
assert is_valid_ip("example.com") is False
def test_is_valid_ip_with_loopback_returns_true(self) -> None:
assert is_valid_ip("127.0.0.1") is True
class TestIsValidNetwork:
"""Tests for :func:`is_valid_network`."""
def test_is_valid_network_with_valid_cidr_returns_true(self) -> None:
assert is_valid_network("192.168.0.0/24") is True
def test_is_valid_network_with_host_bits_set_returns_true(self) -> None:
# strict=False means host bits being set is allowed.
assert is_valid_network("192.168.0.1/24") is True
def test_is_valid_network_with_plain_ip_returns_true(self) -> None:
# A bare IP is treated as a host-only /32 network — this is valid.
assert is_valid_network("192.168.0.1") is True
def test_is_valid_network_with_hostname_returns_false(self) -> None:
assert is_valid_network("example.com") is False
def test_is_valid_network_with_invalid_prefix_returns_false(self) -> None:
assert is_valid_network("10.0.0.0/99") is False
class TestIsValidIpOrNetwork:
"""Tests for :func:`is_valid_ip_or_network`."""
def test_accepts_plain_ip(self) -> None:
assert is_valid_ip_or_network("1.2.3.4") is True
def test_accepts_cidr(self) -> None:
assert is_valid_ip_or_network("10.0.0.0/8") is True
def test_rejects_garbage(self) -> None:
assert is_valid_ip_or_network("not-an-ip") is False
class TestNormaliseIp:
"""Tests for :func:`normalise_ip`."""
def test_normalise_ip_ipv4_unchanged(self) -> None:
assert normalise_ip("10.20.30.40") == "10.20.30.40"
def test_normalise_ip_ipv6_compressed(self) -> None:
assert normalise_ip("2001:0db8:0000:0000:0000:0000:0000:0001") == "2001:db8::1"
def test_normalise_ip_invalid_raises_value_error(self) -> None:
with pytest.raises(ValueError):
normalise_ip("not-an-ip")
class TestNormaliseNetwork:
"""Tests for :func:`normalise_network`."""
def test_normalise_network_masks_host_bits(self) -> None:
assert normalise_network("192.168.1.5/24") == "192.168.1.0/24"
def test_normalise_network_already_canonical(self) -> None:
assert normalise_network("10.0.0.0/8") == "10.0.0.0/8"
class TestIpVersion:
"""Tests for :func:`ip_version`."""
def test_ip_version_ipv4_returns_4(self) -> None:
assert ip_version("8.8.8.8") == 4
def test_ip_version_ipv6_returns_6(self) -> None:
assert ip_version("::1") == 6
def test_ip_version_invalid_raises_value_error(self) -> None:
with pytest.raises(ValueError):
ip_version("garbage")

View File

@@ -0,0 +1,79 @@
"""Tests for app.utils.time_utils."""
import datetime
from app.utils.time_utils import add_minutes, hours_ago, is_expired, utc_from_timestamp, utc_now
class TestUtcNow:
"""Tests for :func:`utc_now`."""
def test_utc_now_returns_timezone_aware_datetime(self) -> None:
result = utc_now()
assert result.tzinfo is not None
def test_utc_now_timezone_is_utc(self) -> None:
result = utc_now()
assert result.tzinfo == datetime.UTC
def test_utc_now_is_recent(self) -> None:
before = datetime.datetime.now(datetime.UTC)
result = utc_now()
after = datetime.datetime.now(datetime.UTC)
assert before <= result <= after
class TestUtcFromTimestamp:
"""Tests for :func:`utc_from_timestamp`."""
def test_utc_from_timestamp_epoch_returns_utc_epoch(self) -> None:
result = utc_from_timestamp(0.0)
assert result == datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)
def test_utc_from_timestamp_returns_aware_datetime(self) -> None:
result = utc_from_timestamp(1_000_000_000.0)
assert result.tzinfo is not None
class TestAddMinutes:
"""Tests for :func:`add_minutes`."""
def test_add_minutes_positive(self) -> None:
dt = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC)
result = add_minutes(dt, 30)
expected = datetime.datetime(2024, 1, 1, 12, 30, 0, tzinfo=datetime.UTC)
assert result == expected
def test_add_minutes_negative(self) -> None:
dt = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC)
result = add_minutes(dt, -60)
expected = datetime.datetime(2024, 1, 1, 11, 0, 0, tzinfo=datetime.UTC)
assert result == expected
class TestIsExpired:
"""Tests for :func:`is_expired`."""
def test_is_expired_past_timestamp_returns_true(self) -> None:
past = datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC)
assert is_expired(past) is True
def test_is_expired_future_timestamp_returns_false(self) -> None:
future = datetime.datetime(2099, 1, 1, tzinfo=datetime.UTC)
assert is_expired(future) is False
class TestHoursAgo:
"""Tests for :func:`hours_ago`."""
def test_hours_ago_returns_past_datetime(self) -> None:
result = hours_ago(24)
assert result < utc_now()
def test_hours_ago_correct_delta(self) -> None:
before = utc_now()
result = hours_ago(1)
after = utc_now()
expected_min = before - datetime.timedelta(hours=1, seconds=1)
expected_max = after - datetime.timedelta(hours=1) + datetime.timedelta(seconds=1)
assert expected_min <= result <= expected_max