Files
gh-christianlouis-dmarq/backend/app/tests/test_imap_client.py
T
2026-05-23 13:21:03 +02:00

922 lines
33 KiB
Python

"""
Tests for IMAPClient service.
Covers connection testing, mailbox listing, email processing, attachment parsing,
and report fetching with mocked IMAP connections.
"""
import email
import imaplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from zipfile import ZipFile
import pytest
from app.models.report import DMARCReport, ForensicReport
from app.models.setting import Setting
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
MINIMAL_DMARC_XML = b"""\
<?xml version="1.0"?>
<feedback>
<report_metadata>
<org_name>Test Org</org_name>
<email>noreply@example.com</email>
<report_id>abc-123</report_id>
<date_range>
<begin>1609459200</begin>
<end>1609545600</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</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>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
</dkim>
<spf>
<domain>example.com</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>
"""
def _make_zip_content(xml_bytes: bytes, filename: str = "report.xml") -> bytes:
buf = BytesIO()
with ZipFile(buf, "w") as zf:
zf.writestr(filename, xml_bytes)
return buf.getvalue()
def _make_email_with_attachment(
filename: str = "dmarc-report.xml",
content: bytes = MINIMAL_DMARC_XML,
content_type: str = "application/xml",
subject: str = "DMARC Report",
from_addr: str = "noreply@example.com",
) -> bytes:
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = from_addr
msg.attach(MIMEText("DMARC report attached."))
part = MIMEApplication(content, Name=filename)
part["Content-Disposition"] = f'attachment; filename="{filename}"'
part.set_type(content_type)
msg.attach(part)
return msg.as_bytes()
# ---------------------------------------------------------------------------
# TestIMAPClientInit
# ---------------------------------------------------------------------------
class TestIMAPClientInit:
def test_default_construction_with_missing_credentials(self):
"""Client can be instantiated even without configured settings."""
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER=None,
IMAP_PORT=993,
IMAP_USERNAME=None,
IMAP_PASSWORD=None,
)
client = IMAPClient()
assert client.server is None
assert client.username is None
def test_explicit_credentials_override_settings(self):
"""Explicit constructor arguments take precedence over settings."""
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="default.example.com",
IMAP_PORT=993,
IMAP_USERNAME="default@example.com",
IMAP_PASSWORD="default-password",
)
client = IMAPClient(
server="custom.example.com",
port=143,
username="user@example.com",
password="secret",
delete_emails=True,
)
assert client.server == "custom.example.com"
assert client.port == 143
assert client.username == "user@example.com"
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(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
IMAP_FOLDER="Archive",
)
with patch("app.services.imap_client.get_settings", return_value=settings):
assert IMAPClient().folder == "Archive"
assert IMAPClient(folder="Junk Mail").folder == "Junk Mail"
def test_report_store_assigned(self):
"""IMAPClient stores a reference to the ReportStore singleton."""
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
client = IMAPClient()
assert client.report_store is ReportStore.get_instance()
# ---------------------------------------------------------------------------
# TestListMailboxes
# ---------------------------------------------------------------------------
class TestListMailboxes:
def _make_client(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient()
def test_parses_standard_mailbox_entry(self):
client = self._make_client()
raw = [b'(\\HasNoChildren) "/" INBOX']
result = client._list_mailboxes(raw)
assert "INBOX" in result
def test_skips_non_bytes_entries(self):
client = self._make_client()
result = client._list_mailboxes(["not bytes", None]) # type: ignore[list-item]
assert result == []
def test_handles_malformed_bytes(self):
client = self._make_client()
# bytes that can't be decoded normally should be silently skipped
result = client._list_mailboxes([b"short"])
# Should not raise; may return empty or partial result
assert isinstance(result, list)
def test_multiple_mailboxes(self):
client = self._make_client()
raw = [
b'(\\HasNoChildren) "/" INBOX',
b'(\\HasNoChildren) "/" Sent',
b'(\\HasNoChildren) "/" Trash',
]
result = client._list_mailboxes(raw)
assert len(result) == 3
# ---------------------------------------------------------------------------
# TestTestConnection
# ---------------------------------------------------------------------------
class TestTestConnection:
def _make_client(self, server="imap.example.com", username="u", password="p"):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER=server,
IMAP_PORT=993,
IMAP_USERNAME=username,
IMAP_PASSWORD=password,
DELETE_IMPORTED_EMAILS=False,
)
return IMAPClient()
def test_returns_false_when_missing_credentials(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER=None,
IMAP_PORT=993,
IMAP_USERNAME=None,
IMAP_PASSWORD=None,
DELETE_IMPORTED_EMAILS=False,
)
client = IMAPClient()
success, message, stats = client.test_connection()
assert success is False
assert "not fully configured" in message
assert stats["diagnostic_detail"] == "missing server, username, or password"
def test_successful_connection(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.list.return_value = ("OK", [b'(\\HasNoChildren) "/" INBOX'])
mock_mail.select.return_value = ("OK", [b"10"])
mock_mail.search.return_value = ("OK", [b"1 2 3"])
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
success, message, stats = client.test_connection()
assert success is True
assert "successful" in message.lower()
assert stats["message_count"] == 10
assert "INBOX" in stats["available_mailboxes"]
def test_connection_selects_configured_folder_with_quotes(self):
client = IMAPClient(
server="imap.example.com",
port=993,
username="u",
password="p",
folder="Junk Mail",
)
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.list.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [b"0"])
mock_mail.search.return_value = ("OK", [b""])
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
success, _, _ = client.test_connection()
assert success is True
mock_mail.select.assert_called_once_with('"Junk Mail"')
def test_connection_exception_returns_false(self):
client = self._make_client()
with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")):
success, message, stats = client.test_connection()
assert success is False
assert "IMAP server" in message
assert stats["diagnostic_detail"] == "refused"
def test_list_status_not_ok_returns_empty_mailboxes(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.list.return_value = ("NO", [])
mock_mail.select.return_value = ("OK", [b"0"])
mock_mail.search.return_value = ("OK", [b""])
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
success, message, stats = client.test_connection()
assert success is True
assert stats["available_mailboxes"] == []
def test_select_not_ok_returns_mailbox_diagnostic(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.list.return_value = ("OK", [b'(\\HasNoChildren) "/" INBOX'])
mock_mail.select.return_value = ("NO", [])
mock_mail.search.return_value = ("OK", [b""])
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
success, message, stats = client.test_connection()
assert success is False
assert "folder" in message
assert stats["available_mailboxes"] == ["INBOX"]
assert "select failed" in stats["diagnostic_detail"]
# ---------------------------------------------------------------------------
# TestIsDmarcReportEmail
# ---------------------------------------------------------------------------
class TestIsDmarcReportEmail:
def _make_client(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient()
def _make_msg(self, subject="", from_addr="", has_xml_attachment=False):
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = from_addr
if has_xml_attachment:
part = MIMEApplication(b"<xml/>", Name="report.xml")
part["Content-Disposition"] = 'attachment; filename="report.xml"'
msg.attach(part)
return msg
def test_dmarc_keyword_in_subject(self):
client = self._make_client()
msg = self._make_msg(subject="DMARC Aggregate Report for example.com")
assert client._is_dmarc_report_email(msg) is True
def test_no_keywords_no_attachments(self):
client = self._make_client()
msg = self._make_msg(subject="Hello World", from_addr="friend@example.com")
assert client._is_dmarc_report_email(msg) is False
def test_dmarc_sender_matches(self):
client = self._make_client()
msg = self._make_msg(subject="Weekly report", from_addr="noreply@google.com")
assert client._is_dmarc_report_email(msg) is True
def test_xml_attachment_matches(self):
client = self._make_client()
msg = self._make_msg(has_xml_attachment=True)
assert client._is_dmarc_report_email(msg) is True
# ---------------------------------------------------------------------------
# TestDecodeEmailHeader
# ---------------------------------------------------------------------------
class TestDecodeEmailHeader:
def _make_client(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient()
def test_plain_ascii_header(self):
client = self._make_client()
assert client._decode_email_header("Hello World") == "Hello World"
def test_encoded_utf8_header(self):
client = self._make_client()
# "=?utf-8?b?..." encoded header
encoded = "=?utf-8?b?RFNIQVJDIG9yZyBuYW1l?="
result = client._decode_email_header(encoded)
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# TestHasDmarcAttachments
# ---------------------------------------------------------------------------
class TestHasDmarcAttachments:
def _make_client(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient()
@pytest.mark.parametrize(
"filename",
["report.xml", "dmarc.zip", "report.gz", "report.gzip"],
)
def test_dmarc_filename_extensions(self, filename):
client = self._make_client()
msg = MIMEMultipart()
part = MIMEApplication(b"data", Name=filename)
part["Content-Disposition"] = f'attachment; filename="{filename}"'
msg.attach(part)
assert client._has_dmarc_attachments(msg) is True
@pytest.mark.parametrize(
"content_type",
[
"application/zip",
"application/gzip",
"application/x-gzip",
"application/xml",
"text/xml",
],
)
def test_dmarc_content_types(self, content_type):
client = self._make_client()
msg = MIMEMultipart()
part = MIMEApplication(b"data")
part["Content-Disposition"] = "attachment"
part.set_type(content_type)
msg.attach(part)
assert client._has_dmarc_attachments(msg) is True
def test_no_attachments_returns_false(self):
client = self._make_client()
msg = MIMEText("plain text body")
assert client._has_dmarc_attachments(msg) is False
# ---------------------------------------------------------------------------
# TestProcessAttachments
# ---------------------------------------------------------------------------
class TestProcessAttachments:
def _make_client(self, db=None):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient(db=db)
def test_processes_xml_attachment(self):
client = self._make_client()
msg = email.message_from_bytes(
_make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml")
)
stats = {"processed": 0, "reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats, message_id="1")
assert count == 1
assert stats["details"][0]["status"] == "imported"
assert stats["details"][0]["message_id"] == "1"
assert stats["details"][0]["report_id"] == "abc-123"
def test_processes_zip_attachment(self):
client = self._make_client()
zip_content = _make_zip_content(MINIMAL_DMARC_XML, "report.xml")
msg = email.message_from_bytes(
_make_email_with_attachment("report.zip", zip_content, "application/zip")
)
count = client._process_attachments(msg)
assert count == 1
def test_processes_xml_attachment_persists_report(self, db_session):
client = self._make_client(db=db_session)
msg = email.message_from_bytes(
_make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml")
)
count = client._process_attachments(msg)
assert count == 1
assert db_session.query(DMARCReport).filter_by(report_id="abc-123").count() == 1
def test_duplicate_report_adds_detail(self):
client = self._make_client()
msg = email.message_from_bytes(
_make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml")
)
first_stats = {"processed": 0, "reports_found": 0, "errors": []}
second_stats = {"processed": 0, "reports_found": 0, "errors": []}
assert client._process_attachments(msg, first_stats) == 1
assert client._process_attachments(msg, second_stats) == 0
assert second_stats["details"][0]["status"] == "duplicate"
assert second_stats["details"][0]["report_id"] == "abc-123"
def test_bad_attachment_does_not_raise(self):
client = self._make_client()
msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b"not xml at all"))
# Should not raise; just returns 0
stats = {"processed": 0, "reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
assert count == 0
assert stats["errors"]
assert stats["details"][0]["status"] == "error"
assert stats["details"][0]["filename"] == "report.xml"
def test_empty_attachment_adds_detail(self):
client = self._make_client()
msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b""))
stats = {"processed": 0, "reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
assert count == 0
assert stats["details"][0]["status"] == "skipped"
assert stats["details"][0]["reason"] == "empty_attachment"
def test_unsupported_attachment_adds_detail(self):
client = self._make_client()
msg = email.message_from_bytes(
_make_email_with_attachment("notes.txt", b"not xml", "text/plain")
)
stats = {"processed": 0, "reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats, message_id="2")
assert count == 0
assert stats["details"] == [
{
"status": "skipped",
"reason": "unsupported_attachment",
"message_id": "2",
"filename": "notes.txt",
}
]
def test_attachment_without_filename_is_skipped(self):
client = self._make_client()
msg = MIMEMultipart()
msg.attach(MIMEText("body"))
part = MIMEApplication(MINIMAL_DMARC_XML)
part["Content-Disposition"] = "attachment"
msg.attach(part)
stats = {"processed": 0, "reports_found": 0, "errors": []}
count = client._process_attachments(msg, stats)
assert count == 0
assert stats.get("details") is None
def test_no_attachments_returns_zero(self):
client = self._make_client()
msg = MIMEText("Just text, no attachments.")
count = client._process_attachments(msg)
assert count == 0
# ---------------------------------------------------------------------------
# TestProcessSingleEmail
# ---------------------------------------------------------------------------
class TestProcessSingleEmail:
def _make_client(self, db=None):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
IMAP_PORT=993,
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient(db=db)
def test_processes_valid_dmarc_email(self):
client = self._make_client()
raw = _make_email_with_attachment(
"report.xml",
MINIMAL_DMARC_XML,
"application/xml",
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, "errors": []}
client._process_single_email(mock_mail, b"1", stats)
assert stats["processed"] == 1
assert stats["reports_found"] == 1
assert stats["details"][0]["status"] == "imported"
def test_processes_forensic_report_without_aggregate_count(self, db_session):
ReportStore.get_instance().clear()
client = self._make_client(db=db_session)
mock_mail = MagicMock()
mock_mail.fetch.return_value = ("OK", [(b"1", SAMPLE_FORENSIC_EMAIL)])
mock_mail.store.return_value = ("OK", None)
stats = {
"processed": 0,
"reports_found": 0,
"forensic_reports_found": 0,
"deleted": 0,
"errors": [],
}
client._process_single_email(mock_mail, b"1", stats)
assert stats["processed"] == 1
assert stats["reports_found"] == 0
assert stats["forensic_reports_found"] == 1
assert stats["details"][0]["reason"] == "forensic_report"
assert db_session.query(DMARCReport).count() == 0
assert db_session.query(ForensicReport).count() == 1
assert ReportStore.get_instance().get_domains() == []
def test_forensic_report_uses_configured_redaction_policy(self, db_session):
setting = (
db_session.query(Setting).filter(Setting.key == "forensics.redaction_mode").first()
)
if setting is None:
setting = Setting(
key="forensics.redaction_mode",
value_type="string",
category="forensics",
)
db_session.add(setting)
setting.value = "domain_only"
db_session.commit()
client = self._make_client(db=db_session)
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
assert client._process_forensic_email(SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="1")
report = db_session.query(ForensicReport).one()
assert report.original_mail_from == "***@example.com"
def test_forensic_report_without_database_is_skipped(self):
client = self._make_client()
stats = {"processed": 0, "reports_found": 0, "errors": []}
imported = client._process_forensic_email(
SAMPLE_FORENSIC_EMAIL,
stats=stats,
message_id="msg-1",
)
assert imported is False
assert stats["details"][0]["reason"] == "forensic_report_requires_database"
def test_duplicate_forensic_report_adds_detail(self, db_session):
client = self._make_client(db=db_session)
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
assert client._process_forensic_email(SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="1")
imported = client._process_forensic_email(
SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="2"
)
assert imported is False
assert stats["duplicate_forensic_reports"] == 1
assert stats["details"][1]["status"] == "duplicate"
def test_forensic_save_duplicate_result_adds_detail(self, db_session):
client = self._make_client(db=db_session)
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
with (
patch("app.services.imap_client.forensic_report_exists", return_value=False),
patch("app.services.imap_client.save_forensic_report", return_value=(None, False)),
):
imported = client._process_forensic_email(
SAMPLE_FORENSIC_EMAIL,
stats=stats,
message_id="msg-1",
)
assert imported is False
assert stats["duplicate_forensic_reports"] == 1
assert stats["details"][0]["status"] == "duplicate"
def test_forensic_parse_error_adds_error_detail(self, db_session):
client = self._make_client(db=db_session)
stats = {"errors": []}
imported = client._process_forensic_email(
b"Subject: not forensic\r\n\r\nbody",
stats=stats,
message_id="bad",
)
assert imported is False
assert stats["errors"]
assert stats["details"][0]["reason"] == "forensic_parse_failed"
def test_fetch_error_skips_email(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.fetch.return_value = ("NO", [])
stats = {"processed": 0, "reports_found": 0, "errors": []}
client._process_single_email(mock_mail, b"1", stats)
assert stats["processed"] == 0
assert stats["details"][0]["status"] == "error"
assert stats["details"][0]["reason"] == "message_fetch_failed"
def test_exception_adds_to_errors(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.fetch.side_effect = RuntimeError("unexpected error")
stats = {"processed": 0, "reports_found": 0, "errors": []}
client._process_single_email(mock_mail, b"1", stats)
assert len(stats["errors"]) == 1
assert stats["details"][0]["reason"] == "message_processing_failed"
def test_marks_deleted_when_flag_set(self):
client = self._make_client()
client.delete_emails = True
raw = _make_email_with_attachment(
"report.xml",
MINIMAL_DMARC_XML,
"application/xml",
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)
# 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
# ---------------------------------------------------------------------------
# TestFetchReports
# ---------------------------------------------------------------------------
class TestFetchReports:
def _make_client(self, server="imap.example.com", username="u", password="p"):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER=server,
IMAP_PORT=993,
IMAP_USERNAME=username,
IMAP_PASSWORD=password,
)
return IMAPClient()
def test_returns_error_when_no_credentials(self):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER=None,
IMAP_PORT=993,
IMAP_USERNAME=None,
IMAP_PASSWORD=None,
)
client = IMAPClient()
result = client.fetch_reports()
assert result["success"] is False
def test_search_failure_returns_error(self):
client = self._make_client()
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.select.return_value = ("OK", [b"0"])
mock_mail.search.return_value = ("NO", [])
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
result = client.fetch_reports(days=7)
assert result["success"] is False
def test_fetch_reports_selects_configured_folder_with_quotes(self):
client = IMAPClient(
server="imap.example.com",
port=993,
username="u",
password="p",
folder="Junk Mail",
)
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=7)
assert result["success"] is True
mock_mail.select.assert_called_once_with('"Junk Mail"')
def test_successful_fetch_with_email(self):
client = self._make_client()
raw = _make_email_with_attachment(
"report.xml",
MINIMAL_DMARC_XML,
"application/xml",
subject="DMARC Report",
)
mock_mail = MagicMock()
mock_mail.login.return_value = None
mock_mail.select.return_value = ("OK", [b"1"])
mock_mail.search.return_value = ("OK", [b"1"])
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
mock_mail.store.return_value = ("OK", None)
mock_mail.logout.return_value = None
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
result = client.fetch_reports(days=7)
assert result["success"] is True
assert result["reports_found"] >= 1
assert result["details"][0]["status"] == "imported"
def test_connection_error_returns_failure(self):
client = self._make_client()
with patch("imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("connection error")):
result = client.fetch_reports(days=7)
assert result["success"] is False
def test_delete_emails_calls_expunge(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"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):
result = client.fetch_reports(days=3)
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