address backend security and test suggestions

This commit is contained in:
Christian Krakau-Louis
2026-05-18 16:44:06 +02:00
parent 8f7c41193f
commit b4191e956c
11 changed files with 320 additions and 35 deletions
+13
View File
@@ -170,3 +170,16 @@ class TestAdminApiKeySetting:
monkeypatch.setenv("ADMIN_API_KEY", long_key)
settings = Settings()
assert settings.ADMIN_API_KEY == long_key
class TestLogtoSettings:
def test_ssl_verification_is_enabled_by_default(self):
settings = Settings()
assert settings.LOGTO_SKIP_SSL_VERIFY is False
def test_ssl_verification_can_be_skipped_explicitly(self, monkeypatch):
monkeypatch.setenv("LOGTO_SKIP_SSL_VERIFY", "true")
settings = Settings()
assert settings.LOGTO_SKIP_SSL_VERIFY is True
+28 -1
View File
@@ -64,7 +64,7 @@ class TestDMARCParser:
def test_invalid_xml(self):
"""Test that invalid XML raises a ValueError."""
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="Error parsing DMARC XML"):
DMARCParser.parse_file(b"not xml at all", "report.xml")
def test_parse_xml_report_with_namespace(self):
@@ -102,3 +102,30 @@ class TestDMARCParser:
"""Test that an unsupported file extension raises ValueError."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"some content", "report.pdf")
def test_bad_zip_file_returns_no_xml_content(self):
"""A corrupt ZIP should be handled as no extractable XML content."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"not a zip file", "report.zip")
def test_bad_gzip_file_returns_no_xml_content(self):
"""A corrupt GZIP should be handled as no extractable XML content."""
with pytest.raises(ValueError, match="Could not extract XML"):
DMARCParser.parse_file(b"not a gzip file", "report.gz")
def test_parse_xml_without_report_metadata(self):
"""Missing report_metadata should not crash parsing otherwise valid XML."""
xml = b"""
<feedback>
<policy_published>
<domain>example.com</domain>
<p>none</p>
</policy_published>
</feedback>
"""
result = DMARCParser.parse_file(xml, "report.xml")
assert result["domain"] == "example.com"
assert result["records"] == []
assert result["summary"]["total_count"] == 0
@@ -0,0 +1,63 @@
import socket
from unittest.mock import patch
from app.utils.domain_validator import (
DomainValidationError,
validate_domain,
validate_domain_config,
)
class TestValidateDomain:
def test_valid_domain_without_dns_check(self):
assert validate_domain("example.com", check_dns=False) == (True, None, None)
def test_domain_longer_than_253_characters_is_rejected(self):
domain = f"{'a' * 250}.com"
valid, message, code = validate_domain(domain, check_dns=False)
assert valid is False
assert "too long" in message
assert code == DomainValidationError.TOO_LONG
def test_dns_resolution_failure_is_reported(self):
with patch("app.utils.domain_validator.socket.gethostbyname", side_effect=socket.gaierror):
valid, message, code = validate_domain("example.com", check_dns=True)
assert valid is False
assert "could not be resolved" in message
assert code == DomainValidationError.DNS_RESOLUTION_FAILED
class TestValidateDomainConfig:
def test_valid_config(self):
result = validate_domain_config(
{"name": "example.com", "description": "Primary reporting domain"}
)
assert result == {"valid": True, "errors": {}}
def test_missing_name_is_invalid(self):
result = validate_domain_config({"description": "No name"})
assert result["valid"] is False
assert result["errors"]["name"] == "Domain name is required"
def test_invalid_domain_name_is_reported(self):
result = validate_domain_config({"name": "bad domain"})
assert result["valid"] is False
assert "whitespace" in result["errors"]["name"]
def test_unsafe_description_is_rejected(self):
result = validate_domain_config({"name": "example.com", "description": "<script></script>"})
assert result["valid"] is False
assert "unsafe HTML" in result["errors"]["description"]
def test_description_length_limit(self):
result = validate_domain_config({"name": "example.com", "description": "a" * 501})
assert result["valid"] is False
assert "too long" in result["errors"]["description"]
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
class TestNextSleepSeconds:
def test_uses_shortest_enabled_source_interval_without_db_query(self):
from app.main import _next_sleep_seconds
slow = SimpleNamespace(polling_interval=30)
fast = SimpleNamespace(polling_interval=5)
with patch("app.main.SessionLocal") as mock_session:
result = _next_sleep_seconds(enabled_sources=[slow, fast])
assert result == 300
mock_session.assert_not_called()
def test_respects_min_sleep(self):
from app.main import _next_sleep_seconds
source = SimpleNamespace(polling_interval=0)
assert _next_sleep_seconds(min_sleep=120, enabled_sources=[source]) == 3600
def test_queries_database_when_sources_not_supplied(self):
from app.main import _next_sleep_seconds
source = SimpleNamespace(polling_interval=2)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [source]
with patch("app.main.SessionLocal", return_value=mock_db):
result = _next_sleep_seconds()
assert result == 120
mock_db.close.assert_called_once()
def test_database_exception_falls_back_to_one_hour(self):
from app.main import _next_sleep_seconds
with patch("app.main.SessionLocal", side_effect=RuntimeError("db unavailable")):
assert _next_sleep_seconds() == 3600
@pytest.mark.asyncio
async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels():
from app.main import scheduled_imap_polling
with (
patch("app.main._poll_all_enabled_sources", return_value=[]),
patch("app.main._next_sleep_seconds", return_value=60),
patch(
"app.main.asyncio.sleep",
side_effect=[RuntimeError("sleep failed"), asyncio.CancelledError()],
),
):
await scheduled_imap_polling()
+33
View File
@@ -58,6 +58,39 @@ class TestReportStore:
assert len(reports) == 1
assert reports[0]["report_id"] == "rpt-001"
def test_get_report_by_id_returns_matching_report(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
store.add_report(report)
assert store.get_report_by_id("rpt-001") is report
def test_get_report_by_id_returns_none_for_missing_report(self):
store = ReportStore.get_instance()
store.add_report(_sample_report("test.com"))
assert store.get_report_by_id("rpt-missing") is None
def test_get_domain_sources_returns_sources_sorted_by_count(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
report["records"].append(
{
"source_ip": "203.0.113.2",
"count": 12,
"disposition": "none",
"dkim_result": "fail",
"spf_result": "pass",
"header_from": "test.com",
}
)
store.add_report(report)
sources = store.get_domain_sources("test.com")
assert [source["source_ip"] for source in sources] == ["203.0.113.2", "203.0.113.1"]
assert [source["count"] for source in sources] == [12, 5]
def test_clear(self):
store = ReportStore.get_instance()
store.add_report(_sample_report("test.com"))
+51 -2
View File
@@ -8,6 +8,7 @@ import pytest
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints.setup import setup_status
from app.core.security import _api_keys, add_api_key
@pytest.fixture(autouse=True)
@@ -57,8 +58,7 @@ class TestSetupAdmin:
assert response.status_code == 201
assert "message" in response.json()
def test_admin_setup_fails_if_already_complete(self, client: TestClient):
# Mark setup as complete
def test_admin_setup_requires_auth_if_already_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
response = client.post(
@@ -69,6 +69,28 @@ class TestSetupAdmin:
"password": "pass",
},
)
assert response.status_code == 401
def test_admin_setup_fails_if_already_complete(self, client: TestClient):
# Mark setup as complete
setup_status["is_setup_complete"] = True
api_key = "a" * 64
add_api_key(api_key)
try:
response = client.post(
"/api/v1/setup/admin",
json={
"email": "admin2@example.com",
"username": "admin2",
"password": "pass",
},
headers={"X-API-Key": api_key},
)
finally:
_api_keys.discard(api_key)
assert response.status_code == 400
assert "already completed" in response.json()["detail"]
@@ -104,3 +126,30 @@ class TestSetupSystem:
json={"app_name": "Test", "base_url": "https://test.example.com"},
)
assert setup_status["is_setup_complete"] is True
def test_system_setup_requires_auth_if_already_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
response = client.post(
"/api/v1/setup/system",
json={"app_name": "Changed", "base_url": "https://changed.example.com"},
)
assert response.status_code == 401
def test_system_setup_allows_authenticated_updates_after_complete(self, client: TestClient):
setup_status["is_setup_complete"] = True
api_key = "b" * 64
add_api_key(api_key)
try:
response = client.post(
"/api/v1/setup/system",
json={"app_name": "Changed", "base_url": "https://changed.example.com"},
headers={"X-API-Key": api_key},
)
finally:
_api_keys.discard(api_key)
assert response.status_code == 200
assert setup_status["app_name"] == "Changed"