address backend security and test suggestions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user