feat: import selected fork operational fixes
This commit is contained in:
@@ -188,6 +188,20 @@ class TestLogtoSettings:
|
||||
assert settings.LOGTO_SKIP_SSL_VERIFY is True
|
||||
|
||||
|
||||
class TestImapSettings:
|
||||
def test_delete_imported_emails_defaults_false(self, monkeypatch):
|
||||
monkeypatch.delenv("DELETE_IMPORTED_EMAILS", raising=False)
|
||||
settings = Settings()
|
||||
|
||||
assert settings.DELETE_IMPORTED_EMAILS is False
|
||||
|
||||
def test_delete_imported_emails_reads_env(self, monkeypatch):
|
||||
monkeypatch.setenv("DELETE_IMPORTED_EMAILS", "true")
|
||||
settings = Settings()
|
||||
|
||||
assert settings.DELETE_IMPORTED_EMAILS is True
|
||||
|
||||
|
||||
class TestProductionStartupSettings:
|
||||
"""Tests for production-critical settings and startup validation."""
|
||||
|
||||
|
||||
@@ -139,6 +139,19 @@ class TestIMAPClientInit:
|
||||
assert client.password == "secret"
|
||||
assert client.delete_emails is True
|
||||
|
||||
def test_delete_emails_defaults_to_settings(self):
|
||||
settings = SimpleNamespace(
|
||||
IMAP_SERVER="imap.example.com",
|
||||
IMAP_PORT=993,
|
||||
IMAP_USERNAME="u",
|
||||
IMAP_PASSWORD="p",
|
||||
DELETE_IMPORTED_EMAILS=True,
|
||||
)
|
||||
with patch("app.services.imap_client.get_settings", return_value=settings):
|
||||
client = IMAPClient()
|
||||
|
||||
assert client.delete_emails is True
|
||||
|
||||
def test_folder_uses_explicit_value_or_settings_default(self):
|
||||
"""Folder defaults to settings and can be overridden explicitly."""
|
||||
settings = SimpleNamespace(
|
||||
@@ -223,6 +236,7 @@ class TestTestConnection:
|
||||
IMAP_PORT=993,
|
||||
IMAP_USERNAME=username,
|
||||
IMAP_PASSWORD=password,
|
||||
DELETE_IMPORTED_EMAILS=False,
|
||||
)
|
||||
return IMAPClient()
|
||||
|
||||
@@ -233,6 +247,7 @@ class TestTestConnection:
|
||||
IMAP_PORT=993,
|
||||
IMAP_USERNAME=None,
|
||||
IMAP_PASSWORD=None,
|
||||
DELETE_IMPORTED_EMAILS=False,
|
||||
)
|
||||
client = IMAPClient()
|
||||
success, message, stats = client.test_connection()
|
||||
@@ -634,11 +649,31 @@ class TestProcessSingleEmail:
|
||||
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
|
||||
mock_mail.store.return_value = ("OK", None)
|
||||
|
||||
stats = {"processed": 0, "reports_found": 0, "errors": []}
|
||||
stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []}
|
||||
client._process_single_email(mock_mail, b"1", stats)
|
||||
|
||||
# store should have been called twice: once for \\Seen, once for \\Deleted
|
||||
assert mock_mail.store.call_count >= 2
|
||||
assert stats["deleted"] == 1
|
||||
|
||||
def test_does_not_delete_when_no_report_imported(self):
|
||||
client = self._make_client()
|
||||
client.delete_emails = True
|
||||
raw = _make_email_with_attachment(
|
||||
"not-a-report.txt",
|
||||
b"not a report",
|
||||
"text/plain",
|
||||
subject="DMARC Report",
|
||||
)
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
|
||||
mock_mail.store.return_value = ("OK", None)
|
||||
|
||||
stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []}
|
||||
client._process_single_email(mock_mail, b"1", stats)
|
||||
|
||||
mock_mail.store.assert_called_once_with(b"1", "+FLAGS", "\\Seen")
|
||||
assert stats["deleted"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -736,7 +771,22 @@ class TestFetchReports:
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.login.return_value = None
|
||||
mock_mail.select.return_value = ("OK", [b"0"])
|
||||
mock_mail.search.return_value = ("OK", [b""])
|
||||
mock_mail.search.return_value = ("OK", [b"1"])
|
||||
mock_mail.fetch.return_value = (
|
||||
"OK",
|
||||
[
|
||||
(
|
||||
b"1",
|
||||
_make_email_with_attachment(
|
||||
"report.xml",
|
||||
MINIMAL_DMARC_XML,
|
||||
"application/xml",
|
||||
subject="DMARC Report",
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
mock_mail.store.return_value = ("OK", None)
|
||||
mock_mail.logout.return_value = None
|
||||
|
||||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||
@@ -744,3 +794,20 @@ class TestFetchReports:
|
||||
|
||||
mock_mail.expunge.assert_called_once()
|
||||
assert result["success"] is True
|
||||
assert result["deleted"] == 1
|
||||
|
||||
def test_delete_emails_skips_expunge_when_nothing_deleted(self):
|
||||
client = self._make_client()
|
||||
client.delete_emails = True
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.login.return_value = None
|
||||
mock_mail.select.return_value = ("OK", [b"0"])
|
||||
mock_mail.search.return_value = ("OK", [b""])
|
||||
mock_mail.logout.return_value = None
|
||||
|
||||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||
result = client.fetch_reports(days=3)
|
||||
|
||||
mock_mail.expunge.assert_not_called()
|
||||
assert result["success"] is True
|
||||
assert result["deleted"] == 0
|
||||
|
||||
@@ -83,7 +83,10 @@ class TestImapFetchReports:
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||
):
|
||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -103,7 +106,10 @@ class TestImapFetchReports:
|
||||
def test_fetch_background_for_long_range(self, authed_client: TestClient):
|
||||
"""Days > 14 should queue a background task."""
|
||||
mock_client = MagicMock()
|
||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||
):
|
||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=30")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -121,7 +127,10 @@ class TestImapFetchReports:
|
||||
"errors": ["Could not parse email 1"],
|
||||
}
|
||||
|
||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||
):
|
||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=3")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -132,7 +141,10 @@ class TestImapFetchReports:
|
||||
mock_client = MagicMock()
|
||||
mock_client.fetch_reports.side_effect = RuntimeError("unexpected")
|
||||
|
||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
||||
with (
|
||||
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||
):
|
||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=5")
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
@@ -875,9 +875,7 @@ class TestManualSourceFetchEndpoint:
|
||||
assert data["error_count"] == 1
|
||||
assert "bad attachment with newline" in caplog.text
|
||||
history_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/imports")
|
||||
assert history_resp.json()[0]["details"] == [
|
||||
{"status": "error", "filename": "bad.xml"}
|
||||
]
|
||||
assert history_resp.json()[0]["details"] == [{"status": "error", "filename": "bad.xml"}]
|
||||
mock_imap.fetch_reports.assert_called_once_with(days=30)
|
||||
|
||||
def test_fetch_gmail_source(self, authed_client: TestClient, db_session: Session):
|
||||
@@ -1806,8 +1804,33 @@ class TestTriggerPollImapSource:
|
||||
assert result["processed"] == 3
|
||||
assert result["reports_found"] == 2
|
||||
assert result["new_domains"] == ["dom.example"]
|
||||
mock_imap.fetch_reports.assert_called_once_with(days=7)
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_uses_requested_days(self):
|
||||
from app.main import _trigger_poll_imap_source
|
||||
|
||||
src = MagicMock()
|
||||
src.id = 5
|
||||
src.name = "My IMAP"
|
||||
src.server = "imap.example.com"
|
||||
src.port = 993
|
||||
src.username = "u"
|
||||
src.password = "p"
|
||||
|
||||
mock_imap = MagicMock()
|
||||
mock_imap.fetch_reports.return_value = {
|
||||
"success": True,
|
||||
"processed": 0,
|
||||
"reports_found": 0,
|
||||
"new_domains": [],
|
||||
}
|
||||
|
||||
with patch("app.main.IMAPClient", return_value=mock_imap):
|
||||
_trigger_poll_imap_source(src, MagicMock(), days=30)
|
||||
|
||||
mock_imap.fetch_reports.assert_called_once_with(days=30)
|
||||
|
||||
|
||||
class TestTriggerPollGmailSource:
|
||||
"""Unit tests for app.main._trigger_poll_gmail_source."""
|
||||
@@ -1938,7 +1961,7 @@ class TestPollSourceForTrigger:
|
||||
result = _poll_source_for_trigger(src, MagicMock())
|
||||
|
||||
assert result is expected
|
||||
mock_fn.assert_called_once()
|
||||
assert mock_fn.call_args.kwargs["days"] == 7
|
||||
|
||||
def test_imap_exception_returns_failure_dict(self):
|
||||
from app.main import _poll_source_for_trigger
|
||||
@@ -2092,15 +2115,20 @@ class TestTriggerPollEndpoint:
|
||||
with TestClient(main_app) as tc:
|
||||
with (
|
||||
patch("app.main.SessionLocal", return_value=mock_db),
|
||||
patch("app.main._poll_source_for_trigger", return_value=mock_result),
|
||||
patch(
|
||||
"app.main._poll_source_for_trigger", return_value=mock_result
|
||||
) as mock_poll,
|
||||
):
|
||||
resp = tc.post("/api/v1/admin/trigger-poll")
|
||||
resp = tc.post("/api/v1/admin/trigger-poll?days=30")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["days"] == 30
|
||||
assert "sources" in data
|
||||
assert len(data["sources"]) == 1
|
||||
assert data["sources"][0]["success"] is True
|
||||
assert mock_result == data["sources"][0]
|
||||
assert mock_poll.call_args.kwargs["days"] == 30
|
||||
finally:
|
||||
main_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ def test_poll_single_imap_source_passes_configured_folder():
|
||||
port=993,
|
||||
username="u",
|
||||
password="p",
|
||||
delete_emails=False,
|
||||
folder="Junk Mail",
|
||||
db=db,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import base64
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.report import DMARCReport
|
||||
|
||||
MINIMAL_DMARC_XML = b"""\
|
||||
<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>Webhook Test</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>webhook-001</report_id>
|
||||
<date_range>
|
||||
<begin>1609459200</begin>
|
||||
<end>1609545600</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>webhook.example</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>1.2.3.4</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>webhook.example</header_from>
|
||||
</identifiers>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
|
||||
|
||||
def _raw_email_with_report() -> bytes:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = "DMARC report"
|
||||
part = MIMEApplication(MINIMAL_DMARC_XML, _subtype="xml")
|
||||
part.add_header("Content-Disposition", "attachment", filename="report.xml")
|
||||
msg.attach(part)
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def _set_webhook_secret(monkeypatch, value="test-webhook-secret"):
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", value)
|
||||
get_settings.cache_clear()
|
||||
return value
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_settings_cache():
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_webhook_requires_configured_secret(client: TestClient, monkeypatch):
|
||||
monkeypatch.delenv("WEBHOOK_SECRET", raising=False)
|
||||
get_settings.cache_clear()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/webhook/email",
|
||||
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_webhook_rejects_invalid_secret(client: TestClient, monkeypatch):
|
||||
_set_webhook_secret(monkeypatch)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/webhook/email",
|
||||
headers={"X-Webhook-Secret": "wrong"},
|
||||
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_imports_base64_email(client: TestClient, db_session, monkeypatch):
|
||||
secret = _set_webhook_secret(monkeypatch)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/webhook/email",
|
||||
headers={"X-Webhook-Secret": secret},
|
||||
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["reports_found"] == 1
|
||||
assert data["imported"] == 1
|
||||
assert db_session.query(DMARCReport).count() == 1
|
||||
|
||||
|
||||
def test_webhook_raw_email_marks_duplicate(client: TestClient, monkeypatch):
|
||||
secret = _set_webhook_secret(monkeypatch)
|
||||
raw_email = _raw_email_with_report()
|
||||
|
||||
first = client.post(
|
||||
"/api/v1/webhook/email/raw",
|
||||
headers={"X-Webhook-Secret": secret},
|
||||
content=raw_email,
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/webhook/email/raw",
|
||||
headers={"X-Webhook-Secret": secret},
|
||||
content=raw_email,
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["duplicates"] == 1
|
||||
Reference in New Issue
Block a user