Add better jail configuration: file CRUD, enable/disable, log paths
Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
This commit is contained in:
379
backend/tests/test_routers/test_file_config.py
Normal file
379
backend/tests/test_routers/test_file_config.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Tests for the file_config router endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.file_config import (
|
||||
ConfFileContent,
|
||||
ConfFileEntry,
|
||||
ConfFilesResponse,
|
||||
JailConfigFile,
|
||||
JailConfigFileContent,
|
||||
JailConfigFilesResponse,
|
||||
)
|
||||
from app.services.file_config_service import (
|
||||
ConfigDirError,
|
||||
ConfigFileExistsError,
|
||||
ConfigFileNameError,
|
||||
ConfigFileNotFoundError,
|
||||
ConfigFileWriteError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def file_config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` for file_config endpoint tests."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "file_config_test.db"),
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
session_secret="test-file-config-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
app.state.http_session = MagicMock()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
login = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _jail_files_resp(files: list[JailConfigFile] | None = None) -> JailConfigFilesResponse:
|
||||
files = files or [JailConfigFile(name="sshd", filename="sshd.conf", enabled=True)]
|
||||
return JailConfigFilesResponse(files=files, total=len(files))
|
||||
|
||||
|
||||
def _conf_files_resp(files: list[ConfFileEntry] | None = None) -> ConfFilesResponse:
|
||||
files = files or [ConfFileEntry(name="nginx", filename="nginx.conf")]
|
||||
return ConfFilesResponse(files=files, total=len(files))
|
||||
|
||||
|
||||
def _conf_file_content(name: str = "nginx") -> ConfFileContent:
|
||||
return ConfFileContent(
|
||||
name=name,
|
||||
filename=f"{name}.conf",
|
||||
content=f"[Definition]\n# {name} filter\n",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/jail-files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListJailConfigFiles:
|
||||
async def test_200_returns_file_list(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.list_jail_config_files",
|
||||
AsyncMock(return_value=_jail_files_resp()),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/jail-files")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["files"][0]["filename"] == "sshd.conf"
|
||||
|
||||
async def test_503_on_config_dir_error(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.list_jail_config_files",
|
||||
AsyncMock(side_effect=ConfigDirError("not found")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/jail-files")
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_401_unauthenticated(self, file_config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=file_config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/jail-files")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/jail-files/{filename}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetJailConfigFile:
|
||||
async def test_200_returns_content(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
content = JailConfigFileContent(
|
||||
name="sshd",
|
||||
filename="sshd.conf",
|
||||
enabled=True,
|
||||
content="[sshd]\nenabled = true\n",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_jail_config_file",
|
||||
AsyncMock(return_value=content),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/jail-files/sshd.conf")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["content"] == "[sshd]\nenabled = true\n"
|
||||
|
||||
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_jail_config_file",
|
||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/jail-files/missing.conf")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_400_invalid_filename(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_jail_config_file",
|
||||
AsyncMock(side_effect=ConfigFileNameError("bad name")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/jail-files/bad.txt")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/config/jail-files/{filename}/enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetJailConfigEnabled:
|
||||
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.set_jail_config_enabled",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/jail-files/sshd.conf/enabled",
|
||||
json={"enabled": False},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.set_jail_config_enabled",
|
||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/jail-files/missing.conf/enabled",
|
||||
json={"enabled": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListFilterFiles:
|
||||
async def test_200_returns_files(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.list_filter_files",
|
||||
AsyncMock(return_value=_conf_files_resp()),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/filters")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
async def test_503_on_config_dir_error(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.list_filter_files",
|
||||
AsyncMock(side_effect=ConfigDirError("x")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/filters")
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/filters/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFilterFile:
|
||||
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_filter_file",
|
||||
AsyncMock(return_value=_conf_file_content("nginx")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/filters/nginx")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "nginx"
|
||||
|
||||
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_filter_file",
|
||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/filters/missing")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/config/filters/{name}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateFilterFile:
|
||||
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_filter_file",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/filters/nginx",
|
||||
json={"content": "[Definition]\nfailregex = test\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_filter_file",
|
||||
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/filters/nginx",
|
||||
json={"content": "x"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateFilterFile:
|
||||
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.create_filter_file",
|
||||
AsyncMock(return_value="myfilter.conf"),
|
||||
):
|
||||
resp = await file_config_client.post(
|
||||
"/api/config/filters",
|
||||
json={"name": "myfilter", "content": "[Definition]\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["filename"] == "myfilter.conf"
|
||||
|
||||
async def test_409_conflict(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.create_filter_file",
|
||||
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
|
||||
):
|
||||
resp = await file_config_client.post(
|
||||
"/api/config/filters",
|
||||
json={"name": "myfilter", "content": "[Definition]\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.create_filter_file",
|
||||
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
|
||||
):
|
||||
resp = await file_config_client.post(
|
||||
"/api/config/filters",
|
||||
json={"name": "../escape", "content": "[Definition]\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/actions (smoke test — same logic as filters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListActionFiles:
|
||||
async def test_200_returns_files(self, file_config_client: AsyncClient) -> None:
|
||||
action_entry = ConfFileEntry(name="iptables", filename="iptables.conf")
|
||||
resp_data = ConfFilesResponse(files=[action_entry], total=1)
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.list_action_files",
|
||||
AsyncMock(return_value=resp_data),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/actions")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["files"][0]["filename"] == "iptables.conf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateActionFile:
|
||||
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.create_action_file",
|
||||
AsyncMock(return_value="myaction.conf"),
|
||||
):
|
||||
resp = await file_config_client.post(
|
||||
"/api/config/actions",
|
||||
json={"name": "myaction", "content": "[Definition]\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["filename"] == "myaction.conf"
|
||||
@@ -215,3 +215,66 @@ class TestReResolve:
|
||||
base_url="http://test",
|
||||
).post("/api/geo/re-resolve")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/geo/stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGeoStats:
|
||||
"""Tests for ``GET /api/geo/stats``."""
|
||||
|
||||
async def test_returns_200_with_stats(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/stats returns 200 with the expected keys."""
|
||||
stats = {
|
||||
"cache_size": 100,
|
||||
"unresolved": 5,
|
||||
"neg_cache_size": 2,
|
||||
"dirty_size": 0,
|
||||
}
|
||||
with patch(
|
||||
"app.routers.geo.geo_service.cache_stats",
|
||||
AsyncMock(return_value=stats),
|
||||
):
|
||||
resp = await geo_client.get("/api/geo/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cache_size"] == 100
|
||||
assert data["unresolved"] == 5
|
||||
assert data["neg_cache_size"] == 2
|
||||
assert data["dirty_size"] == 0
|
||||
|
||||
async def test_stats_empty_cache(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/stats returns all zeros on a fresh database."""
|
||||
resp = await geo_client.get("/api/geo/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cache_size"] >= 0
|
||||
assert data["unresolved"] == 0
|
||||
assert data["neg_cache_size"] >= 0
|
||||
assert data["dirty_size"] >= 0
|
||||
|
||||
async def test_stats_counts_unresolved(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/stats counts NULL-country rows correctly."""
|
||||
app = geo_client._transport.app # type: ignore[attr-defined]
|
||||
db: aiosqlite.Connection = app.state.db
|
||||
await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("7.7.7.7",))
|
||||
await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("8.8.8.8",))
|
||||
await db.commit()
|
||||
|
||||
resp = await geo_client.get("/api/geo/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["unresolved"] >= 2
|
||||
|
||||
async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None:
|
||||
"""GET /api/geo/stats requires authentication."""
|
||||
app = geo_client._transport.app # type: ignore[attr-defined]
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
).get("/api/geo/stats")
|
||||
assert resp.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user