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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user