feat: Task 4 — paginated banned-IPs section on jail detail page
Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)
Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
(300 ms), prev/next pagination, page-size dropdown, per-row unban
button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
This commit is contained in:
@@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel):
|
||||
description="Jails ordered by ban count descending.",
|
||||
)
|
||||
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail-specific paginated bans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailBannedIpsResponse(BaseModel):
|
||||
"""Paginated response for ``GET /api/jails/{name}/banned``.
|
||||
|
||||
Contains only the current page of active ban entries for a single jail,
|
||||
geo-enriched exclusively for the page slice to avoid rate-limit issues.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
items: list[ActiveBan] = Field(
|
||||
default_factory=list,
|
||||
description="Active ban entries for the current page.",
|
||||
)
|
||||
total: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Total matching entries (after applying the search filter).",
|
||||
)
|
||||
page: int = Field(..., ge=1, description="Current page number (1-based).")
|
||||
page_size: int = Field(..., ge=1, description="Number of items per page.")
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails:
|
||||
|
||||
* ``GET /api/jails`` — list all jails
|
||||
* ``GET /api/jails/{name}`` — full detail for one jail
|
||||
* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail
|
||||
* ``POST /api/jails/{name}/start`` — start a jail
|
||||
* ``POST /api/jails/{name}/stop`` — stop a jail
|
||||
* ``POST /api/jails/{name}/idle`` — toggle idle mode
|
||||
@@ -23,6 +24,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import JailBannedIpsResponse
|
||||
from app.models.jail import (
|
||||
IgnoreIpRequest,
|
||||
JailCommandResponse,
|
||||
@@ -540,3 +542,74 @@ async def toggle_ignore_self(
|
||||
raise _conflict(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Currently banned IPs (paginated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{name}/banned",
|
||||
response_model=JailBannedIpsResponse,
|
||||
summary="Return paginated currently-banned IPs for a single jail",
|
||||
)
|
||||
async def get_jail_banned_ips(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
search: str | None = None,
|
||||
) -> JailBannedIpsResponse:
|
||||
"""Return a paginated list of IPs currently banned by a specific jail.
|
||||
|
||||
The full ban list is fetched from the fail2ban socket, filtered by the
|
||||
optional *search* substring, sliced to the requested page, and then
|
||||
geo-enriched exclusively for that page slice.
|
||||
|
||||
Args:
|
||||
request: Incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
name: Jail name.
|
||||
page: 1-based page number (default 1, min 1).
|
||||
page_size: Items per page (default 25, max 100).
|
||||
search: Optional case-insensitive substring filter on the IP address.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when *page* or *page_size* are out of range.
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
if page < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="page must be >= 1.",
|
||||
)
|
||||
if not (1 <= page_size <= 100):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="page_size must be between 1 and 100.",
|
||||
)
|
||||
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session = getattr(request.app.state, "http_session", None)
|
||||
app_db = getattr(request.app.state, "db", None)
|
||||
|
||||
try:
|
||||
return await jail_service.get_jail_banned_ips(
|
||||
socket_path=socket_path,
|
||||
jail_name=name,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
http_session=http_session,
|
||||
app_db=app_db,
|
||||
)
|
||||
except JailNotFoundError:
|
||||
raise _not_found(name) from None
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.ban import ActiveBan, ActiveBanListResponse
|
||||
from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse
|
||||
from app.models.config import BantimeEscalation
|
||||
from app.models.jail import (
|
||||
Jail,
|
||||
@@ -862,6 +862,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Jail-specific paginated bans
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Maximum allowed page size for :func:`get_jail_banned_ips`.
|
||||
_MAX_PAGE_SIZE: int = 100
|
||||
|
||||
|
||||
async def get_jail_banned_ips(
|
||||
socket_path: str,
|
||||
jail_name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
search: str | None = None,
|
||||
http_session: Any | None = None,
|
||||
app_db: Any | None = None,
|
||||
) -> JailBannedIpsResponse:
|
||||
"""Return a paginated list of currently banned IPs for a single jail.
|
||||
|
||||
Fetches the full ban list from the fail2ban socket, applies an optional
|
||||
substring search filter on the IP, paginates server-side, and geo-enriches
|
||||
**only** the current page slice to stay within rate limits.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail_name: Name of the jail to query.
|
||||
page: 1-based page number (default 1).
|
||||
page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25).
|
||||
search: Optional case-insensitive substring filter applied to IP addresses.
|
||||
http_session: Optional shared :class:`aiohttp.ClientSession` for geo
|
||||
enrichment via :func:`~app.services.geo_service.lookup_batch`.
|
||||
app_db: Optional BanGUI application database for persistent geo cache.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
|
||||
|
||||
Raises:
|
||||
JailNotFoundError: If *jail_name* is not a known active jail.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is
|
||||
unreachable.
|
||||
"""
|
||||
from app.services import geo_service # noqa: PLC0415
|
||||
|
||||
# Clamp page_size to the allowed maximum.
|
||||
page_size = min(page_size, _MAX_PAGE_SIZE)
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# Verify the jail exists.
|
||||
try:
|
||||
_ok(await client.send(["status", jail_name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail_name) from exc
|
||||
raise
|
||||
|
||||
# Fetch the full ban list for this jail.
|
||||
try:
|
||||
raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"]))
|
||||
except (ValueError, TypeError):
|
||||
raw_result = []
|
||||
|
||||
ban_list: list[str] = raw_result or []
|
||||
|
||||
# Parse all entries.
|
||||
all_bans: list[ActiveBan] = []
|
||||
for entry in ban_list:
|
||||
ban = _parse_ban_entry(str(entry), jail_name)
|
||||
if ban is not None:
|
||||
all_bans.append(ban)
|
||||
|
||||
# Apply optional substring search filter (case-insensitive).
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
all_bans = [b for b in all_bans if search_lower in b.ip.lower()]
|
||||
|
||||
total = len(all_bans)
|
||||
|
||||
# Slice the requested page.
|
||||
start = (page - 1) * page_size
|
||||
page_bans = all_bans[start : start + page_size]
|
||||
|
||||
# Geo-enrich only the page slice.
|
||||
if http_session is not None and page_bans:
|
||||
page_ips = [b.ip for b in page_bans]
|
||||
try:
|
||||
geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db)
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("jail_banned_ips_geo_failed", jail=jail_name)
|
||||
geo_map = {}
|
||||
enriched_page: list[ActiveBan] = []
|
||||
for ban in page_bans:
|
||||
geo = geo_map.get(ban.ip)
|
||||
if geo is not None:
|
||||
enriched_page.append(ban.model_copy(update={"country": geo.country_code}))
|
||||
else:
|
||||
enriched_page.append(ban)
|
||||
page_bans = enriched_page
|
||||
|
||||
log.info(
|
||||
"jail_banned_ips_fetched",
|
||||
jail=jail_name,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return JailBannedIpsResponse(
|
||||
items=page_bans,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def _enrich_bans(
|
||||
bans: list[ActiveBan],
|
||||
geo_enricher: Any,
|
||||
|
||||
@@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors:
|
||||
resp = await jails_client.post("/api/jails/sshd/reload")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/jails/{name}/banned
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailBannedIps:
|
||||
"""Tests for ``GET /api/jails/{name}/banned``."""
|
||||
|
||||
def _mock_response(
|
||||
self,
|
||||
*,
|
||||
items: list[dict] | None = None,
|
||||
total: int = 2,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> "JailBannedIpsResponse": # type: ignore[name-defined]
|
||||
from app.models.ban import ActiveBan, JailBannedIpsResponse
|
||||
|
||||
ban_items = (
|
||||
[
|
||||
ActiveBan(
|
||||
ip=item.get("ip", "1.2.3.4"),
|
||||
jail="sshd",
|
||||
banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"),
|
||||
expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"),
|
||||
ban_count=1,
|
||||
country=item.get("country", None),
|
||||
)
|
||||
for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}])
|
||||
]
|
||||
)
|
||||
return JailBannedIpsResponse(
|
||||
items=ban_items, total=total, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||
AsyncMock(return_value=self._mock_response()),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert data["total"] == 2
|
||||
|
||||
async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned?search=1.2.3 passes search to service."""
|
||||
mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1))
|
||||
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
|
||||
resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3")
|
||||
|
||||
assert resp.status_code == 200
|
||||
_args, call_kwargs = mock_fn.call_args
|
||||
assert call_kwargs.get("search") == "1.2.3"
|
||||
|
||||
async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service."""
|
||||
mock_fn = AsyncMock(
|
||||
return_value=self._mock_response(page=2, page_size=10, total=0, items=[])
|
||||
)
|
||||
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
|
||||
resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10")
|
||||
|
||||
assert resp.status_code == 200
|
||||
_args, call_kwargs = mock_fn.call_args
|
||||
assert call_kwargs.get("page") == 2
|
||||
assert call_kwargs.get("page_size") == 10
|
||||
|
||||
async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned?page=0 returns 400."""
|
||||
resp = await jails_client.get("/api/jails/sshd/banned?page=0")
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned?page_size=200 returns 400."""
|
||||
resp = await jails_client.get("/api/jails/sshd/banned?page_size=200")
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned?page_size=0 returns 400."""
|
||||
resp = await jails_client.get("/api/jails/sshd/banned?page_size=0")
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/ghost/banned returns 404 when jail is unknown."""
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||
AsyncMock(side_effect=JailNotFoundError("ghost")),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/ghost/banned")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||
AsyncMock(
|
||||
side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")
|
||||
),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
async def test_response_items_have_expected_fields(
|
||||
self, jails_client: AsyncClient
|
||||
) -> None:
|
||||
"""Response items contain ip, jail, banned_at, expires_at, ban_count, country."""
|
||||
with patch(
|
||||
"app.routers.jails.jail_service.get_jail_banned_ips",
|
||||
AsyncMock(return_value=self._mock_response()),
|
||||
):
|
||||
resp = await jails_client.get("/api/jails/sshd/banned")
|
||||
|
||||
item = resp.json()["items"][0]
|
||||
assert "ip" in item
|
||||
assert "jail" in item
|
||||
assert "banned_at" in item
|
||||
assert "expires_at" in item
|
||||
assert "ban_count" in item
|
||||
assert "country" in item
|
||||
|
||||
async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None:
|
||||
"""GET /api/jails/sshd/banned returns 401 without a session cookie."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/jails/sshd/banned")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.ban import ActiveBanListResponse
|
||||
from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
|
||||
from app.models.jail import JailDetailResponse, JailListResponse
|
||||
from app.services import jail_service
|
||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||
@@ -700,3 +700,201 @@ class TestUnbanAllIps:
|
||||
pytest.raises(Fail2BanConnectionError),
|
||||
):
|
||||
await jail_service.unban_all_ips(_SOCKET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_jail_banned_ips
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A raw ban entry string in the format produced by fail2ban --with-time.
|
||||
_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"
|
||||
_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00"
|
||||
_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00"
|
||||
|
||||
|
||||
def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Build mock responses for get_jail_banned_ips tests."""
|
||||
if entries is None:
|
||||
entries = [_BAN_ENTRY_1, _BAN_ENTRY_2]
|
||||
return {
|
||||
f"status|{jail}|short": _make_short_status(),
|
||||
f"get|{jail}|banip|--with-time": (0, entries),
|
||||
}
|
||||
|
||||
|
||||
class TestGetJailBannedIps:
|
||||
"""Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`."""
|
||||
|
||||
async def test_returns_jail_banned_ips_response(self) -> None:
|
||||
"""get_jail_banned_ips returns a JailBannedIpsResponse."""
|
||||
with _patch_client(_banned_ips_responses()):
|
||||
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||
|
||||
assert isinstance(result, JailBannedIpsResponse)
|
||||
|
||||
async def test_total_reflects_all_entries(self) -> None:
|
||||
"""total equals the number of parsed ban entries."""
|
||||
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
|
||||
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||
|
||||
assert result.total == 3
|
||||
|
||||
async def test_page_1_returns_first_n_items(self) -> None:
|
||||
"""page=1 with page_size=2 returns the first two entries."""
|
||||
with _patch_client(
|
||||
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||
):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", page=1, page_size=2
|
||||
)
|
||||
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0].ip == "1.2.3.4"
|
||||
assert result.items[1].ip == "5.6.7.8"
|
||||
assert result.total == 3
|
||||
|
||||
async def test_page_2_returns_remaining_items(self) -> None:
|
||||
"""page=2 with page_size=2 returns the third entry."""
|
||||
with _patch_client(
|
||||
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||
):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", page=2, page_size=2
|
||||
)
|
||||
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].ip == "9.10.11.12"
|
||||
|
||||
async def test_page_beyond_last_returns_empty_items(self) -> None:
|
||||
"""Requesting a page past the end returns an empty items list."""
|
||||
with _patch_client(_banned_ips_responses()):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", page=99, page_size=25
|
||||
)
|
||||
|
||||
assert result.items == []
|
||||
assert result.total == 2
|
||||
|
||||
async def test_search_filter_narrows_results(self) -> None:
|
||||
"""search parameter filters entries by IP substring."""
|
||||
with _patch_client(_banned_ips_responses()):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", search="1.2.3"
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert result.items[0].ip == "1.2.3.4"
|
||||
|
||||
async def test_search_filter_case_insensitive(self) -> None:
|
||||
"""search filter is case-insensitive."""
|
||||
entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"]
|
||||
with _patch_client(_banned_ips_responses(entries=entries)):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", search="192.168"
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
|
||||
async def test_search_no_match_returns_empty(self) -> None:
|
||||
"""search that matches nothing returns empty items and total=0."""
|
||||
with _patch_client(_banned_ips_responses()):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", search="999.999"
|
||||
)
|
||||
|
||||
assert result.total == 0
|
||||
assert result.items == []
|
||||
|
||||
async def test_empty_ban_list_returns_total_zero(self) -> None:
|
||||
"""get_jail_banned_ips handles an empty ban list gracefully."""
|
||||
responses = {
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"get|sshd|banip|--with-time": (0, []),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||
|
||||
assert result.total == 0
|
||||
assert result.items == []
|
||||
|
||||
async def test_page_size_clamped_to_max(self) -> None:
|
||||
"""page_size values above 100 are silently clamped to 100."""
|
||||
entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)]
|
||||
responses = {
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"get|sshd|banip|--with-time": (0, entries),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET, "sshd", page=1, page_size=200
|
||||
)
|
||||
|
||||
assert len(result.items) <= 100
|
||||
|
||||
async def test_geo_enrichment_called_for_page_slice_only(self) -> None:
|
||||
"""Geo enrichment is requested only for IPs in the current page."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services import geo_service
|
||||
|
||||
http_session = MagicMock()
|
||||
geo_enrichment_ips: list[list[str]] = []
|
||||
|
||||
async def _mock_lookup_batch(
|
||||
ips: list[str], _session: Any, **_kw: Any
|
||||
) -> dict[str, Any]:
|
||||
geo_enrichment_ips.append(list(ips))
|
||||
return {}
|
||||
|
||||
with (
|
||||
_patch_client(
|
||||
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
|
||||
),
|
||||
patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch),
|
||||
):
|
||||
result = await jail_service.get_jail_banned_ips(
|
||||
_SOCKET,
|
||||
"sshd",
|
||||
page=1,
|
||||
page_size=2,
|
||||
http_session=http_session,
|
||||
)
|
||||
|
||||
# Only the 2-IP page slice should be passed to geo enrichment.
|
||||
assert len(geo_enrichment_ips) == 1
|
||||
assert len(geo_enrichment_ips[0]) == 2
|
||||
assert result.total == 3
|
||||
|
||||
async def test_unknown_jail_raises_jail_not_found_error(self) -> None:
|
||||
"""get_jail_banned_ips raises JailNotFoundError for unknown jail."""
|
||||
responses = {
|
||||
"status|ghost|short": (0, pytest.raises), # will be overridden
|
||||
}
|
||||
# Simulate fail2ban returning an "unknown jail" error.
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
pass
|
||||
|
||||
async def send(self, command: list[Any]) -> Any:
|
||||
raise ValueError("Unknown jail: ghost")
|
||||
|
||||
with (
|
||||
patch("app.services.jail_service.Fail2BanClient", _FakeClient),
|
||||
pytest.raises(JailNotFoundError),
|
||||
):
|
||||
await jail_service.get_jail_banned_ips(_SOCKET, "ghost")
|
||||
|
||||
async def test_connection_error_propagates(self) -> None:
|
||||
"""get_jail_banned_ips propagates Fail2BanConnectionError."""
|
||||
|
||||
class _FailClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(
|
||||
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.jail_service.Fail2BanClient", _FailClient),
|
||||
pytest.raises(Fail2BanConnectionError),
|
||||
):
|
||||
await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
|
||||
|
||||
Reference in New Issue
Block a user