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

@@ -462,10 +462,9 @@ class TestActivateJail:
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
),pytest.raises(JailNotFoundInConfigError)
):
with pytest.raises(JailNotFoundInConfigError):
await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req)
await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req)
async def test_raises_already_active(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
@@ -476,10 +475,9 @@ class TestActivateJail:
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
),pytest.raises(JailAlreadyActiveError)
):
with pytest.raises(JailAlreadyActiveError):
await activate_jail(str(tmp_path), "/fake.sock", "sshd", req)
await activate_jail(str(tmp_path), "/fake.sock", "sshd", req)
async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None:
from app.models.config import ActivateJailRequest
@@ -538,10 +536,9 @@ class TestDeactivateJail:
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
),pytest.raises(JailNotFoundInConfigError)
):
with pytest.raises(JailNotFoundInConfigError):
await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent")
await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent")
async def test_raises_already_inactive(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
@@ -549,11 +546,309 @@ class TestDeactivateJail:
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
),pytest.raises(JailAlreadyInactiveError)
):
with pytest.raises(JailAlreadyInactiveError):
await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth")
await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth")
async def test_raises_name_error(self, tmp_path: Path) -> None:
with pytest.raises(JailNameError):
await deactivate_jail(str(tmp_path), "/fake.sock", "a/b")
# ---------------------------------------------------------------------------
# _extract_filter_base_name
# ---------------------------------------------------------------------------
class TestExtractFilterBaseName:
def test_simple_name(self) -> None:
from app.services.config_file_service import _extract_filter_base_name
assert _extract_filter_base_name("sshd") == "sshd"
def test_name_with_mode(self) -> None:
from app.services.config_file_service import _extract_filter_base_name
assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd"
def test_name_with_variable_mode(self) -> None:
from app.services.config_file_service import _extract_filter_base_name
assert _extract_filter_base_name("sshd[mode=%(mode)s]") == "sshd"
def test_whitespace_stripped(self) -> None:
from app.services.config_file_service import _extract_filter_base_name
assert _extract_filter_base_name(" nginx ") == "nginx"
def test_empty_string(self) -> None:
from app.services.config_file_service import _extract_filter_base_name
assert _extract_filter_base_name("") == ""
# ---------------------------------------------------------------------------
# _build_filter_to_jails_map
# ---------------------------------------------------------------------------
class TestBuildFilterToJailsMap:
def test_active_jail_maps_to_filter(self) -> None:
from app.services.config_file_service import _build_filter_to_jails_map
result = _build_filter_to_jails_map({"sshd": {"filter": "sshd"}}, {"sshd"})
assert result == {"sshd": ["sshd"]}
def test_inactive_jail_not_included(self) -> None:
from app.services.config_file_service import _build_filter_to_jails_map
result = _build_filter_to_jails_map(
{"apache-auth": {"filter": "apache-auth"}}, set()
)
assert result == {}
def test_multiple_jails_sharing_filter(self) -> None:
from app.services.config_file_service import _build_filter_to_jails_map
all_jails = {
"sshd": {"filter": "sshd"},
"sshd-ddos": {"filter": "sshd"},
}
result = _build_filter_to_jails_map(all_jails, {"sshd", "sshd-ddos"})
assert sorted(result["sshd"]) == ["sshd", "sshd-ddos"]
def test_mode_suffix_stripped(self) -> None:
from app.services.config_file_service import _build_filter_to_jails_map
result = _build_filter_to_jails_map(
{"sshd": {"filter": "sshd[mode=aggressive]"}}, {"sshd"}
)
assert "sshd" in result
def test_missing_filter_key_falls_back_to_jail_name(self) -> None:
from app.services.config_file_service import _build_filter_to_jails_map
# When jail has no "filter" key the code falls back to the jail name.
result = _build_filter_to_jails_map({"sshd": {}}, {"sshd"})
assert "sshd" in result
# ---------------------------------------------------------------------------
# _parse_filters_sync
# ---------------------------------------------------------------------------
_FILTER_CONF = """\
[Definition]
failregex = ^Host: <HOST>
ignoreregex =
"""
class TestParseFiltersSync:
def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
result = _parse_filters_sync(tmp_path / "nonexistent")
assert result == []
def test_single_filter_returned(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "nginx.conf", _FILTER_CONF)
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local = result[0]
assert name == "nginx"
assert filename == "nginx.conf"
assert "failregex" in content
assert has_local is False
def test_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "nginx.conf", _FILTER_CONF)
_write(filter_d / "nginx.local", "[Definition]\nignoreregex = ^safe\n")
result = _parse_filters_sync(filter_d)
_, _, _, has_local = result[0]
assert has_local is True
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "nginx.conf", _FILTER_CONF)
_write(filter_d / "nginx.local", "[Definition]\n# local tweak\n")
result = _parse_filters_sync(filter_d)
_, _, content, _ = result[0]
assert "local tweak" in content
def test_sorted_alphabetically(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
for name in ("zzz", "aaa", "mmm"):
_write(filter_d / f"{name}.conf", _FILTER_CONF)
result = _parse_filters_sync(filter_d)
names = [r[0] for r in result]
assert names == ["aaa", "mmm", "zzz"]
# ---------------------------------------------------------------------------
# list_filters
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestListFilters:
async def test_returns_all_filters(self, tmp_path: Path) -> None:
from app.services.config_file_service import list_filters
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "nginx.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_filters(str(tmp_path), "/fake.sock")
assert result.total == 2
names = {f.name for f in result.filters}
assert "sshd" in names
assert "nginx" in names
async def test_active_flag_set_for_used_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import list_filters
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_filters(str(tmp_path), "/fake.sock")
sshd = next(f for f in result.filters if f.name == "sshd")
assert sshd.active is True
assert "sshd" in sshd.used_by_jails
async def test_inactive_filter_not_marked_active(self, tmp_path: Path) -> None:
from app.services.config_file_service import list_filters
filter_d = tmp_path / "filter.d"
_write(filter_d / "nginx.conf", _FILTER_CONF)
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_filters(str(tmp_path), "/fake.sock")
nginx = next(f for f in result.filters if f.name == "nginx")
assert nginx.active is False
assert nginx.used_by_jails == []
async def test_has_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import list_filters
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_filters(str(tmp_path), "/fake.sock")
sshd = next(f for f in result.filters if f.name == "sshd")
assert sshd.has_local_override is True
async def test_empty_filter_d_returns_empty(self, tmp_path: Path) -> None:
from app.services.config_file_service import list_filters
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_filters(str(tmp_path), "/fake.sock")
assert result.filters == []
assert result.total == 0
# ---------------------------------------------------------------------------
# get_filter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetFilter:
async def test_returns_filter_config(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
assert result.name == "sshd"
assert result.active is True
assert "sshd" in result.used_by_jails
async def test_accepts_conf_extension(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await get_filter(str(tmp_path), "/fake.sock", "sshd.conf")
assert result.name == "sshd"
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterNotFoundError, get_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterNotFoundError):
await get_filter(str(tmp_path), "/fake.sock", "nonexistent")
async def test_has_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
assert result.has_local_override is True