TASK-015: Add validation for GlobalConfigUpdate.log_target and log_level
- Add LogLevel Literal type: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG - Add log_target validation to accept special values (STDOUT, STDERR, SYSLOG) or validated file paths within allowed directories - Update GlobalConfigResponse to use LogLevel type - Add field_validator for log_target in both GlobalConfigUpdate and GlobalConfigResponse following the same pattern as AddLogPathRequest - Add @autouse fixture to test_config_service.py to mock get_settings - Update existing tests to use uppercase log level values - Add 12 comprehensive tests for new validation in test_models.py - Update Features.md to document valid log_target and log_level values - Add section to Backend-Development.md documenting Literal types and field_validator patterns with examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,8 @@ from app.config import get_settings
|
||||
DNSMode = Literal["yes", "warn", "no", "raw"]
|
||||
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
|
||||
BackendType = Literal["auto", "polling", "pyinotify", "systemd", "gamin"]
|
||||
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]
|
||||
LogTarget = Literal["STDOUT", "STDERR", "SYSLOG"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ban-time escalation
|
||||
@@ -177,28 +179,103 @@ class GlobalConfigResponse(BaseModel):
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
log_level: str
|
||||
log_target: str
|
||||
log_level: LogLevel
|
||||
log_target: str = Field(..., description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.")
|
||||
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
|
||||
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
|
||||
|
||||
@field_validator("log_target", mode="after")
|
||||
@classmethod
|
||||
def validate_log_target(cls, value: str) -> str:
|
||||
"""Validate that log_target is either a special value or a valid file path.
|
||||
|
||||
Args:
|
||||
value: The log target to validate.
|
||||
|
||||
Returns:
|
||||
The validated log target.
|
||||
|
||||
Raises:
|
||||
ValueError: If the target is not a special value and not in allowed directories.
|
||||
"""
|
||||
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
|
||||
return value
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
resolved_path = Path(value).resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
|
||||
|
||||
for allowed_dir in settings.allowed_log_dirs:
|
||||
allowed_path = Path(allowed_dir).resolve()
|
||||
try:
|
||||
resolved_path.relative_to(allowed_path)
|
||||
return value
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
|
||||
raise ValueError(
|
||||
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
|
||||
)
|
||||
|
||||
|
||||
class GlobalConfigUpdate(BaseModel):
|
||||
"""Payload for ``PUT /api/config/global``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
log_level: str | None = Field(
|
||||
log_level: LogLevel | None = Field(
|
||||
default=None,
|
||||
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.",
|
||||
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG.",
|
||||
)
|
||||
log_target: str | None = Field(
|
||||
default=None,
|
||||
description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.",
|
||||
description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.",
|
||||
)
|
||||
db_purge_age: int | None = Field(default=None, ge=0)
|
||||
db_max_matches: int | None = Field(default=None, ge=0)
|
||||
|
||||
@field_validator("log_target", mode="after")
|
||||
@classmethod
|
||||
def validate_log_target(cls, value: str | None) -> str | None:
|
||||
"""Validate that log_target is either a special value or a valid file path.
|
||||
|
||||
Args:
|
||||
value: The log target to validate, or None.
|
||||
|
||||
Returns:
|
||||
The validated log target, or None if input was None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the target is not a special value and not in allowed directories.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
|
||||
return value
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
resolved_path = Path(value).resolve()
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
|
||||
|
||||
for allowed_dir in settings.allowed_log_dirs:
|
||||
allowed_path = Path(allowed_dir).resolve()
|
||||
try:
|
||||
resolved_path.relative_to(allowed_path)
|
||||
return value
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
|
||||
raise ValueError(
|
||||
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log observation / preview models
|
||||
|
||||
@@ -139,3 +139,150 @@ def test_add_log_path_request_custom_allowed_dirs(monkeypatch: pytest.MonkeyPatc
|
||||
with pytest.raises(ValidationError):
|
||||
AddLogPathRequest(log_path="/var/log/app.log", tail=True)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GlobalConfigUpdate and GlobalConfigResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_global_config_update_valid_log_level(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate accepts valid log levels."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
for level in ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]:
|
||||
update = GlobalConfigUpdate(log_level=level)
|
||||
assert update.log_level == level
|
||||
|
||||
|
||||
def test_global_config_update_invalid_log_level(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate rejects invalid log levels."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GlobalConfigUpdate(log_level="invalid")
|
||||
error_msg = str(exc_info.value)
|
||||
assert "CRITICAL" in error_msg
|
||||
|
||||
|
||||
def test_global_config_update_log_level_case_sensitive(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate log_level is case-sensitive (must be uppercase)."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
GlobalConfigUpdate(log_level="debug")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
GlobalConfigUpdate(log_level="Debug")
|
||||
|
||||
|
||||
def test_global_config_update_valid_log_target_special(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate accepts special log target values."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
for target in ["STDOUT", "STDERR", "SYSLOG"]:
|
||||
update = GlobalConfigUpdate(log_target=target)
|
||||
assert update.log_target == target
|
||||
|
||||
|
||||
def test_global_config_update_valid_log_target_path(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate accepts log targets that are valid file paths."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
update = GlobalConfigUpdate(log_target="/var/log/fail2ban.log")
|
||||
assert update.log_target == "/var/log/fail2ban.log"
|
||||
|
||||
update = GlobalConfigUpdate(log_target="/config/log/app.log")
|
||||
assert update.log_target == "/config/log/app.log"
|
||||
|
||||
|
||||
def test_global_config_update_invalid_log_target_path(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate rejects log targets outside allowed directories."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GlobalConfigUpdate(log_target="/etc/passwd")
|
||||
error_msg = str(exc_info.value)
|
||||
assert "outside allowed directories" in error_msg
|
||||
|
||||
|
||||
def test_global_config_update_log_target_case_insensitive(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate special log targets are accepted in any case."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
update = GlobalConfigUpdate(log_target="stdout")
|
||||
assert update.log_target == "stdout"
|
||||
|
||||
update = GlobalConfigUpdate(log_target="STDERR")
|
||||
assert update.log_target == "STDERR"
|
||||
|
||||
|
||||
def test_global_config_update_none_fields(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigUpdate allows None for optional fields."""
|
||||
from app.models.config import GlobalConfigUpdate
|
||||
|
||||
update = GlobalConfigUpdate()
|
||||
assert update.log_level is None
|
||||
assert update.log_target is None
|
||||
|
||||
|
||||
def test_global_config_response_log_level(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigResponse enforces valid log levels."""
|
||||
from app.models.config import GlobalConfigResponse
|
||||
|
||||
response = GlobalConfigResponse(
|
||||
log_level="INFO",
|
||||
log_target="STDOUT",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
assert response.log_level == "INFO"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
GlobalConfigResponse(
|
||||
log_level="invalid",
|
||||
log_target="STDOUT",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
|
||||
|
||||
def test_global_config_response_log_target_special(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigResponse accepts special log targets."""
|
||||
from app.models.config import GlobalConfigResponse
|
||||
|
||||
response = GlobalConfigResponse(
|
||||
log_level="INFO",
|
||||
log_target="SYSLOG",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
assert response.log_target == "SYSLOG"
|
||||
|
||||
|
||||
def test_global_config_response_log_target_path(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigResponse accepts validated log target paths."""
|
||||
from app.models.config import GlobalConfigResponse
|
||||
|
||||
response = GlobalConfigResponse(
|
||||
log_level="INFO",
|
||||
log_target="/var/log/fail2ban.log",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
assert response.log_target == "/var/log/fail2ban.log"
|
||||
|
||||
|
||||
def test_global_config_response_log_target_invalid_path(_mock_allowed_dirs: None) -> None:
|
||||
"""GlobalConfigResponse rejects log targets outside allowed directories."""
|
||||
from app.models.config import GlobalConfigResponse
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GlobalConfigResponse(
|
||||
log_level="INFO",
|
||||
log_target="/root/secret.log",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
error_msg = str(exc_info.value)
|
||||
assert "outside allowed directories" in error_msg
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.config import (
|
||||
GlobalConfigUpdate,
|
||||
JailConfigListResponse,
|
||||
@@ -22,6 +23,25 @@ from app.services.config_service import (
|
||||
JailNotFoundError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock get_settings for all tests in this module."""
|
||||
def mock_get_settings() -> Settings:
|
||||
return Settings(
|
||||
database_path=":memory:",
|
||||
fail2ban_socket="/tmp/fake.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.models.config.get_settings", mock_get_settings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -472,7 +492,7 @@ class TestUpdateGlobalConfig:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = GlobalConfigUpdate(log_level="debug", db_purge_age=3600)
|
||||
update = GlobalConfigUpdate(log_level="DEBUG", db_purge_age=3600)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_global_config(_SOCKET, update)
|
||||
|
||||
@@ -492,7 +512,7 @@ class TestUpdateGlobalConfig:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = GlobalConfigUpdate(log_level="debug")
|
||||
update = GlobalConfigUpdate(log_level="DEBUG")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_global_config(_SOCKET, update)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user