feature/ignore-self-toggle #1
@@ -226,7 +226,15 @@ Add the `TopCountriesBarChart` to the dashboard alongside the pie chart.
|
|||||||
|
|
||||||
### Task 4.1 — Add a backend endpoint for time-series ban aggregation
|
### Task 4.1 — Add a backend endpoint for time-series ban aggregation
|
||||||
|
|
||||||
**Status:** `not started`
|
**Status:** `done`
|
||||||
|
|
||||||
|
Added `GET /api/dashboard/bans/trend`. New Pydantic models `BanTrendBucket` and
|
||||||
|
`BanTrendResponse` (plus `BUCKET_SECONDS`, `BUCKET_SIZE_LABEL`, `bucket_count`
|
||||||
|
helpers) in `ban.py`. Service function `ban_trend()` in `ban_service.py` groups
|
||||||
|
`bans.timeofban` into equal-width buckets via SQL and fills empty buckets with
|
||||||
|
zero so the frontend always receives a gap-free series. Route added to
|
||||||
|
`dashboard.py`. 20 new tests (10 service, 10 router) — all pass, total suite
|
||||||
|
480 passed, 83% coverage.
|
||||||
|
|
||||||
The existing endpoints return flat lists or country-aggregated counts but **no time-bucketed series**. A dashboard trend chart needs data grouped into time buckets.
|
The existing endpoints return flat lists or country-aggregated counts but **no time-bucketed series**. A dashboard trend chart needs data grouped into time buckets.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Request, response, and domain models used by the ban router and service.
|
Request, response, and domain models used by the ban router and service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -220,3 +221,62 @@ class BansByCountryResponse(BaseModel):
|
|||||||
description="All bans in the selected time window (up to the server limit).",
|
description="All bans in the selected time window (up to the server limit).",
|
||||||
)
|
)
|
||||||
total: int = Field(..., ge=0, description="Total ban count in the window.")
|
total: int = Field(..., ge=0, description="Total ban count in the window.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trend endpoint models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Bucket size in seconds for each time-range preset.
|
||||||
|
BUCKET_SECONDS: dict[str, int] = {
|
||||||
|
"24h": 3_600, # 1 hour → 24 buckets
|
||||||
|
"7d": 6 * 3_600, # 6 hours → 28 buckets
|
||||||
|
"30d": 86_400, # 1 day → 30 buckets
|
||||||
|
"365d": 7 * 86_400, # 7 days → ~53 buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Human-readable bucket size label for each time-range preset.
|
||||||
|
BUCKET_SIZE_LABEL: dict[str, str] = {
|
||||||
|
"24h": "1h",
|
||||||
|
"7d": "6h",
|
||||||
|
"30d": "1d",
|
||||||
|
"365d": "7d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_count(range_: TimeRange) -> int:
|
||||||
|
"""Return the number of buckets needed to cover *range_* completely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
range_: One of the supported time-range presets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ceiling division of the range duration by the bucket size so that
|
||||||
|
the last bucket is included even when the window is not an exact
|
||||||
|
multiple of the bucket size.
|
||||||
|
"""
|
||||||
|
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
|
||||||
|
|
||||||
|
|
||||||
|
class BanTrendBucket(BaseModel):
|
||||||
|
"""A single time bucket in the ban trend series."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
|
||||||
|
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
|
||||||
|
|
||||||
|
|
||||||
|
class BanTrendResponse(BaseModel):
|
||||||
|
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
|
buckets: list[BanTrendBucket] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Time-ordered list of ban-count buckets covering the full window.",
|
||||||
|
)
|
||||||
|
bucket_size: str = Field(
|
||||||
|
...,
|
||||||
|
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ Provides the ``GET /api/dashboard/status`` endpoint that returns the cached
|
|||||||
fail2ban server health snapshot. The snapshot is maintained by the
|
fail2ban server health snapshot. The snapshot is maintained by the
|
||||||
background health-check task and refreshed every 30 seconds.
|
background health-check task and refreshed every 30 seconds.
|
||||||
|
|
||||||
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table.
|
Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
||||||
|
``GET /api/dashboard/bans/by-country`` for country aggregation, and
|
||||||
|
``GET /api/dashboard/bans/trend`` for time-bucketed ban counts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,6 +22,7 @@ from app.dependencies import AuthDep
|
|||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
BanOrigin,
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
|
BanTrendResponse,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
)
|
)
|
||||||
@@ -161,3 +164,45 @@ async def get_bans_by_country(
|
|||||||
origin=origin,
|
origin=origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/bans/trend",
|
||||||
|
response_model=BanTrendResponse,
|
||||||
|
summary="Return ban counts aggregated into time buckets",
|
||||||
|
)
|
||||||
|
async def get_ban_trend(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||||
|
origin: BanOrigin | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
),
|
||||||
|
) -> BanTrendResponse:
|
||||||
|
"""Return ban counts grouped into equal-width time buckets.
|
||||||
|
|
||||||
|
Each bucket represents a contiguous time interval within the selected
|
||||||
|
window. All buckets are returned — empty buckets (zero bans) are
|
||||||
|
included so the frontend always receives a complete, gap-free series
|
||||||
|
suitable for rendering a continuous area or line chart.
|
||||||
|
|
||||||
|
Bucket sizes:
|
||||||
|
|
||||||
|
* ``24h`` → 1-hour buckets (24 total)
|
||||||
|
* ``7d`` → 6-hour buckets (28 total)
|
||||||
|
* ``30d`` → 1-day buckets (30 total)
|
||||||
|
* ``365d`` → 7-day buckets (~53 total)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request (used to access ``app.state``).
|
||||||
|
_auth: Validated session dependency.
|
||||||
|
range: Time-range preset.
|
||||||
|
origin: Optional filter by ban origin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.BanTrendResponse` with the ordered bucket
|
||||||
|
list and the bucket-size label.
|
||||||
|
"""
|
||||||
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
|
||||||
|
return await ban_service.ban_trend(socket_path, range, origin=origin)
|
||||||
|
|||||||
@@ -19,13 +19,18 @@ import structlog
|
|||||||
|
|
||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
BLOCKLIST_JAIL,
|
BLOCKLIST_JAIL,
|
||||||
|
BUCKET_SECONDS,
|
||||||
|
BUCKET_SIZE_LABEL,
|
||||||
TIME_RANGE_SECONDS,
|
TIME_RANGE_SECONDS,
|
||||||
BanOrigin,
|
BanOrigin,
|
||||||
BansByCountryResponse,
|
BansByCountryResponse,
|
||||||
|
BanTrendBucket,
|
||||||
|
BanTrendResponse,
|
||||||
DashboardBanItem,
|
DashboardBanItem,
|
||||||
DashboardBanListResponse,
|
DashboardBanListResponse,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
_derive_origin,
|
_derive_origin,
|
||||||
|
bucket_count,
|
||||||
)
|
)
|
||||||
from app.utils.fail2ban_client import Fail2BanClient
|
from app.utils.fail2ban_client import Fail2BanClient
|
||||||
|
|
||||||
@@ -479,3 +484,92 @@ async def bans_by_country(
|
|||||||
bans=bans,
|
bans=bans,
|
||||||
total=total,
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ban_trend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def ban_trend(
|
||||||
|
socket_path: str,
|
||||||
|
range_: TimeRange,
|
||||||
|
*,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
|
) -> BanTrendResponse:
|
||||||
|
"""Return ban counts aggregated into equal-width time buckets.
|
||||||
|
|
||||||
|
Queries the fail2ban database ``bans`` table and groups records by a
|
||||||
|
computed bucket index so the frontend can render a continuous time-series
|
||||||
|
chart. All buckets within the requested window are returned — buckets
|
||||||
|
that contain zero bans are included as zero-count entries so the
|
||||||
|
frontend always receives a complete, gap-free series.
|
||||||
|
|
||||||
|
Bucket sizes per time-range preset:
|
||||||
|
|
||||||
|
* ``24h`` → 1-hour buckets (24 total)
|
||||||
|
* ``7d`` → 6-hour buckets (28 total)
|
||||||
|
* ``30d`` → 1-day buckets (30 total)
|
||||||
|
* ``365d`` → 7-day buckets (~53 total)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
|
||||||
|
``"365d"``).
|
||||||
|
origin: Optional origin filter — ``"blocklist"`` restricts to the
|
||||||
|
``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.ban.BanTrendResponse` with a full bucket list
|
||||||
|
and the human-readable bucket-size label.
|
||||||
|
"""
|
||||||
|
since: int = _since_unix(range_)
|
||||||
|
bucket_secs: int = BUCKET_SECONDS[range_]
|
||||||
|
num_buckets: int = bucket_count(range_)
|
||||||
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
|
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||||
|
log.info(
|
||||||
|
"ban_service_ban_trend",
|
||||||
|
db_path=db_path,
|
||||||
|
since=since,
|
||||||
|
range=range_,
|
||||||
|
origin=origin,
|
||||||
|
bucket_secs=bucket_secs,
|
||||||
|
num_buckets=num_buckets,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
|
||||||
|
"COUNT(*) AS cnt "
|
||||||
|
"FROM bans "
|
||||||
|
"WHERE timeofban >= ?"
|
||||||
|
+ origin_clause
|
||||||
|
+ " GROUP BY bucket_idx "
|
||||||
|
"ORDER BY bucket_idx",
|
||||||
|
(since, bucket_secs, since, *origin_params),
|
||||||
|
) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
|
# Map bucket_idx → count; ignore any out-of-range indices.
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
idx: int = int(row["bucket_idx"])
|
||||||
|
if 0 <= idx < num_buckets:
|
||||||
|
counts[idx] = int(row["cnt"])
|
||||||
|
|
||||||
|
buckets: list[BanTrendBucket] = [
|
||||||
|
BanTrendBucket(
|
||||||
|
timestamp=_ts_to_iso(since + i * bucket_secs),
|
||||||
|
count=counts.get(i, 0),
|
||||||
|
)
|
||||||
|
for i in range(num_buckets)
|
||||||
|
]
|
||||||
|
|
||||||
|
return BanTrendResponse(
|
||||||
|
buckets=buckets,
|
||||||
|
bucket_size=BUCKET_SIZE_LABEL[range_],
|
||||||
|
)
|
||||||
|
|||||||
@@ -577,3 +577,137 @@ class TestOriginFilterParam:
|
|||||||
|
|
||||||
_, kwargs = mock_fn.call_args
|
_, kwargs = mock_fn.call_args
|
||||||
assert kwargs.get("origin") is None
|
assert kwargs.get("origin") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ban trend endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ban_trend_response(n_buckets: int = 24) -> object:
|
||||||
|
"""Build a stub :class:`~app.models.ban.BanTrendResponse`."""
|
||||||
|
from app.models.ban import BanTrendBucket, BanTrendResponse
|
||||||
|
|
||||||
|
buckets = [
|
||||||
|
BanTrendBucket(timestamp=f"2026-03-01T{i:02d}:00:00+00:00", count=i)
|
||||||
|
for i in range(n_buckets)
|
||||||
|
]
|
||||||
|
return BanTrendResponse(buckets=buckets, bucket_size="1h")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
class TestBanTrend:
|
||||||
|
"""GET /api/dashboard/bans/trend."""
|
||||||
|
|
||||||
|
async def test_returns_200_when_authenticated(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Authenticated request returns HTTP 200."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.ban_trend",
|
||||||
|
new=AsyncMock(return_value=_make_ban_trend_response()),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
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/dashboard/bans/trend")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
async def test_response_shape(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Response body contains ``buckets`` list and ``bucket_size`` string."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.ban_trend",
|
||||||
|
new=AsyncMock(return_value=_make_ban_trend_response(24)),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
assert "buckets" in body
|
||||||
|
assert "bucket_size" in body
|
||||||
|
assert len(body["buckets"]) == 24
|
||||||
|
assert body["bucket_size"] == "1h"
|
||||||
|
|
||||||
|
async def test_each_bucket_has_timestamp_and_count(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Every element of ``buckets`` has ``timestamp`` and ``count``."""
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.ban_trend",
|
||||||
|
new=AsyncMock(return_value=_make_ban_trend_response(3)),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
|
||||||
|
for bucket in response.json()["buckets"]:
|
||||||
|
assert "timestamp" in bucket
|
||||||
|
assert "count" in bucket
|
||||||
|
assert isinstance(bucket["count"], int)
|
||||||
|
|
||||||
|
async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Omitting ``range`` defaults to ``24h``."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
|
||||||
|
called_range = mock_fn.call_args[0][1]
|
||||||
|
assert called_range == "24h"
|
||||||
|
|
||||||
|
async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""The ``range`` query parameter is forwarded to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_ban_trend_response(28))
|
||||||
|
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/trend?range=7d")
|
||||||
|
|
||||||
|
called_range = mock_fn.call_args[0][1]
|
||||||
|
assert called_range == "7d"
|
||||||
|
|
||||||
|
async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""``?origin=blocklist`` is passed as a keyword arg to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||||
|
await dashboard_client.get(
|
||||||
|
"/api/dashboard/bans/trend?origin=blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") == "blocklist"
|
||||||
|
|
||||||
|
async def test_no_origin_defaults_to_none(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Omitting ``origin`` passes ``None`` to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_ban_trend_response())
|
||||||
|
with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn):
|
||||||
|
await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
|
||||||
|
_, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") is None
|
||||||
|
|
||||||
|
async def test_invalid_range_returns_422(
|
||||||
|
self, dashboard_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""An invalid ``range`` value returns HTTP 422."""
|
||||||
|
response = await dashboard_client.get(
|
||||||
|
"/api/dashboard/bans/trend?range=invalid"
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
|
||||||
|
"""Empty bucket list is serialised correctly."""
|
||||||
|
from app.models.ban import BanTrendResponse
|
||||||
|
|
||||||
|
empty = BanTrendResponse(buckets=[], bucket_size="1h")
|
||||||
|
with patch(
|
||||||
|
"app.routers.dashboard.ban_service.ban_trend",
|
||||||
|
new=AsyncMock(return_value=empty),
|
||||||
|
):
|
||||||
|
response = await dashboard_client.get("/api/dashboard/bans/trend")
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
assert body["buckets"] == []
|
||||||
|
assert body["bucket_size"] == "1h"
|
||||||
|
|
||||||
|
|||||||
@@ -302,9 +302,10 @@ class TestListBansBatchGeoEnrichment:
|
|||||||
self, f2b_db_path: str
|
self, f2b_db_path: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Geo fields are populated via lookup_batch when http_session is given."""
|
"""Geo fields are populated via lookup_batch when http_session is given."""
|
||||||
from app.services.geo_service import GeoInfo
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.services.geo_service import GeoInfo
|
||||||
|
|
||||||
fake_session = MagicMock()
|
fake_session = MagicMock()
|
||||||
fake_geo_map = {
|
fake_geo_map = {
|
||||||
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="Deutsche Telekom"),
|
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="Deutsche Telekom"),
|
||||||
@@ -357,9 +358,10 @@ class TestListBansBatchGeoEnrichment:
|
|||||||
self, f2b_db_path: str
|
self, f2b_db_path: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""When both http_session and geo_enricher are provided, batch wins."""
|
"""When both http_session and geo_enricher are provided, batch wins."""
|
||||||
from app.services.geo_service import GeoInfo
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.services.geo_service import GeoInfo
|
||||||
|
|
||||||
fake_session = MagicMock()
|
fake_session = MagicMock()
|
||||||
fake_geo_map = {
|
fake_geo_map = {
|
||||||
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None),
|
"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None),
|
||||||
@@ -610,3 +612,167 @@ class TestOriginFilter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.total == 3
|
assert result.total == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ban_trend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBanTrend:
|
||||||
|
"""Verify ban_service.ban_trend() behaviour."""
|
||||||
|
|
||||||
|
async def test_24h_returns_24_buckets(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""``range_='24h'`` always yields exactly 24 buckets."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert len(result.buckets) == 24
|
||||||
|
assert result.bucket_size == "1h"
|
||||||
|
|
||||||
|
async def test_7d_returns_28_buckets(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""``range_='7d'`` yields 28 six-hour buckets."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "7d")
|
||||||
|
|
||||||
|
assert len(result.buckets) == 28
|
||||||
|
assert result.bucket_size == "6h"
|
||||||
|
|
||||||
|
async def test_30d_returns_30_buckets(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""``range_='30d'`` yields 30 daily buckets."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "30d")
|
||||||
|
|
||||||
|
assert len(result.buckets) == 30
|
||||||
|
assert result.bucket_size == "1d"
|
||||||
|
|
||||||
|
async def test_365d_bucket_size_label(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""``range_='365d'`` uses '7d' as the bucket size label."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "365d")
|
||||||
|
|
||||||
|
assert result.bucket_size == "7d"
|
||||||
|
assert len(result.buckets) > 0
|
||||||
|
|
||||||
|
async def test_empty_db_all_buckets_zero(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""All bucket counts are zero when the database has no bans."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert all(b.count == 0 for b in result.buckets)
|
||||||
|
|
||||||
|
async def test_buckets_are_time_ordered(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""Buckets are ordered chronologically (ascending timestamps)."""
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "7d")
|
||||||
|
|
||||||
|
timestamps = [b.timestamp for b in result.buckets]
|
||||||
|
assert timestamps == sorted(timestamps)
|
||||||
|
|
||||||
|
async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None:
|
||||||
|
"""A ban at a known time appears in the expected bucket."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = int(_time.time())
|
||||||
|
# Place a ban exactly 30 minutes ago — should land in bucket 0 of a 24h range
|
||||||
|
# (the most recent hour bucket when 'since' is ~24 h ago).
|
||||||
|
thirty_min_ago = now - 1800
|
||||||
|
path = str(tmp_path / "test_bucket.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[{"jail": "sshd", "ip": "1.2.3.4", "timeofban": thirty_min_ago}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "24h")
|
||||||
|
|
||||||
|
# Total ban count across all buckets must be exactly 1.
|
||||||
|
assert sum(b.count for b in result.buckets) == 1
|
||||||
|
|
||||||
|
async def test_origin_filter_blocklist(self, tmp_path: Path) -> None:
|
||||||
|
"""``origin='blocklist'`` counts only blocklist-import bans."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = int(_time.time())
|
||||||
|
one_hour_ago = now - 3600
|
||||||
|
path = str(tmp_path / "test_trend_origin.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend(
|
||||||
|
"/fake/sock", "24h", origin="blocklist"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sum(b.count for b in result.buckets) == 1
|
||||||
|
|
||||||
|
async def test_origin_filter_selfblock(self, tmp_path: Path) -> None:
|
||||||
|
"""``origin='selfblock'`` excludes blocklist-import bans."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = int(_time.time())
|
||||||
|
one_hour_ago = now - 3600
|
||||||
|
path = str(tmp_path / "test_trend_selfblock.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago},
|
||||||
|
{"jail": "nginx", "ip": "10.0.0.3", "timeofban": one_hour_ago},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend(
|
||||||
|
"/fake/sock", "24h", origin="selfblock"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sum(b.count for b in result.buckets) == 2
|
||||||
|
|
||||||
|
async def test_each_bucket_has_iso_timestamp(self, empty_f2b_db_path: str) -> None:
|
||||||
|
"""Every bucket timestamp is a valid ISO 8601 string."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=empty_f2b_db_path),
|
||||||
|
):
|
||||||
|
result = await ban_service.ban_trend("/fake/sock", "24h")
|
||||||
|
|
||||||
|
for bucket in result.buckets:
|
||||||
|
# datetime.fromisoformat raises ValueError on invalid input.
|
||||||
|
parsed = datetime.fromisoformat(bucket.timestamp)
|
||||||
|
assert parsed.tzinfo is not None # Must be timezone-aware (UTC)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user