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:
@@ -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