Files
BanGUI/backend/tests/test_routers/test_file_config.py
Lukas 4c138424a5 Add filter discovery endpoints with active/inactive status (Task 2.1)
- Add list_filters() and get_filter() to config_file_service.py:
  scans filter.d/, parses [Definition] + [Init] sections, merges .local
  overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
  fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
  rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
  add fetchFilters() and fetchFilter() to api/config.ts; remove unused
  fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
  exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
2026-03-13 16:48:27 +01:00

693 lines
25 KiB
Python

"""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.config import (
ActionConfig,
FilterConfig,
JailFileConfig,
JailSectionConfig,
)
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/{name}/raw
# ---------------------------------------------------------------------------
class TestGetFilterFileRaw:
"""Tests for the renamed ``GET /api/config/filters/{name}/raw`` endpoint.
The simple list (``GET /api/config/filters``) and the structured detail
(``GET /api/config/filters/{name}``) are now served by the config router.
This endpoint returns the raw file content only.
"""
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/raw")
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/raw")
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"
# ---------------------------------------------------------------------------
# POST /api/config/jail-files
# ---------------------------------------------------------------------------
class TestCreateJailConfigFile:
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.create_jail_config_file",
AsyncMock(return_value="myjail.conf"),
):
resp = await file_config_client.post(
"/api/config/jail-files",
json={"name": "myjail", "content": "[myjail]\nenabled = true\n"},
)
assert resp.status_code == 201
assert resp.json()["filename"] == "myjail.conf"
async def test_409_conflict(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.create_jail_config_file",
AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
json={"name": "myjail", "content": "[myjail]\nenabled = true\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_jail_config_file",
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
json={"name": "../escape", "content": "[Definition]\n"},
)
assert resp.status_code == 400
async def test_503_on_config_dir_error(
self, file_config_client: AsyncClient
) -> None:
with patch(
"app.routers.file_config.file_config_service.create_jail_config_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.post(
"/api/config/jail-files",
json={"name": "anyjail", "content": "[anyjail]\nenabled = false\n"},
)
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# GET /api/config/filters/{name}/parsed
# ---------------------------------------------------------------------------
class TestGetParsedFilter:
async def test_200_returns_parsed_config(
self, file_config_client: AsyncClient
) -> None:
cfg = FilterConfig(name="nginx", filename="nginx.conf")
with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file",
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get("/api/config/filters/nginx/parsed")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "nginx"
assert data["filename"] == "nginx.conf"
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get(
"/api/config/filters/missing/parsed"
)
assert resp.status_code == 404
async def test_503_on_config_dir_error(
self, file_config_client: AsyncClient
) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_filter_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get("/api/config/filters/nginx/parsed")
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# PUT /api/config/filters/{name}/parsed
# ---------------------------------------------------------------------------
class TestUpdateParsedFilter:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file",
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/parsed",
json={"failregex": ["^<HOST> "]},
)
assert resp.status_code == 204
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/filters/missing/parsed",
json={"failregex": []},
)
assert resp.status_code == 404
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_filter_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/filters/nginx/parsed",
json={"failregex": ["^<HOST> "]},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/config/actions/{name}/parsed
# ---------------------------------------------------------------------------
class TestGetParsedAction:
async def test_200_returns_parsed_config(
self, file_config_client: AsyncClient
) -> None:
cfg = ActionConfig(name="iptables", filename="iptables.conf")
with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file",
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get(
"/api/config/actions/iptables/parsed"
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "iptables"
assert data["filename"] == "iptables.conf"
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get(
"/api/config/actions/missing/parsed"
)
assert resp.status_code == 404
async def test_503_on_config_dir_error(
self, file_config_client: AsyncClient
) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_action_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get(
"/api/config/actions/iptables/parsed"
)
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# PUT /api/config/actions/{name}/parsed
# ---------------------------------------------------------------------------
class TestUpdateParsedAction:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file",
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/parsed",
json={"actionban": "iptables -I INPUT -s <ip> -j DROP"},
)
assert resp.status_code == 204
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/actions/missing/parsed",
json={"actionban": ""},
)
assert resp.status_code == 404
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_action_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/parsed",
json={"actionban": "iptables -I INPUT -s <ip> -j DROP"},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/config/jail-files/{filename}/parsed
# ---------------------------------------------------------------------------
class TestGetParsedJailFile:
async def test_200_returns_parsed_config(
self, file_config_client: AsyncClient
) -> None:
section = JailSectionConfig(enabled=True, port="ssh")
cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section})
with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file",
AsyncMock(return_value=cfg),
):
resp = await file_config_client.get(
"/api/config/jail-files/sshd.conf/parsed"
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "sshd.conf"
assert "sshd" in data["jails"]
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.get(
"/api/config/jail-files/missing.conf/parsed"
)
assert resp.status_code == 404
async def test_503_on_config_dir_error(
self, file_config_client: AsyncClient
) -> None:
with patch(
"app.routers.file_config.file_config_service.get_parsed_jail_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get(
"/api/config/jail-files/sshd.conf/parsed"
)
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# PUT /api/config/jail-files/{filename}/parsed
# ---------------------------------------------------------------------------
class TestUpdateParsedJailFile:
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file",
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/jail-files/sshd.conf/parsed",
json={"jails": {"sshd": {"enabled": False}}},
)
assert resp.status_code == 204
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")),
):
resp = await file_config_client.put(
"/api/config/jail-files/missing.conf/parsed",
json={"jails": {}},
)
assert resp.status_code == 404
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.update_parsed_jail_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/jail-files/sshd.conf/parsed",
json={"jails": {"sshd": {"enabled": True}}},
)
assert resp.status_code == 400