"""Unit tests for Pydantic models and their validators.""" import pytest from pydantic import ValidationError from app.models.config import GlobalConfigResponse, GlobalConfigUpdate 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) def test_setup_request_rejects_common_passwords() -> None: """SetupRequest rejects common passwords that pass all other complexity checks.""" from app.models.setup import SetupRequest # Passw0rd! passes length (10), uppercase, digit, special checks but is too common with pytest.raises(ValidationError) as exc_info: SetupRequest(master_password="Passw0rd!") assert "too common" in str(exc_info.value).lower() def test_setup_request_accepts_valid_unique_password() -> None: """SetupRequest accepts a password that meets all requirements and is not common.""" from app.models.setup import SetupRequest req = SetupRequest(master_password="MyV3ryStr0ng!P@ssw0rd") assert req.master_password == "MyV3ryStr0ng!P@ssw0rd" def test_setup_request_rejects_short_common_passwords() -> None: """SetupRequest rejects short common passwords (rejected for length, not common check).""" from app.models.setup import SetupRequest for password in ["letmein", "admin", "qwerty", "shadow"]: with pytest.raises(ValidationError) as exc_info: SetupRequest(master_password=password) # These fail the minimum length check first assert "at least 8 characters" in str(exc_info.value) # --------------------------------------------------------------------------- # DashboardBanItem country_code validator # --------------------------------------------------------------------------- def test_dashboard_ban_item_country_code_null() -> None: """DashboardBanItem accepts None for country_code.""" from app.models.ban import DashboardBanItem item = DashboardBanItem( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country_code=None, ) assert item.country_code is None def test_dashboard_ban_item_country_code_valid() -> None: """DashboardBanItem accepts a valid 2-char uppercase country code.""" from app.models.ban import DashboardBanItem item = DashboardBanItem( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country_code="US", ) assert item.country_code == "US" def test_dashboard_ban_item_country_code_empty_string_coerced_to_none() -> None: """DashboardBanItem coerces empty-string country_code to None.""" from app.models.ban import DashboardBanItem item = DashboardBanItem( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country_code="", ) assert item.country_code is None # --------------------------------------------------------------------------- # ActiveBan country validator # --------------------------------------------------------------------------- def test_active_ban_country_null() -> None: """ActiveBan accepts None for country.""" from app.models.ban import ActiveBan ban = ActiveBan(ip="1.2.3.4", jail="sshd", country=None) assert ban.country is None def test_active_ban_country_valid() -> None: """ActiveBan accepts a valid country code.""" from app.models.ban import ActiveBan ban = ActiveBan(ip="1.2.3.4", jail="sshd", country="DE") assert ban.country == "DE" def test_active_ban_country_empty_string_coerced_to_none() -> None: """ActiveBan coerces empty-string country to None.""" from app.models.ban import ActiveBan ban = ActiveBan(ip="1.2.3.4", jail="sshd", country="") assert ban.country is None # --------------------------------------------------------------------------- # Ban country validator # --------------------------------------------------------------------------- def test_ban_country_null() -> None: """Ban accepts None for country.""" from app.models.ban import Ban ban = Ban( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country=None, ) assert ban.country is None def test_ban_country_valid() -> None: """Ban accepts a valid country code.""" from app.models.ban import Ban ban = Ban( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country="FR", ) assert ban.country == "FR" def test_ban_country_empty_string_coerced_to_none() -> None: """Ban coerces empty-string country to None.""" from app.models.ban import Ban ban = Ban( ip="1.2.3.4", jail="sshd", banned_at="2026-04-28T07:00:00+00:00", ban_count=1, origin="selfblock", country="", ) assert ban.country is None