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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user