diff --git a/backend/app/tests/test_mail_sources.py b/backend/app/tests/test_mail_sources.py index 3a168c8..b246d94 100644 --- a/backend/app/tests/test_mail_sources.py +++ b/backend/app/tests/test_mail_sources.py @@ -2132,6 +2132,36 @@ class TestTriggerPollEndpoint: finally: main_app.dependency_overrides.clear() + def test_trigger_poll_with_no_enabled_sources(self): + """With no enabled sources, the endpoint returns an empty-success response.""" + from app.core.security import require_admin_auth + from app.main import app as main_app + + async def mock_auth(): + return {"auth_type": "api_key"} + + main_app.dependency_overrides[require_admin_auth] = mock_auth + + try: + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.all.return_value = [] + + with TestClient(main_app) as tc: + with patch("app.main.SessionLocal", return_value=mock_db): + resp = tc.post("/api/v1/admin/trigger-poll?days=14") + + assert resp.status_code == 200 + data = resp.json() + assert data == { + "success": True, + "message": "No enabled mail sources configured.", + "sources_polled": 0, + "days": 14, + "authenticated_by": "api_key", + } + finally: + main_app.dependency_overrides.clear() + # --------------------------------------------------------------------------- # Pytest marker to avoid warnings for test methods without assertions diff --git a/backend/app/tests/test_webhook.py b/backend/app/tests/test_webhook.py index 9960041..ec46f81 100644 --- a/backend/app/tests/test_webhook.py +++ b/backend/app/tests/test_webhook.py @@ -1,8 +1,12 @@ import base64 +from email.header import Header from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from unittest.mock import patch import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from app.core.config import get_settings @@ -55,6 +59,24 @@ def _raw_email_with_report() -> bytes: return msg.as_bytes() +def _raw_email_with_attachment( + filename: str | None, + content: bytes, + subtype: str = "octet-stream", + subject: str = "DMARC report", +) -> bytes: + msg = MIMEMultipart() + msg["Subject"] = subject + msg.attach(MIMEText("attached")) + part = MIMEApplication(content, _subtype=subtype) + if filename is not None: + part.add_header("Content-Disposition", "attachment", filename=filename) + else: + part.add_header("Content-Disposition", "attachment") + 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() @@ -126,3 +148,116 @@ def test_webhook_raw_email_marks_duplicate(client: TestClient, monkeypatch): assert first.status_code == 200 assert second.status_code == 200 assert second.json()["duplicates"] == 1 + + +def test_webhook_rejects_invalid_base64(client: TestClient, monkeypatch): + secret = _set_webhook_secret(monkeypatch) + + response = client.post( + "/api/v1/webhook/email", + headers={"X-Webhook-Secret": secret}, + json={"raw_email": "not-base64"}, + ) + + assert response.status_code == 400 + + +def test_webhook_uses_payload_subject_fallback(client: TestClient, monkeypatch): + secret = _set_webhook_secret(monkeypatch) + raw_email = _raw_email_with_attachment("notes.txt", b"ignored") + + response = client.post( + "/api/v1/webhook/email", + headers={"X-Webhook-Secret": secret}, + json={ + "raw_email": base64.b64encode(raw_email).decode("ascii"), + "subject": "Worker subject", + }, + ) + + assert response.status_code == 200 + assert response.json()["subject"] == "Worker subject" + + +def test_webhook_decodes_encoded_subject(client: TestClient, monkeypatch): + secret = _set_webhook_secret(monkeypatch) + raw_email = _raw_email_with_attachment( + "notes.txt", + b"ignored", + subject=Header("DMARC r\u00e9sum\u00e9", "utf-8").encode(), + ) + + response = client.post( + "/api/v1/webhook/email/raw", + headers={"X-Webhook-Secret": secret}, + content=raw_email, + ) + + assert response.status_code == 200 + assert response.json()["subject"] == "DMARC r\u00e9sum\u00e9" + + +@pytest.mark.parametrize( + "filename,content", [(None, b"ignored"), ("notes.txt", b"ignored"), ("empty.xml", b"")] +) +def test_webhook_ignores_non_report_attachments(client: TestClient, monkeypatch, filename, content): + secret = _set_webhook_secret(monkeypatch) + + response = client.post( + "/api/v1/webhook/email/raw", + headers={"X-Webhook-Secret": secret}, + content=_raw_email_with_attachment(filename, content), + ) + + assert response.status_code == 200 + assert response.json()["reports_found"] == 0 + + +def test_webhook_records_attachment_errors(client: TestClient, monkeypatch): + secret = _set_webhook_secret(monkeypatch) + + response = client.post( + "/api/v1/webhook/email/raw", + headers={"X-Webhook-Secret": secret}, + content=_raw_email_with_attachment("bad.xml", b"not xml", "xml"), + ) + + assert response.status_code == 200 + data = response.json() + assert data["reports_found"] == 0 + assert data["errors"] == ["bad.xml"] + + +def test_webhook_rolls_back_on_unhandled_processing_error( + client: TestClient, db_session, monkeypatch +): + secret = _set_webhook_secret(monkeypatch) + + with patch( + "app.api.api_v1.endpoints.webhook.email.message_from_bytes", + side_effect=RuntimeError("parse failed"), + ): + response = client.post( + "/api/v1/webhook/email/raw", + headers={"X-Webhook-Secret": secret}, + content=b"bad", + ) + + assert response.status_code == 400 + assert db_session.is_active + + +def test_webhook_preserves_http_exceptions(client: TestClient, monkeypatch): + secret = _set_webhook_secret(monkeypatch) + + with patch( + "app.api.api_v1.endpoints.webhook._process_email_attachments", + side_effect=HTTPException(status_code=418, detail="teapot"), + ): + response = client.post( + "/api/v1/webhook/email/raw", + headers={"X-Webhook-Secret": secret}, + content=b"Subject: DMARC\r\n\r\nbody", + ) + + assert response.status_code == 418