From f12461f5cfc0e47fffac70c0998e600b8915b038 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 14:30:36 +0200 Subject: [PATCH] test: add DMARC compatibility fixture pack --- .../dmarc_aggregate/rfc7489-google.xml | 45 ++++ .../rfc9990-multi-auth-overrides.xml | 100 +++++++++ .../rfc9990-namespaced-legacy-fields.xml | 45 ++++ .../rfc9990-treewalk-extension.xml | 65 ++++++ backend/app/tests/test_data.py | 160 ++++++--------- .../test_dmarc_compatibility_fixtures.py | 193 ++++++++++++++++++ backend/app/tests/test_dmarc_parser.py | 72 +------ backend/app/tests/test_reports_api.py | 73 +------ docs/index.md | 2 + docs/milestones.md | 11 +- docs/reference/dmarc-compatibility.md | 55 +++++ 11 files changed, 582 insertions(+), 239 deletions(-) create mode 100644 backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml create mode 100644 backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml create mode 100644 backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml create mode 100644 backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml create mode 100644 backend/app/tests/test_dmarc_compatibility_fixtures.py create mode 100644 docs/reference/dmarc-compatibility.md diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml new file mode 100644 index 0000000..3e834a8 --- /dev/null +++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc7489-google.xml @@ -0,0 +1,45 @@ + + + + google.com + noreply-dmarc-support@google.com + 123456789 + + 1597449600 + 1597535999 + + + + example.com + r + r +

none

+ none + 100 +
+ + + 203.0.113.1 + 2 + + none + pass + fail + + + + example.com + + + + example.com + pass + default + + + example.com + fail + + + +
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml new file mode 100644 index 0000000..fa7cad3 --- /dev/null +++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-multi-auth-overrides.xml @@ -0,0 +1,100 @@ + + + 1.0 + + Mailbox Provider + dmarc-reports@mailbox.example + mailto:dmarc-help@mailbox.example + fixture-rfc9990-multi-auth + + 1779580800 + 1779667199 + + MailboxRUA 2026.05 + + + example.net + psd +

reject

+ quarantine + reject + 1:d:s + r + s + 75 + n +
+ + + Mailbox Provider + us-east + + + + + 192.0.2.44 + 10 + + reject + fail + fail + + forwarded + forwarder modified body + + + + + billing.example.net + bounce.example.net + customer.example.org + + + + example.net + mail + fail + signature expired + + + vendor.example + relay + pass + + + bounce.example.net + mfrom + fail + ip not authorized + + + transactional + + + + 198.51.100.77 + 5 + + none + pass + fail + + + + news.example.net + mailer.example.net + + + + example.net + news + pass + + + mailer.example.net + mfrom + fail + + + +
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml new file mode 100644 index 0000000..4bd01fa --- /dev/null +++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-namespaced-legacy-fields.xml @@ -0,0 +1,45 @@ + + + + web.de + dmarc@web.de + 987654321 + + 1597449600 + 1597535999 + + + + example.com + r + r +

reject

+ reject + 100 +
+ + + 198.51.100.5 + 3 + + reject + pass + pass + + + + example.com + + + + example.com + pass + s1 + + + example.com + pass + + + +
diff --git a/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml new file mode 100644 index 0000000..218e551 --- /dev/null +++ b/backend/app/tests/fixtures/dmarc_aggregate/rfc9990-treewalk-extension.xml @@ -0,0 +1,65 @@ + + + 1.0 + + Example Receiver + dmarc@example.test + https://example.test/dmarc + fixture-rfc9990-treewalk + + 1779494400 + 1779580799 + + Multiple DMARC records were ignored before treewalk. + ExampleRUA 2.0 + + + example.org + treewalk +

quarantine

+ reject + none + 1 + s + r + y +
+ + mx1.example.test + + + + 2001:db8::1 + 5 + + quarantine + fail + pass + + local_policy + trusted relay + + + + + news.example.org + bounce.example.org + customer.example.net + + + + example.net + selector1 + fail + body hash did not verify + + + bounce.example.org + mfrom + pass + sender authorized + + + mail-platform + +
diff --git a/backend/app/tests/test_data.py b/backend/app/tests/test_data.py index b418553..d1d38df 100644 --- a/backend/app/tests/test_data.py +++ b/backend/app/tests/test_data.py @@ -1,97 +1,69 @@ """Shared test data for DMARC report tests.""" -SAMPLE_XML = """\ - - - - google.com - noreply-dmarc-support@google.com - 123456789 - - 1597449600 - 1597535999 - - - - example.com - r - r -

none

- none - 100 -
- - - 203.0.113.1 - 2 - - none - pass - fail - - - - example.com - - - - example.com - pass - default - - - example.com - fail - - - -
-""" +from pathlib import Path -SAMPLE_XML_WITH_NAMESPACE = """\ - - - - web.de - dmarc@web.de - 987654321 - - 1597449600 - 1597535999 - - - - example.com - r - r -

reject

- reject - 100 -
- - - 198.51.100.5 - 3 - - reject - pass - pass - - - - example.com - - - - example.com - pass - s1 - - - example.com - pass - - - -
-""" + +DMARC_FIXTURE_DIR = Path(__file__).with_name("fixtures") / "dmarc_aggregate" + + +def load_dmarc_fixture(filename: str) -> str: + """Load a curated aggregate-report fixture as text.""" + return (DMARC_FIXTURE_DIR / filename).read_text(encoding="utf-8") + + +DMARC_COMPATIBILITY_FIXTURES = [ + { + "id": "rfc7489-google", + "filename": "rfc7489-google.xml", + "domain": "example.com", + "report_id": "123456789", + "variant": "rfc7489-compatible", + "total_count": 2, + }, + { + "id": "rfc9990-namespaced-legacy-fields", + "filename": "rfc9990-namespaced-legacy-fields.xml", + "domain": "example.com", + "report_id": "987654321", + "variant": "rfc9990", + "total_count": 3, + }, + { + "id": "rfc9990-treewalk-extension", + "filename": "rfc9990-treewalk-extension.xml", + "domain": "example.org", + "report_id": "fixture-rfc9990-treewalk", + "variant": "rfc9990", + "schema_version": "1.0", + "total_count": 5, + "policy": { + "p": "quarantine", + "sp": "reject", + "np": "none", + "fo": "1", + "testing": "y", + "discovery_method": "treewalk", + }, + }, + { + "id": "rfc9990-multi-auth-overrides", + "filename": "rfc9990-multi-auth-overrides.xml", + "domain": "example.net", + "report_id": "fixture-rfc9990-multi-auth", + "variant": "rfc9990", + "schema_version": "1.0", + "total_count": 15, + "policy": { + "p": "reject", + "sp": "quarantine", + "np": "reject", + "fo": "1:d:s", + "testing": "n", + "discovery_method": "psd", + }, + }, +] + +SAMPLE_XML = load_dmarc_fixture("rfc7489-google.xml") + +SAMPLE_XML_WITH_NAMESPACE = load_dmarc_fixture("rfc9990-namespaced-legacy-fields.xml") diff --git a/backend/app/tests/test_dmarc_compatibility_fixtures.py b/backend/app/tests/test_dmarc_compatibility_fixtures.py new file mode 100644 index 0000000..a28332c --- /dev/null +++ b/backend/app/tests/test_dmarc_compatibility_fixtures.py @@ -0,0 +1,193 @@ +import base64 +import email +import gzip +import io +import zipfile +from email import encoders as email_encoders +from email.mime.application import MIMEApplication +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.models.report import DMARCReport +from app.services.dmarc_parser import DMARCParser +from app.services.gmail_client import GmailClient +from app.services.imap_client import IMAPClient +from app.tests.test_data import DMARC_COMPATIBILITY_FIXTURES, load_dmarc_fixture + + +def _fixture_id(fixture: dict) -> str: + return fixture["id"] + + +def _fixture_bytes(fixture: dict) -> bytes: + return load_dmarc_fixture(fixture["filename"]).encode("utf-8") + + +def _zip_xml(xml_content: bytes, filename: str = "report.xml") -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr(filename, xml_content) + return buf.getvalue() + + +def _make_mime_attachment(filename: str, content: bytes, content_type: str) -> bytes: + msg = MIMEMultipart() + msg["Subject"] = "DMARC aggregate report" + msg["From"] = "reports@example.test" + msg["To"] = "dmarc@example.test" + msg.attach(MIMEText("DMARC report attached.", "plain")) + + part = MIMEApplication(content, Name=filename) + part["Content-Disposition"] = f'attachment; filename="{filename}"' + part.set_type(content_type) + msg.attach(part) + return msg.as_bytes() + + +def _make_gmail_raw_attachment(filename: str, content: bytes) -> bytes: + msg = MIMEMultipart() + msg["Subject"] = "DMARC aggregate report" + msg["From"] = "reports@example.test" + msg["To"] = "dmarc@example.test" + msg.attach(MIMEText("DMARC report attached.", "plain")) + + part = MIMEBase("application", "zip") + part.set_payload(content) + email_encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=filename) + msg.attach(part) + return msg.as_bytes() + + +def _make_imap_client(db_session) -> IMAPClient: + with patch("app.services.imap_client.get_settings") as mock_settings: + mock_settings.return_value = MagicMock( + IMAP_SERVER="imap.example.test", + IMAP_PORT=993, + IMAP_USERNAME="dmarc@example.test", + IMAP_PASSWORD="password", + ) + return IMAPClient(db=db_session) + + +def _make_gmail_client(db_session) -> GmailClient: + with patch("app.services.gmail_client.Credentials") as mock_credentials_class: + credentials = MagicMock() + credentials.token = "access-token" + credentials.refresh_token = "refresh-token" + credentials.expired = False + mock_credentials_class.return_value = credentials + return GmailClient( + client_id="client-id", + client_secret="client-secret", + access_token="access-token", + refresh_token="refresh-token", + db=db_session, + ) + + +def _assert_expected_report(report: dict, fixture: dict) -> None: + assert report["domain"] == fixture["domain"] + assert report["report_id"] == fixture["report_id"] + assert report["variant"] == fixture["variant"] + assert report["summary"]["total_count"] == fixture["total_count"] + + for key, value in fixture.get("policy", {}).items(): + assert report["policy"][key] == value + + +def _assert_persisted_report(db_session, fixture: dict) -> DMARCReport: + db_session.flush() + report = db_session.query(DMARCReport).filter_by(report_id=fixture["report_id"]).one() + assert report.domain.name == fixture["domain"] + assert report.report_variant == fixture["variant"] + assert sum(record.count for record in report.records) == fixture["total_count"] + return report + + +@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id) +def test_aggregate_compatibility_fixtures_parse_xml_zip_and_gzip(fixture): + xml_bytes = _fixture_bytes(fixture) + + xml_report = DMARCParser.parse_file(xml_bytes, fixture["filename"]) + zip_report = DMARCParser.parse_file(_zip_xml(xml_bytes), fixture["filename"] + ".zip") + gzip_report = DMARCParser.parse_file(gzip.compress(xml_bytes), fixture["filename"] + ".gz") + + for report in (xml_report, zip_report, gzip_report): + _assert_expected_report(report, fixture) + + +@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id) +def test_aggregate_compatibility_fixtures_import_via_upload( + client: TestClient, db_session, fixture +): + zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"]) + + response = client.post( + "/api/v1/reports/upload", + files={"file": (fixture["filename"] + ".zip", zip_bytes, "application/zip")}, + ) + + assert response.status_code == 200 + _assert_persisted_report(db_session, fixture) + + reports = client.get(f"/api/v1/domains/{fixture['domain']}/reports") + assert reports.status_code == 200 + assert reports.json()["reports"][0]["id"] == fixture["report_id"] + + export = client.get(f"/api/v1/domains/{fixture['domain']}/reports/export") + assert export.status_code == 200 + assert fixture["report_id"] in export.text + assert "report_variant" in export.text + + +@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id) +def test_aggregate_compatibility_fixtures_import_via_imap(db_session, fixture): + client = _make_imap_client(db_session) + zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"]) + raw_message = _make_mime_attachment(fixture["filename"] + ".zip", zip_bytes, "application/zip") + msg = email.message_from_bytes(raw_message) + stats = {"processed": 0, "reports_found": 0, "errors": []} + + count = client._process_attachments(msg, stats, message_id="imap-" + fixture["id"]) + + assert count == 1 + assert stats["details"][0]["status"] == "imported" + assert stats["details"][0]["report_id"] == fixture["report_id"] + _assert_persisted_report(db_session, fixture) + + +@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id) +def test_aggregate_compatibility_fixtures_import_via_gmail(db_session, fixture): + client = _make_gmail_client(db_session) + zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"]) + raw_message = _make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes) + msg = email.message_from_bytes(raw_message) + stats = {"reports_found": 0, "errors": []} + + count = client._process_attachments(msg, stats, message_id="gmail-" + fixture["id"]) + + assert count == 1 + assert stats["details"][0]["status"] == "imported" + assert stats["details"][0]["report_id"] == fixture["report_id"] + _assert_persisted_report(db_session, fixture) + + +def test_gmail_fixture_email_shape_matches_api_raw_encoding(): + """The fixture MIME shape can be decoded from Gmail's raw message payload form.""" + fixture = DMARC_COMPATIBILITY_FIXTURES[2] + zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"]) + encoded = base64.urlsafe_b64encode( + _make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes) + ) + + decoded = base64.urlsafe_b64decode(encoded) + msg = email.message_from_bytes(decoded) + + assert msg["Subject"] == "DMARC aggregate report" + assert any(part.get_filename() == fixture["filename"] + ".zip" for part in msg.walk()) diff --git a/backend/app/tests/test_dmarc_parser.py b/backend/app/tests/test_dmarc_parser.py index 440e523..c34785a 100644 --- a/backend/app/tests/test_dmarc_parser.py +++ b/backend/app/tests/test_dmarc_parser.py @@ -4,7 +4,7 @@ import zipfile import pytest from app.services.dmarc_parser import DMARCParser -from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE +from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE, load_dmarc_fixture class TestDMARCParser: @@ -132,80 +132,14 @@ class TestDMARCParser: def test_parse_rfc9990_style_report_variant(self): """RFC 9990-era namespaces and optional fields should parse without breaking legacy shape.""" - xml = b""" - - 1.0 - - Example Receiver - dmarc@example.test - https://example.test/dmarc - 2026-05-23-example.org - - 1779494400 - 1779580799 - - Multiple DMARC records were ignored before treewalk. - ExampleRUA 2.0 - - - example.org - treewalk -

quarantine

- reject - none - 1 - s - r - y -
- - mx1.example.test - - - - 2001:db8::1 - 5 - - quarantine - fail - pass - - local_policy - trusted relay - - - - - news.example.org - bounce.example.org - customer.example.net - - - - example.net - selector1 - fail - body hash did not verify - - - bounce.example.org - mfrom - pass - sender authorized - - - mail-platform - -
- """ + xml = load_dmarc_fixture("rfc9990-treewalk-extension.xml").encode("utf-8") result = DMARCParser.parse_file(xml, "report.xml") assert result["variant"] == "rfc9990" assert result["schema_version"] == "1.0" assert result["xml_namespace"] == "urn:ietf:params:xml:ns:dmarc-2.0" - assert result["report_id"] == "2026-05-23-example.org" + assert result["report_id"] == "fixture-rfc9990-treewalk" assert result["generator"] == "ExampleRUA 2.0" assert result["errors"] == ["Multiple DMARC records were ignored before treewalk."] assert result["extensions"] == {"receiver": "mx1.example.test"} diff --git a/backend/app/tests/test_reports_api.py b/backend/app/tests/test_reports_api.py index 1e9889e..b770a60 100644 --- a/backend/app/tests/test_reports_api.py +++ b/backend/app/tests/test_reports_api.py @@ -7,75 +7,10 @@ from app.models.domain import Domain from app.models.report import DMARCReport, ReportRecord from app.services.report_persistence import persisted_report_to_dict from app.services.report_store import ReportStore -from app.tests.test_data import SAMPLE_XML +from app.tests.test_data import SAMPLE_XML, load_dmarc_fixture -SAMPLE_RFC9990_XML = """ - - 1.0 - - Example Receiver - dmarc@example.test - https://example.test/dmarc - 2026-05-23-example.org - - 1779494400 - 1779580799 - - Multiple records ignored. - ExampleRUA 2.0 - - - example.org - treewalk -

quarantine

- reject - none - 1 - s - r - y -
- - mx1.example.test - - - - 2001:db8::1 - 5 - - quarantine - fail - pass - - local_policy - trusted relay - - - - - news.example.org - bounce.example.org - customer.example.net - - - - example.net - selector1 - fail - body hash did not verify - - - bounce.example.org - mfrom - pass - sender authorized - - - mail-platform - -
-""" +SAMPLE_RFC9990_XML = load_dmarc_fixture("rfc9990-treewalk-extension.xml") def _make_zip(xml_content: str) -> bytes: @@ -123,7 +58,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session) ) assert response.status_code == 200 - report = db_session.query(DMARCReport).filter_by(report_id="2026-05-23-example.org").one() + report = db_session.query(DMARCReport).filter_by(report_id="fixture-rfc9990-treewalk").one() assert report.domain.name == "example.org" assert report.extra_contact_info == "https://example.test/dmarc" assert report.generator == "ExampleRUA 2.0" @@ -134,7 +69,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session) assert report.failure_options == "1" assert report.testing == "y" assert report.discovery_method == "treewalk" - assert "Multiple records ignored." in report.report_errors + assert "Multiple DMARC records were ignored before treewalk." in report.report_errors assert "mx1.example.test" in report.report_extensions record = db_session.query(ReportRecord).filter_by(report_id=report.id).one() diff --git a/docs/index.md b/docs/index.md index 696acbf..29a0b21 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,3 +22,5 @@ DMARQ is a full-stack DMARC monitoring platform designed to help organizations t To get started with DMARQ, please see the [Getting Started](user_guide/getting_started.md) guide. For installation instructions, check the [Docker Setup](deployment/docker.md) or [Manual Installation](deployment/manual.md) guides. Operators should use the [Operator Runbook](deployment/operations.md) for deployment modes, verification, upgrades, and rollback, and the [Troubleshooting Playbooks](deployment/troubleshooting.md) for ingestion, authentication, DNS, database, and notification failures. For production secrets, use [Secret Handling with 1Password](deployment/secrets.md). For database operations, use [Database Backup and Restore](deployment/backups.md). For upgrades, use the [Release Checklist](deployment/release-checklist.md). + +For aggregate-report parser support, known edge cases, and fixture guidance, see [DMARC Aggregate Format Compatibility](reference/dmarc-compatibility.md). diff --git a/docs/milestones.md b/docs/milestones.md index 860a709..5353a23 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -163,7 +163,7 @@ Exit criteria: ## Milestone 10: Forensic Report Support -Status: In Progress +Status: Complete Goal: support DMARC RUF/forensic reports for individual failure investigation. @@ -182,7 +182,7 @@ Exit criteria: ## Milestone 11: DMARC Format Compatibility (DMARCbis) and Standards Alignment -Status: In Progress +Status: Complete Goal: keep DMARQ compatible with evolving DMARC report formats and nomenclature without breaking existing imports. @@ -190,11 +190,8 @@ Delivered: - Add parser compatibility for RFC 9990-style aggregate report namespaces, version detection, policy metadata, identifiers, override reasons, auth-result details, and namespaced extensions. - Keep legacy RFC 7489-style reports backward compatible through fixture coverage. - Persist and CSV-export newly introduced aggregate metadata with nullable, backward-safe database fields. - -Planned: -- Update domain/source reporting to include new metadata where it improves operator actionability. -- Add fixture-driven tests for representative real-world DMARCbis-style reports. -- Update documentation to clarify supported formats and terminology. +- Add a fixture-driven compatibility pack covering parser, upload, IMAP, and Gmail import paths with supported-format documentation. +- Verify domain report views and CSV exports render fixture-backed DMARCbis-style imports correctly. Exit criteria: - A DMARCbis-style aggregate report can be imported via upload/IMAP/Gmail and renders correctly in dashboards and exports. diff --git a/docs/reference/dmarc-compatibility.md b/docs/reference/dmarc-compatibility.md new file mode 100644 index 0000000..d591f3e --- /dev/null +++ b/docs/reference/dmarc-compatibility.md @@ -0,0 +1,55 @@ +# DMARC Aggregate Format Compatibility + +DMARQ imports DMARC aggregate reports from direct uploads, IMAP attachments, Gmail API attachments, and the Cloudflare Email Worker webhook. Compatibility is locked by the fixture pack in `backend/app/tests/fixtures/dmarc_aggregate`. + +## Supported Aggregate Inputs + +- Plain XML files with a `.xml` extension. +- ZIP archives containing an XML report. +- GZIP archives with `.gz` or `.gzip` extensions. +- RFC 7489-compatible aggregate reports without XML namespaces. +- Namespaced aggregate reports using `urn:ietf:params:xml:ns:dmarc-2.0`. +- RFC 9990-style reports with optional metadata such as `version`, `generator`, `extra_contact_info`, repeated `error` values, `np`, `fo`, `testing`, `discovery_method`, `envelope_to`, policy override reasons, `human_result`, SPF `scope`, and namespaced extension elements. + +## Preserved Metadata + +Newer optional fields are parsed without changing the legacy response shape that existing screens use. When a database is configured, DMARQ also persists the optional report metadata, policy metadata, record identifiers, policy override reasons, and extension payloads so exports and future views can use them. + +CSV exports include the most useful aggregate metadata for operators: + +- `subdomain_policy` +- `non_subdomain_policy` +- `adkim` +- `aspf` +- `failure_options` +- `testing` +- `discovery_method` +- `schema_version` +- `report_variant` +- `generator` + +## Known Edge Cases + +- Unknown namespaced extension fields are preserved as best-effort key/value data after namespace prefixes are stripped by the XML parser. +- Malformed optional timestamps and counts use safe defaults so one bad optional value does not reject the whole report. +- Reports with no `` elements import with a zero-count summary. +- Unsupported attachments are skipped by IMAP and Gmail import paths and recorded in import details when stats are available. +- For duplicate detection, DMARQ uses the domain and `report_id` pair. Fixture report IDs must remain unique within a single test import run. + +## Fixture Pack + +The current fixture pack covers: + +- `rfc7489-google.xml`: legacy no-namespace aggregate report. +- `rfc9990-namespaced-legacy-fields.xml`: namespaced aggregate report that keeps legacy fields working. +- `rfc9990-treewalk-extension.xml`: RFC 9990-style policy metadata, treewalk discovery, `envelope_to`, override reasons, auth-result details, and report/record extensions. +- `rfc9990-multi-auth-overrides.xml`: multiple records, multiple DKIM auth results, policy override reasons, PSD-style discovery, and nested vendor extensions. + +The compatibility tests exercise every fixture through parser extraction, upload import, IMAP attachment import, and Gmail attachment import. + +## Adding Fixtures + +1. Add the XML file under `backend/app/tests/fixtures/dmarc_aggregate`. +2. Add its expected metadata to `DMARC_COMPATIBILITY_FIXTURES` in `backend/app/tests/test_data.py`. +3. Include a unique `report_id`, stable domain, expected variant, expected total count, and any policy fields that should be asserted. +4. Run `pytest backend/app/tests/test_dmarc_compatibility_fixtures.py backend/app/tests/test_dmarc_parser.py backend/app/tests/test_reports_api.py`.