Add filter write/create/delete and jail-filter assign endpoints (Task 2.2)

- PUT /api/config/filters/{name}: updates failregex/ignoreregex/datepattern/
  journalmatch in filter.d/{name}.local; validates regex via re.compile();
  supports ?reload=true
- POST /api/config/filters: creates filter.d/{name}.local from FilterCreateRequest;
  returns 409 if file already exists
- DELETE /api/config/filters/{name}: deletes .local only; returns 409 for
  conf-only (readonly) filters
- POST /api/config/jails/{name}/filter: assigns filter to jail by writing
  'filter = {name}' to jail.d/{jail}.local; supports ?reload=true
- New models: FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest
- New service helpers: _safe_filter_name, _validate_regex_patterns,
  _write_filter_local_sync, _set_jail_local_key_sync
- Fixed .local-only filter discovery in _parse_filters_sync (5-tuple return)
- Fixed get_filter extension stripping (.local is 6 chars not 5)
- Renamed file_config.py raw-write routes to /raw suffix
  (PUT /filters/{name}/raw, POST /filters/raw) to avoid routing conflicts
- Full service + router tests; all 930 tests pass
This commit is contained in:
2026-03-13 18:13:03 +01:00
parent 4c138424a5
commit e15ad8fb62
8 changed files with 1885 additions and 64 deletions

View File

@@ -955,3 +955,337 @@ class TestGetFilter:
base_url="http://test",
).get("/api/config/filters/sshd")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# PUT /api/config/filters/{name} (Task 2.2)
# ---------------------------------------------------------------------------
class TestUpdateFilter:
"""Tests for ``PUT /api/config/filters/{name}``."""
async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 200 with updated FilterConfig."""
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")),
):
resp = await config_client.put(
"/api/config/filters/sshd",
json={"failregex": [r"^fail from <HOST>"]},
)
assert resp.status_code == 200
assert resp.json()["name"] == "sshd"
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")),
):
resp = await config_client.put(
"/api/config/filters/missing",
json={},
)
assert resp.status_code == 404
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
):
resp = await config_client.put(
"/api/config/filters/sshd",
json={"failregex": ["[bad"]},
)
assert resp.status_code == 422
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.put(
"/api/config/filters/bad",
json={},
)
assert resp.status_code == 400
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd?reload=true passes do_reload=True."""
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")),
) as mock_update:
resp = await config_client.put(
"/api/config/filters/sshd?reload=true",
json={},
)
assert resp.status_code == 200
assert mock_update.call_args.kwargs.get("do_reload") is True
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""PUT /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",
).put("/api/config/filters/sshd", json={})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/filters (Task 2.2)
# ---------------------------------------------------------------------------
class TestCreateFilter:
"""Tests for ``POST /api/config/filters``."""
async def test_201_creates_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 201 with FilterConfig."""
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(return_value=_make_filter_config("my-custom")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "my-custom", "failregex": [r"^fail from <HOST>"]},
)
assert resp.status_code == 201
assert resp.json()["name"] == "my-custom"
async def test_409_when_already_exists(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 409 if filter exists."""
from app.services.config_file_service import FilterAlreadyExistsError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterAlreadyExistsError("sshd")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "sshd"},
)
assert resp.status_code == 409
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "test", "failregex": ["[bad"]},
)
assert resp.status_code == 422
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 400 for invalid filter name."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "bad"},
)
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/filters", json={"name": "test"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# DELETE /api/config/filters/{name} (Task 2.2)
# ---------------------------------------------------------------------------
class TestDeleteFilter:
"""Tests for ``DELETE /api/config/filters/{name}``."""
async def test_204_deletes_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/my-custom returns 204."""
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(return_value=None),
):
resp = await config_client.delete("/api/config/filters/my-custom")
assert resp.status_code == 204
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")),
):
resp = await config_client.delete("/api/config/filters/missing")
assert resp.status_code == 404
async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter."""
from app.services.config_file_service import FilterReadonlyError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterReadonlyError("sshd")),
):
resp = await config_client.delete("/api/config/filters/sshd")
assert resp.status_code == 409
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.delete("/api/config/filters/bad")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""DELETE /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",
).delete("/api/config/filters/sshd")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/filter (Task 2.2)
# ---------------------------------------------------------------------------
class TestAssignFilterToJail:
"""Tests for ``POST /api/config/jails/{name}/filter``."""
async def test_204_assigns_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 204 on success."""
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(return_value=None),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "myfilter"},
)
assert resp.status_code == 204
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/filter returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
):
resp = await config_client.post(
"/api/config/jails/missing/filter",
json={"filter_name": "sshd"},
)
assert resp.status_code == 404
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 404 when filter not found."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNotFoundError("missing-filter")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "missing-filter"},
)
assert resp.status_code == 404
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/.../filter with bad jail name returns 400."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNameError("bad")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "valid"},
)
assert resp.status_code == 400
async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter with bad filter name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "../evil"},
)
assert resp.status_code == 400
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter?reload=true passes do_reload=True."""
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(return_value=None),
) as mock_assign:
resp = await config_client.post(
"/api/config/jails/sshd/filter?reload=true",
json={"filter_name": "sshd"},
)
assert resp.status_code == 204
assert mock_assign.call_args.kwargs.get("do_reload") is True
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"})
assert resp.status_code == 401

View File

@@ -262,7 +262,7 @@ class TestUpdateFilterFile:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/filters/nginx",
"/api/config/filters/nginx/raw",
json={"content": "[Definition]\nfailregex = test\n"},
)
@@ -274,7 +274,7 @@ class TestUpdateFilterFile:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/filters/nginx",
"/api/config/filters/nginx/raw",
json={"content": "x"},
)
@@ -293,7 +293,7 @@ class TestCreateFilterFile:
AsyncMock(return_value="myfilter.conf"),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -306,7 +306,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -318,7 +318,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "../escape", "content": "[Definition]\n"},
)

View File

@@ -660,11 +660,12 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local = result[0]
name, filename, content, has_local, source_path = result[0]
assert name == "nginx"
assert filename == "nginx.conf"
assert "failregex" in content
assert has_local is False
assert source_path.endswith("nginx.conf")
def test_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
@@ -675,7 +676,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, _, has_local = result[0]
_, _, _, has_local, _ = result[0]
assert has_local is True
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
@@ -687,7 +688,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, content, _ = result[0]
_, _, content, _, _ = result[0]
assert "local tweak" in content
def test_sorted_alphabetically(self, tmp_path: Path) -> None:
@@ -852,3 +853,637 @@ class TestGetFilter:
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
assert result.has_local_override is True
# ---------------------------------------------------------------------------
# _parse_filters_sync — .local-only filters (Task 2.2)
# ---------------------------------------------------------------------------
class TestParseFiltersSyncLocalOnly:
"""Verify that .local-only user-created filters appear in results."""
def test_local_only_included(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n")
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local, source_path = result[0]
assert name == "custom"
assert filename == "custom.local"
assert has_local is False # .local-only: no conf to override
assert source_path.endswith("custom.local")
def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
result = _parse_filters_sync(filter_d)
# sshd should appear exactly once (conf + local, not as separate entry)
names = [r[0] for r in result]
assert names.count("sshd") == 1
_, _, _, has_local, _ = result[0]
assert has_local is True # conf + local → has_local_override
def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "zzz.conf", _FILTER_CONF)
_write(filter_d / "aaa.local", "[Definition]\nfailregex = x\n")
result = _parse_filters_sync(filter_d)
names = [r[0] for r in result]
assert names == ["aaa", "zzz"]
# ---------------------------------------------------------------------------
# get_filter — .local-only filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetFilterLocalOnly:
"""Verify that get_filter handles .local-only user-created filters."""
async def test_returns_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(
filter_d / "custom.local",
"[Definition]\nfailregex = ^fail from <HOST>\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", "custom")
assert result.name == "custom"
assert result.has_local_override is False
assert result.source_file.endswith("custom.local")
assert len(result.failregex) == 1
async def test_raises_when_neither_conf_nor_local(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_accepts_local_extension(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = x\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", "custom.local")
assert result.name == "custom"
# ---------------------------------------------------------------------------
# _validate_regex_patterns (Task 2.2)
# ---------------------------------------------------------------------------
class TestValidateRegexPatterns:
def test_valid_patterns_pass(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"])
def test_empty_list_passes(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([])
def test_invalid_pattern_raises(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"[unclosed"])
assert "[unclosed" in exc_info.value.pattern
def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"\d+", r"[bad", r"\w+"])
assert "[bad" in exc_info.value.pattern
# ---------------------------------------------------------------------------
# _write_filter_local_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestWriteFilterLocalSync:
def test_writes_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\n")
local = filter_d / "myfilter.local"
assert local.is_file()
assert "[Definition]" in local.read_text()
def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
_write_filter_local_sync(filter_d, "test", "[Definition]\n")
assert (filter_d / "test.local").is_file()
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
(filter_d / "myfilter.local").write_text("old content")
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\nnew=1\n")
assert "new=1" in (filter_d / "myfilter.local").read_text()
assert "old content" not in (filter_d / "myfilter.local").read_text()
# ---------------------------------------------------------------------------
# _set_jail_local_key_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestSetJailLocalKeySync:
def test_creates_new_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter")
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "filter" in content
assert "myfilter" in content
def test_updates_existing_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text(
"[sshd]\nenabled = true\n"
)
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# Existing key is preserved
assert "enabled" in content
def test_overwrites_existing_key(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text("[sshd]\nfilter = old\n")
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# ---------------------------------------------------------------------------
# update_filter (Task 2.2)
# ---------------------------------------------------------------------------
_FILTER_CONF_WITH_REGEX = """\
[Definition]
failregex = ^fail from <HOST>
^error from <HOST>
ignoreregex =
"""
@pytest.mark.asyncio
class TestUpdateFilter:
async def test_writes_local_override(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"^new pattern <HOST>"]),
)
local = filter_d / "sshd.local"
assert local.is_file()
assert result.name == "sshd"
assert any("new pattern" in p for p in result.failregex)
async def test_accepts_conf_extension(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd.conf",
FilterUpdateRequest(datepattern="%Y-%m-%d"),
)
assert result.name == "sshd"
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNotFoundError, update_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterNotFoundError):
await update_filter(
str(tmp_path),
"/fake.sock",
"missing",
FilterUpdateRequest(),
)
async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import (
FilterInvalidRegexError,
update_filter,
)
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNameError, update_filter
with pytest.raises(FilterNameError):
await update_filter(
str(tmp_path),
"/fake.sock",
"../etc/passwd",
FilterUpdateRequest(),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_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()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(journalmatch="_SYSTEMD_UNIT=sshd.service"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# create_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestCreateFilter:
async def test_creates_local_file(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(
name="my-custom",
failregex=[r"^fail from <HOST>"],
),
)
local = tmp_path / "filter.d" / "my-custom.local"
assert local.is_file()
assert result.name == "my-custom"
assert result.source_file.endswith("my-custom.local")
async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_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()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="sshd"),
)
async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="custom"),
)
async def test_raises_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterInvalidRegexError, create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="bad", failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterNameError, create_filter
with pytest.raises(FilterNameError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="newfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# delete_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeleteFilter:
async def test_deletes_local_file_when_conf_and_local_exist(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
await delete_filter(str(tmp_path), "sshd")
assert not (filter_d / "sshd.local").exists()
assert (filter_d / "sshd.conf").exists()
async def test_deletes_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
await delete_filter(str(tmp_path), "custom")
assert not (filter_d / "custom.local").exists()
async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterReadonlyError, delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with pytest.raises(FilterReadonlyError):
await delete_filter(str(tmp_path), "sshd")
async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterNotFoundError, delete_filter
with pytest.raises(FilterNotFoundError):
await delete_filter(str(tmp_path), "nonexistent")
async def test_accepts_filter_name_error_for_invalid_name(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import FilterNameError, delete_filter
with pytest.raises(FilterNameError):
await delete_filter(str(tmp_path), "../evil")
# ---------------------------------------------------------------------------
# assign_filter_to_jail (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestAssignFilterToJail:
async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
# Setup: jail.conf with sshd jail, filter.conf for "myfilter"
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
)
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "myfilter" in content
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import (
JailNotFoundInConfigError,
assign_filter_to_jail,
)
_write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF)
with pytest.raises(JailNotFoundInConfigError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"nonexistent-jail",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
with pytest.raises(FilterNotFoundError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="nonexistent-filter"),
)
async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import JailNameError, assign_filter_to_jail
with pytest.raises(JailNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"../etc/evil",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_name_error_for_invalid_filter(
self, tmp_path: Path
) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNameError, assign_filter_to_jail
with pytest.raises(FilterNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()