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>
This commit is contained in:
Sean Whalen
2026-07-14 16:11:47 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 31c928d6fc
commit df9bf82e04
11 changed files with 335 additions and 93 deletions
+170
View File
@@ -2044,6 +2044,38 @@ subject = DMARC Summary
self.assertIn("request-id=rid-1", output)
self.assertIn("client-request-id=crid-1", output)
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.MSGraphConnection")
def testCliPassesSmtpAttachmentAndMessageToMsGraphSend(
self, mock_graph_connection, mock_get_mailbox_reports
):
"""[smtp] attachment/message are parsed but were never wired
through to either summary-email transport. On the Microsoft
Graph path, the configured attachment filename and message
body must reach send_message()."""
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = (
self.CERT_CONFIG
+ """
[smtp]
to = admin@example.com
attachment = custom-report.zip
message = Custom body text
"""
)
cfg_path = self._write_config(config_text)
self._run_main(cfg_path)
send_message = mock_graph_connection.return_value.send_message
send_message.assert_called_once()
call_kwargs = send_message.call_args.kwargs
self.assertEqual(call_kwargs["attachments"][0][0], "custom-report.zip")
self.assertEqual(call_kwargs["plain_message"], "Custom body text")
class TestMSGraphFailureLogging(unittest.TestCase):
"""Microsoft Graph connection/fetch/watch failures log a single
@@ -2208,6 +2240,42 @@ certificate_password = s3cret-cert-pass
self.assertIn("Microsoft Graph mailbox watch failed", output)
self.assertIn("ConnectError", output)
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.IMAPConnection")
def testNonGraphMailboxErrorIsNotMislabeledAsMsGraph(
self, mock_imap_connection, mock_get_mailbox_reports
):
"""The mailbox-fetch handler catches
(ClientAuthenticationError, APIError, httpx.HTTPError) on every
mailbox backend, not just Graph, since httpx.HTTPError can in
principle surface from any HTTP-backed connection. Before this
fix, such an error on a non-Graph connection (e.g. IMAP) still
went through _log_msgraph_failure() and logged a misleading
"Microsoft Graph ... failed (mailbox=None, tenant_id=None,
auth_method=None)" line. It must now log a generic
"Mailbox Error" instead."""
mock_imap_connection.return_value = object()
mock_get_mailbox_reports.side_effect = httpx.ConnectError("boom")
config_text = """[general]
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
"""
cfg_path = self._write_config(config_text)
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
with self.assertRaises(SystemExit) as system_exit:
self._run_main(cfg_path)
self.assertEqual(system_exit.exception.code, 1)
output = "\n".join(cm.output)
self.assertIn("Mailbox Error", output)
self.assertNotIn("Microsoft Graph", output)
class TestSighupReload(unittest.TestCase):
"""Tests for SIGHUP-driven configuration reload in watch mode."""
@@ -3637,6 +3705,108 @@ class TestParseConfigSmtp(unittest.TestCase):
_parse_config(cp, _opts())
class TestSmtpAttachmentAndMessageWiring(unittest.TestCase):
"""[smtp] attachment/message are documented and parsed into
opts.smtp_attachment/opts.smtp_message, but were never passed to
email_results(), so a configured custom attachment filename or
message body was silently ignored on the SMTP transport."""
def _write_config(self, config_text):
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
cfg.write(config_text)
cfg_path = cfg.name
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
return cfg_path
def _run_main(self, cfg_path, *cli_args):
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, *cli_args]):
parsedmarc.cli._main()
@patch("parsedmarc.cli.email_results")
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.IMAPConnection")
def testSmtpAttachmentAndMessageArePassedToEmailResults(
self, mock_imap_connection, mock_get_mailbox_reports, mock_email_results
):
"""A configured attachment filename and message body reach
email_results() rather than being silently dropped."""
mock_imap_connection.return_value = object()
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = """[general]
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
[smtp]
host = smtp.example.com
user = smtp-user
password = smtp-password
from = dmarc@example.com
to = admin@example.com
attachment = custom-report.zip
message = Custom body text
"""
cfg_path = self._write_config(config_text)
self._run_main(cfg_path)
mock_email_results.assert_called_once()
call_kwargs = mock_email_results.call_args.kwargs
# The configured value passes through _expand_path(), which is a
# no-op here since the filename has no ~ or $VAR references.
self.assertTrue(
call_kwargs["attachment_filename"].endswith("custom-report.zip")
)
self.assertEqual(call_kwargs["message"], "Custom body text")
@patch("parsedmarc.cli.email_results")
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
@patch("parsedmarc.cli.IMAPConnection")
def testSmtpDefaultsFlowThroughWhenNotConfigured(
self, mock_imap_connection, mock_get_mailbox_reports, mock_email_results
):
"""When [smtp] attachment/message are not set, email_results()
still gets the documented defaults (None for the attachment
filename, and the long-documented default message body) rather
than being called with no attachment/message context at all."""
mock_imap_connection.return_value = object()
mock_get_mailbox_reports.return_value = {
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
}
config_text = """[general]
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
[smtp]
host = smtp.example.com
user = smtp-user
password = smtp-password
from = dmarc@example.com
to = admin@example.com
"""
cfg_path = self._write_config(config_text)
self._run_main(cfg_path)
mock_email_results.assert_called_once()
call_kwargs = mock_email_results.call_args.kwargs
self.assertIsNone(call_kwargs["attachment_filename"])
self.assertEqual(
call_kwargs["message"], "Please see the attached DMARC results."
)
class TestParseConfigS3(unittest.TestCase):
def test_s3_complete(self):
from parsedmarc.cli import _parse_config
+36 -11
View File
@@ -3,7 +3,7 @@
import json
import time
import unittest
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from parsedmarc.splunk import HECClient, SplunkError
from tests.tzutil import force_tz
@@ -211,7 +211,7 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_aggregate_reports_to_splunk(report)
body = client.session.post.call_args.kwargs["data"]
body = client.session.post.call_args.kwargs["content"]
events = [json.loads(line) for line in body.strip().split("\n")]
self.assertEqual(len(events), 2)
for event in events:
@@ -225,7 +225,7 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_aggregate_reports_to_splunk(_aggregate_report())
body = client.session.post.call_args.kwargs["data"]
body = client.session.post.call_args.kwargs["content"]
event = json.loads(body.strip())["event"]
self.assertEqual(event["source_ip_address"], "192.0.2.1")
self.assertEqual(event["header_from"], "example.com")
@@ -238,7 +238,7 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_aggregate_reports_to_splunk(_aggregate_report())
event = json.loads(client.session.post.call_args.kwargs["data"].strip())[
event = json.loads(client.session.post.call_args.kwargs["content"].strip())[
"event"
]
self.assertEqual(
@@ -258,7 +258,10 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.save_aggregate_reports_to_splunk([])
client.session.post.assert_not_called()
def test_post_uses_session_verify_and_timeout(self):
def test_post_uses_session_timeout(self):
"""httpx has no per-request verify= kwarg — verification is set
at client construction (see test_client_constructed_with_verify_
false below), so only the per-request timeout is asserted here."""
client = HECClient(
url="https://h:8088",
access_token="t",
@@ -270,9 +273,25 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.session.post.return_value = _ok_response()
client.save_aggregate_reports_to_splunk(_aggregate_report())
kwargs = client.session.post.call_args.kwargs
self.assertEqual(kwargs["verify"], False)
self.assertNotIn("verify", kwargs)
self.assertEqual(kwargs["timeout"], 15)
def test_client_constructed_with_verify_false(self):
"""verify=False must disable TLS verification on the underlying
httpx.Client at construction time, since httpx.Client does not
accept a per-request verify= kwarg like requests.Session did.
Mocked at the httpx SDK boundary (httpx.Client itself)."""
with patch("parsedmarc.splunk.httpx.Client") as mock_client_cls:
HECClient(
url="https://h:8088",
access_token="t",
index="dmarc",
verify=False,
timeout=15,
)
_, kwargs = mock_client_cls.call_args
self.assertEqual(kwargs["verify"], False)
def test_non_zero_response_code_raises_splunk_error(self):
"""HEC returns code=0 on success and non-zero codes for
token/index/format errors. The error text from HEC carries
@@ -306,7 +325,9 @@ class TestSaveAggregateReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_aggregate_reports_to_splunk(_aggregate_report())
event_wrapper = json.loads(client.session.post.call_args.kwargs["data"].strip())
event_wrapper = json.loads(
client.session.post.call_args.kwargs["content"].strip()
)
self.assertEqual(event_wrapper["time"], 1704067200)
@@ -318,7 +339,9 @@ class TestSaveFailureReportsToSplunk(unittest.TestCase):
client.save_failure_reports_to_splunk([_failure_report(), _failure_report()])
events = [
json.loads(line)
for line in client.session.post.call_args.kwargs["data"].strip().split("\n")
for line in client.session.post.call_args.kwargs["content"]
.strip()
.split("\n")
]
self.assertEqual(len(events), 2)
for event in events:
@@ -329,7 +352,7 @@ class TestSaveFailureReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_failure_reports_to_splunk(_failure_report())
event = json.loads(client.session.post.call_args.kwargs["data"].strip())[
event = json.loads(client.session.post.call_args.kwargs["content"].strip())[
"event"
]
self.assertEqual(event["reported_domain"], "example.com")
@@ -354,7 +377,7 @@ class TestSaveFailureReportsToSplunk(unittest.TestCase):
client.session = MagicMock()
client.session.post.return_value = _ok_response()
client.save_failure_reports_to_splunk(_failure_report())
event = json.loads(client.session.post.call_args.kwargs["data"].strip())
event = json.loads(client.session.post.call_args.kwargs["content"].strip())
# Fixture arrival_date_utc is 2024-01-01 00:00:00 UTC.
self.assertEqual(event["time"], 1704067200)
@@ -397,7 +420,9 @@ class TestSaveSmtpTlsReportsToSplunk(unittest.TestCase):
client.save_smtp_tls_reports_to_splunk([_smtp_tls_report()])
events = [
json.loads(line)
for line in client.session.post.call_args.kwargs["data"].strip().split("\n")
for line in client.session.post.call_args.kwargs["content"]
.strip()
.split("\n")
]
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["sourcetype"], "smtp:tls")
+30 -34
View File
@@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
import dns.exception
import dns.resolver
import requests
import httpx
from expiringdict import ExpiringDict
import parsedmarc
@@ -159,7 +159,7 @@ class Test(unittest.TestCase):
def _mock_response(status_code, json_body=None):
resp = MagicMock()
resp.status_code = status_code
resp.ok = 200 <= status_code < 300
resp.is_success = 200 <= status_code < 300
resp.json.return_value = json_body or {}
return resp
@@ -173,7 +173,7 @@ class Test(unittest.TestCase):
"country_code": "US",
}
with patch(
"parsedmarc.utils.requests.get",
"parsedmarc.utils.httpx.get",
return_value=_mock_response(200, api_json),
) as mock_get:
configure_ipinfo_api("fake-token", probe=False)
@@ -188,7 +188,7 @@ class Test(unittest.TestCase):
# Invalid key: 401 raises a fatal exception even on a random lookup.
with patch(
"parsedmarc.utils.requests.get",
"parsedmarc.utils.httpx.get",
return_value=_mock_response(401),
):
configure_ipinfo_api("bad-token", probe=False)
@@ -198,7 +198,7 @@ class Test(unittest.TestCase):
# Any other non-2xx (e.g. 500, 503) falls back to the MMDB silently.
configure_ipinfo_api("fake-token", probe=False)
with patch(
"parsedmarc.utils.requests.get",
"parsedmarc.utils.httpx.get",
return_value=_mock_response(500),
):
record = get_ip_address_db_record("8.8.8.8")
@@ -419,7 +419,7 @@ class TestLoadPSLOverrides(unittest.TestCase):
mock_response.text = fake_body
mock_response.raise_for_status = MagicMock()
with patch(
"parsedmarc.utils.requests.get", return_value=mock_response
"parsedmarc.utils.httpx.get", return_value=mock_response
) as mock_get:
result = parsedmarc.utils.load_psl_overrides(url="https://example.test/ov")
self.assertEqual(result, ["-fetched-brand.com", ".cdn-fetched.net"])
@@ -427,11 +427,9 @@ class TestLoadPSLOverrides(unittest.TestCase):
def test_url_failure_falls_back_to_local(self):
"""A network error falls back to the bundled copy."""
import requests
with patch(
"parsedmarc.utils.requests.get",
side_effect=requests.exceptions.ConnectionError("nope"),
"parsedmarc.utils.httpx.get",
side_effect=httpx.ConnectError("nope"),
):
result = parsedmarc.utils.load_psl_overrides(url="https://example.test/ov")
# Bundled file still loaded.
@@ -439,8 +437,8 @@ class TestLoadPSLOverrides(unittest.TestCase):
self.assertIn(".linode.com", result)
def test_always_use_local_skips_network(self):
"""always_use_local_file=True must not call requests.get."""
with patch("parsedmarc.utils.requests.get") as mock_get:
"""always_use_local_file=True must not call httpx.get."""
with patch("parsedmarc.utils.httpx.get") as mock_get:
parsedmarc.utils.load_psl_overrides(always_use_local_file=True)
mock_get.assert_not_called()
@@ -1052,8 +1050,8 @@ class TestUtilsReverseDnsMap(unittest.TestCase):
"""load_reverse_dns_map falls back to bundled on network error"""
rdns_map = {}
with patch(
"parsedmarc.utils.requests.get",
side_effect=requests.exceptions.ConnectionError("no network"),
"parsedmarc.utils.httpx.get",
side_effect=httpx.ConnectError("no network"),
):
parsedmarc.utils.load_reverse_dns_map(rdns_map)
self.assertTrue(len(rdns_map) > 0)
@@ -1065,7 +1063,7 @@ class TestUtilsReverseDnsMap(unittest.TestCase):
response.text = "not,the,map\nfoo,bar,baz\n"
response.raise_for_status.return_value = None
rdns_map = {}
with patch("parsedmarc.utils.requests.get", return_value=response):
with patch("parsedmarc.utils.httpx.get", return_value=response):
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
parsedmarc.utils.load_reverse_dns_map(rdns_map)
self.assertTrue(any("Not a valid CSV file" in message for message in cm.output))
@@ -1172,7 +1170,7 @@ class TestQueryDnsRetries(unittest.TestCase):
class TestLoadIpDb(unittest.TestCase):
"""Tests for the load_ip_db() download/cache/bundled fallback chain,
mocking at the requests SDK boundary."""
mocking at the httpx SDK boundary."""
def setUp(self):
old_ip_db_path = parsedmarc.utils._IP_DB_PATH
@@ -1198,7 +1196,7 @@ class TestLoadIpDb(unittest.TestCase):
local_path = os.path.join(self.tmp_dir, "local.mmdb")
with open(local_path, "wb") as f:
f.write(b"local db")
with patch("parsedmarc.utils.requests.get") as mock_get:
with patch("parsedmarc.utils.httpx.get") as mock_get:
parsedmarc.utils.load_ip_db(local_file_path=local_path)
mock_get.assert_not_called()
self.assertEqual(parsedmarc.utils._IP_DB_PATH, local_path)
@@ -1208,7 +1206,7 @@ class TestLoadIpDb(unittest.TestCase):
response = MagicMock()
response.content = b"downloaded db bytes"
response.raise_for_status.return_value = None
with patch("parsedmarc.utils.requests.get", return_value=response) as mock_get:
with patch("parsedmarc.utils.httpx.get", return_value=response) as mock_get:
parsedmarc.utils.load_ip_db(url="https://example.com/db.mmdb")
self.assertEqual(mock_get.call_args.args[0], "https://example.com/db.mmdb")
cached_path = os.path.join(self.tmp_dir, "parsedmarc", "ipinfo_lite.mmdb")
@@ -1224,8 +1222,8 @@ class TestLoadIpDb(unittest.TestCase):
with open(cached_path, "wb") as f:
f.write(b"stale cached db")
with patch(
"parsedmarc.utils.requests.get",
side_effect=requests.exceptions.ConnectionError("no network"),
"parsedmarc.utils.httpx.get",
side_effect=httpx.ConnectError("no network"),
):
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
parsedmarc.utils.load_ip_db()
@@ -1237,8 +1235,8 @@ class TestLoadIpDb(unittest.TestCase):
def testDownloadFailureFallsBackToBundledCopy(self):
"""On a network error with no cached copy, the bundled db is used"""
with patch(
"parsedmarc.utils.requests.get",
side_effect=requests.exceptions.ConnectionError("no network"),
"parsedmarc.utils.httpx.get",
side_effect=httpx.ConnectError("no network"),
):
parsedmarc.utils.load_ip_db()
bundled = str(files(parsedmarc.resources.ipinfo).joinpath("ipinfo_lite.mmdb"))
@@ -1255,7 +1253,7 @@ class TestLoadIpDb(unittest.TestCase):
response.content = b"downloaded db bytes"
response.raise_for_status.return_value = None
with patch("parsedmarc.utils.tempfile.gettempdir", return_value=blocker):
with patch("parsedmarc.utils.requests.get", return_value=response):
with patch("parsedmarc.utils.httpx.get", return_value=response):
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
parsedmarc.utils.load_ip_db()
self.assertTrue(
@@ -1275,7 +1273,7 @@ class TestConfigureIpinfoApiProbe(unittest.TestCase):
def _response(status_code, json_body=None):
response = MagicMock()
response.status_code = status_code
response.ok = 200 <= status_code < 300
response.is_success = 200 <= status_code < 300
response.json.return_value = json_body if json_body is not None else {}
return response
@@ -1283,7 +1281,7 @@ class TestConfigureIpinfoApiProbe(unittest.TestCase):
"""A successful probe logs that the API is configured"""
api_json = {"ip": "1.1.1.1", "asn": "AS13335", "country_code": "US"}
with patch(
"parsedmarc.utils.requests.get",
"parsedmarc.utils.httpx.get",
return_value=self._response(200, api_json),
):
with self.assertLogs("parsedmarc.log", level="INFO") as cm:
@@ -1296,8 +1294,8 @@ class TestConfigureIpinfoApiProbe(unittest.TestCase):
"""A probe network error logs a warning but keeps the token so
per-request fallback can take over later"""
with patch(
"parsedmarc.utils.requests.get",
side_effect=requests.exceptions.ConnectionError("no network"),
"parsedmarc.utils.httpx.get",
side_effect=httpx.ConnectError("no network"),
):
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
parsedmarc.utils.configure_ipinfo_api("fake-token", probe=True)
@@ -1308,7 +1306,7 @@ class TestConfigureIpinfoApiProbe(unittest.TestCase):
def testProbeInvalidKeyRaises(self):
"""A 401 during the probe raises InvalidIPinfoAPIKey"""
with patch("parsedmarc.utils.requests.get", return_value=self._response(401)):
with patch("parsedmarc.utils.httpx.get", return_value=self._response(401)):
with self.assertRaises(parsedmarc.utils.InvalidIPinfoAPIKey):
parsedmarc.utils.configure_ipinfo_api("bad-token", probe=True)
@@ -1323,7 +1321,7 @@ class TestIpinfoApiLookupFallbacks(unittest.TestCase):
def _assert_mmdb_fallback(self, response=None, side_effect=None):
with patch(
"parsedmarc.utils.requests.get",
"parsedmarc.utils.httpx.get",
return_value=response,
side_effect=side_effect,
):
@@ -1334,21 +1332,19 @@ class TestIpinfoApiLookupFallbacks(unittest.TestCase):
self.assertEqual(record["asn"], 15169)
def testNetworkErrorFallsBackToMmdb(self):
self._assert_mmdb_fallback(
side_effect=requests.exceptions.ConnectionError("no network")
)
self._assert_mmdb_fallback(side_effect=httpx.ConnectError("no network"))
def testNonJsonBodyFallsBackToMmdb(self):
response = MagicMock()
response.status_code = 200
response.ok = True
response.is_success = True
response.json.side_effect = ValueError("not JSON")
self._assert_mmdb_fallback(response=response)
def testNonDictPayloadFallsBackToMmdb(self):
response = MagicMock()
response.status_code = 200
response.ok = True
response.is_success = True
response.json.return_value = ["not", "a", "dict"]
self._assert_mmdb_fallback(response=response)
+17 -3
View File
@@ -49,7 +49,7 @@ class TestWebhookClientSaveMethods(unittest.TestCase):
client.session = MagicMock()
client.save_aggregate_report_to_webhook('{"agg": 1}')
client.session.post.assert_called_once_with(
"http://agg.example.com", data='{"agg": 1}', timeout=60
"http://agg.example.com", content='{"agg": 1}', timeout=60
)
def test_failure_posts_to_failure_url(self):
@@ -57,7 +57,7 @@ class TestWebhookClientSaveMethods(unittest.TestCase):
client.session = MagicMock()
client.save_failure_report_to_webhook('{"fail": 1}')
client.session.post.assert_called_once_with(
"http://fail.example.com", data='{"fail": 1}', timeout=60
"http://fail.example.com", content='{"fail": 1}', timeout=60
)
def test_smtp_tls_posts_to_smtp_tls_url(self):
@@ -65,7 +65,21 @@ class TestWebhookClientSaveMethods(unittest.TestCase):
client.session = MagicMock()
client.save_smtp_tls_report_to_webhook('{"tls": 1}')
client.session.post.assert_called_once_with(
"http://tls.example.com", data='{"tls": 1}', timeout=60
"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
)