Stage 9: ban history — backend service, router, frontend history page
- history.py models: HistoryBanItem, HistoryListResponse, IpTimelineEvent, IpDetailResponse
- history_service.py: list_history() with dynamic WHERE clauses (range/jail/ip
prefix/all-time), get_ip_detail() with timeline aggregation
- history.py router: GET /api/history + GET /api/history/{ip} (404 for unknown)
- Fixed latent bug in ban_service._parse_data_json: json.loads('null') -> None
-> AttributeError; now checks isinstance(parsed, dict) before assigning obj
- 317 tests pass (27 new), ruff + mypy clean (46 files)
- types/history.ts, api/history.ts, hooks/useHistory.ts created
- HistoryPage.tsx: filter bar (time range/jail/IP), DataGrid table,
high-ban-count row highlighting, per-IP IpDetailView with timeline,
pagination
- Frontend tsc + ESLint clean (0 errors/warnings)
- Tasks.md Stage 9 marked done
This commit is contained in:
@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, bans, config, dashboard, geo, health, jails, server, setup
|
||||
from app.routers import auth, bans, config, dashboard, geo, health, history, jails, server, setup
|
||||
from app.tasks import health_check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -278,5 +278,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(geo.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(server.router)
|
||||
app.include_router(history.router)
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,45 +1,142 @@
|
||||
"""Ban history Pydantic models."""
|
||||
"""Ban history Pydantic models.
|
||||
|
||||
Request, response, and domain models used by the history router and service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.ban import TimeRange
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
"""A single historical ban record from the fail2ban database."""
|
||||
__all__ = [
|
||||
"HistoryBanItem",
|
||||
"HistoryListResponse",
|
||||
"IpDetailResponse",
|
||||
"IpTimelineEvent",
|
||||
"TimeRange",
|
||||
]
|
||||
|
||||
|
||||
class HistoryBanItem(BaseModel):
|
||||
"""A single row in the history ban-list table.
|
||||
|
||||
Populated from the fail2ban database and optionally enriched with
|
||||
geolocation data.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
ip: str
|
||||
jail: str
|
||||
ip: str = Field(..., description="Banned IP address.")
|
||||
jail: str = Field(..., description="Jail that issued the ban.")
|
||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||
released_at: str | None = Field(default=None, description="ISO 8601 UTC timestamp when the ban expired.")
|
||||
ban_count: int = Field(..., ge=1, description="Total number of times this IP was banned.")
|
||||
country: str | None = None
|
||||
matched_lines: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class IpTimeline(BaseModel):
|
||||
"""Per-IP ban history timeline."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
ip: str
|
||||
total_bans: int = Field(..., ge=0)
|
||||
total_failures: int = Field(..., ge=0)
|
||||
events: list[HistoryEntry] = Field(default_factory=list)
|
||||
ban_count: int = Field(..., ge=1, description="How many times this IP was banned.")
|
||||
failures: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Total failure count extracted from the ``data`` column.",
|
||||
)
|
||||
matches: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Matched log lines stored in the ``data`` column.",
|
||||
)
|
||||
country_code: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
|
||||
)
|
||||
country_name: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable country name, or ``null`` if unknown.",
|
||||
)
|
||||
asn: str | None = Field(
|
||||
default=None,
|
||||
description="Autonomous System Number string (e.g. ``'AS3320'``).",
|
||||
)
|
||||
org: str | None = Field(
|
||||
default=None,
|
||||
description="Organisation name associated with the IP.",
|
||||
)
|
||||
|
||||
|
||||
class HistoryListResponse(BaseModel):
|
||||
"""Paginated response for ``GET /api/history``."""
|
||||
"""Paginated history ban-list response."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
entries: list[HistoryEntry] = Field(default_factory=list)
|
||||
total: int = Field(..., ge=0)
|
||||
items: list[HistoryBanItem] = Field(default_factory=list)
|
||||
total: int = Field(..., ge=0, description="Total matching records.")
|
||||
page: int = Field(..., ge=1)
|
||||
page_size: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class IpHistoryResponse(BaseModel):
|
||||
"""Response for ``GET /api/history/{ip}``."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-IP timeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IpTimelineEvent(BaseModel):
|
||||
"""A single ban event in a per-IP timeline.
|
||||
|
||||
Represents one row from the fail2ban ``bans`` table for a specific IP.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
timeline: IpTimeline
|
||||
jail: str = Field(..., description="Jail that triggered this ban.")
|
||||
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
|
||||
ban_count: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Running ban counter for this IP at the time of this event.",
|
||||
)
|
||||
failures: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Failure count at the time of the ban.",
|
||||
)
|
||||
matches: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Matched log lines that triggered the ban.",
|
||||
)
|
||||
|
||||
|
||||
class IpDetailResponse(BaseModel):
|
||||
"""Full historical record for a single IP address.
|
||||
|
||||
Contains aggregated totals and a chronological timeline of all ban events
|
||||
recorded in the fail2ban database for the given IP.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
ip: str = Field(..., description="The IP address.")
|
||||
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
|
||||
total_failures: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Sum of all failure counts across all ban events.",
|
||||
)
|
||||
last_ban_at: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 8601 UTC timestamp of the most recent ban, or ``null``.",
|
||||
)
|
||||
country_code: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.",
|
||||
)
|
||||
country_name: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable country name, or ``null`` if unknown.",
|
||||
)
|
||||
asn: str | None = Field(
|
||||
default=None,
|
||||
description="Autonomous System Number string.",
|
||||
)
|
||||
org: str | None = Field(
|
||||
default=None,
|
||||
description="Organisation name associated with the IP.",
|
||||
)
|
||||
timeline: list[IpTimelineEvent] = Field(
|
||||
default_factory=list,
|
||||
description="All ban events for this IP, ordered newest-first.",
|
||||
)
|
||||
|
||||
141
backend/app/routers/history.py
Normal file
141
backend/app/routers/history.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""History router.
|
||||
|
||||
Provides endpoints for forensic exploration of all historical ban records
|
||||
stored in the fail2ban SQLite database.
|
||||
|
||||
Routes
|
||||
------
|
||||
``GET /api/history``
|
||||
Paginated list of all historical bans, filterable by jail, IP prefix, and
|
||||
time range.
|
||||
|
||||
``GET /api/history/{ip}``
|
||||
Per-IP detail: complete ban timeline, aggregated totals, and geolocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import TimeRange
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.services import geo_service, history_service
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/history", tags=["History"])
|
||||
|
||||
_DEFAULT_PAGE_SIZE: int = 100
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=HistoryListResponse,
|
||||
summary="Return a paginated list of historical bans",
|
||||
)
|
||||
async def get_history(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange | None = Query(
|
||||
default=None,
|
||||
description="Optional time-range filter. Omit for all-time.",
|
||||
),
|
||||
jail: str | None = Query(
|
||||
default=None,
|
||||
description="Restrict results to this jail name.",
|
||||
),
|
||||
ip: str | None = Query(
|
||||
default=None,
|
||||
description="Restrict results to IPs matching this prefix.",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||
page_size: int = Query(
|
||||
default=_DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=500,
|
||||
description="Items per page (max 500).",
|
||||
),
|
||||
) -> HistoryListResponse:
|
||||
"""Return a paginated list of historical bans with optional filters.
|
||||
|
||||
Queries the fail2ban database for all ban records, applying the requested
|
||||
filters. Results are ordered newest-first and enriched with geolocation.
|
||||
|
||||
Args:
|
||||
request: The incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication.
|
||||
range: Optional time-range preset. ``None`` means all-time.
|
||||
jail: Optional jail name filter (exact match).
|
||||
ip: Optional IP prefix filter (prefix match).
|
||||
page: 1-based page number.
|
||||
page_size: Items per page (1–500).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.HistoryListResponse` with paginated items
|
||||
and the total matching count.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(addr, http_session)
|
||||
|
||||
return await history_service.list_history(
|
||||
socket_path,
|
||||
range_=range,
|
||||
jail=jail,
|
||||
ip_filter=ip,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ip}",
|
||||
response_model=IpDetailResponse,
|
||||
summary="Return the full ban history for a single IP address",
|
||||
)
|
||||
async def get_ip_history(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
ip: str,
|
||||
) -> IpDetailResponse:
|
||||
"""Return the complete historical record for a single IP address.
|
||||
|
||||
Fetches all ban events for the given IP from the fail2ban database and
|
||||
aggregates them into a timeline. Returns ``404`` if the IP has no
|
||||
recorded history.
|
||||
|
||||
Args:
|
||||
request: The incoming request.
|
||||
_auth: Validated session dependency.
|
||||
ip: The IP address to look up.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.IpDetailResponse` with aggregated totals
|
||||
and a full ban timeline.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the IP has no history in the database.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
http_session: aiohttp.ClientSession = request.app.state.http_session
|
||||
|
||||
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
|
||||
return await geo_service.lookup(addr, http_session)
|
||||
|
||||
detail: IpDetailResponse | None = await history_service.get_ip_detail(
|
||||
socket_path,
|
||||
ip,
|
||||
geo_enricher=_enricher,
|
||||
)
|
||||
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
|
||||
|
||||
return detail
|
||||
@@ -124,7 +124,10 @@ def _parse_data_json(raw: Any) -> tuple[list[str], int]:
|
||||
obj: dict[str, Any] = {}
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
parsed: Any = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
obj = parsed
|
||||
# json.loads("null") → None, or other non-dict — treat as empty
|
||||
except json.JSONDecodeError:
|
||||
return [], 0
|
||||
elif isinstance(raw, dict):
|
||||
|
||||
269
backend/app/services/history_service.py
Normal file
269
backend/app/services/history_service.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""History service.
|
||||
|
||||
Queries the fail2ban SQLite database for all historical ban records.
|
||||
Supports filtering by jail, IP, and time range. For per-IP forensics the
|
||||
service provides a full ban timeline with matched log lines and failure counts.
|
||||
|
||||
All database I/O uses aiosqlite in **read-only** mode so BanGUI never
|
||||
modifies or locks the fail2ban database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
import structlog
|
||||
|
||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
IpDetailResponse,
|
||||
IpTimelineEvent,
|
||||
)
|
||||
from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_PAGE_SIZE: int = 100
|
||||
_MAX_PAGE_SIZE: int = 500
|
||||
|
||||
|
||||
def _since_unix(range_: TimeRange) -> int:
|
||||
"""Return the Unix timestamp for the start of the given time window.
|
||||
|
||||
Args:
|
||||
range_: One of the supported time-range presets.
|
||||
|
||||
Returns:
|
||||
Unix timestamp (seconds since epoch) equal to *now − range_*.
|
||||
"""
|
||||
seconds: int = TIME_RANGE_SECONDS[range_]
|
||||
return int(datetime.now(tz=UTC).timestamp()) - seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_history(
|
||||
socket_path: str,
|
||||
*,
|
||||
range_: TimeRange | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
geo_enricher: Any | None = None,
|
||||
) -> HistoryListResponse:
|
||||
"""Return a paginated list of historical ban records with optional filters.
|
||||
|
||||
Queries the fail2ban ``bans`` table applying the requested filters and
|
||||
returns a paginated list ordered newest-first. When *geo_enricher* is
|
||||
supplied, each record is enriched with country and ASN data.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
range_: Time-range preset. ``None`` means all-time (no time filter).
|
||||
jail: If given, restrict results to bans from this jail.
|
||||
ip_filter: If given, restrict results to bans for this exact IP
|
||||
(or a prefix — the query uses ``LIKE ip_filter%``).
|
||||
page: 1-based page number (default: ``1``).
|
||||
page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``.
|
||||
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.HistoryListResponse` with paginated items
|
||||
and the total matching count.
|
||||
"""
|
||||
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
|
||||
offset: int = (page - 1) * effective_page_size
|
||||
|
||||
# Build WHERE clauses dynamically.
|
||||
wheres: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if range_ is not None:
|
||||
since: int = _since_unix(range_)
|
||||
wheres.append("timeofban >= ?")
|
||||
params.append(since)
|
||||
|
||||
if jail is not None:
|
||||
wheres.append("jail = ?")
|
||||
params.append(jail)
|
||||
|
||||
if ip_filter is not None:
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||
|
||||
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||
log.info(
|
||||
"history_service_list",
|
||||
db_path=db_path,
|
||||
range=range_,
|
||||
jail=jail,
|
||||
ip_filter=ip_filter,
|
||||
page=page,
|
||||
)
|
||||
|
||||
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
||||
f2b_db.row_factory = aiosqlite.Row
|
||||
|
||||
async with f2b_db.execute(
|
||||
f"SELECT COUNT(*) FROM bans {where_sql}", # noqa: S608
|
||||
params,
|
||||
) as cur:
|
||||
count_row = await cur.fetchone()
|
||||
total: int = int(count_row[0]) if count_row else 0
|
||||
|
||||
async with f2b_db.execute(
|
||||
f"SELECT jail, ip, timeofban, bancount, data " # noqa: S608
|
||||
f"FROM bans {where_sql} "
|
||||
"ORDER BY timeofban DESC "
|
||||
"LIMIT ? OFFSET ?",
|
||||
[*params, effective_page_size, offset],
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
items: list[HistoryBanItem] = []
|
||||
for row in rows:
|
||||
jail_name: str = str(row["jail"])
|
||||
ip: str = str(row["ip"])
|
||||
banned_at: str = _ts_to_iso(int(row["timeofban"]))
|
||||
ban_count: int = int(row["bancount"])
|
||||
matches, failures = _parse_data_json(row["data"])
|
||||
|
||||
country_code: str | None = None
|
||||
country_name: str | None = None
|
||||
asn: str | None = None
|
||||
org: str | None = None
|
||||
|
||||
if geo_enricher is not None:
|
||||
try:
|
||||
geo = await geo_enricher(ip)
|
||||
if geo is not None:
|
||||
country_code = geo.country_code
|
||||
country_name = geo.country_name
|
||||
asn = geo.asn
|
||||
org = geo.org
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("history_service_geo_lookup_failed", ip=ip)
|
||||
|
||||
items.append(
|
||||
HistoryBanItem(
|
||||
ip=ip,
|
||||
jail=jail_name,
|
||||
banned_at=banned_at,
|
||||
ban_count=ban_count,
|
||||
failures=failures,
|
||||
matches=matches,
|
||||
country_code=country_code,
|
||||
country_name=country_name,
|
||||
asn=asn,
|
||||
org=org,
|
||||
)
|
||||
)
|
||||
|
||||
return HistoryListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_ip_detail(
|
||||
socket_path: str,
|
||||
ip: str,
|
||||
*,
|
||||
geo_enricher: Any | None = None,
|
||||
) -> IpDetailResponse | None:
|
||||
"""Return the full historical record for a single IP address.
|
||||
|
||||
Fetches all ban events for *ip* from the fail2ban database, ordered
|
||||
newest-first. Aggregates total bans, total failures, and the timestamp of
|
||||
the most recent ban. Optionally enriches with geolocation data.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
ip: The IP address to look up.
|
||||
geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.history.IpDetailResponse` if any records exist
|
||||
for *ip*, or ``None`` if the IP has no history in the database.
|
||||
"""
|
||||
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||
log.info("history_service_ip_detail", db_path=db_path, ip=ip)
|
||||
|
||||
async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db:
|
||||
f2b_db.row_factory = aiosqlite.Row
|
||||
async with f2b_db.execute(
|
||||
"SELECT jail, ip, timeofban, bancount, data "
|
||||
"FROM bans "
|
||||
"WHERE ip = ? "
|
||||
"ORDER BY timeofban DESC",
|
||||
(ip,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
timeline: list[IpTimelineEvent] = []
|
||||
total_failures: int = 0
|
||||
|
||||
for row in rows:
|
||||
jail_name: str = str(row["jail"])
|
||||
banned_at: str = _ts_to_iso(int(row["timeofban"]))
|
||||
ban_count: int = int(row["bancount"])
|
||||
matches, failures = _parse_data_json(row["data"])
|
||||
total_failures += failures
|
||||
timeline.append(
|
||||
IpTimelineEvent(
|
||||
jail=jail_name,
|
||||
banned_at=banned_at,
|
||||
ban_count=ban_count,
|
||||
failures=failures,
|
||||
matches=matches,
|
||||
)
|
||||
)
|
||||
|
||||
last_ban_at: str | None = timeline[0].banned_at if timeline else None
|
||||
|
||||
country_code: str | None = None
|
||||
country_name: str | None = None
|
||||
asn: str | None = None
|
||||
org: str | None = None
|
||||
|
||||
if geo_enricher is not None:
|
||||
try:
|
||||
geo = await geo_enricher(ip)
|
||||
if geo is not None:
|
||||
country_code = geo.country_code
|
||||
country_name = geo.country_name
|
||||
asn = geo.asn
|
||||
org = geo.org
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("history_service_geo_lookup_failed_detail", ip=ip)
|
||||
|
||||
return IpDetailResponse(
|
||||
ip=ip,
|
||||
total_bans=len(timeline),
|
||||
total_failures=total_failures,
|
||||
last_ban_at=last_ban_at,
|
||||
country_code=country_code,
|
||||
country_name=country_name,
|
||||
asn=asn,
|
||||
org=org,
|
||||
timeline=timeline,
|
||||
)
|
||||
333
backend/tests/test_routers/test_history.py
Normal file
333
backend/tests/test_routers/test_history.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Tests for the history router (GET /api/history, GET /api/history/{ip})."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
IpDetailResponse,
|
||||
IpTimelineEvent,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
def _make_history_item(ip: str = "1.2.3.4", jail: str = "sshd") -> HistoryBanItem:
|
||||
"""Build a single ``HistoryBanItem`` for use in test responses."""
|
||||
return HistoryBanItem(
|
||||
ip=ip,
|
||||
jail=jail,
|
||||
banned_at="2026-03-01T10:00:00+00:00",
|
||||
ban_count=3,
|
||||
failures=5,
|
||||
matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"],
|
||||
country_code="DE",
|
||||
country_name="Germany",
|
||||
asn="AS3320",
|
||||
org="Telekom",
|
||||
)
|
||||
|
||||
|
||||
def _make_history_list(n: int = 2) -> HistoryListResponse:
|
||||
"""Build a mock ``HistoryListResponse`` with *n* items."""
|
||||
items = [_make_history_item(ip=f"1.2.3.{i}") for i in range(n)]
|
||||
return HistoryListResponse(items=items, total=n, page=1, page_size=100)
|
||||
|
||||
|
||||
def _make_ip_detail(ip: str = "1.2.3.4") -> IpDetailResponse:
|
||||
"""Build a mock ``IpDetailResponse`` for *ip*."""
|
||||
events = [
|
||||
IpTimelineEvent(
|
||||
jail="sshd",
|
||||
banned_at="2026-03-01T10:00:00+00:00",
|
||||
ban_count=3,
|
||||
failures=5,
|
||||
matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"],
|
||||
),
|
||||
IpTimelineEvent(
|
||||
jail="sshd",
|
||||
banned_at="2026-02-28T08:00:00+00:00",
|
||||
ban_count=2,
|
||||
failures=5,
|
||||
matches=[],
|
||||
),
|
||||
]
|
||||
return IpDetailResponse(
|
||||
ip=ip,
|
||||
total_bans=2,
|
||||
total_failures=10,
|
||||
last_ban_at="2026-03-01T10:00:00+00:00",
|
||||
country_code="DE",
|
||||
country_name="Germany",
|
||||
asn="AS3320",
|
||||
org="Telekom",
|
||||
timeline=events,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def history_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for history endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "history_test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
session_secret="test-history-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
|
||||
login_resp = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHistoryList:
|
||||
"""GET /api/history — paginated history list."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, history_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200."""
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=AsyncMock(return_value=_make_history_list()),
|
||||
):
|
||||
response = await history_client.get("/api/history")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_returns_401_when_unauthenticated(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Unauthenticated request returns HTTP 401."""
|
||||
await client.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
response = await client.get("/api/history")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_response_shape(self, history_client: AsyncClient) -> None:
|
||||
"""Response body contains the expected keys."""
|
||||
mock_response = _make_history_list(n=1)
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=AsyncMock(return_value=mock_response),
|
||||
):
|
||||
response = await history_client.get("/api/history")
|
||||
|
||||
body = response.json()
|
||||
assert "items" in body
|
||||
assert "total" in body
|
||||
assert "page" in body
|
||||
assert "page_size" in body
|
||||
assert body["total"] == 1
|
||||
|
||||
item = body["items"][0]
|
||||
assert "ip" in item
|
||||
assert "jail" in item
|
||||
assert "banned_at" in item
|
||||
assert "ban_count" in item
|
||||
assert "failures" in item
|
||||
assert "matches" in item
|
||||
assert item["country_code"] == "DE"
|
||||
|
||||
async def test_forwards_jail_filter(self, history_client: AsyncClient) -> None:
|
||||
"""The ``jail`` query parameter is forwarded to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=mock_fn,
|
||||
):
|
||||
await history_client.get("/api/history?jail=nginx")
|
||||
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("jail") == "nginx"
|
||||
|
||||
async def test_forwards_ip_filter(self, history_client: AsyncClient) -> None:
|
||||
"""The ``ip`` query parameter is forwarded as ``ip_filter`` to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=mock_fn,
|
||||
):
|
||||
await history_client.get("/api/history?ip=192.168")
|
||||
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("ip_filter") == "192.168"
|
||||
|
||||
async def test_forwards_time_range(self, history_client: AsyncClient) -> None:
|
||||
"""The ``range`` query parameter is forwarded as ``range_`` to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=mock_fn,
|
||||
):
|
||||
await history_client.get("/api/history?range=7d")
|
||||
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("range_") == "7d"
|
||||
|
||||
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
||||
"""An empty history returns items=[] and total=0."""
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=AsyncMock(
|
||||
return_value=HistoryListResponse(items=[], total=0, page=1, page_size=100)
|
||||
),
|
||||
):
|
||||
response = await history_client.get("/api/history")
|
||||
|
||||
body = response.json()
|
||||
assert body["items"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/history/{ip}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIpHistory:
|
||||
"""GET /api/history/{ip} — per-IP detail."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, history_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200 for a known IP."""
|
||||
with patch(
|
||||
"app.routers.history.history_service.get_ip_detail",
|
||||
new=AsyncMock(return_value=_make_ip_detail("1.2.3.4")),
|
||||
):
|
||||
response = await history_client.get("/api/history/1.2.3.4")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_returns_401_when_unauthenticated(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Unauthenticated request returns HTTP 401."""
|
||||
await client.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
response = await client.get("/api/history/1.2.3.4")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_returns_404_for_unknown_ip(
|
||||
self, history_client: AsyncClient
|
||||
) -> None:
|
||||
"""Returns 404 when the IP has no records in the database."""
|
||||
with patch(
|
||||
"app.routers.history.history_service.get_ip_detail",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
response = await history_client.get("/api/history/9.9.9.9")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_response_shape(self, history_client: AsyncClient) -> None:
|
||||
"""Response body contains the expected keys and nested timeline."""
|
||||
mock_detail = _make_ip_detail("1.2.3.4")
|
||||
with patch(
|
||||
"app.routers.history.history_service.get_ip_detail",
|
||||
new=AsyncMock(return_value=mock_detail),
|
||||
):
|
||||
response = await history_client.get("/api/history/1.2.3.4")
|
||||
|
||||
body = response.json()
|
||||
assert body["ip"] == "1.2.3.4"
|
||||
assert body["total_bans"] == 2
|
||||
assert body["total_failures"] == 10
|
||||
assert body["country_code"] == "DE"
|
||||
assert "timeline" in body
|
||||
assert len(body["timeline"]) == 2
|
||||
|
||||
event = body["timeline"][0]
|
||||
assert "jail" in event
|
||||
assert "banned_at" in event
|
||||
assert "ban_count" in event
|
||||
assert "failures" in event
|
||||
assert "matches" in event
|
||||
|
||||
async def test_aggregation_sums_failures(
|
||||
self, history_client: AsyncClient
|
||||
) -> None:
|
||||
"""total_failures reflects the sum across all timeline events."""
|
||||
mock_detail = _make_ip_detail("10.0.0.1")
|
||||
mock_detail = IpDetailResponse(
|
||||
ip="10.0.0.1",
|
||||
total_bans=3,
|
||||
total_failures=15,
|
||||
last_ban_at="2026-03-01T10:00:00+00:00",
|
||||
country_code=None,
|
||||
country_name=None,
|
||||
asn=None,
|
||||
org=None,
|
||||
timeline=[
|
||||
IpTimelineEvent(
|
||||
jail="sshd",
|
||||
banned_at="2026-03-01T10:00:00+00:00",
|
||||
ban_count=3,
|
||||
failures=7,
|
||||
matches=[],
|
||||
),
|
||||
IpTimelineEvent(
|
||||
jail="sshd",
|
||||
banned_at="2026-02-28T08:00:00+00:00",
|
||||
ban_count=2,
|
||||
failures=8,
|
||||
matches=[],
|
||||
),
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"app.routers.history.history_service.get_ip_detail",
|
||||
new=AsyncMock(return_value=mock_detail),
|
||||
):
|
||||
response = await history_client.get("/api/history/10.0.0.1")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["total_failures"] == 15
|
||||
assert body["total_bans"] == 3
|
||||
341
backend/tests/test_services/test_history_service.py
Normal file
341
backend/tests/test_services/test_history_service.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""Tests for history_service.list_history() and history_service.get_ip_detail()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from app.services import history_service
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NOW: int = int(time.time())
|
||||
_ONE_HOUR_AGO: int = _NOW - 3600
|
||||
_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600
|
||||
_THIRTY_DAYS_AGO: int = _NOW - 30 * 24 * 3600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB fixture helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""Create a minimal fail2ban SQLite database with the given ban rows."""
|
||||
async with aiosqlite.connect(path) as db:
|
||||
await db.execute(
|
||||
"CREATE TABLE jails ("
|
||||
"name TEXT NOT NULL UNIQUE, "
|
||||
"enabled INTEGER NOT NULL DEFAULT 1"
|
||||
")"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE TABLE bans ("
|
||||
"jail TEXT NOT NULL, "
|
||||
"ip TEXT, "
|
||||
"timeofban INTEGER NOT NULL, "
|
||||
"bantime INTEGER NOT NULL, "
|
||||
"bancount INTEGER NOT NULL DEFAULT 1, "
|
||||
"data JSON"
|
||||
")"
|
||||
)
|
||||
for row in rows:
|
||||
await db.execute(
|
||||
"INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
row["jail"],
|
||||
row["ip"],
|
||||
row["timeofban"],
|
||||
row.get("bantime", 3600),
|
||||
row.get("bancount", 1),
|
||||
json.dumps(row["data"]) if "data" in row else None,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc]
|
||||
"""Return the path to a test fail2ban SQLite database."""
|
||||
path = str(tmp_path / "fail2ban_test.sqlite3")
|
||||
await _create_f2b_db(
|
||||
path,
|
||||
[
|
||||
{
|
||||
"jail": "sshd",
|
||||
"ip": "1.2.3.4",
|
||||
"timeofban": _ONE_HOUR_AGO,
|
||||
"bantime": 3600,
|
||||
"bancount": 3,
|
||||
"data": {
|
||||
"matches": ["Mar 1 sshd[1]: Failed password for root"],
|
||||
"failures": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
"jail": "nginx",
|
||||
"ip": "5.6.7.8",
|
||||
"timeofban": _ONE_HOUR_AGO,
|
||||
"bantime": 7200,
|
||||
"bancount": 1,
|
||||
"data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3},
|
||||
},
|
||||
{
|
||||
"jail": "sshd",
|
||||
"ip": "1.2.3.4",
|
||||
"timeofban": _TWO_DAYS_AGO,
|
||||
"bantime": 3600,
|
||||
"bancount": 2,
|
||||
"data": {"failures": 5},
|
||||
},
|
||||
{
|
||||
"jail": "sshd",
|
||||
"ip": "9.0.0.1",
|
||||
"timeofban": _THIRTY_DAYS_AGO,
|
||||
"bantime": 3600,
|
||||
"bancount": 1,
|
||||
"data": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_history tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListHistory:
|
||||
"""history_service.list_history()."""
|
||||
|
||||
async def test_returns_all_bans_with_no_filter(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""No filter returns every record in the database."""
|
||||
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")
|
||||
assert result.total == 4
|
||||
assert len(result.items) == 4
|
||||
|
||||
async def test_time_range_filter_excludes_old_bans(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""The ``range_`` filter excludes bans older than the window."""
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
# "24h" window should include only the two recent bans
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", range_="24h"
|
||||
)
|
||||
assert result.total == 2
|
||||
|
||||
async def test_jail_filter(self, f2b_db_path: str) -> None:
|
||||
"""Jail filter restricts results to bans from that jail."""
|
||||
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", jail="nginx")
|
||||
assert result.total == 1
|
||||
assert result.items[0].jail == "nginx"
|
||||
|
||||
async def test_ip_prefix_filter(self, f2b_db_path: str) -> None:
|
||||
"""IP prefix filter restricts results to matching IPs."""
|
||||
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", ip_filter="1.2.3"
|
||||
)
|
||||
assert result.total == 2
|
||||
for item in result.items:
|
||||
assert item.ip.startswith("1.2.3")
|
||||
|
||||
async def test_combined_filters(self, f2b_db_path: str) -> None:
|
||||
"""Jail + IP prefix filters applied together narrow the result set."""
|
||||
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", jail="sshd", ip_filter="1.2.3.4"
|
||||
)
|
||||
# 2 sshd bans for 1.2.3.4
|
||||
assert result.total == 2
|
||||
|
||||
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(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.list_history(
|
||||
"fake_socket", ip_filter="99.99.99.99"
|
||||
)
|
||||
assert result.total == 0
|
||||
assert result.items == []
|
||||
|
||||
async def test_failures_extracted_from_data(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""``failures`` field is parsed from the JSON ``data`` column."""
|
||||
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", ip_filter="5.6.7.8"
|
||||
)
|
||||
assert result.total == 1
|
||||
assert result.items[0].failures == 3
|
||||
|
||||
async def test_matches_extracted_from_data(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""``matches`` list is parsed from the JSON ``data`` column."""
|
||||
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", ip_filter="1.2.3.4", range_="24h"
|
||||
)
|
||||
# Most recent record for 1.2.3.4 has a matches list
|
||||
recent = result.items[0]
|
||||
assert len(recent.matches) == 1
|
||||
assert "Failed password" in recent.matches[0]
|
||||
|
||||
async def test_null_data_column_handled_gracefully(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Records with ``data=NULL`` produce failures=0 and matches=[]."""
|
||||
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", ip_filter="9.0.0.1"
|
||||
)
|
||||
assert result.total == 1
|
||||
item = result.items[0]
|
||||
assert item.failures == 0
|
||||
assert item.matches == []
|
||||
|
||||
async def test_pagination(self, f2b_db_path: str) -> None:
|
||||
"""Pagination returns the correct slice."""
|
||||
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", page=1, page_size=2
|
||||
)
|
||||
assert result.total == 4
|
||||
assert len(result.items) == 2
|
||||
assert result.page == 1
|
||||
assert result.page_size == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_ip_detail tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetIpDetail:
|
||||
"""history_service.get_ip_detail()."""
|
||||
|
||||
async def test_returns_none_for_unknown_ip(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Returns ``None`` when the IP has no records in the database."""
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "99.99.99.99")
|
||||
assert result is None
|
||||
|
||||
async def test_returns_ip_detail_for_known_ip(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Returns an IpDetailResponse with correct totals for a known IP."""
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
|
||||
assert result is not None
|
||||
assert result.ip == "1.2.3.4"
|
||||
assert result.total_bans == 2
|
||||
assert result.total_failures == 10 # 5 + 5
|
||||
|
||||
async def test_timeline_ordered_newest_first(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Timeline events are ordered newest-first."""
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
|
||||
assert result is not None
|
||||
assert len(result.timeline) == 2
|
||||
# First event should be the most recent
|
||||
assert result.timeline[0].banned_at > result.timeline[1].banned_at
|
||||
|
||||
async def test_last_ban_at_is_most_recent(self, f2b_db_path: str) -> None:
|
||||
"""``last_ban_at`` matches the banned_at of the first timeline event."""
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")
|
||||
|
||||
assert result is not None
|
||||
assert result.last_ban_at == result.timeline[0].banned_at
|
||||
|
||||
async def test_geo_enrichment_applied_when_provided(
|
||||
self, f2b_db_path: str
|
||||
) -> None:
|
||||
"""Geolocation is applied when a geo_enricher is provided."""
|
||||
from app.services.geo_service import GeoInfo
|
||||
|
||||
mock_geo = GeoInfo(
|
||||
country_code="US",
|
||||
country_name="United States",
|
||||
asn="AS15169",
|
||||
org="Google",
|
||||
)
|
||||
fake_enricher = AsyncMock(return_value=mock_geo)
|
||||
|
||||
with patch(
|
||||
"app.services.history_service._get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=f2b_db_path),
|
||||
):
|
||||
result = await history_service.get_ip_detail(
|
||||
"fake_socket", "1.2.3.4", geo_enricher=fake_enricher
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.country_code == "US"
|
||||
assert result.country_name == "United States"
|
||||
assert result.asn == "AS15169"
|
||||
assert result.org == "Google"
|
||||
Reference in New Issue
Block a user