Add filter discovery endpoints with active/inactive status (Task 2.1)

- Add list_filters() and get_filter() to config_file_service.py:
  scans filter.d/, parses [Definition] + [Init] sections, merges .local
  overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
  fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
  rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
  add fetchFilters() and fetchFilter() to api/config.ts; remove unused
  fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
  exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
This commit is contained in:
2026-03-13 16:48:27 +01:00
parent 8d9d63b866
commit 4c138424a5
14 changed files with 989 additions and 92 deletions

View File

@@ -13,6 +13,7 @@ from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.config import (
FilterConfig,
GlobalConfigResponse,
JailConfig,
JailConfigListResponse,
@@ -817,3 +818,140 @@ class TestDeactivateJail:
base_url="http://test",
).post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/filters
# ---------------------------------------------------------------------------
def _make_filter_config(name: str, active: bool = False) -> FilterConfig:
return FilterConfig(
name=name,
filename=f"{name}.conf",
before=None,
after=None,
variables={},
prefregex=None,
failregex=[],
ignoreregex=[],
maxlines=None,
datepattern=None,
journalmatch=None,
active=active,
used_by_jails=[name] if active else [],
source_file=f"/etc/fail2ban/filter.d/{name}.conf",
has_local_override=False,
)
class TestListFilters:
"""Tests for ``GET /api/config/filters``."""
async def test_200_returns_filter_list(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters returns 200 with FilterListResponse."""
from app.models.config import FilterListResponse
mock_response = FilterListResponse(
filters=[_make_filter_config("sshd", active=True)],
total=1,
)
with patch(
"app.routers.config.config_file_service.list_filters",
AsyncMock(return_value=mock_response),
):
resp = await config_client.get("/api/config/filters")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["filters"][0]["name"] == "sshd"
assert data["filters"][0]["active"] is True
async def test_200_empty_filter_list(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters returns 200 with empty list when no filters found."""
from app.models.config import FilterListResponse
with patch(
"app.routers.config.config_file_service.list_filters",
AsyncMock(return_value=FilterListResponse(filters=[], total=0)),
):
resp = await config_client.get("/api/config/filters")
assert resp.status_code == 200
assert resp.json()["total"] == 0
assert resp.json()["filters"] == []
async def test_active_filters_sorted_before_inactive(
self, config_client: AsyncClient
) -> None:
"""GET /api/config/filters returns active filters before inactive ones."""
from app.models.config import FilterListResponse
mock_response = FilterListResponse(
filters=[
_make_filter_config("nginx", active=False),
_make_filter_config("sshd", active=True),
],
total=2,
)
with patch(
"app.routers.config.config_file_service.list_filters",
AsyncMock(return_value=mock_response),
):
resp = await config_client.get("/api/config/filters")
data = resp.json()
assert data["filters"][0]["name"] == "sshd" # active first
assert data["filters"][1]["name"] == "nginx" # inactive second
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters returns 401 without a valid session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/filters")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/filters/{name}
# ---------------------------------------------------------------------------
class TestGetFilter:
"""Tests for ``GET /api/config/filters/{name}``."""
async def test_200_returns_filter(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters/sshd returns 200 with FilterConfig."""
with patch(
"app.routers.config.config_file_service.get_filter",
AsyncMock(return_value=_make_filter_config("sshd")),
):
resp = await config_client.get("/api/config/filters/sshd")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "sshd"
assert "failregex" in data
assert "active" in data
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.get_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")),
):
resp = await config_client.get("/api/config/filters/missing")
assert resp.status_code == 404
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/filters/sshd returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/filters/sshd")
assert resp.status_code == 401