Compare commits
10 Commits
26af69e2a3
...
v0.9.12
| Author | SHA1 | Date | |
|---|---|---|---|
| d909f93efc | |||
| 965cdd765b | |||
| 0663740b08 | |||
| 29587f2353 | |||
| 798ed08ddd | |||
| ed184f1c84 | |||
| 8e1b4fa978 | |||
| e604e3aadf | |||
| cf721513e8 | |||
| a32cc82851 |
@@ -1 +1 @@
|
||||
v0.9.10
|
||||
v0.9.12
|
||||
|
||||
@@ -1001,8 +1001,7 @@ class ServiceStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
|
||||
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
|
||||
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
|
||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||
|
||||
@@ -24,7 +24,6 @@ class ServerStatusResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
status: ServerStatus
|
||||
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||
|
||||
|
||||
class ServerSettings(BaseModel):
|
||||
|
||||
@@ -294,6 +294,7 @@ async def get_history_page(
|
||||
since: int | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> tuple[list[HistoryRecord], int]:
|
||||
@@ -314,6 +315,12 @@ async def get_history_page(
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||
if origin_clause:
|
||||
origin_clause_clean = origin_clause.removeprefix(" AND ")
|
||||
wheres.append(origin_clause_clean)
|
||||
params.extend(origin_params)
|
||||
|
||||
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
|
||||
effective_page_size: int = page_size
|
||||
|
||||
@@ -70,7 +70,8 @@ async def get_server_status(
|
||||
"server_status",
|
||||
ServerStatus(online=False),
|
||||
)
|
||||
return ServerStatusResponse(status=cached, bangui_version=__version__)
|
||||
cached.version = __version__
|
||||
return ServerStatusResponse(status=cached)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
@@ -24,8 +23,12 @@ import structlog
|
||||
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from app import __version__
|
||||
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
BantimeEscalation,
|
||||
@@ -44,11 +47,13 @@ from app.models.config import (
|
||||
RegexTestResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
from app.utils.log_utils import preview_log as util_preview_log, test_regex as util_test_regex
|
||||
from app.utils.log_utils import preview_log as util_preview_log
|
||||
from app.utils.log_utils import test_regex as util_test_regex
|
||||
from app.utils.setup_utils import (
|
||||
get_map_color_thresholds as util_get_map_color_thresholds,
|
||||
)
|
||||
from app.utils.setup_utils import (
|
||||
set_map_color_thresholds as util_set_map_color_thresholds,
|
||||
)
|
||||
|
||||
@@ -814,7 +819,7 @@ async def get_service_status(
|
||||
|
||||
return ServiceStatusResponse(
|
||||
online=server_status.online,
|
||||
version=server_status.version,
|
||||
version=__version__,
|
||||
jail_count=server_status.active_jails,
|
||||
total_bans=server_status.total_bans,
|
||||
total_failures=server_status.total_failures,
|
||||
|
||||
@@ -17,16 +17,21 @@ from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from app.exceptions import FilterInvalidRegexError
|
||||
from app.models.config import (
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
FilterListResponse,
|
||||
FilterUpdateRequest,
|
||||
AssignFilterRequest,
|
||||
)
|
||||
from app.exceptions import FilterInvalidRegexError, JailNotFoundError
|
||||
from app.services.config_file_service import _TRUE_VALUES, ConfigWriteError, JailNotFoundInConfigError
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.config_file_utils import (
|
||||
_get_active_jail_names,
|
||||
_parse_jails_sync,
|
||||
)
|
||||
from app.utils.jail_utils import reload_jails
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -90,6 +95,7 @@ class JailNameError(Exception):
|
||||
"""Raised when a jail name contains invalid characters."""
|
||||
|
||||
|
||||
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import structlog
|
||||
if TYPE_CHECKING:
|
||||
from app.models.geo import GeoEnricher
|
||||
|
||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
||||
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
@@ -62,6 +62,7 @@ async def list_history(
|
||||
range_: TimeRange | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
@@ -108,6 +109,7 @@ async def list_history(
|
||||
since=since,
|
||||
jail=jail,
|
||||
ip_filter=ip_filter,
|
||||
origin=origin,
|
||||
page=page,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
|
||||
@@ -26,22 +26,17 @@ from app.models.config import (
|
||||
InactiveJail,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
JailValidationIssue,
|
||||
JailValidationResult,
|
||||
RollbackResponse,
|
||||
)
|
||||
from app.utils.config_file_utils import (
|
||||
_build_inactive_jail,
|
||||
_ordered_config_files,
|
||||
_get_active_jail_names,
|
||||
_parse_jails_sync,
|
||||
_validate_jail_config_sync,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
from app.utils.jail_utils import reload_jails
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
|
||||
276
backend/tests/test_regression_500s.py
Normal file
276
backend/tests/test_regression_500s.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Regression tests for the four 500-error bugs discovered on 2026-03-22.
|
||||
|
||||
Each test targets the exact code path that caused a 500 Internal Server Error.
|
||||
These tests call the **real** service/repository functions (not the router)
|
||||
so they fail even if the route layer is mocked in router-level tests.
|
||||
|
||||
Bugs covered:
|
||||
1. ``list_history`` rejected the ``origin`` keyword argument (TypeError).
|
||||
2. ``jail_config_service`` used ``_get_active_jail_names`` without importing it.
|
||||
3. ``filter_config_service`` used ``_parse_jails_sync`` / ``_get_active_jail_names``
|
||||
without importing them.
|
||||
4. ``config_service.get_service_status`` omitted the required ``bangui_version``
|
||||
field from the ``ServiceStatusResponse`` constructor (Pydantic ValidationError).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
# ── Bug 1 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryOriginParameter:
|
||||
"""Bug 1: ``origin`` parameter must be threaded through service → repo."""
|
||||
|
||||
# -- Service layer --
|
||||
|
||||
async def test_list_history_accepts_origin_kwarg(self) -> None:
|
||||
"""``history_service.list_history()`` must accept an ``origin`` keyword."""
|
||||
from app.services import history_service
|
||||
|
||||
sig = inspect.signature(history_service.list_history)
|
||||
assert "origin" in sig.parameters, (
|
||||
"list_history() is missing the 'origin' parameter — "
|
||||
"the router passes origin=… which would cause a TypeError"
|
||||
)
|
||||
|
||||
async def test_list_history_forwards_origin_to_repo(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_history(origin='blocklist')`` must forward origin to the DB repo."""
|
||||
from app.services import history_service
|
||||
|
||||
db_path = str(tmp_path / "f2b.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"CREATE TABLE jails (name TEXT, enabled INTEGER DEFAULT 1)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE TABLE bans "
|
||||
"(jail TEXT, ip TEXT, timeofban INTEGER, bantime INTEGER, "
|
||||
"bancount INTEGER DEFAULT 1, data JSON)"
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("blocklist-import", "10.0.0.1", int(time.time()), 3600, 1, "{}"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("sshd", "10.0.0.2", int(time.time()), 3600, 1, "{}"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", origin="blocklist"
|
||||
)
|
||||
|
||||
assert all(
|
||||
item.jail == "blocklist-import" for item in result.items
|
||||
), "origin='blocklist' must filter to blocklist-import jail only"
|
||||
|
||||
# -- Repository layer --
|
||||
|
||||
async def test_get_history_page_accepts_origin_kwarg(self) -> None:
|
||||
"""``fail2ban_db_repo.get_history_page()`` must accept ``origin``."""
|
||||
from app.repositories import fail2ban_db_repo
|
||||
|
||||
sig = inspect.signature(fail2ban_db_repo.get_history_page)
|
||||
assert "origin" in sig.parameters, (
|
||||
"get_history_page() is missing the 'origin' parameter"
|
||||
)
|
||||
|
||||
async def test_get_history_page_filters_by_origin(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``get_history_page(origin='selfblock')`` excludes blocklist-import."""
|
||||
from app.repositories import fail2ban_db_repo
|
||||
|
||||
db_path = str(tmp_path / "f2b.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await db.execute(
|
||||
"CREATE TABLE bans "
|
||||
"(jail TEXT, ip TEXT, timeofban INTEGER, bancount INTEGER, data TEXT)"
|
||||
)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("blocklist-import", "10.0.0.1", 100, 1, "{}"),
|
||||
("sshd", "10.0.0.2", 200, 1, "{}"),
|
||||
("sshd", "10.0.0.3", 300, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
rows, total = await fail2ban_db_repo.get_history_page(
|
||||
db_path=db_path, origin="selfblock"
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert all(r.jail != "blocklist-import" for r in rows)
|
||||
|
||||
|
||||
# ── Bug 2 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJailConfigImports:
|
||||
"""Bug 2: ``jail_config_service`` must import ``_get_active_jail_names``."""
|
||||
|
||||
async def test_get_active_jail_names_is_importable(self) -> None:
|
||||
"""The module must successfully import ``_get_active_jail_names``."""
|
||||
import app.services.jail_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_get_active_jail_names") or callable(
|
||||
getattr(mod, "_get_active_jail_names", None)
|
||||
), (
|
||||
"_get_active_jail_names is not available in jail_config_service — "
|
||||
"any call site will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_list_inactive_jails_does_not_raise_name_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_inactive_jails`` must not crash with NameError."""
|
||||
from app.services import jail_config_service
|
||||
|
||||
config_dir = str(tmp_path / "fail2ban")
|
||||
Path(config_dir).mkdir()
|
||||
(Path(config_dir) / "jail.conf").write_text("[DEFAULT]\n")
|
||||
|
||||
with patch(
|
||||
"app.services.jail_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await jail_config_service.list_inactive_jails(
|
||||
config_dir, "/fake/socket"
|
||||
)
|
||||
|
||||
assert result.total >= 0
|
||||
|
||||
|
||||
# ── Bug 3 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterConfigImports:
|
||||
"""Bug 3: ``filter_config_service`` must import ``_parse_jails_sync``
|
||||
and ``_get_active_jail_names``."""
|
||||
|
||||
async def test_parse_jails_sync_is_available(self) -> None:
|
||||
"""``_parse_jails_sync`` must be resolvable at module scope."""
|
||||
import app.services.filter_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_parse_jails_sync"), (
|
||||
"_parse_jails_sync is not available in filter_config_service — "
|
||||
"list_filters() will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_get_active_jail_names_is_available(self) -> None:
|
||||
"""``_get_active_jail_names`` must be resolvable at module scope."""
|
||||
import app.services.filter_config_service as mod
|
||||
|
||||
assert hasattr(mod, "_get_active_jail_names"), (
|
||||
"_get_active_jail_names is not available in filter_config_service — "
|
||||
"list_filters() will raise NameError → 500"
|
||||
)
|
||||
|
||||
async def test_list_filters_does_not_raise_name_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``list_filters`` must not crash with NameError."""
|
||||
from app.services import filter_config_service
|
||||
|
||||
config_dir = str(tmp_path / "fail2ban")
|
||||
filter_d = Path(config_dir) / "filter.d"
|
||||
filter_d.mkdir(parents=True)
|
||||
|
||||
# Create a minimal filter file so _parse_filters_sync has something to scan.
|
||||
(filter_d / "sshd.conf").write_text(
|
||||
"[Definition]\nfailregex = ^Failed password\n"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.filter_config_service._parse_jails_sync",
|
||||
return_value=({}, {}),
|
||||
),
|
||||
patch(
|
||||
"app.services.filter_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
):
|
||||
result = await filter_config_service.list_filters(
|
||||
config_dir, "/fake/socket"
|
||||
)
|
||||
|
||||
assert result.total >= 0
|
||||
|
||||
|
||||
# ── Bug 4 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestServiceStatusBanguiVersion:
|
||||
"""Bug 4: ``get_service_status`` must include application version
|
||||
in the ``version`` field of the ``ServiceStatusResponse``."""
|
||||
|
||||
async def test_online_response_contains_bangui_version(self) -> None:
|
||||
"""The returned model must contain the ``bangui_version`` field."""
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import config_service
|
||||
import app
|
||||
|
||||
online_status = ServerStatus(
|
||||
online=True,
|
||||
version="1.0.0",
|
||||
active_jails=2,
|
||||
total_bans=5,
|
||||
total_failures=3,
|
||||
)
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key == "get|loglevel":
|
||||
return (0, "INFO")
|
||||
if key == "get|logtarget":
|
||||
return (0, "/var/log/fail2ban.log")
|
||||
return (0, None)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
result = await config_service.get_service_status(
|
||||
"/fake/socket",
|
||||
probe_fn=AsyncMock(return_value=online_status),
|
||||
)
|
||||
|
||||
assert result.version == app.__version__, (
|
||||
"ServiceStatusResponse must expose BanGUI version in version field"
|
||||
)
|
||||
|
||||
async def test_offline_response_contains_bangui_version(self) -> None:
|
||||
"""Even when fail2ban is offline, ``bangui_version`` must be present."""
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import config_service
|
||||
import app
|
||||
|
||||
offline_status = ServerStatus(online=False)
|
||||
|
||||
result = await config_service.get_service_status(
|
||||
"/fake/socket",
|
||||
probe_fn=AsyncMock(return_value=offline_status),
|
||||
)
|
||||
|
||||
assert result.version == app.__version__
|
||||
@@ -136,3 +136,32 @@ async def test_get_history_page_and_for_ip(tmp_path: Path) -> None:
|
||||
history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2")
|
||||
assert len(history_for_ip) == 1
|
||||
assert history_for_ip[0].ip == "2.2.2.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_page_origin_filter(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await _create_bans_table(db)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("jail1", "1.1.1.1", 100, 1, "{}"),
|
||||
("blocklist-import", "2.2.2.2", 200, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
page, total = await fail2ban_db_repo.get_history_page(
|
||||
db_path=db_path,
|
||||
since=None,
|
||||
jail=None,
|
||||
ip_filter=None,
|
||||
origin="selfblock",
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert len(page) == 1
|
||||
assert page[0].ip == "1.1.1.1"
|
||||
|
||||
@@ -2001,8 +2001,7 @@ class TestGetServiceStatus:
|
||||
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
|
||||
return ServiceStatusResponse(
|
||||
online=online,
|
||||
version="1.0.0" if online else None,
|
||||
bangui_version=app.__version__,
|
||||
version=app.__version__,
|
||||
jail_count=2 if online else 0,
|
||||
total_bans=10 if online else 0,
|
||||
total_failures=3 if online else 0,
|
||||
@@ -2021,7 +2020,7 @@ class TestGetServiceStatus:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["online"] is True
|
||||
assert data["bangui_version"] == app.__version__
|
||||
assert data["version"] == app.__version__
|
||||
assert data["jail_count"] == 2
|
||||
assert data["log_level"] == "INFO"
|
||||
|
||||
@@ -2035,7 +2034,7 @@ class TestGetServiceStatus:
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bangui_version"] == app.__version__
|
||||
assert data["version"] == app.__version__
|
||||
assert data["online"] is False
|
||||
assert data["log_level"] == "UNKNOWN"
|
||||
|
||||
|
||||
@@ -153,8 +153,6 @@ class TestDashboardStatus:
|
||||
body = response.json()
|
||||
|
||||
assert "status" in body
|
||||
assert "bangui_version" in body
|
||||
assert body["bangui_version"] == app.__version__
|
||||
|
||||
status = body["status"]
|
||||
assert "online" in status
|
||||
@@ -171,9 +169,8 @@ class TestDashboardStatus:
|
||||
body = response.json()
|
||||
status = body["status"]
|
||||
|
||||
assert body["bangui_version"] == app.__version__
|
||||
assert status["online"] is True
|
||||
assert status["version"] == "1.0.2"
|
||||
assert status["version"] == app.__version__
|
||||
assert status["active_jails"] == 2
|
||||
assert status["total_bans"] == 10
|
||||
assert status["total_failures"] == 5
|
||||
@@ -187,9 +184,8 @@ class TestDashboardStatus:
|
||||
body = response.json()
|
||||
status = body["status"]
|
||||
|
||||
assert body["bangui_version"] == app.__version__
|
||||
assert status["online"] is False
|
||||
assert status["version"] is None
|
||||
assert status["version"] == app.__version__
|
||||
assert status["active_jails"] == 0
|
||||
assert status["total_bans"] == 0
|
||||
assert status["total_failures"] == 0
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -748,8 +749,10 @@ class TestGetServiceStatus:
|
||||
probe_fn=AsyncMock(return_value=online_status),
|
||||
)
|
||||
|
||||
from app import __version__
|
||||
|
||||
assert result.online is True
|
||||
assert result.version == "1.0.0"
|
||||
assert result.version == __version__
|
||||
assert result.jail_count == 2
|
||||
assert result.total_bans == 5
|
||||
assert result.total_failures == 3
|
||||
@@ -771,3 +774,62 @@ class TestGetServiceStatus:
|
||||
assert result.jail_count == 0
|
||||
assert result.log_level == "UNKNOWN"
|
||||
assert result.log_target == "UNKNOWN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestConfigModuleIntegration:
|
||||
async def test_jail_config_service_list_inactive_jails_uses_imports(self, tmp_path: Any) -> None:
|
||||
from app.services.jail_config_service import list_inactive_jails
|
||||
|
||||
# Arrange: fake parse_jails output with one active and one inactive
|
||||
def fake_parse_jails_sync(path: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
|
||||
return (
|
||||
{
|
||||
"sshd": {
|
||||
"enabled": "true",
|
||||
"filter": "sshd",
|
||||
"logpath": "/var/log/auth.log",
|
||||
},
|
||||
"apache-auth": {
|
||||
"enabled": "false",
|
||||
"filter": "apache-auth",
|
||||
"logpath": "/var/log/apache2/error.log",
|
||||
},
|
||||
},
|
||||
{
|
||||
"sshd": str(path / "jail.conf"),
|
||||
"apache-auth": str(path / "jail.conf"),
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.jail_config_service._parse_jails_sync",
|
||||
new=fake_parse_jails_sync,
|
||||
), patch(
|
||||
"app.services.jail_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
names = {j.name for j in result.jails}
|
||||
assert "apache-auth" in names
|
||||
assert "sshd" not in names
|
||||
|
||||
async def test_filter_config_service_list_filters_uses_imports(self, tmp_path: Any) -> None:
|
||||
from app.services.filter_config_service import list_filters
|
||||
|
||||
# Arrange minimal filter and jail config files
|
||||
filter_d = tmp_path / "filter.d"
|
||||
filter_d.mkdir(parents=True)
|
||||
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = ^%(__prefix_line)s.*$\n")
|
||||
(tmp_path / "jail.conf").write_text("[sshd]\nfilter = sshd\nenabled = true\n")
|
||||
|
||||
with patch(
|
||||
"app.services.filter_config_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_filters(str(tmp_path), "/fake.sock")
|
||||
|
||||
assert result.total == 1
|
||||
assert result.filters[0].name == "sshd"
|
||||
assert result.filters[0].active is True
|
||||
|
||||
@@ -179,6 +179,19 @@ class TestListHistory:
|
||||
# 2 sshd bans for 1.2.3.4
|
||||
assert result.total == 2
|
||||
|
||||
async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
|
||||
"""Origin filter should include only selfblock entries."""
|
||||
with patch(
|
||||
"app.services.history_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", origin="selfblock"
|
||||
)
|
||||
|
||||
assert result.total == 4
|
||||
assert all(item.jail != "blocklist-import" for item in result.items)
|
||||
|
||||
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
|
||||
"""Filtering by a non-existent IP returns an empty result set."""
|
||||
with patch(
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.10",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.12",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -70,7 +70,7 @@ const useStyles = makeStyles({
|
||||
*/
|
||||
export function ServerStatusBar(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { status, banguiVersion, loading, error, refresh } = useServerStatus();
|
||||
const { status, loading, error, refresh } = useServerStatus();
|
||||
|
||||
const cardStyles = useCardStyles();
|
||||
|
||||
@@ -98,21 +98,13 @@ export function ServerStatusBar(): React.JSX.Element {
|
||||
{/* Version */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{status?.version != null && (
|
||||
<Tooltip content="fail2ban daemon version" relationship="description">
|
||||
<Tooltip content="BanGUI version" relationship="description">
|
||||
<Text size={200} className={styles.statValue}>
|
||||
v{status.version}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{banguiVersion != null && (
|
||||
<Tooltip content="BanGUI version" relationship="description">
|
||||
<Badge appearance="filled" size="small">
|
||||
BanGUI v{banguiVersion}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Stats (only when online) */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("ServerStatusBar", () => {
|
||||
it("shows a spinner while the initial load is in progress", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: null,
|
||||
banguiVersion: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -60,7 +59,6 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 10,
|
||||
total_failures: 5,
|
||||
},
|
||||
banguiVersion: "1.1.0",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -78,7 +76,6 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
banguiVersion: "1.1.0",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -96,7 +93,6 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
banguiVersion: "1.2.3",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -105,7 +101,7 @@ describe("ServerStatusBar", () => {
|
||||
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a BanGUI version badge", () => {
|
||||
it("does not render a separate BanGUI version badge", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: {
|
||||
online: true,
|
||||
@@ -114,13 +110,12 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
banguiVersion: "9.9.9",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
expect(screen.getByText("BanGUI v9.9.9")).toBeInTheDocument();
|
||||
expect(screen.queryByText("BanGUI v9.9.9")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the version element when version is null", () => {
|
||||
@@ -132,7 +127,6 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
},
|
||||
banguiVersion: "1.2.3",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -151,7 +145,6 @@ describe("ServerStatusBar", () => {
|
||||
total_bans: 21,
|
||||
total_failures: 99,
|
||||
},
|
||||
banguiVersion: "1.0.0",
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
@@ -167,7 +160,6 @@ describe("ServerStatusBar", () => {
|
||||
it("renders an error message when the status fetch fails", () => {
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: null,
|
||||
banguiVersion: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
refresh: vi.fn(),
|
||||
|
||||
@@ -352,12 +352,6 @@ export function ServerHealthSection(): React.JSX.Element {
|
||||
<Text className={styles.statValue}>{status.version}</Text>
|
||||
</div>
|
||||
)}
|
||||
{status.bangui_version && (
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>BanGUI</Text>
|
||||
<Text className={styles.statValue}>{status.bangui_version}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Active Jails</Text>
|
||||
<Text className={styles.statValue}>{status.jail_count}</Text>
|
||||
|
||||
@@ -15,11 +15,10 @@ describe("ServerHealthSection", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the BanGUI version in the service health panel", async () => {
|
||||
it("shows the version in the service health panel", async () => {
|
||||
mockedFetchServiceStatus.mockResolvedValue({
|
||||
online: true,
|
||||
version: "1.2.3",
|
||||
bangui_version: "1.2.3",
|
||||
jail_count: 2,
|
||||
total_bans: 5,
|
||||
total_failures: 1,
|
||||
@@ -41,11 +40,11 @@ describe("ServerHealthSection", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// The service health panel should render and include the BanGUI version.
|
||||
const banGuiLabel = await screen.findByText("BanGUI");
|
||||
expect(banGuiLabel).toBeInTheDocument();
|
||||
// The service health panel should render and include the version.
|
||||
const versionLabel = await screen.findByText("Version");
|
||||
expect(versionLabel).toBeInTheDocument();
|
||||
|
||||
const banGuiCard = banGuiLabel.closest("div");
|
||||
expect(banGuiCard).toHaveTextContent("1.2.3");
|
||||
const versionCard = versionLabel.closest("div");
|
||||
expect(versionCard).toHaveTextContent("1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls start() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.startJail).mockResolvedValue(undefined);
|
||||
vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "jail started", jail: "sshd" });
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls stop() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.stopJail).mockResolvedValue(undefined);
|
||||
vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "jail stopped", jail: "sshd" });
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls reload() and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.reloadJail).mockResolvedValue(undefined);
|
||||
vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "jail reloaded", jail: "sshd" });
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("useJailDetail control methods", () => {
|
||||
});
|
||||
|
||||
it("calls setIdle() with correct parameter and refetches jail data", async () => {
|
||||
vi.mocked(jailsApi.setJailIdle).mockResolvedValue(undefined);
|
||||
vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "jail idle toggled", jail: "sshd" });
|
||||
|
||||
const { result } = renderHook(() => useJailDetail("sshd"));
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ const POLL_INTERVAL_MS = 30_000;
|
||||
export interface UseServerStatusResult {
|
||||
/** The most recent server status snapshot, or `null` before the first fetch. */
|
||||
status: ServerStatus | null;
|
||||
/** BanGUI application version string. */
|
||||
banguiVersion: string | null;
|
||||
/** Whether a fetch is currently in flight. */
|
||||
loading: boolean;
|
||||
/** Error message string when the last fetch failed, otherwise `null`. */
|
||||
@@ -35,7 +33,6 @@ export interface UseServerStatusResult {
|
||||
*/
|
||||
export function useServerStatus(): UseServerStatusResult {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -47,7 +44,6 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
try {
|
||||
const data = await fetchServerStatus();
|
||||
setStatus(data.status);
|
||||
setBanguiVersion(data.bangui_version);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Failed to fetch server status");
|
||||
@@ -82,5 +78,5 @@ export function useServerStatus(): UseServerStatusResult {
|
||||
void doFetch().catch((): void => undefined);
|
||||
}, [doFetch]);
|
||||
|
||||
return { status, banguiVersion, loading, error, refresh };
|
||||
return { status, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
|
||||
import { useBlocklistStyles } from "../theme/commonStyles";
|
||||
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
|
||||
|
||||
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
|
||||
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
|
||||
|
||||
@@ -659,10 +659,8 @@ export interface Fail2BanLogResponse {
|
||||
export interface ServiceStatusResponse {
|
||||
/** Whether fail2ban is reachable via its socket. */
|
||||
online: boolean;
|
||||
/** fail2ban version string, or null when offline. */
|
||||
/** BanGUI application version (or null when offline). */
|
||||
version: string | null;
|
||||
/** BanGUI application version (from the API). */
|
||||
bangui_version: string;
|
||||
/** Number of currently active jails. */
|
||||
jail_count: number;
|
||||
/** Aggregated current ban count across all jails. */
|
||||
|
||||
@@ -21,6 +21,4 @@ export interface ServerStatus {
|
||||
/** Response shape for ``GET /api/dashboard/status``. */
|
||||
export interface ServerStatusResponse {
|
||||
status: ServerStatus;
|
||||
/** BanGUI application version (from the API). */
|
||||
bangui_version: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user