Files
parsedmarc/tests/test_webhook.py
T
df9bf82e04 Post-review follow-ups for Graph send (#825/#826) and requests-to-httpx migration (#827)
Follow-ups from the review of PR #825 (whose implementation had already
landed on master via #826's stacked merge):

- Honor the documented [smtp] attachment and [smtp] message options.
  Both were parsed into opts but never passed to either summary-email
  transport (also broken in released 10.2.2), so a configured custom
  attachment filename or message body was silently ignored. Both the
  SMTP and Microsoft Graph transports now receive them, and the missing
  smtp_attachment Namespace default is added (also covers SIGHUP
  reload, which rebuilds opts from the CLI Namespace).
- Don't mislabel non-Graph mailbox errors as Microsoft Graph failures:
  the shared mailbox-fetch and watch handlers now log a generic
  "Mailbox Error" with traceback when the connection isn't Graph.
- Declare microsoft-kiota-abstractions as a direct dependency (imported
  directly in cli.py for Graph error handling; previously transitive).

Migrate all runtime HTTP from requests to httpx (webhook client, Splunk
HEC client, and the PSL-overrides / IP-database / reverse-DNS-map /
IPinfo-API fetches in utils.py):

- follow_redirects=True everywhere to preserve requests' default
  redirect-following; httpx does not follow redirects by default.
- The PSL-overrides and reverse-DNS-map fetches gain a 60s timeout
  (previously none), matching the IP-database fetch.
- response.ok -> response.is_success; requests.RequestException ->
  httpx.HTTPError; raw string bodies use content= (httpx's data= is
  form-encoding only); Splunk HEC verification moves to client
  construction (httpx has no per-request verify).
- requests drops out of [project] dependencies and moves to the [build]
  extra for the out-of-wheel maintainer script collect_domain_info.py,
  which deliberately stays on requests/urllib3 for its permissive-TLS
  adapter.
- Remove the requests-era module-level
  urllib3.disable_warnings(InsecureRequestWarning) in splunk.py; httpx
  doesn't route through urllib3, so its only remaining effect was
  globally silencing insecure-TLS warnings from other urllib3-based
  components as an import side effect. Nothing imports urllib3 directly
  anymore, so it also leaves [project] dependencies.

Tests: config-to-transport wiring for attachment/message on both
transports (including defaults), non-Graph errors keep the generic log
line, webhook/Splunk payload assertions moved to content=, and Splunk
verify asserted at httpx.Client construction. 736 passed; ruff and
pyright clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:11:47 -04:00

136 lines
5.0 KiB
Python

"""Tests for parsedmarc.webhook"""
import unittest
from unittest.mock import MagicMock
from parsedmarc.webhook import WebhookClient
def _client():
return WebhookClient(
aggregate_url="http://agg.example.com",
failure_url="http://fail.example.com",
smtp_tls_url="http://tls.example.com",
)
class TestWebhookClientInit(unittest.TestCase):
"""The constructor stores URLs per report type. A mix-up here
would route reports to the wrong endpoint silently."""
def test_urls_and_timeout_stored(self):
client = _client()
self.assertEqual(client.aggregate_url, "http://agg.example.com")
self.assertEqual(client.failure_url, "http://fail.example.com")
self.assertEqual(client.smtp_tls_url, "http://tls.example.com")
self.assertEqual(client.timeout, 60)
def test_custom_timeout_respected(self):
client = WebhookClient(
aggregate_url="a", failure_url="f", smtp_tls_url="t", timeout=120
)
self.assertEqual(client.timeout, 120)
def test_session_headers_set(self):
"""The Content-Type is required by virtually every webhook
receiver to know how to deserialize the body."""
client = _client()
self.assertEqual(client.session.headers["Content-Type"], "application/json")
self.assertIn("parsedmarc", client.session.headers["User-Agent"])
class TestWebhookClientSaveMethods(unittest.TestCase):
"""Each save_* sends the payload to the URL configured for that
report type. A typo on which URL each method uses would
permanently mis-route reports of that type."""
def test_aggregate_posts_to_aggregate_url(self):
client = _client()
client.session = MagicMock()
client.save_aggregate_report_to_webhook('{"agg": 1}')
client.session.post.assert_called_once_with(
"http://agg.example.com", content='{"agg": 1}', timeout=60
)
def test_failure_posts_to_failure_url(self):
client = _client()
client.session = MagicMock()
client.save_failure_report_to_webhook('{"fail": 1}')
client.session.post.assert_called_once_with(
"http://fail.example.com", content='{"fail": 1}', timeout=60
)
def test_smtp_tls_posts_to_smtp_tls_url(self):
client = _client()
client.session = MagicMock()
client.save_smtp_tls_report_to_webhook('{"tls": 1}')
client.session.post.assert_called_once_with(
"http://tls.example.com", content='{"tls": 1}', timeout=60
)
class TestWebhookClientDictPayload(unittest.TestCase):
"""``_send_to_webhook`` accepts ``bytes | str | dict``. httpx only
form-encodes a dict via ``data=``; string/bytes payloads must use
``content=`` since httpx's ``data=`` is form-encoding only."""
def test_dict_payload_uses_data_kwarg(self):
client = _client()
client.session = MagicMock()
client._send_to_webhook("http://agg.example.com", {"agg": 1})
client.session.post.assert_called_once_with(
"http://agg.example.com", data={"agg": 1}, timeout=60
)
class TestWebhookErrorHandling(unittest.TestCase):
"""HTTP / network failures from the webhook receiver must NOT
abort the surrounding parse-and-output batch — they're logged
and swallowed. Misbehaving webhooks shouldn't take down DMARC
processing."""
def test_network_error_is_logged_and_swallowed(self):
client = _client()
client.session = MagicMock()
client.session.post.side_effect = OSError("connection refused")
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
# Should NOT raise.
client.save_aggregate_report_to_webhook('{"a": 1}')
self.assertTrue(any("Webhook Error" in m for m in cm.output))
self.assertTrue(any("connection refused" in m for m in cm.output))
def test_error_in_failure_save_is_swallowed(self):
client = _client()
client.session = MagicMock()
client.session.post.side_effect = RuntimeError("timeout")
with self.assertLogs("parsedmarc.log", level="ERROR"):
client.save_failure_report_to_webhook('{"f": 1}')
def test_error_in_smtp_tls_save_is_swallowed(self):
client = _client()
client.session = MagicMock()
client.session.post.side_effect = RuntimeError("boom")
with self.assertLogs("parsedmarc.log", level="ERROR"):
client.save_smtp_tls_report_to_webhook('{"t": 1}')
class TestWebhookClientClose(unittest.TestCase):
def test_close_closes_session(self):
client = _client()
mock_close = MagicMock()
client.session.close = mock_close
client.close()
mock_close.assert_called_once()
class TestWebhookBackwardCompatAlias(unittest.TestCase):
def test_forensic_alias_points_to_failure_method(self):
self.assertIs(
WebhookClient.save_forensic_report_to_webhook,
WebhookClient.save_failure_report_to_webhook,
)
if __name__ == "__main__":
unittest.main(verbosity=2)