Files
BanGUI/backend/tests/test_models.py
Lukas 100fd47c4b Refactor: Make model packages true leaf nodes - remove app-layer dependencies
Models in app/models/ are now pure data classes with no cross-layer dependencies.
This ensures the models layer remains a true leaf node in the dependency graph.

Changes:
- Create app/models/_common.py with shared types (TimeRange, bucket_count, constants)
- Move TimeRange and time-range constants from ban.py to _common.py
- Update history.py, routers, and services to import from _common.py
- Remove imports from app.config and app.utils from config.py models
- Move field validators from models to router layer:
  - Add log_target validation in config_misc router
  - Add log_path validation in jail_config router
- Update test_models.py to reflect validators moved to router layer
- Update documentation (Architekture.md, Backend-Development.md) with model layering rules
- Fix import ordering and type annotations in affected files

Model layering rule: Models may only import from:
✓ Standard library and third-party packages (Pydantic, typing)
✓ Other models in app/models/ (sibling models)
✓ app.models.response (response envelopes)
✗ app.services, app.config, app.utils, or any application layer

Validation requiring app-level state (settings, allowed directories) now happens
at the router or service layer, not in model validators.

Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:31:11 +02:00

191 lines
6.7 KiB
Python

"""Unit tests for Pydantic models and their validators."""
import pytest
from pydantic import ValidationError
from app.models.config import GlobalConfigUpdate, GlobalConfigResponse
def test_add_log_path_request_default_tail_is_true() -> None:
"""Tail defaults to True."""
from app.models.config import AddLogPathRequest
req = AddLogPathRequest(log_path="/var/log/app.log")
assert req.tail is True
def test_add_log_path_request_can_be_created() -> None:
"""AddLogPathRequest can be created with valid data (no validators in model)."""
from app.models.config import AddLogPathRequest
req = AddLogPathRequest(log_path="/etc/passwd", tail=True)
# Note: path validation is now in the router layer, not in the model
assert req.log_path == "/etc/passwd"
assert req.tail is True
# ---------------------------------------------------------------------------
# GlobalConfigUpdate and GlobalConfigResponse
# ---------------------------------------------------------------------------
def test_global_config_update_valid_log_level() -> None:
"""GlobalConfigUpdate accepts valid log levels."""
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() -> None:
"""GlobalConfigUpdate rejects invalid log levels."""
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() -> None:
"""GlobalConfigUpdate log_level is case-sensitive (must be uppercase)."""
with pytest.raises(ValidationError):
GlobalConfigUpdate(log_level="debug")
with pytest.raises(ValidationError):
GlobalConfigUpdate(log_level="Debug")
def test_global_config_update_can_set_log_target() -> None:
"""GlobalConfigUpdate can set log_target (validation moved to router)."""
# Note: path validation for log_target is now in the router layer
update = GlobalConfigUpdate(log_target="/etc/passwd")
assert update.log_target == "/etc/passwd"
def test_global_config_update_special_log_targets() -> None:
"""GlobalConfigUpdate accepts special log target values (no validation in model)."""
for target in ["STDOUT", "STDERR", "SYSLOG"]:
update = GlobalConfigUpdate(log_target=target)
assert update.log_target == target
def test_global_config_update_none_fields() -> None:
"""GlobalConfigUpdate allows None for optional fields."""
update = GlobalConfigUpdate()
assert update.log_level is None
assert update.log_target is None
def test_global_config_response_log_level() -> None:
"""GlobalConfigResponse enforces valid log levels."""
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() -> None:
"""GlobalConfigResponse accepts special log targets."""
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() -> None:
"""GlobalConfigResponse accepts any log target path (validation in router)."""
response = GlobalConfigResponse(
log_level="INFO",
log_target="/root/secret.log",
db_purge_age=86400,
db_max_matches=10,
)
assert response.log_target == "/root/secret.log"
# ---------------------------------------------------------------------------
# LoginRequest and SetupRequest password validation
# ---------------------------------------------------------------------------
def test_login_request_password_accepted_at_72_bytes() -> None:
"""LoginRequest accepts passwords exactly 72 bytes long."""
from app.models.auth import LoginRequest
password_72 = "a" * 72
req = LoginRequest(password=password_72)
assert req.password == password_72
def test_login_request_password_rejected_over_72_bytes() -> None:
"""LoginRequest rejects passwords exceeding 72 bytes."""
from app.models.auth import LoginRequest
password_73 = "a" * 73
with pytest.raises(ValidationError) as exc_info:
LoginRequest(password=password_73)
error_msg = str(exc_info.value)
assert "at most 72 characters" in error_msg or "max_length" in error_msg
def test_setup_request_master_password_accepted_at_72_bytes() -> None:
"""SetupRequest accepts master_password exactly 72 bytes long."""
from app.models.setup import SetupRequest
password_72 = "Password1!" + "a" * 61 # 72 chars total with required complexity
req = SetupRequest(master_password=password_72)
assert req.master_password == password_72
def test_setup_request_master_password_rejected_over_72_bytes() -> None:
"""SetupRequest rejects master_password exceeding 72 bytes."""
from app.models.setup import SetupRequest
password_73 = "Password1!" + "a" * 63 # 73 chars total
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password=password_73)
error_msg = str(exc_info.value)
assert "at most 72 characters" in error_msg or "string_too_long" in error_msg
def test_setup_request_master_password_min_length_still_enforced() -> None:
"""SetupRequest still enforces minimum password length (8 characters)."""
from app.models.setup import SetupRequest
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Short1!")
error_msg = str(exc_info.value)
assert "8 characters" in error_msg
def test_setup_request_master_password_complexity_still_enforced() -> None:
"""SetupRequest still enforces password complexity requirements."""
from app.models.setup import SetupRequest
# Missing uppercase
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="password123!")
assert "uppercase" in str(exc_info.value)
# Missing number
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Password!")
assert "number" in str(exc_info.value)
# Missing special character
with pytest.raises(ValidationError) as exc_info:
SetupRequest(master_password="Password1")
assert "special character" in str(exc_info.value)