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 typing import Optional from unittest.mock import patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from app.core.config import get_settings from app.models.report import DMARCReport MINIMAL_DMARC_XML = b"""\ Webhook Test dmarc@example.com webhook-001 1609459200 1609545600 webhook.example r r

none

none 100
1.2.3.4 1 none pass pass webhook.example
""" 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 _raw_email_with_attachment( filename: Optional[str], 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() 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 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