- Add ~75 common plaintext passwords to setup.py validator - Check case-insensitively; passes complexity but blocked - Add tests: reject common, accept unique, short common fail on length - Update Security.md docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
350 lines
11 KiB
Python
350 lines
11 KiB
Python
"""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
|