diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b52f2d6..1b86e95 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -92,7 +92,7 @@ jobs:
- name: Run tests with coverage
run: |
cd backend
- pytest --cov=app --cov-report=xml --cov-report=term-missing
+ pytest --cov=app --cov-report=xml --cov-report=term-missing --cov-fail-under=80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
diff --git a/backend/app/tests/test_imap_client.py b/backend/app/tests/test_imap_client.py
new file mode 100644
index 0000000..1a4ed35
--- /dev/null
+++ b/backend/app/tests/test_imap_client.py
@@ -0,0 +1,615 @@
+"""
+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 unittest.mock import MagicMock, patch
+from zipfile import ZipFile
+
+import pytest
+
+from app.services.imap_client import IMAPClient
+from app.services.report_store import ReportStore
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+MINIMAL_DMARC_XML = b"""\
+
+
+
+ Test Org
+ noreply@example.com
+ abc-123
+
+ 1609459200
+ 1609545600
+
+
+
+ example.com
+ r
+ r
+ none
+ none
+ 100
+
+
+
+ 1.2.3.4
+ 1
+
+ none
+ pass
+ pass
+
+
+
+ example.com
+
+
+
+ example.com
+ pass
+
+
+ example.com
+ pass
+
+
+
+
+"""
+
+
+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_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,
+ )
+ 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,
+ )
+ client = IMAPClient()
+ success, message, stats = client.test_connection()
+ assert success is False
+ assert "not fully configured" in message
+ assert stats == {}
+
+ 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_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 stats == {}
+
+ 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_skips_message_count(self):
+ client = self._make_client()
+ mock_mail = MagicMock()
+ mock_mail.login.return_value = None
+ mock_mail.list.return_value = ("OK", [])
+ 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 True
+ assert stats["message_count"] == 0
+
+
+# ---------------------------------------------------------------------------
+# 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"", 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):
+ 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_processes_xml_attachment(self):
+ client = self._make_client()
+ msg = email.message_from_bytes(
+ _make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml")
+ )
+ count = client._process_attachments(msg)
+ assert count == 1
+
+ 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_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
+ count = client._process_attachments(msg)
+ assert count == 0
+
+ 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):
+ 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_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
+
+ 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
+
+ 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
+
+ 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, "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
+
+
+# ---------------------------------------------------------------------------
+# 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_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
+
+ 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""])
+ 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
diff --git a/backend/app/tests/test_imap_endpoints.py b/backend/app/tests/test_imap_endpoints.py
new file mode 100644
index 0000000..d868917
--- /dev/null
+++ b/backend/app/tests/test_imap_endpoints.py
@@ -0,0 +1,160 @@
+"""
+Tests for the /api/v1/imap endpoints.
+
+Covers connection testing, report fetching (foreground and background), and status.
+"""
+
+from unittest.mock import MagicMock, patch
+
+from fastapi.testclient import TestClient
+
+
+class TestImapTestConnection:
+ """Tests for POST /api/v1/imap/test-connection"""
+
+ def test_successful_connection(self, authed_client: TestClient):
+ mock_client = MagicMock()
+ mock_client.test_connection.return_value = (
+ True,
+ "Connection successful",
+ {
+ "message_count": 5,
+ "unread_count": 2,
+ "dmarc_count": 1,
+ "available_mailboxes": ["INBOX", "Sent"],
+ "server": "imap.example.com",
+ "port": 993,
+ },
+ )
+
+ with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
+ response = authed_client.post(
+ "/api/v1/imap/test-connection",
+ json={
+ "server": "imap.example.com",
+ "port": 993,
+ "username": "user@example.com",
+ "password": "secret",
+ "ssl": True,
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert data["message_count"] == 5
+ assert data["unread_count"] == 2
+ assert "INBOX" in data["available_mailboxes"]
+
+ def test_failed_connection(self, authed_client: TestClient):
+ mock_client = MagicMock()
+ mock_client.test_connection.return_value = (False, "Connection failed", {})
+
+ with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
+ response = authed_client.post(
+ "/api/v1/imap/test-connection",
+ json={"server": "bad.example.com", "port": 993},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is False
+ assert data["message_count"] == 0
+
+ def test_requires_auth(self, client: TestClient):
+ """Without auth, the endpoint should return 401."""
+ response = client.post(
+ "/api/v1/imap/test-connection",
+ json={"server": "imap.example.com", "port": 993},
+ )
+ assert response.status_code == 401
+
+
+class TestImapFetchReports:
+ """Tests for POST /api/v1/imap/fetch-reports"""
+
+ def test_fetch_foreground_success(self, authed_client: TestClient):
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": True,
+ "processed": 3,
+ "reports_found": 2,
+ "new_domains": ["example.com"],
+ "errors": [],
+ }
+
+ with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=7")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert data["processed_emails"] == 3
+ assert data["reports_found"] == 2
+
+ def test_fetch_days_too_low(self, authed_client: TestClient):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=0")
+ assert response.status_code == 400
+
+ def test_fetch_days_too_high(self, authed_client: TestClient):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=400")
+ assert response.status_code == 400
+
+ 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):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=30")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "background" in data["message"].lower()
+
+ def test_fetch_with_errors_in_result(self, authed_client: TestClient):
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": True,
+ "processed": 1,
+ "reports_found": 0,
+ "new_domains": [],
+ "errors": ["Could not parse email 1"],
+ }
+
+ with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=3")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["errors"] is not None
+
+ def test_fetch_exception_returns_500(self, authed_client: TestClient):
+ mock_client = MagicMock()
+ mock_client.fetch_reports.side_effect = RuntimeError("unexpected")
+
+ with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
+ response = authed_client.post("/api/v1/imap/fetch-reports?days=5")
+
+ assert response.status_code == 500
+
+ def test_requires_auth(self, client: TestClient):
+ response = client.post("/api/v1/imap/fetch-reports?days=7")
+ assert response.status_code == 401
+
+
+class TestImapStatus:
+ """Tests for GET /api/v1/imap/status"""
+
+ def test_status_returns_200(self, authed_client: TestClient):
+ response = authed_client.get("/api/v1/imap/status")
+ assert response.status_code == 200
+
+ def test_status_response_structure(self, authed_client: TestClient):
+ response = authed_client.get("/api/v1/imap/status")
+ data = response.json()
+ assert "is_running" in data
+ assert "timestamp" in data
+
+ def test_requires_auth(self, client: TestClient):
+ response = client.get("/api/v1/imap/status")
+ assert response.status_code == 401
diff --git a/backend/app/tests/test_security_extra.py b/backend/app/tests/test_security_extra.py
new file mode 100644
index 0000000..48183d1
--- /dev/null
+++ b/backend/app/tests/test_security_extra.py
@@ -0,0 +1,174 @@
+"""
+Additional tests for app.core.security covering JWT, password utilities,
+create_access_token, and require_admin_auth branches not yet exercised.
+"""
+
+from datetime import timedelta
+
+import pytest
+from jose import jwt
+
+from app.core.security import (
+ add_api_key,
+ create_access_token,
+ generate_api_key,
+ verify_token,
+)
+
+
+# ---------------------------------------------------------------------------
+# create_access_token
+# ---------------------------------------------------------------------------
+
+
+class TestCreateAccessToken:
+ def test_returns_decodable_token(self):
+ from app.core.config import get_settings
+
+ settings = get_settings()
+ token = create_access_token("test-subject")
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+ assert payload["sub"] == "test-subject"
+
+ def test_custom_expiry_is_respected(self):
+ import time
+
+ from app.core.config import get_settings
+
+ settings = get_settings()
+ delta = timedelta(seconds=60)
+ token = create_access_token("user@example.com", expires_delta=delta)
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+ # exp should be roughly `now + 60 seconds` (within a 2-second tolerance)
+ assert abs(payload["exp"] - (int(time.time()) + 60)) <= 2
+
+
+# ---------------------------------------------------------------------------
+# verify_token (JWT bearer dependency)
+# ---------------------------------------------------------------------------
+
+
+class TestVerifyToken:
+ @pytest.mark.asyncio
+ async def test_valid_token_returns_payload(self):
+ token = create_access_token("unit-test-user")
+
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
+ payload = await verify_token(creds)
+ assert payload["sub"] == "unit-test-user"
+
+ @pytest.mark.asyncio
+ async def test_no_credentials_raises_401(self):
+ from fastapi import HTTPException
+
+ with pytest.raises(HTTPException) as exc_info:
+ await verify_token(None)
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_invalid_token_raises_401(self):
+ from fastapi import HTTPException
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="invalid.token.here")
+ with pytest.raises(HTTPException) as exc_info:
+ await verify_token(creds)
+ assert exc_info.value.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# require_admin_auth – branches: valid API key, valid JWT, no auth
+# ---------------------------------------------------------------------------
+
+
+class TestRequireAdminAuth:
+ @pytest.mark.asyncio
+ async def test_valid_api_key_returns_auth_context(self):
+ from app.core.security import require_admin_auth
+
+ key = generate_api_key()
+ add_api_key(key)
+ try:
+ result = await require_admin_auth(api_key=key, bearer=None)
+ assert result["auth_type"] == "api_key"
+ finally:
+ from app.core.security import _api_keys
+
+ _api_keys.discard(key)
+
+ @pytest.mark.asyncio
+ async def test_valid_jwt_returns_auth_context(self):
+ from app.core.security import require_admin_auth
+
+ token = create_access_token("admin-user")
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
+ result = await require_admin_auth(api_key=None, bearer=creds)
+ assert result["auth_type"] == "jwt"
+ assert result["payload"]["sub"] == "admin-user"
+
+ @pytest.mark.asyncio
+ async def test_invalid_jwt_and_no_api_key_raises_401(self):
+ from fastapi import HTTPException
+ from fastapi.security import HTTPAuthorizationCredentials
+
+ from app.core.security import require_admin_auth
+
+ creds = HTTPAuthorizationCredentials(
+ scheme="Bearer", credentials="bad.token.value"
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ await require_admin_auth(api_key=None, bearer=creds)
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_no_auth_at_all_raises_401(self):
+ from fastapi import HTTPException
+
+ from app.core.security import require_admin_auth
+
+ with pytest.raises(HTTPException) as exc_info:
+ await require_admin_auth(api_key=None, bearer=None)
+ assert exc_info.value.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# get_api_key dependency
+# ---------------------------------------------------------------------------
+
+
+class TestGetApiKeyDependency:
+ @pytest.mark.asyncio
+ async def test_missing_key_raises_401(self):
+ from fastapi import HTTPException
+
+ from app.core.security import get_api_key
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_api_key(None)
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_invalid_key_raises_401(self):
+ from fastapi import HTTPException
+
+ from app.core.security import get_api_key
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_api_key("this-key-does-not-exist")
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_valid_key_returns_key(self):
+ from app.core.security import _api_keys, get_api_key
+
+ key = generate_api_key()
+ add_api_key(key)
+ try:
+ result = await get_api_key(key)
+ assert result == key
+ finally:
+ _api_keys.discard(key)
diff --git a/backend/app/tests/test_setup_endpoints.py b/backend/app/tests/test_setup_endpoints.py
new file mode 100644
index 0000000..e66f8b9
--- /dev/null
+++ b/backend/app/tests/test_setup_endpoints.py
@@ -0,0 +1,106 @@
+"""
+Tests for the /api/v1/setup endpoints.
+
+Covers initial setup status, admin user setup, and system configuration.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.api.api_v1.endpoints.setup import setup_status
+
+
+@pytest.fixture(autouse=True)
+def reset_setup_status():
+ """Reset in-memory setup_status before each test to avoid state leakage."""
+ original = dict(setup_status)
+ setup_status["is_setup_complete"] = False
+ setup_status["admin_email"] = None
+ setup_status["app_name"] = "DMARQ"
+ yield
+ setup_status.update(original)
+
+
+class TestSetupStatus:
+ """Tests for GET /api/v1/setup/status"""
+
+ def test_initial_status_not_complete(self, client: TestClient):
+ response = client.get("/api/v1/setup/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_setup_complete"] is False
+ assert data["app_name"] == "DMARQ"
+
+ def test_status_after_system_setup(self, client: TestClient):
+ client.post(
+ "/api/v1/setup/system",
+ json={"app_name": "MyApp", "base_url": "https://example.com"},
+ )
+ response = client.get("/api/v1/setup/status")
+ data = response.json()
+ assert data["is_setup_complete"] is True
+ assert data["app_name"] == "MyApp"
+
+
+class TestSetupAdmin:
+ """Tests for POST /api/v1/setup/admin"""
+
+ def test_admin_setup_succeeds_on_first_call(self, client: TestClient):
+ response = client.post(
+ "/api/v1/setup/admin",
+ json={
+ "email": "admin@example.com",
+ "username": "admin",
+ "password": "test-placeholder-password",
+ },
+ )
+ 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
+ setup_status["is_setup_complete"] = True
+
+ response = client.post(
+ "/api/v1/setup/admin",
+ json={
+ "email": "admin2@example.com",
+ "username": "admin2",
+ "password": "pass",
+ },
+ )
+ assert response.status_code == 400
+ assert "already completed" in response.json()["detail"]
+
+ def test_admin_setup_rejects_invalid_email(self, client: TestClient):
+ response = client.post(
+ "/api/v1/setup/admin",
+ json={
+ "email": "not-an-email",
+ "username": "admin",
+ "password": "pass",
+ },
+ )
+ assert response.status_code == 422
+
+
+class TestSetupSystem:
+ """Tests for POST /api/v1/setup/system"""
+
+ def test_system_setup_saves_app_name(self, client: TestClient):
+ response = client.post(
+ "/api/v1/setup/system",
+ json={"app_name": "DMARQ Custom", "base_url": "https://dmarq.example.com"},
+ )
+ assert response.status_code == 200
+ assert "message" in response.json()
+ assert setup_status["app_name"] == "DMARQ Custom"
+ assert setup_status["is_setup_complete"] is True
+
+ def test_system_setup_marks_complete(self, client: TestClient):
+ assert setup_status["is_setup_complete"] is False
+ client.post(
+ "/api/v1/setup/system",
+ json={"app_name": "Test", "base_url": "https://test.example.com"},
+ )
+ assert setup_status["is_setup_complete"] is True
diff --git a/backend/app/tests/test_stats_endpoints.py b/backend/app/tests/test_stats_endpoints.py
new file mode 100644
index 0000000..bdf5e35
--- /dev/null
+++ b/backend/app/tests/test_stats_endpoints.py
@@ -0,0 +1,115 @@
+"""
+Tests for the /api/v1/stats endpoints.
+
+Covers dashboard statistics and per-domain statistics with cache refresh.
+"""
+
+from unittest.mock import MagicMock, patch
+
+from fastapi.testclient import TestClient
+
+
+class TestDashboardStatistics:
+ """Tests for GET /api/v1/stats/dashboard"""
+
+ def test_dashboard_returns_200(self, client: TestClient):
+ response = client.get("/api/v1/stats/dashboard")
+ assert response.status_code == 200
+
+ def test_dashboard_response_contains_api_version(self, client: TestClient):
+ response = client.get("/api/v1/stats/dashboard")
+ data = response.json()
+ assert data["api_version"] == "1.0"
+
+ def test_dashboard_response_contains_period_days(self, client: TestClient):
+ response = client.get("/api/v1/stats/dashboard")
+ data = response.json()
+ assert data["period_days"] == 30
+
+ def test_dashboard_period_days_query_param(self, client: TestClient):
+ response = client.get("/api/v1/stats/dashboard?period_days=7")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["period_days"] == 7
+
+ def test_dashboard_force_refresh(self, client: TestClient):
+ """force_refresh=true should trigger cache invalidation without error."""
+ response = client.get("/api/v1/stats/dashboard?force_refresh=true")
+ assert response.status_code == 200
+ data = response.json()
+ assert "api_version" in data
+
+ def test_dashboard_force_refresh_calls_invalidate_cache(self, client: TestClient):
+ """Verify StatsSummarizer.invalidate_cache is called when force_refresh is set."""
+ with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
+ mock_instance = MagicMock()
+ mock_instance.calculate_summary_statistics.return_value = {"total": 0}
+ MockSummarizer.return_value = mock_instance
+
+ response = client.get("/api/v1/stats/dashboard?force_refresh=true")
+ assert response.status_code == 200
+ mock_instance.invalidate_cache.assert_called_once()
+
+ def test_dashboard_no_force_refresh_skips_invalidate(self, client: TestClient):
+ """Without force_refresh, invalidate_cache should NOT be called."""
+ with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
+ mock_instance = MagicMock()
+ mock_instance.calculate_summary_statistics.return_value = {"total": 0}
+ MockSummarizer.return_value = mock_instance
+
+ response = client.get("/api/v1/stats/dashboard")
+ assert response.status_code == 200
+ mock_instance.invalidate_cache.assert_not_called()
+
+
+class TestDomainStatistics:
+ """Tests for GET /api/v1/stats/domain/{domain_id}"""
+
+ def test_domain_stats_returns_200(self, client: TestClient):
+ response = client.get("/api/v1/stats/domain/example.com")
+ assert response.status_code == 200
+
+ def test_domain_stats_contains_api_version(self, client: TestClient):
+ response = client.get("/api/v1/stats/domain/example.com")
+ data = response.json()
+ assert data["api_version"] == "1.0"
+
+ def test_domain_stats_contains_period_days(self, client: TestClient):
+ response = client.get("/api/v1/stats/domain/example.com")
+ data = response.json()
+ assert data["period_days"] == 30
+
+ def test_domain_stats_custom_period(self, client: TestClient):
+ response = client.get("/api/v1/stats/domain/example.com?period_days=14")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["period_days"] == 14
+
+ def test_domain_stats_force_refresh(self, client: TestClient):
+ response = client.get("/api/v1/stats/domain/example.com?force_refresh=true")
+ assert response.status_code == 200
+
+ def test_domain_stats_force_refresh_calls_invalidate_with_domain(
+ self, client: TestClient
+ ):
+ """Verify invalidate_cache is called with the domain ID."""
+ with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
+ mock_instance = MagicMock()
+ mock_instance.calculate_summary_statistics.return_value = {"total": 0}
+ MockSummarizer.return_value = mock_instance
+
+ response = client.get(
+ "/api/v1/stats/domain/example.com?force_refresh=true"
+ )
+ assert response.status_code == 200
+ mock_instance.invalidate_cache.assert_called_once_with("example.com")
+
+ def test_domain_stats_no_force_refresh_skips_invalidate(self, client: TestClient):
+ with patch("app.api.api_v1.endpoints.stats.StatsSummarizer") as MockSummarizer:
+ mock_instance = MagicMock()
+ mock_instance.calculate_summary_statistics.return_value = {"total": 0}
+ MockSummarizer.return_value = mock_instance
+
+ response = client.get("/api/v1/stats/domain/example.com")
+ assert response.status_code == 200
+ mock_instance.invalidate_cache.assert_not_called()