Merge pull request #180 from christianlouis/codex/m11-compat-fixtures-docs
[codex] Add DMARC compatibility fixture pack
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>google.com</org_name>
|
||||||
|
<email>noreply-dmarc-support@google.com</email>
|
||||||
|
<report_id>123456789</report_id>
|
||||||
|
<date_range>
|
||||||
|
<begin>1597449600</begin>
|
||||||
|
<end>1597535999</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>203.0.113.1</source_ip>
|
||||||
|
<count>2</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>pass</dkim>
|
||||||
|
<spf>fail</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers>
|
||||||
|
<header_from>example.com</header_from>
|
||||||
|
</identifiers>
|
||||||
|
<auth_results>
|
||||||
|
<dkim>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<result>pass</result>
|
||||||
|
<selector>default</selector>
|
||||||
|
</dkim>
|
||||||
|
<spf>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<result>fail</result>
|
||||||
|
</spf>
|
||||||
|
</auth_results>
|
||||||
|
</record>
|
||||||
|
</feedback>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
||||||
|
<version>1.0</version>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>Mailbox Provider</org_name>
|
||||||
|
<email>dmarc-reports@mailbox.example</email>
|
||||||
|
<extra_contact_info>mailto:dmarc-help@mailbox.example</extra_contact_info>
|
||||||
|
<report_id>fixture-rfc9990-multi-auth</report_id>
|
||||||
|
<date_range>
|
||||||
|
<begin>1779580800</begin>
|
||||||
|
<end>1779667199</end>
|
||||||
|
</date_range>
|
||||||
|
<generator>MailboxRUA 2026.05</generator>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>example.net</domain>
|
||||||
|
<discovery_method>psd</discovery_method>
|
||||||
|
<p>reject</p>
|
||||||
|
<sp>quarantine</sp>
|
||||||
|
<np>reject</np>
|
||||||
|
<fo>1:d:s</fo>
|
||||||
|
<adkim>r</adkim>
|
||||||
|
<aspf>s</aspf>
|
||||||
|
<pct>75</pct>
|
||||||
|
<testing>n</testing>
|
||||||
|
</policy_published>
|
||||||
|
<extension>
|
||||||
|
<vendor:provider>
|
||||||
|
<vendor:name>Mailbox Provider</vendor:name>
|
||||||
|
<vendor:region>us-east</vendor:region>
|
||||||
|
</vendor:provider>
|
||||||
|
</extension>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>192.0.2.44</source_ip>
|
||||||
|
<count>10</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>reject</disposition>
|
||||||
|
<dkim>fail</dkim>
|
||||||
|
<spf>fail</spf>
|
||||||
|
<reason>
|
||||||
|
<type>forwarded</type>
|
||||||
|
<comment>forwarder modified body</comment>
|
||||||
|
</reason>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers>
|
||||||
|
<header_from>billing.example.net</header_from>
|
||||||
|
<envelope_from>bounce.example.net</envelope_from>
|
||||||
|
<envelope_to>customer.example.org</envelope_to>
|
||||||
|
</identifiers>
|
||||||
|
<auth_results>
|
||||||
|
<dkim>
|
||||||
|
<domain>example.net</domain>
|
||||||
|
<selector>mail</selector>
|
||||||
|
<result>fail</result>
|
||||||
|
<human_result>signature expired</human_result>
|
||||||
|
</dkim>
|
||||||
|
<dkim>
|
||||||
|
<domain>vendor.example</domain>
|
||||||
|
<selector>relay</selector>
|
||||||
|
<result>pass</result>
|
||||||
|
</dkim>
|
||||||
|
<spf>
|
||||||
|
<domain>bounce.example.net</domain>
|
||||||
|
<scope>mfrom</scope>
|
||||||
|
<result>fail</result>
|
||||||
|
<human_result>ip not authorized</human_result>
|
||||||
|
</spf>
|
||||||
|
</auth_results>
|
||||||
|
<vendor:category>transactional</vendor:category>
|
||||||
|
</record>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>198.51.100.77</source_ip>
|
||||||
|
<count>5</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>pass</dkim>
|
||||||
|
<spf>fail</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers>
|
||||||
|
<header_from>news.example.net</header_from>
|
||||||
|
<envelope_from>mailer.example.net</envelope_from>
|
||||||
|
</identifiers>
|
||||||
|
<auth_results>
|
||||||
|
<dkim>
|
||||||
|
<domain>example.net</domain>
|
||||||
|
<selector>news</selector>
|
||||||
|
<result>pass</result>
|
||||||
|
</dkim>
|
||||||
|
<spf>
|
||||||
|
<domain>mailer.example.net</domain>
|
||||||
|
<scope>mfrom</scope>
|
||||||
|
<result>fail</result>
|
||||||
|
</spf>
|
||||||
|
</auth_results>
|
||||||
|
</record>
|
||||||
|
</feedback>
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>web.de</org_name>
|
||||||
|
<email>dmarc@web.de</email>
|
||||||
|
<report_id>987654321</report_id>
|
||||||
|
<date_range>
|
||||||
|
<begin>1597449600</begin>
|
||||||
|
<end>1597535999</end>
|
||||||
|
</date_range>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<adkim>r</adkim>
|
||||||
|
<aspf>r</aspf>
|
||||||
|
<p>reject</p>
|
||||||
|
<sp>reject</sp>
|
||||||
|
<pct>100</pct>
|
||||||
|
</policy_published>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>198.51.100.5</source_ip>
|
||||||
|
<count>3</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>reject</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>
|
||||||
|
<selector>s1</selector>
|
||||||
|
</dkim>
|
||||||
|
<spf>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<result>pass</result>
|
||||||
|
</spf>
|
||||||
|
</auth_results>
|
||||||
|
</record>
|
||||||
|
</feedback>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
||||||
|
<version>1.0</version>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>Example Receiver</org_name>
|
||||||
|
<email>dmarc@example.test</email>
|
||||||
|
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
||||||
|
<report_id>fixture-rfc9990-treewalk</report_id>
|
||||||
|
<date_range>
|
||||||
|
<begin>1779494400</begin>
|
||||||
|
<end>1779580799</end>
|
||||||
|
</date_range>
|
||||||
|
<error>Multiple DMARC records were ignored before treewalk.</error>
|
||||||
|
<generator>ExampleRUA 2.0</generator>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>example.org</domain>
|
||||||
|
<discovery_method>treewalk</discovery_method>
|
||||||
|
<p>quarantine</p>
|
||||||
|
<sp>reject</sp>
|
||||||
|
<np>none</np>
|
||||||
|
<fo>1</fo>
|
||||||
|
<adkim>s</adkim>
|
||||||
|
<aspf>r</aspf>
|
||||||
|
<testing>y</testing>
|
||||||
|
</policy_published>
|
||||||
|
<extension>
|
||||||
|
<vendor:receiver>mx1.example.test</vendor:receiver>
|
||||||
|
</extension>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>2001:db8::1</source_ip>
|
||||||
|
<count>5</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>quarantine</disposition>
|
||||||
|
<dkim>fail</dkim>
|
||||||
|
<spf>pass</spf>
|
||||||
|
<reason>
|
||||||
|
<type>local_policy</type>
|
||||||
|
<comment>trusted relay</comment>
|
||||||
|
</reason>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers>
|
||||||
|
<header_from>news.example.org</header_from>
|
||||||
|
<envelope_from>bounce.example.org</envelope_from>
|
||||||
|
<envelope_to>customer.example.net</envelope_to>
|
||||||
|
</identifiers>
|
||||||
|
<auth_results>
|
||||||
|
<dkim>
|
||||||
|
<domain>example.net</domain>
|
||||||
|
<selector>selector1</selector>
|
||||||
|
<result>fail</result>
|
||||||
|
<human_result>body hash did not verify</human_result>
|
||||||
|
</dkim>
|
||||||
|
<spf>
|
||||||
|
<domain>bounce.example.org</domain>
|
||||||
|
<scope>mfrom</scope>
|
||||||
|
<result>pass</result>
|
||||||
|
<human_result>sender authorized</human_result>
|
||||||
|
</spf>
|
||||||
|
</auth_results>
|
||||||
|
<vendor:source>mail-platform</vendor:source>
|
||||||
|
</record>
|
||||||
|
</feedback>
|
||||||
@@ -1,97 +1,69 @@
|
|||||||
"""Shared test data for DMARC report tests."""
|
"""Shared test data for DMARC report tests."""
|
||||||
|
|
||||||
SAMPLE_XML = """\
|
from pathlib import Path
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<feedback>
|
|
||||||
<report_metadata>
|
|
||||||
<org_name>google.com</org_name>
|
|
||||||
<email>noreply-dmarc-support@google.com</email>
|
|
||||||
<report_id>123456789</report_id>
|
|
||||||
<date_range>
|
|
||||||
<begin>1597449600</begin>
|
|
||||||
<end>1597535999</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>203.0.113.1</source_ip>
|
|
||||||
<count>2</count>
|
|
||||||
<policy_evaluated>
|
|
||||||
<disposition>none</disposition>
|
|
||||||
<dkim>pass</dkim>
|
|
||||||
<spf>fail</spf>
|
|
||||||
</policy_evaluated>
|
|
||||||
</row>
|
|
||||||
<identifiers>
|
|
||||||
<header_from>example.com</header_from>
|
|
||||||
</identifiers>
|
|
||||||
<auth_results>
|
|
||||||
<dkim>
|
|
||||||
<domain>example.com</domain>
|
|
||||||
<result>pass</result>
|
|
||||||
<selector>default</selector>
|
|
||||||
</dkim>
|
|
||||||
<spf>
|
|
||||||
<domain>example.com</domain>
|
|
||||||
<result>fail</result>
|
|
||||||
</spf>
|
|
||||||
</auth_results>
|
|
||||||
</record>
|
|
||||||
</feedback>
|
|
||||||
"""
|
|
||||||
|
|
||||||
SAMPLE_XML_WITH_NAMESPACE = """\
|
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
DMARC_FIXTURE_DIR = Path(__file__).with_name("fixtures") / "dmarc_aggregate"
|
||||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
|
|
||||||
<report_metadata>
|
|
||||||
<org_name>web.de</org_name>
|
def load_dmarc_fixture(filename: str) -> str:
|
||||||
<email>dmarc@web.de</email>
|
"""Load a curated aggregate-report fixture as text."""
|
||||||
<report_id>987654321</report_id>
|
return (DMARC_FIXTURE_DIR / filename).read_text(encoding="utf-8")
|
||||||
<date_range>
|
|
||||||
<begin>1597449600</begin>
|
|
||||||
<end>1597535999</end>
|
DMARC_COMPATIBILITY_FIXTURES = [
|
||||||
</date_range>
|
{
|
||||||
</report_metadata>
|
"id": "rfc7489-google",
|
||||||
<policy_published>
|
"filename": "rfc7489-google.xml",
|
||||||
<domain>example.com</domain>
|
"domain": "example.com",
|
||||||
<adkim>r</adkim>
|
"report_id": "123456789",
|
||||||
<aspf>r</aspf>
|
"variant": "rfc7489-compatible",
|
||||||
<p>reject</p>
|
"total_count": 2,
|
||||||
<sp>reject</sp>
|
},
|
||||||
<pct>100</pct>
|
{
|
||||||
</policy_published>
|
"id": "rfc9990-namespaced-legacy-fields",
|
||||||
<record>
|
"filename": "rfc9990-namespaced-legacy-fields.xml",
|
||||||
<row>
|
"domain": "example.com",
|
||||||
<source_ip>198.51.100.5</source_ip>
|
"report_id": "987654321",
|
||||||
<count>3</count>
|
"variant": "rfc9990",
|
||||||
<policy_evaluated>
|
"total_count": 3,
|
||||||
<disposition>reject</disposition>
|
},
|
||||||
<dkim>pass</dkim>
|
{
|
||||||
<spf>pass</spf>
|
"id": "rfc9990-treewalk-extension",
|
||||||
</policy_evaluated>
|
"filename": "rfc9990-treewalk-extension.xml",
|
||||||
</row>
|
"domain": "example.org",
|
||||||
<identifiers>
|
"report_id": "fixture-rfc9990-treewalk",
|
||||||
<header_from>example.com</header_from>
|
"variant": "rfc9990",
|
||||||
</identifiers>
|
"schema_version": "1.0",
|
||||||
<auth_results>
|
"total_count": 5,
|
||||||
<dkim>
|
"policy": {
|
||||||
<domain>example.com</domain>
|
"p": "quarantine",
|
||||||
<result>pass</result>
|
"sp": "reject",
|
||||||
<selector>s1</selector>
|
"np": "none",
|
||||||
</dkim>
|
"fo": "1",
|
||||||
<spf>
|
"testing": "y",
|
||||||
<domain>example.com</domain>
|
"discovery_method": "treewalk",
|
||||||
<result>pass</result>
|
},
|
||||||
</spf>
|
},
|
||||||
</auth_results>
|
{
|
||||||
</record>
|
"id": "rfc9990-multi-auth-overrides",
|
||||||
</feedback>
|
"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")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -4,7 +4,7 @@ import zipfile
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.dmarc_parser import DMARCParser
|
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:
|
class TestDMARCParser:
|
||||||
@@ -132,80 +132,14 @@ class TestDMARCParser:
|
|||||||
|
|
||||||
def test_parse_rfc9990_style_report_variant(self):
|
def test_parse_rfc9990_style_report_variant(self):
|
||||||
"""RFC 9990-era namespaces and optional fields should parse without breaking legacy shape."""
|
"""RFC 9990-era namespaces and optional fields should parse without breaking legacy shape."""
|
||||||
xml = b"""
|
xml = load_dmarc_fixture("rfc9990-treewalk-extension.xml").encode("utf-8")
|
||||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0"
|
|
||||||
xmlns:vendor="https://reports.example.test/dmarc">
|
|
||||||
<version>1.0</version>
|
|
||||||
<report_metadata>
|
|
||||||
<org_name>Example Receiver</org_name>
|
|
||||||
<email>dmarc@example.test</email>
|
|
||||||
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
|
||||||
<report_id>2026-05-23-example.org</report_id>
|
|
||||||
<date_range>
|
|
||||||
<begin>1779494400</begin>
|
|
||||||
<end>1779580799</end>
|
|
||||||
</date_range>
|
|
||||||
<error>Multiple DMARC records were ignored before treewalk.</error>
|
|
||||||
<generator>ExampleRUA 2.0</generator>
|
|
||||||
</report_metadata>
|
|
||||||
<policy_published>
|
|
||||||
<domain>example.org</domain>
|
|
||||||
<discovery_method>treewalk</discovery_method>
|
|
||||||
<p>quarantine</p>
|
|
||||||
<sp>reject</sp>
|
|
||||||
<np>none</np>
|
|
||||||
<fo>1</fo>
|
|
||||||
<adkim>s</adkim>
|
|
||||||
<aspf>r</aspf>
|
|
||||||
<testing>y</testing>
|
|
||||||
</policy_published>
|
|
||||||
<extension>
|
|
||||||
<vendor:receiver>mx1.example.test</vendor:receiver>
|
|
||||||
</extension>
|
|
||||||
<record>
|
|
||||||
<row>
|
|
||||||
<source_ip>2001:db8::1</source_ip>
|
|
||||||
<count>5</count>
|
|
||||||
<policy_evaluated>
|
|
||||||
<disposition>quarantine</disposition>
|
|
||||||
<dkim>fail</dkim>
|
|
||||||
<spf>pass</spf>
|
|
||||||
<reason>
|
|
||||||
<type>local_policy</type>
|
|
||||||
<comment>trusted relay</comment>
|
|
||||||
</reason>
|
|
||||||
</policy_evaluated>
|
|
||||||
</row>
|
|
||||||
<identifiers>
|
|
||||||
<header_from>news.example.org</header_from>
|
|
||||||
<envelope_from>bounce.example.org</envelope_from>
|
|
||||||
<envelope_to>customer.example.net</envelope_to>
|
|
||||||
</identifiers>
|
|
||||||
<auth_results>
|
|
||||||
<dkim>
|
|
||||||
<domain>example.net</domain>
|
|
||||||
<selector>selector1</selector>
|
|
||||||
<result>fail</result>
|
|
||||||
<human_result>body hash did not verify</human_result>
|
|
||||||
</dkim>
|
|
||||||
<spf>
|
|
||||||
<domain>bounce.example.org</domain>
|
|
||||||
<scope>mfrom</scope>
|
|
||||||
<result>pass</result>
|
|
||||||
<human_result>sender authorized</human_result>
|
|
||||||
</spf>
|
|
||||||
</auth_results>
|
|
||||||
<vendor:source>mail-platform</vendor:source>
|
|
||||||
</record>
|
|
||||||
</feedback>
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = DMARCParser.parse_file(xml, "report.xml")
|
result = DMARCParser.parse_file(xml, "report.xml")
|
||||||
|
|
||||||
assert result["variant"] == "rfc9990"
|
assert result["variant"] == "rfc9990"
|
||||||
assert result["schema_version"] == "1.0"
|
assert result["schema_version"] == "1.0"
|
||||||
assert result["xml_namespace"] == "urn:ietf:params:xml:ns:dmarc-2.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["generator"] == "ExampleRUA 2.0"
|
||||||
assert result["errors"] == ["Multiple DMARC records were ignored before treewalk."]
|
assert result["errors"] == ["Multiple DMARC records were ignored before treewalk."]
|
||||||
assert result["extensions"] == {"receiver": "mx1.example.test"}
|
assert result["extensions"] == {"receiver": "mx1.example.test"}
|
||||||
|
|||||||
@@ -7,75 +7,10 @@ from app.models.domain import Domain
|
|||||||
from app.models.report import DMARCReport, ReportRecord
|
from app.models.report import DMARCReport, ReportRecord
|
||||||
from app.services.report_persistence import persisted_report_to_dict
|
from app.services.report_persistence import persisted_report_to_dict
|
||||||
from app.services.report_store import ReportStore
|
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 = """
|
SAMPLE_RFC9990_XML = load_dmarc_fixture("rfc9990-treewalk-extension.xml")
|
||||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
|
||||||
<version>1.0</version>
|
|
||||||
<report_metadata>
|
|
||||||
<org_name>Example Receiver</org_name>
|
|
||||||
<email>dmarc@example.test</email>
|
|
||||||
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
|
||||||
<report_id>2026-05-23-example.org</report_id>
|
|
||||||
<date_range>
|
|
||||||
<begin>1779494400</begin>
|
|
||||||
<end>1779580799</end>
|
|
||||||
</date_range>
|
|
||||||
<error>Multiple records ignored.</error>
|
|
||||||
<generator>ExampleRUA 2.0</generator>
|
|
||||||
</report_metadata>
|
|
||||||
<policy_published>
|
|
||||||
<domain>example.org</domain>
|
|
||||||
<discovery_method>treewalk</discovery_method>
|
|
||||||
<p>quarantine</p>
|
|
||||||
<sp>reject</sp>
|
|
||||||
<np>none</np>
|
|
||||||
<fo>1</fo>
|
|
||||||
<adkim>s</adkim>
|
|
||||||
<aspf>r</aspf>
|
|
||||||
<testing>y</testing>
|
|
||||||
</policy_published>
|
|
||||||
<extension>
|
|
||||||
<vendor:receiver>mx1.example.test</vendor:receiver>
|
|
||||||
</extension>
|
|
||||||
<record>
|
|
||||||
<row>
|
|
||||||
<source_ip>2001:db8::1</source_ip>
|
|
||||||
<count>5</count>
|
|
||||||
<policy_evaluated>
|
|
||||||
<disposition>quarantine</disposition>
|
|
||||||
<dkim>fail</dkim>
|
|
||||||
<spf>pass</spf>
|
|
||||||
<reason>
|
|
||||||
<type>local_policy</type>
|
|
||||||
<comment>trusted relay</comment>
|
|
||||||
</reason>
|
|
||||||
</policy_evaluated>
|
|
||||||
</row>
|
|
||||||
<identifiers>
|
|
||||||
<header_from>news.example.org</header_from>
|
|
||||||
<envelope_from>bounce.example.org</envelope_from>
|
|
||||||
<envelope_to>customer.example.net</envelope_to>
|
|
||||||
</identifiers>
|
|
||||||
<auth_results>
|
|
||||||
<dkim>
|
|
||||||
<domain>example.net</domain>
|
|
||||||
<selector>selector1</selector>
|
|
||||||
<result>fail</result>
|
|
||||||
<human_result>body hash did not verify</human_result>
|
|
||||||
</dkim>
|
|
||||||
<spf>
|
|
||||||
<domain>bounce.example.org</domain>
|
|
||||||
<scope>mfrom</scope>
|
|
||||||
<result>pass</result>
|
|
||||||
<human_result>sender authorized</human_result>
|
|
||||||
</spf>
|
|
||||||
</auth_results>
|
|
||||||
<vendor:source>mail-platform</vendor:source>
|
|
||||||
</record>
|
|
||||||
</feedback>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _make_zip(xml_content: str) -> bytes:
|
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
|
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.domain.name == "example.org"
|
||||||
assert report.extra_contact_info == "https://example.test/dmarc"
|
assert report.extra_contact_info == "https://example.test/dmarc"
|
||||||
assert report.generator == "ExampleRUA 2.0"
|
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.failure_options == "1"
|
||||||
assert report.testing == "y"
|
assert report.testing == "y"
|
||||||
assert report.discovery_method == "treewalk"
|
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
|
assert "mx1.example.test" in report.report_extensions
|
||||||
|
|
||||||
record = db_session.query(ReportRecord).filter_by(report_id=report.id).one()
|
record = db_session.query(ReportRecord).filter_by(report_id=report.id).one()
|
||||||
|
|||||||
@@ -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.
|
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 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).
|
||||||
|
|||||||
+4
-7
@@ -163,7 +163,7 @@ Exit criteria:
|
|||||||
|
|
||||||
## Milestone 10: Forensic Report Support
|
## Milestone 10: Forensic Report Support
|
||||||
|
|
||||||
Status: In Progress
|
Status: Complete
|
||||||
|
|
||||||
Goal: support DMARC RUF/forensic reports for individual failure investigation.
|
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
|
## 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.
|
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.
|
- 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.
|
- 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.
|
- Persist and CSV-export newly introduced aggregate metadata with nullable, backward-safe database fields.
|
||||||
|
- Add a fixture-driven compatibility pack covering parser, upload, IMAP, and Gmail import paths with supported-format documentation.
|
||||||
Planned:
|
- Verify domain report views and CSV exports render fixture-backed DMARCbis-style imports correctly.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
- A DMARCbis-style aggregate report can be imported via upload/IMAP/Gmail and renders correctly in dashboards and exports.
|
- A DMARCbis-style aggregate report can be imported via upload/IMAP/Gmail and renders correctly in dashboards and exports.
|
||||||
|
|||||||
@@ -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 `<record>` 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`.
|
||||||
Reference in New Issue
Block a user