mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-11 03:11:33 +00:00
* chore: update IPinfo Lite MMDB (#941) Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Cap report date range and indexes created per report A report whose <date_range> spans millions of days was normalized into one record per day, exhausting memory, and with Elasticsearch or OpenSearch output created one dated index (one shard) per day, exhausting the cluster's shard budget. Reject a range longer than MAX_REPORT_SPAN_DAYS (366) or negative with InvalidAggregateReport. In both save functions, once a report has used MAX_INDEXES_PER_REPORT (31) distinct dated indexes, route further records to the report's first index, keeping their own dates, and log one warning. Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Catch output-stage exceptions and validate SMTP TLS report types One report sent to a monitored mailbox could make a configured output raise an exception that process_reports() did not catch. It escaped through the mailbox save callback, _main logged "Mailbox Error" and exited 1, and the batch stayed in the mailbox, so every one-shot run died on the same message and duplicated the legitimate reports in non-deduplicating outputs. Three sender-controlled inputs did it: - A failure report whose sample has an empty From:, To: or Reply-To: (To: undisclosed-recipients:; included): mail-parser gives an empty address list, and the Elasticsearch and OpenSearch saves indexed [0] (IndexError). - An SMTP TLS report with a wrong-typed field: "policy-string": [1] made the CSV writer raise TypeError; "start-datetime": 5 made the Elasticsearch, OpenSearch and Splunk saves raise AttributeError and _ensure_utc_suffix() in the PostgreSQL save raise TypeError. - An aggregate report whose <reason> has no <type> (KeyError in the CSV writer) or a <type> that xmltodict parses to a dict or a list (TypeError in its join). Fix: - Every output destination block in process_reports() now ends with the catch-all the aggregate Elasticsearch block had: the failure and SMTP TLS Elasticsearch/OpenSearch blocks, every PostgreSQL and Splunk HEC block, and the file output (its OSError/ValueError branch is kept). The error goes through log_output_error(), so the batch is reported unsaved and fail_on_output_error still raises ParserError. - parse_smtp_tls_report_json() and its helpers check each field against the type RFC 8460 section 4.4 gives it and raise InvalidSMTPTLSReport otherwise: strings for organization-name, report-id, policy-domain, policy-type, result-type and the optional failure-details fields; contact-info a string or a list of strings; policy-string and a list mx-host-pattern lists of strings; the session counts integers or null; failure-details a list of objects; start-datetime and end-datetime strings of at most 64 characters that the outputs' own conversions accept. dateutil took 12.4 s to fail on a 1,000,000-digit date, hence the length bound. - _parse_report_record() makes a reason's type and comment a string or None (a dict unwrapped with _text(), a list as its str()), and the aggregate CSV writer reads both with .get(). - The Elasticsearch and OpenSearch failure saves flatten From, To and Reply-To with a helper that returns None for an empty list, and leave such a header out of the document and the duplicate query. - The Splunk SMTP TLS save converts begin_date inside its try, so a bad value raises SplunkError; _ensure_utc_suffix() returns a non-string unchanged. Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Limit XML nesting depth and node count in aggregate reports parse_aggregate_report_xml() gave the decompressed report (up to the 100 MiB decompression cap) to xmltodict with no limit on depth or element count. xmltodict keeps one stack entry per open element and expat reports missing end tags only at the end of the input, so a few KB of gzipped unclosed <a> tags grew to gigabytes of RSS and the kernel OOM killer ended the process before the message was moved; a wide document of millions of siblings did the same at a smaller scale. A failed first parse also re-parsed the document through lxml recovery, which has no node limit, and a second xmltodict pass. _NoDoctypeExpat.ParserCreate() now returns a proxy around the expat parser. pyexpat parsers cannot be subclassed and xmltodict assigns its handlers after ParserCreate() returns, so the proxy's __setattr__ wraps StartElementHandler to count depth and nodes (elements plus attributes) and EndElementHandler to decrement depth, and forwards every other attribute. Over MAX_XML_DEPTH (100) or MAX_XML_NODES (5,000,000) it raises before xmltodict's handler runs. Both xmltodict.parse() call sites turn that into InvalidAggregateReport naming the limit; the first does so instead of trying lxml recovery. The lxml recovery path now runs a counting pass first, with a parser target that builds no tree and counts elements, attributes and namespace declarations against MAX_XML_NODES; the tree is built only for a document within the limit. Depth there is bounded by libxml2's own limit of 256 (huge_tree stays off) and then by the second xmltodict pass. A 22 MiB wide document behind a malformed XML declaration peaked at +1532 MiB before this pass and +23 MiB after. The largest aggregate sample (909 KB) has 32,019 nodes, so a legitimate report at the 100 MiB cap stays near 3.6 million. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Create Elasticsearch/OpenSearch indexes with mapping guards create_indexes() and create_data_streams() created indexes and the data stream template with settings only, so the cluster typed every report field from the first document that held it, with date_detection on. A sender could be first into a day's index (its name comes from the report) and poison it: a report_id of "2026-11-09" mapped report_id as a date, a failure sample header named "Subject.x" mapped sample.headers.subject as an object, and samples with invented header names used up index.mapping.total_fields.limit. Every later legitimate report for that index was then rejected and ended up in Unsaved; in data stream mode this lasted until a rollover. Both functions now take a mappings body, and the three save functions pass one per report type. Each turns date_detection off and declares the fields that hold dates (@timestamp, date_range, date_begin, date_end, arrival_date, sample.date) as dates, the type date detection gave them before; on the sample reports every other field keeps its old type. The failure mapping maps sample.headers as an object with dynamic: false and declares, as text with a keyword sub-field (the dynamic string default), every header the shipped dashboards reference plus date, message-id and cc; all headers still stay in _source. A "." in a header name is replaced with "_" before the header is stored. The index is now created through the client's indices.create with the settings and mappings body, because opensearch-py's Index.create cannot take a mappings argument; the data stream template merges the mappings with its @timestamp date mapping. Existing indexes are not changed. The other report fields stay dynamic and object-mapped (no nested), which the dashboards need (issue #169). Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read the RFC 8460 mx-host key in SMTP TLS reports RFC 8460 section 4.4 keys a policy's MX host patterns "mx-host"; its schema line is "mx-host": mx-host-pattern, where mx-host-pattern is the value type, described as "a JSON array of strings", and the RFC's Appendix B example gives the value as a plain string. The parser read only "mx-host-pattern", so every RFC-conformant report lost the field (samples/smtp_tls/rfc8460.json, the Appendix B example, among them). _parse_smtp_tls_report_policy() now reads "mx-host" as a string or a list of strings, a string becoming a one-element list, and falls back to "mx-host-pattern" (same rules) when "mx-host" is absent, null or empty. Any other type raises InvalidSMTPTLSReport. The output key is still mx_host_patterns, so the PostgreSQL TEXT[] column, the Elasticsearch/OpenSearch field and the CSV column are unchanged. Refs #942 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject over-limit messages with the mailsuite guard parse_report_email() and parse_email() handed every message to mail-parser and the email package with no bound on blank lines, header lines, header field size, MIME parts or nested multiparts, so a small crafted message took seconds to minutes and gigabytes to parse while the rest of the mailbox waited. Both now call mailsuite.utils.check_message_limits() first, one linear pass. parse_report_email() turns MessageLimitExceeded into InvalidDMARCReport so the mailbox loop files the message under Invalid; parse_failure_report() turns one from the sample into InvalidFailureReport. The mailsuite floor rises to 2.3.5, which adds the guard. Messages nested deeper than 100 multiparts are now rejected by the guard instead of reaching the recursion handler; the tests that used the 400-level message expect the new message. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Cite RFC 7489 §6.3 for the daily report interval The comment on MAX_REPORT_SPAN_DAYS cited RFC 7489 Appendix A for the one-day aggregate report. The daily interval comes from §6.3: the "ri" tag requests the interval between aggregate reports, and its default is 86400 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add the 11.2.2 changelog: five advisories and #942 One Security bullet per advisory (GHSA-qcc2, GHSA-q7gm, GHSA-p85v, GHSA-4272, GHSA-qpcg) and a Bug fixes bullet for the RFC 8460 mx-host key. The version in constants.py is bumped by the release pull request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the date range rejection message and pyright's analysis With the XML limits and the date range limit merged, parse_aggregate_report_xml() became too complex for pyright to analyze ("Code is too complex to analyze"), and the date range rejection, raised inside the function's catch-all, reached the caller as "Unexpected error: Report date range spans ...". The date range check moves to _check_report_span() and the second xmltodict pass to _parse_limited_xml(), which raises the XML limit breach as InvalidAggregateReport; the catch-all now re-raises an InvalidAggregateReport as it is. The tests assert the message is not re-wrapped. Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put the over-limit failure samples in a delivery-status body mailsuite 2.3.5's check_message_limits() follows the MIME structure and counts blank lines only inside message/delivery-status bodies, the shape whose parse cost is per blank line; blank lines elsewhere in a body are not limited. The two failure-sample tests used blank lines in a plain body, which the final check accepts, so they now put them in a delivery-status body. parsedmarc's call with the default limits is unchanged. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Describe the final mailsuite check and the index cap in the docs The GHSA-qcc2 changelog bullet now describes check_message_limits() as mailsuite 2.3.5 ships it: limits follow the MIME structure, and body lines outside message/delivery-status bodies are not limited. The changelog section moves to Unreleased, as parsedmarc's AGENTS.md has fix pull requests write it; the release pull request names the version. The Elasticsearch and OpenSearch pages now say that an address header with no address is left out of the failure document, and that a report spanning more than 31 days saves its later records to its first dated index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound records times days in normalization and fix the span message The date range limit bounds how far one record is split, but not how many records are split: 10,000 records over 366 days, each with a count of 1,000,000 (3.3 MB of XML), produced 3,670,000 records in 13.5 s and 1.7 GB, and 30,000 of them (a 44 KB gzip) ran out of memory and reached the caller as "Unexpected error: ". A report whose split would exceed MAX_NORMALIZED_RECORDS (250,000) now keeps its records whole over its own range, with timespan_requires_normalization false and one warning; a large legitimate multi-day report is kept rather than rejected. The record loop moves to _parse_report_records() so pyright can still analyze parse_aggregate_report_xml(). The span message used float division, which raises OverflowError for an <end> of 4,001 digits; it now uses integer division. The index-routing warning escapes the sender's report ID with sanitize_for_log(). The changelog and the Elasticsearch and OpenSearch pages say the index cap is per report, that dated index names still come from report dates, and that data streams are affected by neither. Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop XML attributes from aggregate reports before they are used xmltodict turns an element with an attribute, such as <p a="1">none</p>, into {"@a": "1", "#text": "none"}. Such objects passed through the parser into Elasticsearch and OpenSearch documents for version, p, fo, disposition, envelope_to and other scalar fields, so the first one mapped the field as an object and every later report to that index failed, and hundreds of attributes on one element used up total_fields.limit. The parsed report now goes through one linear pass that replaces an element with only attributes and text by its text and drops attributes from the others; the existing _text() calls now receive strings. The changelog and the Elasticsearch and OpenSearch pages also say that an existing data stream's next backing index gets the guarded mappings, since parsedmarc puts the template again in every process, and the indexed-header comment and changelog name cc as the one header added to those the dashboards reference. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject lone surrogates in SMTP TLS reports and guard the stdout print An SMTP TLS string field holding a lone surrogate (JSON "\ud800") passed every type check and survived json.dumps(ensure_ascii=False), and print(output_str) in process_reports(), outside every try, raised UnicodeEncodeError: parsedmarc exited 1, and in mailbox mode the error escaped the save callback. RFC 8460 section 4 reports are I-JSON, whose RFC 7493 section 2.1 forbids surrogate code points, so the string and string-list checks now reject one as InvalidSMTPTLSReport, and the print records a UnicodeEncodeError as a "Standard output" output error. The _smtp_tls_datetime() docstring no longer says a parsed date cannot fail in any output: dateutil accepts partial dates that PostgreSQL may reject, which is contained as PostgreSQLError. The _reason_text() docstring says a dict with child elements gives None. Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Describe mailsuite 2.3.5's address group handling in the changelog The final mailsuite 2.3.5 no longer rejects an address header by its comma count; its parse_email() removes RFC 5322 group syntax from address header values over 8 KiB before parsing them. The boundary limit counts lines that are the boundary of an open multipart. parsedmarc's call with the default limits is unchanged. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the keep-alive call of the extracted record loop _parse_report_records() holds the record loop moved out of parse_aggregate_report_xml(); its keep-alive branch had no test. Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a policy override type with child elements With XML attributes dropped at parse time, a dict reaches _reason_text() only for an element with child elements; the test pins its None result and the CSV's "none". Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check plain-string contact-info and mx-host for lone surrogates The surrogate check covered the string fields and string lists, but a contact-info or mx-host given as one string took a branch that skipped it, and a lone surrogate there still made the stdout print raise. Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say which sample headers the failure document leaves out The GHSA-qpcg changelog bullet said every sample header stays in _source, but the failure save leaves out a From, To or Reply-To header that holds no address, as the Elasticsearch and OpenSearch pages say. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep sender-derived numbers out of the date range message The rejection message printed the report's span in days, so an <end> of thousands of digits put a number of thousands of digits into the exception text and the log. It now says only that the range is negative or longer than MAX_REPORT_SPAN_DAYS. Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Store every string-typed aggregate field as a string or null Dropping attributes did not cover child elements: <version><x>1</x></version>, <p><x>1</x></p> or <p>none<x/></p> still reached the parsed report, and the Elasticsearch and OpenSearch documents, as a dict, and the first such document mapped the field as an object. _scalar() now makes each field the schema types as a string a string (an element's own text) or None, applied by _scalarize_report() to version, the report_metadata strings and every policy_published child, and by _parse_report_record() to source_ip, disposition, dkim, spf, the identifiers and every auth_results string. A <count> with no text of its own takes the ValueError path of a non-numeric count. _drop_xml_attributes() stays, so a field missed here still loses its attribute keys. The <email> dict unwrap is gone, since the field can no longer be a dict; a text-only report_metadata still raises the AttributeError that reports a missing section. Comments that described attribute dicts now say the dict shape comes only from child elements. The namespace test, which the new code could not fail, is replaced by one that detects RFC 9990 through an attributed np field. Tests walk the to_dict() of a mocked Elasticsearch and OpenSearch save and allow lists and objects only in the known object fields, so a scalar field added later is covered too. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record a closed stdout pipe as an output error and fix two comments The standard-output guard in process_reports() caught only UnicodeEncodeError, so a closed pipe (BrokenPipeError, an OSError) still ended the run; it now records both as a "Standard output" output error. _reason_text()'s docstring said a dict gives None because the element has no text of its own, but mixed content (<type>forwarded<x/></type>) gives the text beside the children; the docstring now says what the code does, and a test pins it. The comment above the reason normalization no longer describes attribute dicts, which no longer reach it. Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the normalization limit at its boundary and reword the index cap The suite passed with the limit's <= changed to < and with _days_touched()'s ceiling changed to a floor. New tests: 1,000 records over 250 days (exactly MAX_NORMALIZED_RECORDS) are split; 683 records over 366 days (249,978) are split and 684 (250,344) are not; 682 records over 366 days starting 1 s after midnight touch 367 days (250,294) and are not split. Each mutant was run against these tests and fails: the < mutant fails the exact-limit test, the floor mutant the after-midnight test. The Elasticsearch and OpenSearch pages now say the index cap applies when a report's records would use more than 31 dated indexes, which is what it counts (with monthly_indexes the span limit keeps a report under it). Refs GHSA-p85v-578x-3wcf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Match the tests and changelog to mailsuite 2.3.5's final check mailsuite 2.3.5's check_message_limits() now parses the message with the email package's own feed parser under compat32 and counts the parts it creates (each message/delivery-status block among them), their nesting depth and its boundary tests, then limits header field length and count on the parsed tree; the limit names changed. The over-limit failure samples now trip max_parts, and the deeply nested poison message max_depth; the tests assert those texts. The GHSA-qcc2 changelog bullet describes the final check, the epilogue mail-parser parses again, and the changes that affect only mailsuite's own parse_email(). Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a repeated policy_published and note mixed-content behaviour No test failed when _scalarize_report() stopped coercing the fields of a repeated <policy_published> (the list case): with that mutant, a report with two policy_published elements and <p><x>1</x></p> puts a dict into published_policy.p. The parse test and the Elasticsearch and OpenSearch document-walk tests now include that report; each fails on the mutant. The changelog says that an element with text and a stray child element now yields its text (samples/aggregate/invalid_xml.xml gives header_from "bad" where it gave ""). AGENTS.md and the _normalize_result_word() and _scalarize_report() docstrings describe what runs now: attributes are dropped as soon as the XML is parsed, and _scalar() coerces the string fields, among them the RFC 7489 and RFC 9990 policy_published fields. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Coerce policy override reasons with the same rule as other strings _reason_text() turned a repeated <type> or <comment> into the str() of the list, while _scalar() turns every other repeated string field into None. One class of input now has one rule: the reason's type and comment go through _scalar(), so a repeated one is None ("none" in CSV), and _reason_text() is removed. The test and the GHSA-4272 changelog bullet say so. Refs GHSA-4272-4r8g-6c5h Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test an aggregate <error> with a child element With attributes dropped and reasons coerced by _scalar(), only <error>, which may repeat, still reaches _text() with a dict; its branch had no test. An <error> with text beside a child element keeps the text, and one with no text is dropped. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Describe mailsuite's six message limits in the changelog mailsuite 2.3.5's check now counts every line of every header block (folded lines and lines the parser keeps only as defects included) and stops the parse early, and limits the total length of the address headers mail-parser parses to 262,144 characters. The GHSA-qcc2 bullet names the six limits. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render a report email's parts only where their text is read parse_report_email() did str(payload_obj[0]) for every part walk() visits, before the multipart/ continue. str() of a composite part renders its whole subtree, and walk() visits every level, so a nesting within mailsuite's limits rendered its leaf once per level: 98 nested multiparts around a 20 MiB one-line leaf took 26.6 s, and a chain of 99 message/rfc822 parts around a 10 MiB leaf 11.3 s. A part is now rendered only in the branches that read it; other message/* bodies are skipped, since walk() visits their parts. Both shapes now take under 0.5 s, and every sample under samples/failure/ parses to the same output. A feedback report's sample is taken from the first sample part only: a message/rfc822 nested inside the sample used to replace it, but a feedback report has one sample (RFC 6591 section 3.1). Multipart nesting alone no longer reaches the recursion limit, so the GHSA-qxwv test now reaches the recursion guard through a deeply nested sample, which is still rendered. The changelog describes this and the address headers mailsuite 2.3.5 counts (From, Sender, Reply-To, To, Cc, Bcc, Delivered-To, in every part; mail-parser parses all but Sender). Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Parse aggregate XML without attributes and lower the node limit _drop_xml_attributes() rebuilt the parsed tree while the original was alive, and the counting wrappers of _LimitedExpatParser referred to the proxy, closing a reference cycle through the expat parser that kept xmltodict's tree alive until the cyclic garbage collector ran. Both xmltodict passes now use xml_attribs=False, so no attribute key is ever built (expat still hands the attributes to the start handler, so they are still counted: a test shows one element with 1,000 attributes is 1,001 nodes), _drop_xml_attributes() is removed, and the counts live in an _XMLCounts object the wrappers share, so a parse leaves no cycle (a test asserts gc.collect() finds nothing after one). Peak extra memory for 2,000,000 distinct sibling elements: +999 MiB before, +386 MiB after (master +391 MiB); for one element with 2,000,000 attributes: +1,274 MiB before, +445 MiB after (master +734 MiB). At the old limit of 5,000,000 nodes a report could still need about 3 GB. Measured after the change, the worst shape is distinct elements with text behind a malformed XML declaration (the lxml recovery path): 559 bytes per node, +533 MiB for 999,000 nodes and +1,023 MiB for 2,000,000; on the expat path about 220 bytes per node. MAX_XML_NODES is now 1,000,000, so a report at the limit needs at most about 550 MiB; the largest sample has 32,019 nodes (909 KB), and the limit is a report of about 28 MB. The constant's comment and the changelog state the bound. AGENTS.md describes the attribute handling as it now is, and says that <error>, which may repeat, goes through _text() and stays a list. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Qualify the mixed-content compatibility note to string fields An element with text beside a stray child element yields its text only for the fields the schema types as strings; a <begin> or <end> with mixed content is still rejected, as before. Refs GHSA-qpcg-29vr-jwm3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject over-long XML tags before any parser reads them expat and libxml2 build a start tag's whole attribute list before they report the element, so the node counter only sees the attributes after they cost memory: one element with 4,999,000 attributes (56 MiB) took 5.25 s and +767 MiB before the node limit rejected it, and a single element is bounded only by the 100 MiB decompression cap. parse_aggregate_report_xml() now scans the decoded text once, before the first parse and so before the lxml recovery path too, and rejects a document in which any text between a "<" and the next ">" (or the end of the text) is longer than MAX_XML_TAG_LENGTH (65,536 characters). The scan uses no regex. It splits the text on ">" one 64 KiB chunk at a time, carrying the unfinished piece from its last "<": a single split of the whole text makes one string per tag, which peaked at +1,318 MiB (1.9 s) for 100 MiB of "<a/>"; the chunked scan needs about 2 MiB and takes 0.6 s on that text, and a test bounds its memory and covers a tag across a chunk boundary. The longest tag in the samples is 108 characters. The same shape is now rejected in under 0.1 s, with a tracemalloc peak under 200 MB in the test, behind a well-formed or a malformed declaration. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Render a report email's feedback-report part only once The message/* skip exempted message/feedback-report, which was rendered with str() every time walk() reached one: a chain of 99 nested message/feedback-report parts around a 10 MiB leaf passed mailsuite's check in 0.02 s and then took 11.6 s. A feedback report has one message/feedback-report part (RFC 6591 section 3), so, as with the sample, only the first is read and rendered; the chain now takes 0.56 s. The failure samples parse to byte-identical output. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound attribute counts by their = signs and cap reports at 32 MiB XML allows a raw > inside a quoted attribute value, so attributes such as x=">" cut a tag into short pieces that pass the tag scan: 4,999,000 of them cost 5.61 s and +781 MiB, and 8,000,000 (98 MiB) 8.9 s and +1,215 MiB, before the node limit fired. Every attribute needs an =, so a document with more = characters than MAX_XML_NODES is now rejected in one str.count() pass, before the tag scan; the samples hold at most 3. The tag scan stays for long tags, and its docstring says it does not bound attribute counts. The stated memory bound was false: text per node is bounded only by the size of the report, so at the node limit with 80 characters per element (94 MiB) the recovery path reached +827 MiB. MAX_DECOMPRESSED_REPORT_SIZE falls from 100 MiB to 32 MiB (the node limit already means a legitimate report is at most about 28 MB, at the samples' 35 nodes per KB, and the largest sample is 909 KB), and parse_aggregate_report_xml() holds an uncompressed report to the same length, so the bound holds for every input. Measured at the cap, 999,000 distinct elements with 14 characters of text (31 MiB) need +587 MiB on the recovery path and +302 MiB on the expat path; the constant comment and the changelog state it. When the first parse fails, the exception's traceback kept the partial xmltodict tree alive while the recovery parse ran; it is cleared first (+603 to +587 MiB, and +263 to +241 MiB for 330,000 elements of 80 characters). A test shows the recovery path leaves the same 18 lxml helper objects for a document ten times larger, so no tree survives a parse there either. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the RFC 6591 feedback part win over legacy report text The guard that reads only the first message/feedback-report part tested feedback_report, which the legacy text/plain branch ("A message claiming to be from you has failed ... detected.") also sets, so a legacy text part followed by a real feedback-report part and sample now took the legacy part and never read the ARF, where master read the ARF. The guard now tests is_feedback_report, and reading the feedback-report part discards a sample a legacy part set before it. The same rule closes an older gap: a text/plain part inside the sample, which is the spoofed message's own text, could hold the legacy phrase and replace the report's Source-IP and its sample. Once a feedback-report part is read, the legacy branch is skipped; a legacy report has no such part. Tests cover both orders and the in-sample case; the failure samples parse to byte-identical output. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hold every report input to the size cap and free failed parses parse_report_file() kept the aggregate parser's exception, whose traceback holds the parser's frames and the parsed tree, while it tried SMTP TLS and then the email parser: a 31 MiB file at the cap cost +1,046 MiB there against +587 MiB parsing it directly. It now keeps only each failure's text and raises from a fresh exception of the relevant format, so the chain survives without the tree. A file that begins with "<" is not tried as JSON (json.loads() decoded it all) or as an email, one that begins with "{" is not tried as an email (an RFC 822 message begins with a header line), and text over the size cap is never handed to the email parser, which has none: a 200 MiB <x/> file reached +2,801 MiB in 4.6 s and now costs +232 MiB (reading the file) in 0.05 s. extract_report() reads an uncompressed report at most one byte past the cap instead of decoding it whole. The cap counted characters for text and bytes for compressed reports, so a non-ASCII report could be four times the cap in bytes: 999,000 elements of 14 emoji (31.2 Mi characters, 71 MiB of UTF-8) cost +649 MiB. _exceeds_size_cap() measures text as UTF-8 (ASCII by length, other text 1 MiB of characters at a time, without a full copy), and bytes are checked before they are decoded. Clearing e.__traceback__ inside the except block did nothing on Python 3.10, which keeps the handled exception's traceback while the handler runs. The lxml recovery now runs after the except block, in _recover_xml(), so the first parse's frames and partial tree are unreachable when it starts on every supported Python; a test holds a weak reference to the first parse's counter and fails on the old code under Python 3.10.19 and, with the clearing removed, under 3.14. The changelog's examples are re-measured under the 32 MiB cap, and the test docstrings cite the same figures. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Skip the parts inside a failure report's sample Once the sample part was taken, walk() still visited its descendants, and they reached the SMTP TLS and base64 attachment branches: a sample whose spoofed message carried an SVG or zipped HTML attachment made the email rejected ("Missing field: 'feedback'", "Unable to parse"), and a gzipped aggregate report or a tlsrpt+json attachment inside it was returned as that report instead of the failure report. The sample is the reported message, so after it is taken every part inside it is skipped (by the id() of each part its walk() yields). Tests cover SVG, zipped HTML, JSON, gzipped aggregate XML, tlsrpt+json and PDF attachments; the failure samples parse to byte-identical output. The Bug fixes bullet also says that a legacy text part followed by a feedback-report part with no sample after it is now invalid, with a test. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cap SMTP TLS reports and test the format skips parse_smtp_tls_report_json() decoded and json.loads()-ed a report of any size; only the gzipped path was capped, and 60 MiB of "[]," cost +1,565 MiB. It now rejects bytes or text over MAX_DECOMPRESSED_REPORT_SIZE first, as InvalidSMTPTLSReport, so every report type is held to the cap. parse_report_file() names the actual reason it skips the email parser (the leading "<" or "{", or the size cap) rather than a combined one, and a size message for bytes no longer says "of UTF-8". New tests kill the mutants the review found surviving: removing the "<" skip for JSON, narrowing the email skip to "<", the bytes check >= for >, and removing the SMTP TLS cap. The constant comment and the changelog state the memory at the cap as measured on the current code: +345 MiB on the lxml recovery path and +302 MiB on expat parsing the text directly, +500 MiB and +452 MiB through parse_report_file(). The changelog says only the exception's cause changed for files skipped by format, not the message. Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Skip every sample candidate and correct the stated measurements sample_parts held only the first sample candidate's parts, so a later message/rfc822 (or one after a text/rfc822-headers sample) was dropped as a sample but its parts were still walked, and a gzipped aggregate report inside it was returned in place of the failure report. Every candidate's parts are now skipped. Tests cover both shapes, and a test that a gzipped aggregate report beside the sample is still returned pins the rule (inside the sample: skipped; beside it: read) and fails when the set is built from the whole message. parse_report_file() no longer builds email reasons for content that begins with "<" or "{", which its message never shows, and the size reason reads "Invalid report email: the report is larger than 32 MiB". The memory figures are re-measured in a fresh process with the input read from disk before the baseline: +399 MiB on the recovery path and +338 to +355 MiB on expat for the 31 MiB shape, +1,505 MiB for 60 MiB of JSON before the SMTP TLS cap; the constant comment, the changelog and the test docstrings state them. RFC 6591 section 3.1 is cited for the sample's place, and application/json is named among the attachments that made a failure report invalid. Refs GHSA-qcc2-4x3v-v4g7 Refs GHSA-q7gm-8cj9-94hr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Scale the timed tests' bounds to the machine running them The mailsuite fix's first CI run on GitHub's shared runners failed six timed tests: the suite ran four to six times slower there than on a workstation, and checks doing real work took longer than their fixed bounds. Several bounds here have far less margin (0.2 s on a test that takes 0.18 s whole), so they would fail the same way. tests/timing.py adds budget(seconds), which measures a fixed reference parse twice just before the timed call and multiplies the bound by the slowdown against a reference machine; a faster machine keeps the written bound. Every timed assert now compares against budget(<written seconds>) computed before the timer starts. No written bound changed and no assert was removed. Refs GHSA-qcc2-4x3v-v4g7 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
10797 lines
426 KiB
Python
10797 lines
426 KiB
Python
"""Tests for parsedmarc.cli — CLI entry point, config parsing,
|
|
env-var overrides, mailbox watch wiring, and SIGHUP reload."""
|
|
|
|
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import gzip
|
|
import io
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import mailbox
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import traceback
|
|
import unittest
|
|
import zipfile
|
|
from collections.abc import Iterator, Sequence
|
|
from configparser import ConfigParser
|
|
from contextlib import ExitStack, contextmanager
|
|
from email.mime.application import MIMEApplication
|
|
from email.mime.multipart import MIMEMultipart
|
|
from tempfile import NamedTemporaryFile
|
|
from types import ModuleType, SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import dns.message
|
|
import dns.query
|
|
import dns.rcode
|
|
import dns.rdata
|
|
import dns.rdataclass
|
|
import dns.rdatatype
|
|
import httpx
|
|
import opensearchpy
|
|
from azure.core.exceptions import ClientAuthenticationError
|
|
from elasticsearch import Elasticsearch
|
|
from kiota_abstractions.api_error import APIError
|
|
from msgraph.generated.models.o_data_errors.inner_error import InnerError
|
|
from msgraph.generated.models.o_data_errors.main_error import MainError
|
|
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
|
|
|
|
import parsedmarc
|
|
import parsedmarc.cli
|
|
import parsedmarc.elastic
|
|
import parsedmarc.log
|
|
import parsedmarc.mail
|
|
import parsedmarc.opensearch as opensearch_module
|
|
import parsedmarc.resources.ipinfo
|
|
import parsedmarc.utils
|
|
from parsedmarc.types import AggregateReport, ParsedReport
|
|
|
|
SAMPLE_AGGREGATE_REPORT_PATH = (
|
|
"samples/aggregate/!example.com!1538204542!1538463818.xml"
|
|
)
|
|
|
|
|
|
def _sample_aggregate_reports() -> list[AggregateReport]:
|
|
"""Parses a real sample aggregate report so tests that exercise the
|
|
real zip/CSV-building code path (email_results / email_results_via_msgraph)
|
|
have a fully valid AggregateReport rather than a hand-built partial
|
|
dict that would fail in parsed_aggregate_reports_to_csv_rows()."""
|
|
result = parsedmarc.parse_report_file(SAMPLE_AGGREGATE_REPORT_PATH, offline=True)
|
|
assert result["report_type"] == "aggregate"
|
|
return [cast(AggregateReport, result["report"])]
|
|
|
|
|
|
def _fetch_invoking_save_callback(reports, *, batch=None):
|
|
"""Build a ``get_dmarc_reports_from_mailbox`` mock side effect that
|
|
honors the real contract: every fetched batch is handed to
|
|
``save_callback`` *before* the function returns, and the accumulated
|
|
results are returned afterward.
|
|
|
|
A plain ``return_value`` mock would leave ``save_callback`` uncalled, so
|
|
nothing would ever be written to any output destination -- which is not
|
|
how the CLI behaves against a real mailbox since #242.
|
|
|
|
``batch`` overrides what the callback receives, for tests that need the
|
|
batch dict to be a distinct object from the returned results (the real
|
|
function builds its return value from its own accumulated lists, not
|
|
from the dict it passed to the callback).
|
|
"""
|
|
|
|
def _fetch_and_save(**kwargs):
|
|
kwargs["save_callback"](reports if batch is None else batch)
|
|
return reports
|
|
|
|
return _fetch_and_save
|
|
|
|
|
|
class _BreakLoop(BaseException):
|
|
pass
|
|
|
|
|
|
class _DummyMailboxConnection(parsedmarc.MailboxConnection):
|
|
def __init__(self):
|
|
self.fetch_calls: list[dict[str, object]] = []
|
|
|
|
def create_folder(self, folder_name: str):
|
|
return None
|
|
|
|
def fetch_messages(self, reports_folder: str, **kwargs):
|
|
self.fetch_calls.append({"reports_folder": reports_folder, **kwargs})
|
|
return []
|
|
|
|
def fetch_message(self, message_id, **kwargs) -> str:
|
|
return ""
|
|
|
|
def delete_message(self, message_id):
|
|
return None
|
|
|
|
def move_message(self, message_id, folder_name: str):
|
|
return None
|
|
|
|
def keepalive(self):
|
|
return None
|
|
|
|
def watch(self, check_callback, check_timeout, config_reloading=None):
|
|
return None
|
|
|
|
|
|
class Test(unittest.TestCase):
|
|
"""Kitchen-sink tests redistributed from the original
|
|
tests.py monolith. Future PRs should split these further
|
|
into purpose-specific TestCase subclasses as natural
|
|
groupings emerge."""
|
|
|
|
def testOpenSearchSigV4RequiresRegion(self):
|
|
with self.assertRaises(opensearch_module.OpenSearchError):
|
|
opensearch_module.set_hosts(
|
|
"https://example.org:9200",
|
|
auth_type="awssigv4",
|
|
)
|
|
|
|
def testOpenSearchSigV4ConfiguresConnectionClass(self):
|
|
fake_credentials = object()
|
|
with patch.object(opensearch_module.boto3, "Session") as session_cls:
|
|
session_cls.return_value.get_credentials.return_value = fake_credentials
|
|
with patch.object(
|
|
opensearch_module, "AWSV4SignerAuth", return_value="auth"
|
|
) as signer:
|
|
with patch.object(
|
|
opensearch_module.connections, "create_connection"
|
|
) as create_connection:
|
|
opensearch_module.set_hosts(
|
|
"https://example.org:9200",
|
|
use_ssl=True,
|
|
auth_type="awssigv4",
|
|
aws_region="eu-west-1",
|
|
)
|
|
signer.assert_called_once_with(fake_credentials, "eu-west-1", "es")
|
|
create_connection.assert_called_once()
|
|
self.assertEqual(
|
|
create_connection.call_args.kwargs.get("connection_class"),
|
|
opensearch_module.RequestsHttpConnection,
|
|
)
|
|
self.assertEqual(create_connection.call_args.kwargs.get("http_auth"), "auth")
|
|
|
|
def testOpenSearchSigV4RejectsUnknownAuthType(self):
|
|
with self.assertRaises(opensearch_module.OpenSearchError):
|
|
opensearch_module.set_hosts(
|
|
"https://example.org:9200",
|
|
auth_type="kerberos",
|
|
)
|
|
|
|
def testOpenSearchSigV4RequiresAwsCredentials(self):
|
|
with patch.object(opensearch_module.boto3, "Session") as session_cls:
|
|
session_cls.return_value.get_credentials.return_value = None
|
|
with self.assertRaises(opensearch_module.OpenSearchError):
|
|
opensearch_module.set_hosts(
|
|
"https://example.org:9200",
|
|
auth_type="awssigv4",
|
|
aws_region="eu-west-1",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.opensearch.migrate_indexes")
|
|
@patch("parsedmarc.cli.opensearch.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testCliPassesOpenSearchSigV4Settings(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config = """[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[opensearch]
|
|
hosts = localhost
|
|
authentication_type = awssigv4
|
|
aws_region = eu-west-1
|
|
aws_service = aoss
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_set_hosts.call_args.kwargs.get("auth_type"), "awssigv4")
|
|
self.assertEqual(mock_set_hosts.call_args.kwargs.get("aws_region"), "eu-west-1")
|
|
self.assertEqual(mock_set_hosts.call_args.kwargs.get("aws_service"), "aoss")
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testFailOnOutputErrorExits(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
mock_save_aggregate,
|
|
):
|
|
"""CLI should exit with code 1 when fail_on_output_error is enabled"""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [{"policy_published": {"domain": "example.com"}}],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
)
|
|
mock_save_aggregate.side_effect = parsedmarc.elastic.ElasticsearchError(
|
|
"simulated output failure"
|
|
)
|
|
|
|
config = """[general]
|
|
save_aggregate = true
|
|
fail_on_output_error = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
mock_save_aggregate.assert_called_once()
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testOutputErrorDoesNotExitWhenDisabled(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
mock_save_aggregate,
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [{"policy_published": {"domain": "example.com"}}],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
)
|
|
mock_save_aggregate.side_effect = parsedmarc.elastic.ElasticsearchError(
|
|
"simulated output failure"
|
|
)
|
|
|
|
config = """[general]
|
|
save_aggregate = true
|
|
fail_on_output_error = false
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
mock_save_aggregate.assert_called_once()
|
|
|
|
@patch("parsedmarc.cli.opensearch.save_failure_report_to_opensearch")
|
|
@patch("parsedmarc.cli.opensearch.migrate_indexes")
|
|
@patch("parsedmarc.cli.opensearch.set_hosts")
|
|
@patch("parsedmarc.cli.elastic.save_failure_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testFailOnOutputErrorExitsWithMultipleSinkErrors(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_es_set_hosts,
|
|
_mock_es_migrate,
|
|
mock_save_aggregate,
|
|
_mock_save_failure_elastic,
|
|
_mock_os_set_hosts,
|
|
_mock_os_migrate,
|
|
mock_save_failure_opensearch,
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [{"policy_published": {"domain": "example.com"}}],
|
|
"failure_reports": [{"reported_domain": "example.com"}],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
)
|
|
mock_save_aggregate.side_effect = parsedmarc.elastic.ElasticsearchError(
|
|
"aggregate sink failed"
|
|
)
|
|
mock_save_failure_opensearch.side_effect = (
|
|
parsedmarc.cli.opensearch.OpenSearchError("failure sink failed")
|
|
)
|
|
|
|
config = """[general]
|
|
save_aggregate = true
|
|
save_failure = true
|
|
fail_on_output_error = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
|
|
[opensearch]
|
|
hosts = localhost
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
mock_save_aggregate.assert_called_once()
|
|
mock_save_failure_opensearch.assert_called_once()
|
|
|
|
def test_resolve_section_key_simple(self):
|
|
"""Simple section names resolve correctly."""
|
|
from parsedmarc.cli import _resolve_section_key
|
|
|
|
self.assertEqual(_resolve_section_key("IMAP_PASSWORD"), ("imap", "password"))
|
|
self.assertEqual(_resolve_section_key("GENERAL_DEBUG"), ("general", "debug"))
|
|
self.assertEqual(_resolve_section_key("S3_BUCKET"), ("s3", "bucket"))
|
|
self.assertEqual(_resolve_section_key("GELF_HOST"), ("gelf", "host"))
|
|
|
|
def test_resolve_section_key_underscore_sections(self):
|
|
"""Multi-word section names (splunk_hec, gmail_api, etc.) resolve correctly."""
|
|
from parsedmarc.cli import _resolve_section_key
|
|
|
|
self.assertEqual(
|
|
_resolve_section_key("SPLUNK_HEC_TOKEN"), ("splunk_hec", "token")
|
|
)
|
|
self.assertEqual(
|
|
_resolve_section_key("GMAIL_API_CREDENTIALS_FILE"),
|
|
("gmail_api", "credentials_file"),
|
|
)
|
|
self.assertEqual(
|
|
_resolve_section_key("LOG_ANALYTICS_CLIENT_ID"),
|
|
("log_analytics", "client_id"),
|
|
)
|
|
|
|
def test_resolve_section_key_unknown(self):
|
|
"""Unknown prefixes return (None, None)."""
|
|
from parsedmarc.cli import _resolve_section_key
|
|
|
|
self.assertEqual(_resolve_section_key("UNKNOWN_FOO"), (None, None))
|
|
# Just a section name with no key should not match
|
|
self.assertEqual(_resolve_section_key("IMAP"), (None, None))
|
|
|
|
def test_expand_file_path_args_keeps_bracketed_filenames(self):
|
|
"""Literal report filenames containing glob metacharacters must not
|
|
be dropped.
|
|
|
|
Regression test: ``_main`` expanded every file argument with
|
|
``glob()``, which treats ``[...]`` as a character class. A real
|
|
file named ``[Provider DMARC Failure Report] Subject.eml`` (the
|
|
shape Netease and others use) matched nothing and was silently
|
|
skipped, so the report never reached the parser.
|
|
See https://docs.python.org/3/library/glob.html.
|
|
"""
|
|
from glob import glob
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
bracket = os.path.join(d, "[Netease DMARC Failure Report] Rent.eml")
|
|
plain = os.path.join(d, "report.eml")
|
|
for p in (bracket, plain):
|
|
with open(p, "w") as f:
|
|
f.write("x")
|
|
|
|
# Sanity: raw glob drops the bracketed path (documents the bug).
|
|
self.assertEqual(glob(bracket), [])
|
|
|
|
# The literal bracketed path is preserved as-is.
|
|
self.assertEqual(_expand_file_path_args([bracket]), [bracket])
|
|
|
|
# Wildcards (non-existent as literal paths) still expand.
|
|
wildcard = os.path.join(d, "*.eml")
|
|
self.assertEqual(
|
|
sorted(_expand_file_path_args([wildcard])),
|
|
sorted([bracket, plain]),
|
|
)
|
|
|
|
def test_expand_file_path_args_directory(self):
|
|
"""A directory argument expands to the files directly inside it,
|
|
matching shell ``<dir>/*`` glob semantics: dotfile entries are
|
|
excluded and subdirectories are skipped (not descended into).
|
|
See https://docs.python.org/3/library/glob.html.
|
|
"""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
os.makedirs(os.path.join(d, "sub"))
|
|
a_xml = os.path.join(d, "a.xml")
|
|
b_eml = os.path.join(d, "b.eml")
|
|
c_xml = os.path.join(d, "sub", "c.xml")
|
|
hidden = os.path.join(d, ".hidden")
|
|
for p in (a_xml, b_eml, c_xml, hidden):
|
|
with open(p, "w") as f:
|
|
f.write("x")
|
|
|
|
result = _expand_file_path_args([d])
|
|
|
|
self.assertEqual(sorted(result), sorted([a_xml, b_eml]))
|
|
self.assertNotIn(os.path.join(d, "sub"), result)
|
|
self.assertNotIn(c_xml, result)
|
|
self.assertNotIn(hidden, result)
|
|
|
|
# A trailing separator on the directory argument behaves the same.
|
|
result_trailing = _expand_file_path_args([d + os.sep])
|
|
self.assertEqual(
|
|
sorted(os.path.basename(p) for p in result_trailing),
|
|
sorted(os.path.basename(p) for p in result),
|
|
)
|
|
|
|
def test_expand_file_path_args_directory_recursive(self):
|
|
"""With recursive=True, a directory expands via ``<dir>/**``,
|
|
descending into subdirectories but still excluding dotfiles and
|
|
dot-directories (glob's ``**`` does not descend into hidden
|
|
directories unless explicitly matched).
|
|
See https://docs.python.org/3/library/glob.html.
|
|
"""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
os.makedirs(os.path.join(d, "sub"))
|
|
os.makedirs(os.path.join(d, ".hiddendir"))
|
|
a_xml = os.path.join(d, "a.xml")
|
|
b_eml = os.path.join(d, "b.eml")
|
|
c_xml = os.path.join(d, "sub", "c.xml")
|
|
hidden = os.path.join(d, ".hidden")
|
|
hidden_dir_file = os.path.join(d, ".hiddendir", "d.xml")
|
|
for p in (a_xml, b_eml, c_xml, hidden, hidden_dir_file):
|
|
with open(p, "w") as f:
|
|
f.write("x")
|
|
|
|
result = _expand_file_path_args([d], recursive=True)
|
|
|
|
self.assertEqual(sorted(result), sorted([a_xml, b_eml, c_xml]))
|
|
self.assertNotIn(d, result)
|
|
self.assertNotIn(os.path.join(d, "sub"), result)
|
|
self.assertNotIn(hidden, result)
|
|
self.assertNotIn(hidden_dir_file, result)
|
|
|
|
def test_expand_file_path_args_directory_with_glob_metacharacters(self):
|
|
"""A directory name containing glob metacharacters must still be
|
|
expanded correctly, not treated as a character class.
|
|
|
|
Without escaping the directory component with ``glob.escape``,
|
|
a directory named ``reports [2024]`` would have ``[2024]``
|
|
interpreted as a character class matching a single '2', '0', or
|
|
'4' character, matching nothing and silently dropping every file
|
|
inside it. See https://docs.python.org/3/library/glob.html.
|
|
"""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
bracket_dir = os.path.join(d, "reports [2024]")
|
|
os.makedirs(bracket_dir)
|
|
report = os.path.join(bracket_dir, "report.xml")
|
|
with open(report, "w") as f:
|
|
f.write("x")
|
|
|
|
self.assertEqual(_expand_file_path_args([bracket_dir]), [report])
|
|
self.assertEqual(
|
|
_expand_file_path_args([bracket_dir], recursive=True), [report]
|
|
)
|
|
|
|
def test_expand_file_path_args_recursive_glob_pattern(self):
|
|
"""``recursive`` also governs whether ``**`` in an explicit glob
|
|
pattern recurses into subdirectories, matching stdlib ``glob()``
|
|
semantics exactly.
|
|
|
|
Per https://docs.python.org/3/library/glob.html: "If recursive is
|
|
true, the pattern '**' will match any files and zero or more
|
|
directories... If recursive is false (the default), the pattern
|
|
'**' will match the same files and directories described for the
|
|
pattern '*'" (i.e. exactly one path segment). For a pattern like
|
|
``d/**/*.xml`` that means the non-recursive default only matches
|
|
files exactly one directory level below ``d`` (here, only the
|
|
nested file); this is unchanged from today's behavior since
|
|
``_expand_file_path_args`` previously always called ``glob()``
|
|
without ``recursive=True``.
|
|
"""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
os.makedirs(os.path.join(d, "sub"))
|
|
top_xml = os.path.join(d, "x.xml")
|
|
nested_xml = os.path.join(d, "sub", "y.xml")
|
|
for p in (top_xml, nested_xml):
|
|
with open(p, "w") as f:
|
|
f.write("x")
|
|
|
|
pattern = os.path.join(d, "**", "*.xml")
|
|
|
|
self.assertEqual(
|
|
sorted(_expand_file_path_args([pattern], recursive=True)),
|
|
sorted([top_xml, nested_xml]),
|
|
)
|
|
# Default (no recursive kwarg): '**' degrades to matching a
|
|
# single path component, so only the one-level-nested file
|
|
# is found; the top-level file is not matched by this pattern.
|
|
self.assertEqual(
|
|
_expand_file_path_args([pattern]),
|
|
[nested_xml],
|
|
)
|
|
|
|
def test_expand_file_path_args_warns_on_nonexistent_path(self):
|
|
"""A file_path argument that matches no files at all -- a plain
|
|
path that doesn't exist and isn't a glob match -- logs a WARNING
|
|
naming that argument, instead of silently vanishing as a
|
|
zero-match glob. The argument still contributes no paths to the
|
|
returned list.
|
|
|
|
Regression test: ``parsedmarc --offline -o out /nonexistent.xml``
|
|
used to exit 0 with empty results and no message of any kind, so
|
|
a typo'd path in a cron job "worked" forever while processing
|
|
nothing.
|
|
"""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
missing = os.path.join(d, "nonexistent.xml")
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
|
|
result = _expand_file_path_args([missing])
|
|
self.assertEqual(result, [])
|
|
self.assertTrue(
|
|
any(missing in line for line in cm.output),
|
|
f"expected a warning naming {missing}, got: {cm.output}",
|
|
)
|
|
|
|
def test_expand_file_path_args_no_warning_for_matching_path(self):
|
|
"""A file_path argument that matches at least one file logs no
|
|
warning at all."""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
report = os.path.join(d, "report.xml")
|
|
with open(report, "w") as f:
|
|
f.write("x")
|
|
|
|
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
|
|
result = _expand_file_path_args([report])
|
|
self.assertEqual(result, [report])
|
|
|
|
wildcard = os.path.join(d, "*.xml")
|
|
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
|
|
result = _expand_file_path_args([wildcard])
|
|
self.assertEqual(result, [report])
|
|
|
|
def test_expand_file_path_args_empty_list_is_silent(self):
|
|
"""An empty ``paths`` list (e.g. a mailbox-only run with no file
|
|
arguments) logs nothing."""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
|
|
result = _expand_file_path_args([])
|
|
self.assertEqual(result, [])
|
|
|
|
def test_expand_file_path_args_mixed_warns_only_for_unmatched(self):
|
|
"""When some arguments match and others don't, only the
|
|
unmatched argument produces a warning."""
|
|
from parsedmarc.cli import _expand_file_path_args
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
report = os.path.join(d, "report.xml")
|
|
with open(report, "w") as f:
|
|
f.write("x")
|
|
missing = os.path.join(d, "nonexistent.xml")
|
|
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
|
|
result = _expand_file_path_args([report, missing])
|
|
|
|
self.assertEqual(result, [report])
|
|
warning_lines = [line for line in cm.output if "WARNING" in line]
|
|
self.assertEqual(len(warning_lines), 1)
|
|
self.assertIn(missing, warning_lines[0])
|
|
self.assertNotIn(report, warning_lines[0])
|
|
|
|
def test_apply_env_overrides_injects_values(self):
|
|
"""Env vars are injected into an existing ConfigParser."""
|
|
from configparser import ConfigParser
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
config.add_section("imap")
|
|
config.set("imap", "host", "original.example.com")
|
|
|
|
env = {
|
|
"PARSEDMARC_IMAP_HOST": "new.example.com",
|
|
"PARSEDMARC_IMAP_PASSWORD": "secret123",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
|
|
self.assertEqual(config.get("imap", "host"), "new.example.com")
|
|
self.assertEqual(config.get("imap", "password"), "secret123")
|
|
|
|
def test_apply_env_overrides_creates_sections(self):
|
|
"""Env vars create new sections when they don't exist."""
|
|
from configparser import ConfigParser
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
|
|
env = {"PARSEDMARC_ELASTICSEARCH_HOSTS": "http://localhost:9200"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
|
|
self.assertTrue(config.has_section("elasticsearch"))
|
|
self.assertEqual(config.get("elasticsearch", "hosts"), "http://localhost:9200")
|
|
|
|
def test_apply_env_overrides_postgresql_section(self):
|
|
"""PARSEDMARC_POSTGRESQL_* env vars must resolve to the [postgresql]
|
|
section.
|
|
|
|
Regression test: ``postgresql`` was missing from ``_KNOWN_SECTIONS``,
|
|
so ``_resolve_section_key`` returned ``(None, None)`` for every
|
|
``PARSEDMARC_POSTGRESQL_*`` var and the override was silently dropped.
|
|
The PostgreSQL backend is only initialized when ``"postgresql" in
|
|
config.sections()`` (cli.py), so the section must exist for env-var /
|
|
Docker-secret configuration of the backend to work at all.
|
|
"""
|
|
from configparser import ConfigParser
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
|
|
env = {
|
|
"PARSEDMARC_POSTGRESQL_HOST": "db.example.com",
|
|
"PARSEDMARC_POSTGRESQL_PORT": "5432",
|
|
"PARSEDMARC_POSTGRESQL_USER": "parsedmarc",
|
|
"PARSEDMARC_POSTGRESQL_DATABASE": "parsedmarc",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
|
|
self.assertIn("postgresql", config.sections())
|
|
self.assertEqual(config.get("postgresql", "host"), "db.example.com")
|
|
self.assertEqual(config.get("postgresql", "port"), "5432")
|
|
self.assertEqual(config.get("postgresql", "database"), "parsedmarc")
|
|
|
|
def test_apply_env_overrides_ignores_config_file_var(self):
|
|
"""PARSEDMARC_CONFIG_FILE is not injected as a config key."""
|
|
from configparser import ConfigParser
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
|
|
env = {"PARSEDMARC_CONFIG_FILE": "/some/path.ini"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
|
|
self.assertEqual(config.sections(), [])
|
|
|
|
def test_load_config_with_file_and_env_override(self):
|
|
"""Env vars override values from an INI file."""
|
|
from parsedmarc.cli import _load_config
|
|
|
|
with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
|
|
f.write(
|
|
"[imap]\nhost = file.example.com\nuser = alice\npassword = fromfile\n"
|
|
)
|
|
f.flush()
|
|
config_path = f.name
|
|
|
|
try:
|
|
env = {"PARSEDMARC_IMAP_PASSWORD": "fromenv"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(config_path)
|
|
|
|
self.assertEqual(config.get("imap", "host"), "file.example.com")
|
|
self.assertEqual(config.get("imap", "user"), "alice")
|
|
self.assertEqual(config.get("imap", "password"), "fromenv")
|
|
finally:
|
|
os.unlink(config_path)
|
|
|
|
def test_load_config_env_only(self):
|
|
"""Config can be loaded purely from env vars with no file."""
|
|
from parsedmarc.cli import _load_config
|
|
|
|
env = {
|
|
"PARSEDMARC_GENERAL_DEBUG": "true",
|
|
"PARSEDMARC_ELASTICSEARCH_HOSTS": "http://localhost:9200",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
|
|
self.assertEqual(config.get("general", "debug"), "true")
|
|
self.assertEqual(config.get("elasticsearch", "hosts"), "http://localhost:9200")
|
|
|
|
def test_parse_config_from_env(self):
|
|
"""Full round-trip: env vars -> ConfigParser -> opts."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
|
|
env = {
|
|
"PARSEDMARC_GENERAL_DEBUG": "true",
|
|
"PARSEDMARC_GENERAL_SAVE_AGGREGATE": "true",
|
|
"PARSEDMARC_GENERAL_OFFLINE": "true",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
|
|
self.assertTrue(opts.debug)
|
|
self.assertTrue(opts.save_aggregate)
|
|
self.assertTrue(opts.offline)
|
|
|
|
def test_config_file_env_var(self):
|
|
"""PARSEDMARC_CONFIG_FILE env var specifies the config file path."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
|
|
with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
|
|
f.write("[general]\ndebug = true\noffline = true\n")
|
|
f.flush()
|
|
config_path = f.name
|
|
|
|
try:
|
|
env = {"PARSEDMARC_CONFIG_FILE": config_path}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(os.environ.get("PARSEDMARC_CONFIG_FILE"))
|
|
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertTrue(opts.debug)
|
|
self.assertTrue(opts.offline)
|
|
finally:
|
|
os.unlink(config_path)
|
|
|
|
def test_boolean_values_from_env(self):
|
|
"""Various boolean string representations work through ConfigParser."""
|
|
from configparser import ConfigParser
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
for true_val in ("true", "yes", "1", "on", "True", "YES"):
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_GENERAL_DEBUG": true_val}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertTrue(
|
|
config.getboolean("general", "debug"),
|
|
f"Expected truthy for {true_val!r}",
|
|
)
|
|
|
|
for false_val in ("false", "no", "0", "off", "False", "NO"):
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_GENERAL_DEBUG": false_val}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertFalse(
|
|
config.getboolean("general", "debug"),
|
|
f"Expected falsy for {false_val!r}",
|
|
)
|
|
|
|
def test_short_alias_debug(self):
|
|
"""The bare DEBUG alias maps to [general] debug."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("general", "debug"), "true")
|
|
|
|
def test_short_alias_parsedmarc_debug(self):
|
|
"""The PARSEDMARC_DEBUG alias maps to [general] debug."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
with patch.dict(os.environ, {"PARSEDMARC_DEBUG": "true"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("general", "debug"), "true")
|
|
|
|
def test_file_env_var_reads_secret(self):
|
|
"""*_FILE env vars are loaded from a file (Docker secret style)."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
with NamedTemporaryFile(
|
|
mode="w", suffix=".secret", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("sekret-123\n")
|
|
secret_path = f.name
|
|
|
|
try:
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_IMAP_PASSWORD_FILE": secret_path}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("imap", "password"), "sekret-123")
|
|
finally:
|
|
os.unlink(secret_path)
|
|
|
|
def test_file_env_var_strips_trailing_crlf(self):
|
|
"""Leading and internal whitespace is preserved; trailing CR/LF is stripped."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
with NamedTemporaryFile(
|
|
mode="w", suffix=".secret", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write(" pre inside\r\n")
|
|
secret_path = f.name
|
|
|
|
try:
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_IMAP_PASSWORD_FILE": secret_path}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("imap", "password"), " pre inside")
|
|
finally:
|
|
os.unlink(secret_path)
|
|
|
|
def test_file_env_var_supersedes_direct_env(self):
|
|
"""*_FILE wins when both the direct env var and _FILE are set."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
with NamedTemporaryFile(
|
|
mode="w", suffix=".secret", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("from-file")
|
|
secret_path = f.name
|
|
|
|
try:
|
|
config = ConfigParser()
|
|
env = {
|
|
"PARSEDMARC_IMAP_PASSWORD": "from-env",
|
|
"PARSEDMARC_IMAP_PASSWORD_FILE": secret_path,
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("imap", "password"), "from-file")
|
|
finally:
|
|
os.unlink(secret_path)
|
|
|
|
def test_file_env_var_missing_file_raises(self):
|
|
"""A missing secret file aborts with ConfigurationError."""
|
|
from parsedmarc.cli import ConfigurationError, _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_IMAP_PASSWORD_FILE": "/tmp/parsedmarc-nonexistent-secret"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_apply_env_overrides(config)
|
|
self.assertIn("PARSEDMARC_IMAP_PASSWORD_FILE", str(ctx.exception))
|
|
|
|
def test_file_env_var_unreadable_file_raises(self):
|
|
"""A secret file we can't read aborts with ConfigurationError."""
|
|
import platform
|
|
|
|
# ``os.geteuid`` is POSIX-only; the ``platform.system() == "Windows"``
|
|
# check short-circuits on Windows so the second clause never runs.
|
|
if platform.system() == "Windows" or os.geteuid() == 0:
|
|
self.skipTest("chmod 000 doesn't restrict the running user")
|
|
|
|
from parsedmarc.cli import ConfigurationError, _apply_env_overrides
|
|
|
|
with NamedTemporaryFile(
|
|
mode="w", suffix=".secret", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("data")
|
|
secret_path = f.name
|
|
|
|
try:
|
|
os.chmod(secret_path, 0o000)
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_IMAP_PASSWORD_FILE": secret_path}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
with self.assertRaises(ConfigurationError):
|
|
_apply_env_overrides(config)
|
|
finally:
|
|
os.chmod(secret_path, 0o600)
|
|
os.unlink(secret_path)
|
|
|
|
def test_file_env_var_path_expansion(self):
|
|
"""~ and $VAR references in the path are expanded."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
secret_path = os.path.join(tmpdir, "secret")
|
|
with open(secret_path, "w", encoding="utf-8") as f:
|
|
f.write("expanded-value")
|
|
|
|
config = ConfigParser()
|
|
env = {
|
|
"PARSEDMARC_TEST_SECRET_DIR": tmpdir,
|
|
"PARSEDMARC_IMAP_PASSWORD_FILE": "$PARSEDMARC_TEST_SECRET_DIR/secret",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("imap", "password"), "expanded-value")
|
|
|
|
def test_file_env_var_unknown_section_ignored(self):
|
|
"""_FILE vars whose base name doesn't resolve to a section are ignored.
|
|
|
|
Uses ``clear=True`` so the assertion isn't perturbed by ambient
|
|
``PARSEDMARC_*`` vars set in the dev shell or CI runner.
|
|
"""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_UNKNOWN_FOO_FILE": "/tmp/should-not-be-read"}
|
|
with patch.dict(os.environ, env, clear=True):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.sections(), [])
|
|
|
|
def test_file_env_var_direct_file_keys_keep_direct_semantics(self):
|
|
"""Config keys ending in _file (log_file, token_file, ...) stay direct."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
config = ConfigParser()
|
|
env = {
|
|
"PARSEDMARC_GENERAL_LOG_FILE": "/var/log/parsedmarc.log",
|
|
"PARSEDMARC_GMAIL_API_CREDENTIALS_FILE": "/etc/parsedmarc/gmail.json",
|
|
"PARSEDMARC_GMAIL_API_TOKEN_FILE": "/etc/parsedmarc/gmail.token",
|
|
"PARSEDMARC_MSGRAPH_TOKEN_FILE": "/etc/parsedmarc/msgraph.token",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(config.get("general", "log_file"), "/var/log/parsedmarc.log")
|
|
self.assertEqual(
|
|
config.get("gmail_api", "credentials_file"),
|
|
"/etc/parsedmarc/gmail.json",
|
|
)
|
|
self.assertEqual(
|
|
config.get("gmail_api", "token_file"), "/etc/parsedmarc/gmail.token"
|
|
)
|
|
self.assertEqual(
|
|
config.get("msgraph", "token_file"), "/etc/parsedmarc/msgraph.token"
|
|
)
|
|
|
|
def test_file_env_var_double_suffix_wraps_direct_file_key(self):
|
|
"""GMAIL_API_CREDENTIALS_FILE_FILE provides the file path via a secret."""
|
|
from parsedmarc.cli import _apply_env_overrides
|
|
|
|
with NamedTemporaryFile(
|
|
mode="w", suffix=".secret", delete=False, encoding="utf-8"
|
|
) as f:
|
|
f.write("/run/secrets/real-gmail-credentials.json\n")
|
|
secret_path = f.name
|
|
|
|
try:
|
|
config = ConfigParser()
|
|
env = {"PARSEDMARC_GMAIL_API_CREDENTIALS_FILE_FILE": secret_path}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
_apply_env_overrides(config)
|
|
self.assertEqual(
|
|
config.get("gmail_api", "credentials_file"),
|
|
"/run/secrets/real-gmail-credentials.json",
|
|
)
|
|
finally:
|
|
os.unlink(secret_path)
|
|
|
|
def test_direct_file_keys_matches_parse_config_source(self):
|
|
"""``_DIRECT_FILE_KEYS`` must cover every ``*_file`` key in ``_parse_config``.
|
|
|
|
Regression guard for the keep-in-sync comment: when someone adds a new
|
|
``[section] some_file`` config option in ``_parse_config`` without
|
|
also extending ``_DIRECT_FILE_KEYS``, ``PARSEDMARC_SECTION_SOME_FILE``
|
|
would silently be treated as a Docker-secret wrapper (and try to read
|
|
a file at the supplied path) instead of as the direct value.
|
|
"""
|
|
import re
|
|
import inspect
|
|
import parsedmarc.cli as cli_module
|
|
|
|
# Scan the cli source for every ``<section>_config[...]("<key>_file")``
|
|
# / ``["<key>_file"]`` access and rebuild the expected upper-case set.
|
|
# Skip ``_filename`` keys (e.g. ``aggregate_json_filename``).
|
|
src = inspect.getsource(cli_module)
|
|
pattern = re.compile(
|
|
r'(\w+?)_config(?:\.get|\[)\(?["\'](\w+_file)["\']',
|
|
)
|
|
seen: set[str] = set()
|
|
for sect_var, key in pattern.findall(src):
|
|
if key.endswith("_filename"):
|
|
continue
|
|
# Map the local variable name (graph_config / general_config /
|
|
# gmail_api_config / ...) to its config-section name. The
|
|
# convention is "<section>_config", but ``msgraph`` is bound to
|
|
# ``graph_config`` — handle that one alias.
|
|
section = "msgraph" if sect_var == "graph" else sect_var
|
|
seen.add(f"{section.upper()}_{key.upper()}")
|
|
self.assertEqual(
|
|
seen,
|
|
set(cli_module._DIRECT_FILE_KEYS),
|
|
"_DIRECT_FILE_KEYS is out of sync with *_file keys in _parse_config",
|
|
)
|
|
|
|
|
|
class TestDirectoryFilePaths(unittest.TestCase):
|
|
"""End-to-end coverage of issue #397: a directory passed as a
|
|
``file_path`` CLI argument expands to the report files inside it, and
|
|
``-r``/``--recursive`` opts into descending into subdirectories. Runs
|
|
the real ``_main()`` entry point (real multiprocessing worker, real
|
|
parsing, real JSON output) against on-disk sample reports with no
|
|
mocking of parsedmarc's own code, per AGENTS.md's mock-at-SDK-boundary
|
|
rule (there is no external SDK boundary in this code path to mock)."""
|
|
|
|
TOP_LEVEL_SAMPLE = "samples/aggregate/!example.com!1538204542!1538463818.xml"
|
|
NESTED_SAMPLE = "samples/aggregate/!large-example.com!1711897200!1711983600.xml"
|
|
|
|
def setUp(self):
|
|
# SEEN_AGGREGATE_REPORT_IDS is a module-level ExpiringDict that
|
|
# dedupes report IDs across parses within one process; clear it so
|
|
# a report "seen" by an earlier test isn't silently dropped here.
|
|
# Precedent: tests/test_init.py TestGetDmarcReportsFromMailboxMaildir.setUp.
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
# Also clear on the way out: these tests reuse
|
|
# SAMPLE_AGGREGATE_REPORT_PATH's report ID, and other test classes
|
|
# later in this file (e.g. TestSkipsResultsEmailWhenNoReportsParsed)
|
|
# parse that same sample through the real _main() dedup path, so a
|
|
# left-over "seen" entry here would make their report look like a
|
|
# duplicate and silently vanish.
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
self._env_patcher = patch.dict(
|
|
os.environ, {"GITHUB_ACTIONS": "true"}, clear=False
|
|
)
|
|
self._env_patcher.start()
|
|
self.addCleanup(self._env_patcher.stop)
|
|
|
|
def _build_reports_dir(self, tmp_dir):
|
|
reports_dir = os.path.join(tmp_dir, "reports")
|
|
nested_dir = os.path.join(reports_dir, "nested")
|
|
os.makedirs(nested_dir)
|
|
top_level_path = os.path.join(
|
|
reports_dir, os.path.basename(self.TOP_LEVEL_SAMPLE)
|
|
)
|
|
nested_path = os.path.join(nested_dir, os.path.basename(self.NESTED_SAMPLE))
|
|
with (
|
|
open(self.TOP_LEVEL_SAMPLE, "rb") as src,
|
|
open(top_level_path, "wb") as dst,
|
|
):
|
|
dst.write(src.read())
|
|
with open(self.NESTED_SAMPLE, "rb") as src, open(nested_path, "wb") as dst:
|
|
dst.write(src.read())
|
|
return reports_dir
|
|
|
|
def _write_config(self, tmp_dir, output_dirname, n_procs=None):
|
|
cfg_path = os.path.join(tmp_dir, "parsedmarc.ini")
|
|
output_dir = os.path.join(tmp_dir, output_dirname)
|
|
config_text = (
|
|
f"[general]\noffline = True\nsilent = True\noutput = {output_dir}\n"
|
|
)
|
|
if n_procs is not None:
|
|
config_text += f"n_procs = {n_procs}\n"
|
|
with open(cfg_path, "w") as f:
|
|
f.write(config_text)
|
|
return cfg_path, output_dir
|
|
|
|
def _report_ids(self, output_dir):
|
|
with open(os.path.join(output_dir, "aggregate.json")) as f:
|
|
reports = json.load(f)
|
|
return {report["report_metadata"]["report_id"] for report in reports}
|
|
|
|
def _sample_report_id(self, path):
|
|
result = parsedmarc.parse_report_file(path, offline=True)
|
|
assert result["report_type"] == "aggregate"
|
|
report = cast(AggregateReport, result["report"])
|
|
return report["report_metadata"]["report_id"]
|
|
|
|
def test_directory_file_path_non_recursive_skips_nested(self):
|
|
"""A bare directory ``file_path`` argument behaves like shell
|
|
``reports/*``: the top-level sample is parsed, and the nested
|
|
subdirectory is skipped (not descended into) without ``-r``.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
reports_dir = self._build_reports_dir(tmp_dir)
|
|
cfg_path, output_dir = self._write_config(tmp_dir, "output_non_recursive")
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, reports_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
report_ids = self._report_ids(output_dir)
|
|
|
|
top_level_id = self._sample_report_id(self.TOP_LEVEL_SAMPLE)
|
|
nested_id = self._sample_report_id(self.NESTED_SAMPLE)
|
|
|
|
self.assertIn(top_level_id, report_ids)
|
|
self.assertNotIn(nested_id, report_ids)
|
|
|
|
def test_directory_file_path_recursive_includes_nested(self):
|
|
"""With ``-r``/``--recursive``, the directory expands via ``**``
|
|
and both the top-level and nested sample reports are parsed.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
reports_dir = self._build_reports_dir(tmp_dir)
|
|
cfg_path, output_dir = self._write_config(tmp_dir, "output_recursive")
|
|
|
|
with patch.object(
|
|
sys, "argv", ["parsedmarc", "-c", cfg_path, "-r", reports_dir]
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
report_ids = self._report_ids(output_dir)
|
|
|
|
top_level_id = self._sample_report_id(self.TOP_LEVEL_SAMPLE)
|
|
nested_id = self._sample_report_id(self.NESTED_SAMPLE)
|
|
|
|
self.assertIn(top_level_id, report_ids)
|
|
self.assertIn(nested_id, report_ids)
|
|
|
|
def test_directory_file_path_recursive_includes_nested_n_procs_2(self):
|
|
"""The same recursive-directory scenario as
|
|
``test_directory_file_path_recursive_includes_nested``, but with
|
|
``n_procs = 2`` in the config file so the direct-file parsing path
|
|
runs through ``parallel_map``'s process pool with more than one
|
|
worker. The report-id sets in the output JSON must match the
|
|
``n_procs = 1`` (default) run exactly — parallelism must not change
|
|
which reports get parsed or deduplicated.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
reports_dir = self._build_reports_dir(tmp_dir)
|
|
cfg_path, output_dir = self._write_config(
|
|
tmp_dir, "output_recursive_n_procs_2", n_procs=2
|
|
)
|
|
|
|
with patch.object(
|
|
sys, "argv", ["parsedmarc", "-c", cfg_path, "-r", reports_dir]
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
report_ids = self._report_ids(output_dir)
|
|
|
|
top_level_id = self._sample_report_id(self.TOP_LEVEL_SAMPLE)
|
|
nested_id = self._sample_report_id(self.NESTED_SAMPLE)
|
|
|
|
self.assertIn(top_level_id, report_ids)
|
|
self.assertIn(nested_id, report_ids)
|
|
self.assertEqual(report_ids, {top_level_id, nested_id})
|
|
|
|
|
|
class TestMixedInputAggregateDedup(unittest.TestCase):
|
|
"""The direct-file and mbox paths of one ``_main()`` run share the
|
|
``SEEN_AGGREGATE_REPORT_IDS`` cache, so they must key it identically.
|
|
|
|
If the direct-file path in ``_main()`` wrote ``{org_name}_{report_id}``
|
|
while ``get_dmarc_reports_from_mbox`` looked up a JSON
|
|
``[org_name, report_id, policy_domain]`` key, a report passed both as a
|
|
file and inside an mbox in the same run would be saved twice. RFC 9990
|
|
§3.5.1 scopes Report-ID uniqueness to "reports to the same domain", so
|
|
the shared key also keeps same-ID reports for different domains apart in
|
|
the direct-file path. Runs the real ``_main()`` with real parsing and
|
|
asserts on the written ``aggregate.json``.
|
|
"""
|
|
|
|
REPORT_ID = "1721054318-shared.example@reporter.example"
|
|
|
|
def setUp(self):
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
env_patcher = patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}, clear=False)
|
|
env_patcher.start()
|
|
self.addCleanup(env_patcher.stop)
|
|
self._tmp = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, self._tmp, ignore_errors=True)
|
|
|
|
def _xml(self, policy_domain: str, record_count: int) -> bytes:
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
"<feedback><report_metadata>"
|
|
"<org_name>reporter.example</org_name>"
|
|
"<email>dmarc@reporter.example</email>"
|
|
f"<report_id>{self.REPORT_ID}</report_id>"
|
|
"<date_range><begin>1680000000</begin><end>1680086400</end>"
|
|
"</date_range></report_metadata>"
|
|
f"<policy_published><domain>{policy_domain}</domain><p>none</p>"
|
|
"</policy_published>"
|
|
"<record><row><source_ip>192.0.2.1</source_ip>"
|
|
f"<count>{record_count}</count>"
|
|
"<policy_evaluated><disposition>none</disposition><dkim>pass</dkim>"
|
|
"<spf>pass</spf></policy_evaluated></row>"
|
|
f"<identifiers><header_from>{policy_domain}</header_from></identifiers>"
|
|
f"<auth_results><spf><domain>{policy_domain}</domain>"
|
|
"<result>pass</result></spf></auth_results></record></feedback>"
|
|
).encode()
|
|
|
|
def _xml_file(self, name: str, policy_domain: str, record_count: int) -> str:
|
|
path = os.path.join(self._tmp, name)
|
|
with open(path, "wb") as f:
|
|
f.write(self._xml(policy_domain, record_count))
|
|
return path
|
|
|
|
def _mbox_file(self, name: str, policy_domain: str, record_count: int) -> str:
|
|
message = MIMEMultipart()
|
|
message["From"] = "dmarc@reporter.example"
|
|
message["To"] = "postmaster@example.net"
|
|
message["Subject"] = f"Report domain: {policy_domain}"
|
|
message["Date"] = "Mon, 1 Jan 2024 00:00:00 +0000"
|
|
attachment = MIMEApplication(
|
|
gzip.compress(self._xml(policy_domain, record_count)), "gzip"
|
|
)
|
|
attachment.add_header(
|
|
"Content-Disposition", "attachment", filename="report.xml.gz"
|
|
)
|
|
message.attach(attachment)
|
|
path = os.path.join(self._tmp, name)
|
|
box = mailbox.mbox(path)
|
|
try:
|
|
box.add(mailbox.mboxMessage(message.as_bytes()))
|
|
box.flush()
|
|
finally:
|
|
box.close()
|
|
return path
|
|
|
|
def _run(self, *paths: str) -> list[tuple[str, int]]:
|
|
output_dir = os.path.join(self._tmp, "output")
|
|
cfg_path = os.path.join(self._tmp, "parsedmarc.ini")
|
|
with open(cfg_path, "w") as f:
|
|
f.write(
|
|
f"[general]\noffline = True\nsilent = True\noutput = {output_dir}\n"
|
|
)
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, *paths]):
|
|
parsedmarc.cli._main()
|
|
with open(os.path.join(output_dir, "aggregate.json")) as f:
|
|
reports = json.load(f)
|
|
return sorted(
|
|
(report["policy_published"]["domain"], report["records"][0]["count"])
|
|
for report in reports
|
|
)
|
|
|
|
def test_same_report_in_file_and_mbox_is_saved_once(self):
|
|
"""A report given as a file and again inside an mbox is one report."""
|
|
xml_path = self._xml_file("report.xml", "a.example", 5)
|
|
mbox_path = self._mbox_file("reports.mbox", "a.example", 7)
|
|
|
|
kept = self._run(xml_path, mbox_path)
|
|
|
|
# Direct files are parsed before mbox files, so the file's copy wins.
|
|
self.assertEqual(kept, [("a.example", 5)])
|
|
|
|
def test_same_id_for_other_domain_in_file_and_mbox_is_kept(self):
|
|
"""The shared key keeps the domain, so a same-ID report for another
|
|
domain in the mbox is not mistaken for the file's report."""
|
|
xml_path = self._xml_file("report.xml", "a.example", 5)
|
|
mbox_path = self._mbox_file("reports.mbox", "b.example", 7)
|
|
|
|
kept = self._run(xml_path, mbox_path)
|
|
|
|
self.assertEqual(kept, [("a.example", 5), ("b.example", 7)])
|
|
|
|
def test_direct_files_with_same_id_for_different_domains_are_kept(self):
|
|
"""Direct-file dedup is domain-scoped too: two files from one
|
|
reporter sharing a Report-ID for different domains are both kept,
|
|
while an ASCII-case variant of the same domain is still a duplicate.
|
|
"""
|
|
a_path = self._xml_file("a.xml", "a.example", 5)
|
|
b_path = self._xml_file("b.xml", "b.example", 7)
|
|
a_upper_path = self._xml_file("a-upper.xml", "A.EXAMPLE", 9)
|
|
|
|
kept = self._run(a_path, b_path, a_upper_path)
|
|
|
|
# file_paths is deduplicated through a set, so which case variant of
|
|
# a.example survives is unordered; compare domains case-folded.
|
|
# tests/test_init.py covers that the kept report's case is unchanged.
|
|
domains = sorted(domain.lower() for domain, _ in kept)
|
|
self.assertEqual(domains, ["a.example", "b.example"])
|
|
self.assertIn(("b.example", 7), kept)
|
|
|
|
|
|
class TestArchiveDirectory(unittest.TestCase):
|
|
"""End-to-end coverage of issue #570: ``[general] archive_directory``
|
|
moves successfully processed local report files into a dated
|
|
``<year>/<month>/<Aggregate|Failure|SMTP-TLS>/`` tree, and unparseable
|
|
files into ``<archive_directory>/Invalid/``. Runs the real ``_main()``
|
|
entry point via ``patch.object(sys, "argv", ...)`` against on-disk
|
|
sample reports, per AGENTS.md's mock-at-SDK-boundary rule (there is no
|
|
external SDK boundary in this code path to mock)."""
|
|
|
|
AGGREGATE_SAMPLE_1 = "samples/aggregate/!example.com!1538204542!1538463818.xml"
|
|
AGGREGATE_SAMPLE_2 = (
|
|
"samples/aggregate/!large-example.com!1711897200!1711983600.xml"
|
|
)
|
|
# Not dmarc_ruf_report_linkedin.eml: that sample begins with a
|
|
# "From dmarc-noreply@linkedin.com ..." mbox envelope line, which is
|
|
# why Python's mailbox module reads it as a (single-message) mbox
|
|
# file and is_mbox() classifies it as an mbox — routing it through
|
|
# _main()'s mbox_paths branch instead of the direct-file archiving
|
|
# path this test exercises (mbox files are intentionally never
|
|
# archived — see the archive_directory docs in docs/source/usage.md).
|
|
# The sharepoint sample below starts with a MIME header instead, so
|
|
# it isn't mbox-classified.
|
|
FAILURE_SAMPLE = (
|
|
"samples/failure/DMARC Failure Report for domain.de "
|
|
"(mail-from=sharepoint@domain.de, ip=10.10.10.10).eml"
|
|
)
|
|
SMTP_TLS_SAMPLE = "samples/smtp_tls/rfc8460.json"
|
|
|
|
def setUp(self):
|
|
# SEEN_AGGREGATE_REPORT_IDS is a module-level ExpiringDict that
|
|
# dedupes report IDs across parses within one process; clear it so
|
|
# a report "seen" by an earlier test isn't silently dropped here,
|
|
# and so tests within this class don't interfere with each other.
|
|
# Precedent: TestDirectoryFilePaths.setUp above.
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
self._env_patcher = patch.dict(
|
|
os.environ, {"GITHUB_ACTIONS": "true"}, clear=False
|
|
)
|
|
self._env_patcher.start()
|
|
self.addCleanup(self._env_patcher.stop)
|
|
|
|
def _copy_sample(self, src_path, dest_dir, dest_basename=None):
|
|
dest_basename = dest_basename or os.path.basename(src_path)
|
|
dest_path = os.path.join(dest_dir, dest_basename)
|
|
with open(src_path, "rb") as src, open(dest_path, "wb") as dst:
|
|
dst.write(src.read())
|
|
return dest_path
|
|
|
|
def _expected_subdir(self, sample_path):
|
|
"""Parse *sample_path* the same way the CLI does and return the
|
|
``<year>/<month>/<type>`` subdirectory ``_archive_subdir_for_result``
|
|
computes for it. Computed dynamically rather than hardcoded because
|
|
aggregate ``begin_date`` is local-time, so month buckets are
|
|
timezone-dependent."""
|
|
result = parsedmarc.parse_report_file(sample_path, offline=True)
|
|
subdir = parsedmarc.cli._archive_subdir_for_result(result)
|
|
assert subdir is not None
|
|
return subdir
|
|
|
|
def _write_config(
|
|
self, tmp_dir, output_dirname, archive_dirname=None, n_procs=None
|
|
):
|
|
cfg_path = os.path.join(tmp_dir, "parsedmarc.ini")
|
|
output_dir = os.path.join(tmp_dir, output_dirname)
|
|
config_text = (
|
|
f"[general]\noffline = True\nsilent = True\noutput = {output_dir}\n"
|
|
)
|
|
archive_dir = None
|
|
if archive_dirname is not None:
|
|
archive_dir = os.path.join(tmp_dir, archive_dirname)
|
|
config_text += f"archive_directory = {archive_dir}\n"
|
|
if n_procs is not None:
|
|
config_text += f"n_procs = {n_procs}\n"
|
|
with open(cfg_path, "w") as f:
|
|
f.write(config_text)
|
|
return cfg_path, output_dir, archive_dir
|
|
|
|
def test_archive_moves_all_three_types(self):
|
|
"""Aggregate, failure, and SMTP TLS report files given as direct
|
|
``file_path`` arguments are moved into
|
|
``<archive>/<year>/<month>/<Aggregate|Failure|SMTP-TLS>/`` after a
|
|
successful parse, the source files are gone from the input
|
|
directory, and the aggregate JSON output is still produced."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
agg1 = self._copy_sample(self.AGGREGATE_SAMPLE_1, input_dir)
|
|
agg2 = self._copy_sample(self.AGGREGATE_SAMPLE_2, input_dir)
|
|
failure = self._copy_sample(self.FAILURE_SAMPLE, input_dir)
|
|
smtp_tls = self._copy_sample(self.SMTP_TLS_SAMPLE, input_dir)
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(
|
|
tmp_dir, "output", archive_dirname="archive"
|
|
)
|
|
assert archive_dir is not None
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
for src, sample_path in (
|
|
(agg1, self.AGGREGATE_SAMPLE_1),
|
|
(agg2, self.AGGREGATE_SAMPLE_2),
|
|
(failure, self.FAILURE_SAMPLE),
|
|
(smtp_tls, self.SMTP_TLS_SAMPLE),
|
|
):
|
|
subdir = self._expected_subdir(sample_path)
|
|
dest = os.path.join(archive_dir, subdir, os.path.basename(src))
|
|
self.assertTrue(os.path.isfile(dest), f"missing {dest}")
|
|
self.assertFalse(os.path.isfile(src))
|
|
|
|
self.assertTrue(os.path.isfile(os.path.join(output_dir, "aggregate.json")))
|
|
|
|
def test_failed_parse_moved_to_invalid(self):
|
|
"""A file that fails to parse (garbage content) is moved to
|
|
``<archive_directory>/Invalid/`` rather than left in place, and
|
|
the run completes without raising."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
garbage_path = os.path.join(input_dir, "garbage.xml")
|
|
with open(garbage_path, "wb") as f:
|
|
f.write(b"not a report")
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(
|
|
tmp_dir, "output", archive_dirname="archive"
|
|
)
|
|
assert archive_dir is not None
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
dest = os.path.join(archive_dir, "Invalid", "garbage.xml")
|
|
self.assertTrue(os.path.isfile(dest))
|
|
self.assertFalse(os.path.isfile(garbage_path))
|
|
|
|
def test_collision_appends_numeric_suffix(self):
|
|
"""A destination file that already exists at the computed archive
|
|
path is never overwritten: the newly archived file gets a
|
|
numeric suffix appended before its extension instead, and the
|
|
pre-existing file's content is untouched."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
src = self._copy_sample(self.AGGREGATE_SAMPLE_1, input_dir)
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(
|
|
tmp_dir, "output", archive_dirname="archive"
|
|
)
|
|
assert archive_dir is not None
|
|
|
|
subdir = self._expected_subdir(self.AGGREGATE_SAMPLE_1)
|
|
dest_dir = os.path.join(archive_dir, subdir)
|
|
os.makedirs(dest_dir)
|
|
basename = os.path.basename(src)
|
|
preexisting_path = os.path.join(dest_dir, basename)
|
|
with open(preexisting_path, "wb") as f:
|
|
f.write(b"PREEXISTING DUMMY CONTENT")
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
with open(preexisting_path, "rb") as f:
|
|
self.assertEqual(f.read(), b"PREEXISTING DUMMY CONTENT")
|
|
|
|
base, ext = os.path.splitext(basename)
|
|
suffixed_path = os.path.join(dest_dir, f"{base}-1{ext}")
|
|
self.assertTrue(os.path.isfile(suffixed_path))
|
|
self.assertFalse(os.path.isfile(src))
|
|
|
|
def test_second_run_skips_archived_files(self):
|
|
"""Files already inside ``archive_directory`` are excluded from
|
|
the next run's ``file_path`` expansion, so the archive may safely
|
|
live inside an input directory without its own contents being
|
|
re-parsed and re-archived on a later run. SEEN_AGGREGATE_REPORT_IDS
|
|
is cleared between runs to prove the exclusion doesn't rely on
|
|
aggregate-report dedup (the failure/SMTP-TLS samples aren't
|
|
deduped at all, so they alone would already prove this, but
|
|
clearing it removes any doubt for the aggregate sample too)."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
self._copy_sample(self.AGGREGATE_SAMPLE_1, input_dir)
|
|
self._copy_sample(self.FAILURE_SAMPLE, input_dir)
|
|
self._copy_sample(self.SMTP_TLS_SAMPLE, input_dir)
|
|
|
|
archive_dir = os.path.join(input_dir, "archive")
|
|
output_dir = os.path.join(tmp_dir, "output")
|
|
cfg_path = os.path.join(tmp_dir, "parsedmarc.ini")
|
|
config_text = (
|
|
"[general]\noffline = True\nsilent = True\n"
|
|
f"output = {output_dir}\narchive_directory = {archive_dir}\n"
|
|
)
|
|
with open(cfg_path, "w") as f:
|
|
f.write(config_text)
|
|
|
|
def _archive_tree():
|
|
tree = {}
|
|
for root, _dirs, files in os.walk(archive_dir):
|
|
for name in files:
|
|
path = os.path.join(root, name)
|
|
rel = os.path.relpath(path, archive_dir)
|
|
with open(path, "rb") as f:
|
|
tree[rel] = f.read()
|
|
return tree
|
|
|
|
with patch.object(
|
|
sys, "argv", ["parsedmarc", "-c", cfg_path, "-r", input_dir]
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
first_run_tree = _archive_tree()
|
|
self.assertEqual(len(first_run_tree), 3)
|
|
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
|
|
with patch.object(
|
|
sys, "argv", ["parsedmarc", "-c", cfg_path, "-r", input_dir]
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
second_run_tree = _archive_tree()
|
|
self.assertEqual(first_run_tree, second_run_tree)
|
|
for rel in second_run_tree:
|
|
self.assertNotIn("-1", os.path.basename(rel))
|
|
|
|
def test_archive_with_n_procs_2(self):
|
|
"""The same archiving behavior as
|
|
``test_archive_moves_all_three_types``, but with ``n_procs = 2``
|
|
so the direct-file parsing path runs through the multiprocessing
|
|
pool."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
agg1 = self._copy_sample(self.AGGREGATE_SAMPLE_1, input_dir)
|
|
agg2 = self._copy_sample(self.AGGREGATE_SAMPLE_2, input_dir)
|
|
failure = self._copy_sample(self.FAILURE_SAMPLE, input_dir)
|
|
smtp_tls = self._copy_sample(self.SMTP_TLS_SAMPLE, input_dir)
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(
|
|
tmp_dir, "output", archive_dirname="archive", n_procs=2
|
|
)
|
|
assert archive_dir is not None
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
for src, sample_path in (
|
|
(agg1, self.AGGREGATE_SAMPLE_1),
|
|
(agg2, self.AGGREGATE_SAMPLE_2),
|
|
(failure, self.FAILURE_SAMPLE),
|
|
(smtp_tls, self.SMTP_TLS_SAMPLE),
|
|
):
|
|
subdir = self._expected_subdir(sample_path)
|
|
dest = os.path.join(archive_dir, subdir, os.path.basename(src))
|
|
self.assertTrue(os.path.isfile(dest), f"missing {dest}")
|
|
self.assertFalse(os.path.isfile(src))
|
|
|
|
self.assertTrue(os.path.isfile(os.path.join(output_dir, "aggregate.json")))
|
|
|
|
def test_no_archive_when_option_unset(self):
|
|
"""Without ``archive_directory`` configured, input files are left
|
|
in place after processing and no ``archive`` directory is
|
|
created."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
agg1 = self._copy_sample(self.AGGREGATE_SAMPLE_1, input_dir)
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(tmp_dir, "output")
|
|
self.assertIsNone(archive_dir)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertTrue(os.path.isfile(agg1))
|
|
self.assertFalse(os.path.isdir(os.path.join(tmp_dir, "archive")))
|
|
|
|
def test_duplicate_aggregate_reports_both_archived(self):
|
|
"""The same aggregate report content under two different
|
|
filenames is deduplicated down to one report in the JSON output,
|
|
but both source files are archived normally: archiving runs for
|
|
every file that parsed successfully, independent of the
|
|
in-process report-ID dedup."""
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
input_dir = os.path.join(tmp_dir, "input")
|
|
os.makedirs(input_dir)
|
|
copy1 = self._copy_sample(
|
|
self.AGGREGATE_SAMPLE_1, input_dir, dest_basename="copy-a.xml"
|
|
)
|
|
copy2 = self._copy_sample(
|
|
self.AGGREGATE_SAMPLE_1, input_dir, dest_basename="copy-b.xml"
|
|
)
|
|
|
|
cfg_path, output_dir, archive_dir = self._write_config(
|
|
tmp_dir, "output", archive_dirname="archive"
|
|
)
|
|
assert archive_dir is not None
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path, input_dir]):
|
|
parsedmarc.cli._main()
|
|
|
|
with open(os.path.join(output_dir, "aggregate.json")) as f:
|
|
reports = json.load(f)
|
|
self.assertEqual(len(reports), 1)
|
|
|
|
subdir = self._expected_subdir(self.AGGREGATE_SAMPLE_1)
|
|
dest1 = os.path.join(archive_dir, subdir, "copy-a.xml")
|
|
dest2 = os.path.join(archive_dir, subdir, "copy-b.xml")
|
|
self.assertTrue(os.path.isfile(dest1))
|
|
self.assertTrue(os.path.isfile(dest2))
|
|
self.assertFalse(os.path.isfile(copy1))
|
|
self.assertFalse(os.path.isfile(copy2))
|
|
|
|
|
|
class TestGmailAuthModes(unittest.TestCase):
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.GmailConnection")
|
|
def testCliPassesGmailServiceAccountAuthSettings(
|
|
self, mock_gmail_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_gmail_connection.return_value = MagicMock()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config = """[general]
|
|
silent = true
|
|
|
|
[gmail_api]
|
|
credentials_file = /tmp/service-account.json
|
|
auth_mode = service_account
|
|
service_account_user = dmarc@example.com
|
|
scopes = https://www.googleapis.com/auth/gmail.modify
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg_file:
|
|
cfg_file.write(config)
|
|
config_path = cfg_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_gmail_connection.call_args.kwargs.get("auth_mode"), "service_account"
|
|
)
|
|
self.assertEqual(
|
|
mock_gmail_connection.call_args.kwargs.get("service_account_user"),
|
|
"dmarc@example.com",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.GmailConnection")
|
|
def testCliAcceptsDelegatedUserAlias(self, mock_gmail_connection, mock_get_reports):
|
|
mock_gmail_connection.return_value = MagicMock()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config = """[general]
|
|
silent = true
|
|
|
|
[gmail_api]
|
|
credentials_file = /tmp/service-account.json
|
|
auth_mode = service_account
|
|
delegated_user = delegated@example.com
|
|
scopes = https://www.googleapis.com/auth/gmail.modify
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg_file:
|
|
cfg_file.write(config)
|
|
config_path = cfg_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_gmail_connection.call_args.kwargs.get("service_account_user"),
|
|
"delegated@example.com",
|
|
)
|
|
|
|
|
|
class TestMailboxWatchSince(unittest.TestCase):
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
def testWatchInboxPassesSinceToMailboxFetch(self):
|
|
mailbox_connection = SimpleNamespace()
|
|
|
|
def fake_watch(check_callback, check_timeout, config_reloading=None):
|
|
check_callback(mailbox_connection)
|
|
raise _BreakLoop()
|
|
|
|
mailbox_connection.watch = fake_watch
|
|
callback = MagicMock()
|
|
with patch.object(
|
|
parsedmarc, "get_dmarc_reports_from_mailbox", return_value={}
|
|
) as mocked:
|
|
with self.assertRaises(_BreakLoop):
|
|
parsedmarc.watch_inbox(
|
|
mailbox_connection=cast(
|
|
parsedmarc.MailboxConnection, mailbox_connection
|
|
),
|
|
callback=callback,
|
|
check_timeout=1,
|
|
batch_size=10,
|
|
since="1d",
|
|
)
|
|
self.assertEqual(mocked.call_args.kwargs.get("since"), "1d")
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testCliPassesSinceToWatchInbox(
|
|
self, mock_imap_connection, mock_watch_inbox, mock_get_mailbox_reports
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_watch_inbox.side_effect = FileExistsError("stop-watch-loop")
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
watch = true
|
|
since = 2d
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, 1)
|
|
self.assertEqual(mock_watch_inbox.call_args.kwargs.get("since"), "2d")
|
|
|
|
|
|
class TestMailboxPerTypeDeleteOptions(unittest.TestCase):
|
|
"""The [mailbox] per-report-type delete options (issue #256) must reach
|
|
the library unchanged, including the difference between an explicit
|
|
``false`` and an unset option (``None``, meaning "inherit ``delete``")."""
|
|
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
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
|
|
|
|
IMAP_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
delete = true
|
|
delete_failure = false
|
|
"""
|
|
|
|
def _assert_per_type_delete_kwargs(self, kwargs):
|
|
self.assertIs(kwargs.get("delete"), True)
|
|
self.assertIs(kwargs.get("delete_failure"), False)
|
|
# Unset options stay None so the library applies the inheritance.
|
|
self.assertIsNone(kwargs.get("delete_aggregate"))
|
|
self.assertIsNone(kwargs.get("delete_smtp_tls"))
|
|
self.assertIsNone(kwargs.get("delete_invalid"))
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testCliPassesPerTypeDeleteToWatchInbox(
|
|
self, mock_imap_connection, mock_watch_inbox, mock_get_mailbox_reports
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_watch_inbox.side_effect = FileExistsError("stop-watch-loop")
|
|
cfg_path = self._write_config(self.IMAP_CONFIG + "watch = true\n")
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, 1)
|
|
self._assert_per_type_delete_kwargs(mock_watch_inbox.call_args.kwargs)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testCliPassesPerTypeDeleteToOneShotMailboxRun(
|
|
self, mock_imap_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.IMAP_CONFIG)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self._assert_per_type_delete_kwargs(mock_get_mailbox_reports.call_args.kwargs)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.GmailConnection")
|
|
def testGmailScopeGuardForcesAllDeleteFlagsOff(
|
|
self, mock_gmail_connection, mock_get_mailbox_reports
|
|
):
|
|
"""The Gmail deletion scope is a mailbox-wide capability grant, so a
|
|
per-report-type delete option alone (with ``delete`` unset) is enough
|
|
to trip the guard, and a missing scope turns every delete flag off."""
|
|
mock_gmail_connection.return_value = MagicMock()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config("""[general]
|
|
silent = true
|
|
|
|
[gmail_api]
|
|
credentials_file = /tmp/gmail-credentials.json
|
|
scopes = https://www.googleapis.com/auth/gmail.modify
|
|
|
|
[mailbox]
|
|
delete_aggregate = true
|
|
""")
|
|
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
try:
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as logs:
|
|
parsedmarc.cli._main()
|
|
finally:
|
|
_logger.disabled = True
|
|
|
|
self.assertTrue(
|
|
any("Message deletion requires scope" in line for line in logs.output),
|
|
logs.output,
|
|
)
|
|
kwargs = mock_get_mailbox_reports.call_args.kwargs
|
|
for option in (
|
|
"delete",
|
|
"delete_aggregate",
|
|
"delete_failure",
|
|
"delete_smtp_tls",
|
|
"delete_invalid",
|
|
):
|
|
with self.subTest(option=option):
|
|
self.assertIs(kwargs.get(option), False)
|
|
|
|
|
|
class TestCliParserConfigWiring(unittest.TestCase):
|
|
"""Tests that _main() builds a single ParserConfig (via
|
|
_build_parser_config) from parsed opts and passes it as ``config=`` to
|
|
the library's mailbox-fetching functions, rather than forwarding
|
|
individual option kwargs (offline, dns_timeout, ip_db_path, etc.) by
|
|
hand at each call site."""
|
|
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
# SEEN_AGGREGATE_REPORT_IDS is a module-level ExpiringDict shared
|
|
# across tests in this process; clear it both ways so state from an
|
|
# earlier test class doesn't leak in, and so this class doesn't leak
|
|
# into a later one. Precedent: TestDirectoryFilePaths.setUp above.
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
def _run_one_shot_mailbox(
|
|
self,
|
|
dns_timeout: float | None = None,
|
|
dns_retries: int | None = None,
|
|
extra_argv: list[str] | None = None,
|
|
) -> parsedmarc.ParserConfig:
|
|
"""Runs a real one-shot _main() against a mocked IMAP connection and
|
|
mocked get_dmarc_reports_from_mailbox, and returns the ParserConfig
|
|
the CLI passed as ``config=``.
|
|
|
|
``extra_argv``, when given, is appended to the CLI invocation after
|
|
``-c <config_path>`` -- e.g. to exercise a command-line flag such as
|
|
``--dns-timeout`` without involving the config file's [general]
|
|
dns_timeout key at all. It defaults to None/no extra arguments, so
|
|
existing callers are unaffected.
|
|
"""
|
|
config_lines = ["[general]", "silent = true"]
|
|
if dns_timeout is not None:
|
|
config_lines.append(f"dns_timeout = {dns_timeout}")
|
|
if dns_retries is not None:
|
|
config_lines.append(f"dns_retries = {dns_retries}")
|
|
config_lines += [
|
|
"",
|
|
"[imap]",
|
|
"host = imap.example.com",
|
|
"user = user",
|
|
"password = pass",
|
|
]
|
|
config_text = "\n".join(config_lines) + "\n"
|
|
|
|
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))
|
|
|
|
argv = ["parsedmarc", "-c", cfg_path] + list(extra_argv or [])
|
|
|
|
with (
|
|
patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") as mock_get_reports,
|
|
patch("parsedmarc.cli.IMAPConnection") as mock_imap,
|
|
):
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
with patch.object(sys, "argv", argv):
|
|
parsedmarc.cli._main()
|
|
return mock_get_reports.call_args.kwargs["config"]
|
|
|
|
def test_one_shot_mailbox_run_honors_general_dns_timeout(self):
|
|
"""Regression test for the one-shot mailbox call site in _main():
|
|
before this fix, that call to get_dmarc_reports_from_mailbox
|
|
omitted the dns_timeout/dns_retries kwargs entirely (and, before
|
|
config= existed, had no way to pass them at all), so a one-shot run
|
|
silently used the library's own hardcoded default
|
|
(``dns_timeout: float = 6.0`` on master's
|
|
get_dmarc_reports_from_mailbox signature) instead of the operator's
|
|
``[general] dns_timeout`` / ``dns_retries`` config values. Watch-mode
|
|
runs were unaffected because the watch_inbox call site did pass
|
|
dns_timeout/dns_retries directly.
|
|
"""
|
|
cfg = self._run_one_shot_mailbox(dns_timeout=11.5, dns_retries=3)
|
|
self.assertEqual(cfg.dns_timeout, 11.5)
|
|
self.assertEqual(cfg.dns_retries, 3)
|
|
|
|
def test_dns_timeout_hyphenated_alias_reaches_parser_config(self):
|
|
"""``--dns-timeout`` (hyphenated) is an alias for ``--dns_timeout``
|
|
(underscored), which has been the public spelling since 6.0.0. The
|
|
hyphenated alias exists because ``--dns-retries`` (added in 9.7.1)
|
|
uses a hyphen while ``--dns_timeout`` predates it and must keep
|
|
working unchanged, so the change adds ``--dns-timeout`` as an
|
|
additional option string rather than renaming anything. Both
|
|
spellings must resolve to the same argparse dest (``dns_timeout``)
|
|
and reach the same ParserConfig field passed to
|
|
get_dmarc_reports_from_mailbox — and both halves are exercised
|
|
here, because dropping the legacy option string would leave the
|
|
dest (derived from the surviving long option) and every
|
|
config-file test green while breaking the promised compatibility.
|
|
"""
|
|
cfg = self._run_one_shot_mailbox(extra_argv=["--dns-timeout", "7"])
|
|
self.assertEqual(cfg.dns_timeout, 7.0)
|
|
cfg = self._run_one_shot_mailbox(extra_argv=["--dns_timeout", "9"])
|
|
self.assertEqual(cfg.dns_timeout, 9.0)
|
|
|
|
def test_cli_config_binds_module_default_caches(self):
|
|
"""The ParserConfig built by the CLI must bind the process-wide
|
|
default caches (parsedmarc.IP_ADDRESS_CACHE,
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS, parsedmarc.REVERSE_DNS_MAP) by
|
|
identity, not fresh/isolated caches — otherwise every CLI run would
|
|
get its own empty caches (defeating the point of the 4-hour IP
|
|
cache and the 1-hour dedup cache) even though ParserConfig's default
|
|
factories exist specifically to give library callers isolated
|
|
caches when they don't pass config=.
|
|
"""
|
|
cfg = self._run_one_shot_mailbox()
|
|
self.assertIs(cfg.ip_address_cache, parsedmarc.IP_ADDRESS_CACHE)
|
|
self.assertIs(
|
|
cfg.seen_aggregate_report_ids, parsedmarc.SEEN_AGGREGATE_REPORT_IDS
|
|
)
|
|
self.assertIs(cfg.reverse_dns_map, parsedmarc.REVERSE_DNS_MAP)
|
|
|
|
|
|
class TestMailboxPerformance(unittest.TestCase):
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
def testBatchModeAvoidsExtraFullFetch(self):
|
|
connection = _DummyMailboxConnection()
|
|
parsedmarc.get_dmarc_reports_from_mailbox(
|
|
connection=connection,
|
|
reports_folder="INBOX",
|
|
test=True,
|
|
batch_size=10,
|
|
create_folders=False,
|
|
)
|
|
self.assertEqual(len(connection.fetch_calls), 1)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliPassesMsGraphCertificateAuthSettings(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
certificate_password = cert-pass
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("auth_method"), "Certificate"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("certificate_path"),
|
|
"/tmp/msgraph-cert.pem",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("certificate_password"),
|
|
"cert-pass",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphCertificatePath(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"certificate_path setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliUsesMsGraphUserAsMailboxForUsernamePasswordAuth(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = UsernamePassword
|
|
client_id = client-id
|
|
client_secret = client-secret
|
|
user = owner@example.com
|
|
password = test-password
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("mailbox"),
|
|
"owner@example.com",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("username"),
|
|
"owner@example.com",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphPasswordForUsernamePasswordAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = UsernamePassword
|
|
client_id = client-id
|
|
client_secret = client-secret
|
|
user = owner@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"password setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
|
|
class TestMSGraphCliValidation(unittest.TestCase):
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliPassesMsGraphClientSecretAuthSettings(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientSecret
|
|
client_id = client-id
|
|
client_secret = client-secret
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("auth_method"), "ClientSecret"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("client_secret"),
|
|
"client-secret",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("tenant_id"), "tenant-id"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("mailbox"),
|
|
"shared@example.com",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphClientSecretForClientSecretAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientSecret
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"client_secret setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphTenantIdForClientSecretAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientSecret
|
|
client_id = client-id
|
|
client_secret = client-secret
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"tenant_id setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphMailboxForClientSecretAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientSecret
|
|
client_id = client-id
|
|
client_secret = client-secret
|
|
tenant_id = tenant-id
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"mailbox setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliAllowsMsGraphDeviceCodeWithoutUser(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = DeviceCode
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("auth_method"), "DeviceCode"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("mailbox"),
|
|
"shared@example.com",
|
|
)
|
|
self.assertIsNone(mock_graph_connection.call_args.kwargs.get("username"))
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphTenantIdForDeviceCodeAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = DeviceCode
|
|
client_id = client-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"tenant_id setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphMailboxForDeviceCodeAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = DeviceCode
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"mailbox setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphTenantIdForCertificateAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"tenant_id setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphMailboxForCertificateAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"mailbox setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliPassesMsGraphClientAssertionAuthSettings(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientAssertion
|
|
client_id = client-id
|
|
client_assertion = signed-jwt-assertion
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("auth_method"),
|
|
"ClientAssertion",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("client_assertion"),
|
|
"signed-jwt-assertion",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("tenant_id"), "tenant-id"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("mailbox"),
|
|
"shared@example.com",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphClientAssertionForClientAssertionAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientAssertion
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"client_assertion setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphTenantIdForClientAssertionAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientAssertion
|
|
client_id = client-id
|
|
client_assertion = signed-jwt-assertion
|
|
mailbox = shared@example.com
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"tenant_id setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRequiresMsGraphMailboxForClientAssertionAuth(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientAssertion
|
|
client_id = client-id
|
|
client_assertion = signed-jwt-assertion
|
|
tenant_id = tenant-id
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"mailbox setting missing from the msgraph config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliAcceptsLowercaseMsGraphCertificateAuthMethod(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""auth_method values are case-insensitive (e.g. ``certificate``)."""
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = certificate
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
certificate_password = cert-pass
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("auth_method"), "Certificate"
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("mailbox"),
|
|
"shared@example.com",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("certificate_path"),
|
|
"/tmp/msgraph-cert.pem",
|
|
)
|
|
self.assertEqual(
|
|
mock_graph_connection.call_args.kwargs.get("certificate_password"),
|
|
"cert-pass",
|
|
)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliRejectsInvalidMsGraphAuthMethod(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = NotARealMethod
|
|
client_id = client-id
|
|
tenant_id = tenant-id
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
"""
|
|
|
|
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))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once()
|
|
critical_message = mock_logger.critical.call_args.args[0]
|
|
self.assertIn("Invalid msgraph auth_method", critical_message)
|
|
self.assertIn("NotARealMethod", critical_message)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
|
|
class TestMSGraphConnectionLogging(unittest.TestCase):
|
|
"""MS Graph connection observability (issue #814): a redacted
|
|
connection summary is logged before connecting, timing after, secret
|
|
values never appear in log output, and the dependency loggers that
|
|
carry the actual auth/HTTP activity (mailsuite, azure, msgraph,
|
|
httpx, httpcore) follow parsedmarc's --verbose/--debug level."""
|
|
|
|
CERT_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id-1234
|
|
tenant_id = tenant-id-5678
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
certificate_password = s3cret-cert-pass
|
|
"""
|
|
|
|
def setUp(self):
|
|
# _configure_dependency_logging mutates process-global loggers;
|
|
# snapshot and restore their levels and handlers so these tests
|
|
# don't leak state into the rest of the suite.
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
|
|
self.addCleanup(restore)
|
|
|
|
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.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliLogsMsGraphConnectionSummaryAndTiming(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""The INFO summary identifies the auth method, tenant, client,
|
|
mailbox, and Graph URL before any network I/O, and a timing line
|
|
follows once the connection object is initialized."""
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="INFO") as cm:
|
|
self._run_main(cfg_path, "--verbose")
|
|
|
|
output = "\n".join(cm.output)
|
|
self.assertIn("Connecting to Microsoft Graph", output)
|
|
self.assertIn("auth_method=Certificate", output)
|
|
self.assertIn("tenant_id=tenant-id-5678", output)
|
|
self.assertIn("client_id=client-id-1234", output)
|
|
self.assertIn("mailbox=shared@example.com", output)
|
|
self.assertIn("graph_url=https://graph.microsoft.com", output)
|
|
self.assertIn("Microsoft Graph connection initialized in", output)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliMsGraphLoggingNeverLogsSecretValues(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""Even at --debug, secret values (certificate_password and
|
|
client_assertion here) must not appear anywhere in parsedmarc's
|
|
log output — the debug detail line reports set/not-set flags
|
|
instead."""
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="DEBUG") as cm:
|
|
self._run_main(cfg_path, "--debug")
|
|
|
|
output = "\n".join(cm.output)
|
|
self.assertNotIn("s3cret-cert-pass", output)
|
|
self.assertIn("certificate_path=/tmp/msgraph-cert.pem", output)
|
|
self.assertIn("certificate_password set", output)
|
|
self.assertIn("client_assertion not set", output)
|
|
|
|
assertion_config = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = ClientAssertion
|
|
client_id = client-id-1234
|
|
tenant_id = tenant-id-5678
|
|
mailbox = shared@example.com
|
|
client_assertion = s3cret-signed-jwt-assertion
|
|
"""
|
|
cfg_path = self._write_config(assertion_config)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="DEBUG") as cm:
|
|
self._run_main(cfg_path, "--debug")
|
|
|
|
output = "\n".join(cm.output)
|
|
self.assertNotIn("s3cret-signed-jwt-assertion", output)
|
|
self.assertIn("client_assertion set", output)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliDebugEnablesDependencyLoggers(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""--debug propagates DEBUG level and parsedmarc's handlers to the
|
|
mailsuite/azure/msgraph/httpx/httpcore loggers, so token and HTTP
|
|
activity reaches the console (and log file) instead of being
|
|
dropped."""
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
self._run_main(cfg_path, "--debug")
|
|
|
|
parsedmarc_logger = logging.getLogger("parsedmarc.log")
|
|
# dnspython 2.9+ logs DNS over HTTPS traffic under httpx2 and
|
|
# httpcore2 (httpx and httpcore on 2.7 and 2.8), so all four must
|
|
# follow --debug.
|
|
for wanted_name in ("httpx", "httpcore", "httpx2", "httpcore2"):
|
|
self.assertIn(wanted_name, parsedmarc.cli._DEPENDENCY_LOGGERS)
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
self.assertEqual(dep.level, logging.DEBUG, name)
|
|
# Propagation is disabled so a stray logging.basicConfig()
|
|
# elsewhere in the process can't double-print these records.
|
|
self.assertFalse(dep.propagate, name)
|
|
for wanted in parsedmarc_logger.handlers:
|
|
self.assertIn(wanted, dep.handlers, name)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliDefaultKeepsDependencyLoggersAtWarning(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""Without --verbose/--debug, dependency loggers sit at WARNING —
|
|
their warnings surface (formatted) but no new noise appears."""
|
|
mock_graph_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
self._run_main(cfg_path)
|
|
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
self.assertEqual(logging.getLogger(name).level, logging.WARNING, name)
|
|
|
|
|
|
def _recording_smtp_class(kind: str, calls: list, *, advertise_starttls: bool):
|
|
"""A stand-in for ``smtplib.SMTP`` / ``smtplib.SMTP_SSL`` that appends
|
|
each call it receives to ``calls`` in order. ``advertise_starttls=False``
|
|
plays a server, or an on-path attacker, whose EHLO reply has no
|
|
STARTTLS (GHSA-3588-2mr7-vrcm)."""
|
|
|
|
class _RecordingSMTP:
|
|
def __init__(self, host="", port=0, *args, **kwargs):
|
|
calls.append((kind, host, port))
|
|
|
|
def ehlo_or_helo_if_needed(self):
|
|
calls.append(("ehlo",))
|
|
|
|
def ehlo(self):
|
|
calls.append(("ehlo",))
|
|
|
|
def has_extn(self, name):
|
|
return name.lower() == "starttls" and advertise_starttls
|
|
|
|
def starttls(self, context=None):
|
|
calls.append(("starttls",))
|
|
|
|
def login(self, user, password):
|
|
calls.append(("login", user, password))
|
|
|
|
def sendmail(self, sender, to, msg):
|
|
calls.append(("sendmail", sender, sorted(to), msg))
|
|
|
|
return _RecordingSMTP
|
|
|
|
|
|
class TestCliStartTLS(unittest.TestCase):
|
|
"""GHSA-3588-2mr7-vrcm: the ``[smtp]`` and ``[imap]`` ``starttls``
|
|
option. Unset, a plain-text (``ssl = False``) connection must be
|
|
upgraded with STARTTLS before credentials are sent; ``starttls =
|
|
false`` is the explicit plain-text opt-out and is logged as a WARNING.
|
|
SMTP is exercised end to end through ``_main()`` with ``smtplib``
|
|
patched; IMAP through the kwargs handed to ``IMAPConnection``."""
|
|
|
|
PASSWORD = "Sup3rSecret-placeholder"
|
|
|
|
SMTP_CONFIG = """[general]
|
|
offline = true
|
|
silent = true
|
|
|
|
[smtp]
|
|
host = smtp.example.com
|
|
port = 587
|
|
user = dmarc@example.com
|
|
password = Sup3rSecret-placeholder
|
|
from = dmarc@example.com
|
|
to = admin@example.com
|
|
"""
|
|
|
|
IMAP_CONFIG = """[general]
|
|
offline = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
port = 143
|
|
ssl = false
|
|
user = dmarc@example.com
|
|
password = Sup3rSecret-placeholder
|
|
"""
|
|
|
|
def setUp(self):
|
|
# Every SMTP test parses the same sample report; without a fresh
|
|
# dedup cache the second run would skip it as already seen, parse
|
|
# nothing, and skip the results email.
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
# _main() reconfigures process-global dependency loggers; restore
|
|
# them so these tests don't leak state into the rest of the suite.
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
|
|
self.addCleanup(restore)
|
|
|
|
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 _capture_records(self) -> list[logging.LogRecord]:
|
|
"""Collect every record the parsedmarc logger emits from here on.
|
|
|
|
The level is lowered to WARNING (as ``assertLogs`` does in the
|
|
positive tests) because ``_parse_config()`` runs before ``_main()``
|
|
sets the level, and an earlier ``_main()`` in this process may have
|
|
left it at ERROR -- which would make a "no WARNING" assertion pass
|
|
vacuously."""
|
|
records: list[logging.LogRecord] = []
|
|
handler = logging.Handler()
|
|
handler.emit = records.append # pyright: ignore[reportAttributeAccessIssue]
|
|
log = parsedmarc.log.logger
|
|
previous_level = log.level
|
|
log.setLevel(logging.WARNING)
|
|
log.addHandler(handler)
|
|
self.addCleanup(log.setLevel, previous_level)
|
|
self.addCleanup(log.removeHandler, handler)
|
|
return records
|
|
|
|
def _run_smtp(self, config_text, *, advertise_starttls):
|
|
calls: list = []
|
|
cfg_path = self._write_config(config_text)
|
|
argv = ["parsedmarc", "-c", cfg_path, SAMPLE_AGGREGATE_REPORT_PATH]
|
|
with (
|
|
patch(
|
|
"smtplib.SMTP",
|
|
_recording_smtp_class(
|
|
"SMTP", calls, advertise_starttls=advertise_starttls
|
|
),
|
|
),
|
|
patch(
|
|
"smtplib.SMTP_SSL",
|
|
_recording_smtp_class(
|
|
"SMTP_SSL", calls, advertise_starttls=advertise_starttls
|
|
),
|
|
),
|
|
patch.object(sys, "argv", argv),
|
|
):
|
|
parsedmarc.cli._main()
|
|
return calls
|
|
|
|
def testDefaultSmtpRefusesPlainTextAuthentication(self):
|
|
"""GHSA-3588-2mr7-vrcm: with the default ``[smtp]`` settings
|
|
(``ssl = False``, no ``starttls``) and a server whose EHLO reply has
|
|
no STARTTLS, the CLI logs "Failed to email results" and exits 1
|
|
without sending LOGIN or the report, and the password appears
|
|
nowhere in the logged error or traceback."""
|
|
calls: list = []
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as logs:
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
calls = self._run_smtp(self.SMTP_CONFIG, advertise_starttls=False)
|
|
|
|
self.assertEqual(system_exit.exception.code, 1)
|
|
output = "\n".join(logs.output)
|
|
self.assertIn("Failed to email results", output)
|
|
self.assertIn("STARTTLS", output)
|
|
self.assertNotIn(self.PASSWORD, output)
|
|
names = [call[0] for call in calls]
|
|
self.assertNotIn("login", names)
|
|
self.assertNotIn("sendmail", names)
|
|
|
|
def testDefaultSmtpUpgradesBeforeLogin(self):
|
|
"""With the default settings and a server that advertises STARTTLS,
|
|
the connection is upgraded before LOGIN and the report is sent, and
|
|
no plain-text WARNING is logged (GHSA-3588-2mr7-vrcm)."""
|
|
records = self._capture_records()
|
|
calls = self._run_smtp(self.SMTP_CONFIG, advertise_starttls=True)
|
|
|
|
names = [call[0] for call in calls]
|
|
self.assertEqual(calls[0], ("SMTP", "smtp.example.com", 587))
|
|
self.assertLess(names.index("starttls"), names.index("login"))
|
|
self.assertLess(names.index("login"), names.index("sendmail"))
|
|
self.assertEqual(
|
|
calls[names.index("login")],
|
|
("login", "dmarc@example.com", self.PASSWORD),
|
|
)
|
|
self.assertFalse(any("plain text" in record.getMessage() for record in records))
|
|
|
|
def testSmtpStartTLSFalseOptsOutWithWarning(self):
|
|
"""``[smtp] starttls = false`` reaches mailsuite: LOGIN and the report
|
|
are sent without STARTTLS, and a WARNING says the credentials go out
|
|
in plain text, without naming them (GHSA-3588-2mr7-vrcm)."""
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as logs:
|
|
calls = self._run_smtp(
|
|
self.SMTP_CONFIG + "starttls = false\n", advertise_starttls=False
|
|
)
|
|
|
|
names = [call[0] for call in calls]
|
|
self.assertNotIn("starttls", names)
|
|
self.assertEqual(
|
|
calls[names.index("login")],
|
|
("login", "dmarc@example.com", self.PASSWORD),
|
|
)
|
|
sendmail = calls[names.index("sendmail")]
|
|
self.assertEqual(sendmail[2], ["admin@example.com"])
|
|
output = "\n".join(logs.output)
|
|
self.assertIn("WARNING:parsedmarc.log:[smtp] starttls = False", output)
|
|
self.assertIn("SMTP credentials", output)
|
|
self.assertNotIn(self.PASSWORD, output)
|
|
|
|
def testSmtpStartTLSFalseWithSslLogsNoWarning(self):
|
|
"""With ``ssl = true`` the ``starttls`` option is ignored, so no
|
|
plain-text WARNING is logged and implicit TLS is used
|
|
(GHSA-3588-2mr7-vrcm)."""
|
|
records = self._capture_records()
|
|
calls = self._run_smtp(
|
|
self.SMTP_CONFIG + "ssl = true\nstarttls = false\n",
|
|
advertise_starttls=False,
|
|
)
|
|
|
|
names = [call[0] for call in calls]
|
|
self.assertEqual(names[0], "SMTP_SSL")
|
|
self.assertNotIn("starttls", names)
|
|
self.assertIn("sendmail", names)
|
|
self.assertFalse(any("plain text" in record.getMessage() for record in records))
|
|
|
|
def _run_imap(self, config_text, env=None):
|
|
cfg_path = self._write_config(config_text)
|
|
with (
|
|
patch("parsedmarc.cli.IMAPConnection") as mock_imap,
|
|
patch("parsedmarc.cli.get_dmarc_reports_from_mailbox") as mock_get,
|
|
patch.dict(os.environ, env or {}),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]),
|
|
):
|
|
mock_imap.return_value = object()
|
|
mock_get.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
parsedmarc.cli._main()
|
|
return mock_imap.call_args.kwargs
|
|
|
|
def testImapStartTLSDefaultsToNone(self):
|
|
"""Without ``[imap] starttls``, ``IMAPConnection`` gets
|
|
``starttls=None`` -- mailsuite's default, which refuses to log in
|
|
over a connection that was not upgraded (GHSA-3588-2mr7-vrcm) --
|
|
and no plain-text WARNING is logged."""
|
|
records = self._capture_records()
|
|
|
|
kwargs = self._run_imap(self.IMAP_CONFIG)
|
|
|
|
self.assertIn("starttls", kwargs)
|
|
self.assertIsNone(kwargs["starttls"])
|
|
self.assertFalse(kwargs["ssl"])
|
|
self.assertFalse(any("plain text" in record.getMessage() for record in records))
|
|
|
|
def testImapStartTLSFalseFromIni(self):
|
|
"""``[imap] starttls = false`` with ``ssl = false`` hands
|
|
``starttls=False`` to ``IMAPConnection`` and logs the plain-text
|
|
WARNING (GHSA-3588-2mr7-vrcm)."""
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as logs:
|
|
kwargs = self._run_imap(self.IMAP_CONFIG + "starttls = false\n")
|
|
|
|
self.assertIs(kwargs["starttls"], False)
|
|
output = "\n".join(logs.output)
|
|
self.assertIn("WARNING:parsedmarc.log:[imap] starttls = False", output)
|
|
self.assertIn("IMAP credentials", output)
|
|
self.assertNotIn(self.PASSWORD, output)
|
|
|
|
def testImapStartTLSFromEnvVar(self):
|
|
"""``PARSEDMARC_IMAP_STARTTLS`` reaches ``IMAPConnection`` through
|
|
the generic section/key mapping, in both directions
|
|
(GHSA-3588-2mr7-vrcm)."""
|
|
for value, expected in (("false", False), ("true", True)):
|
|
with self.subTest(value=value):
|
|
kwargs = self._run_imap(
|
|
self.IMAP_CONFIG, env={"PARSEDMARC_IMAP_STARTTLS": value}
|
|
)
|
|
self.assertIs(kwargs["starttls"], expected)
|
|
|
|
def testImapStartTLSFalseWithSslLogsNoWarning(self):
|
|
"""With ``[imap] ssl = true`` the ``starttls`` option is ignored by
|
|
mailsuite, so no plain-text WARNING is logged
|
|
(GHSA-3588-2mr7-vrcm)."""
|
|
records = self._capture_records()
|
|
|
|
kwargs = self._run_imap(
|
|
self.IMAP_CONFIG.replace("ssl = false", "ssl = true") + "starttls = false\n"
|
|
)
|
|
|
|
self.assertTrue(kwargs["ssl"])
|
|
self.assertIs(kwargs["starttls"], False)
|
|
self.assertFalse(any("plain text" in record.getMessage() for record in records))
|
|
|
|
|
|
class TestMSGraphEmailResults(unittest.TestCase):
|
|
"""#472: the periodic summary email is sent via the same
|
|
already-authenticated Microsoft Graph mailbox connection when
|
|
[smtp] host is not configured but [msgraph] is, so M365 tenants that
|
|
block legacy SMTP AUTH can still receive the summary from the
|
|
mailbox they already read reports from. SMTP is preferred when
|
|
[smtp] host is set."""
|
|
|
|
CERT_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id-1234
|
|
tenant_id = tenant-id-5678
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
certificate_password = s3cret-cert-pass
|
|
"""
|
|
|
|
def setUp(self):
|
|
# _configure_dependency_logging mutates process-global loggers;
|
|
# snapshot and restore their levels and handlers so these tests
|
|
# don't leak state into the rest of the suite.
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
|
|
self.addCleanup(restore)
|
|
|
|
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.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliSendsSummaryEmailViaMsGraphWhenNoSmtpHost(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""[smtp] with only to/subject (no host) plus [msgraph] sends the
|
|
summary via Microsoft Graph's sendMail."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": _sample_aggregate_reports(),
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_text = (
|
|
self.CERT_CONFIG
|
|
+ """
|
|
[smtp]
|
|
to = admin@example.com
|
|
subject = DMARC Summary
|
|
"""
|
|
)
|
|
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["message_to"], ["admin@example.com"])
|
|
self.assertEqual(call_kwargs["subject"], "DMARC Summary")
|
|
|
|
filename, payload = call_kwargs["attachments"][0]
|
|
self.assertRegex(filename, r"^DMARC-\d{4}-\d{2}-\d{2}\.zip$")
|
|
with zipfile.ZipFile(io.BytesIO(payload)) as zf:
|
|
self.assertIsNone(zf.testzip())
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliSkipsMsGraphSummaryEmailWhenNothingWasParsed(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""Regression test for the Microsoft Graph half of the #200
|
|
empty-run guard: with an empty mailbox (no aggregate, failure,
|
|
or SMTP TLS reports), the Graph-sent summary email must not be
|
|
sent, and an INFO log line should explain why it was skipped.
|
|
A refactor that narrows the skip guard's condition to only the
|
|
SMTP branch (dropping the msgraph_connection/smtp_to_value
|
|
check) must fail this test."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
# verbose=true is added (CERT_CONFIG only sets silent=true) so the
|
|
# logger's effective level is INFO and the skip message is
|
|
# actually emitted for assertLogs to capture.
|
|
config_text = (
|
|
self.CERT_CONFIG.replace("silent = true", "silent = true\nverbose = true")
|
|
+ """
|
|
[smtp]
|
|
to = admin@example.com
|
|
subject = DMARC Summary
|
|
"""
|
|
)
|
|
cfg_path = self._write_config(config_text)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="INFO") as logs:
|
|
self._run_main(cfg_path)
|
|
|
|
mock_graph_connection.return_value.send_message.assert_not_called()
|
|
self.assertTrue(
|
|
any("skipping the results email" in line for line in logs.output)
|
|
)
|
|
|
|
@patch("parsedmarc.cli.email_results")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliPrefersSmtpWhenBothSmtpAndMsGraphConfigured(
|
|
self, mock_graph_connection, mock_get_mailbox_reports, mock_email_results
|
|
):
|
|
"""SMTP is preferred over Microsoft Graph when [smtp] host is
|
|
set, even with [msgraph] also configured — no fallback, no
|
|
dual-send."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": _sample_aggregate_reports(),
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_text = (
|
|
self.CERT_CONFIG
|
|
+ """
|
|
[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_args = mock_email_results.call_args
|
|
self.assertEqual(call_args.args[1], "smtp.example.com")
|
|
self.assertEqual(call_args.args[2], "dmarc@example.com")
|
|
self.assertEqual(call_args.args[3], ["admin@example.com"])
|
|
self.assertEqual(call_args.kwargs["username"], "smtp-user")
|
|
self.assertEqual(call_args.kwargs["password"], "smtp-password")
|
|
mock_graph_connection.return_value.send_message.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliSkipsGraphSendWithoutSmtpSection(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""A [msgraph]-only, read-only config (no [smtp] section at all)
|
|
sends nothing — unchanged behavior for existing reading-only
|
|
users."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
self._run_main(cfg_path)
|
|
|
|
mock_graph_connection.return_value.send_message.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
@patch("parsedmarc.cli.logger")
|
|
def testCliSmtpWithoutHostRequiresMsGraph(
|
|
self, mock_logger, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""[smtp] with to but no host, and no [msgraph] configured at
|
|
all, still fails config parsing exactly as it did before this
|
|
feature — host is only optional when a Graph connection can
|
|
send instead."""
|
|
config_text = """[general]
|
|
silent = true
|
|
|
|
[smtp]
|
|
to = admin@example.com
|
|
"""
|
|
cfg_path = self._write_config(config_text)
|
|
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
self._run_main(cfg_path)
|
|
|
|
self.assertEqual(system_exit.exception.code, -1)
|
|
mock_logger.critical.assert_called_once_with(
|
|
"host setting missing from the smtp config section"
|
|
)
|
|
mock_graph_connection.assert_not_called()
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliLogsMsGraphSendFailure(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""A Graph sendMail failure gets the same single-ERROR-line
|
|
treatment as connection/fetch failures."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": _sample_aggregate_reports(),
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_graph_connection.return_value.send_message.side_effect = ODataError(
|
|
response_status_code=403,
|
|
error=MainError(
|
|
message="Access is denied",
|
|
inner_error=InnerError(request_id="rid-1", client_request_id="crid-1"),
|
|
),
|
|
)
|
|
config_text = (
|
|
self.CERT_CONFIG
|
|
+ """
|
|
[smtp]
|
|
to = admin@example.com
|
|
subject = DMARC Summary
|
|
"""
|
|
)
|
|
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("Microsoft Graph message send failed", output)
|
|
self.assertIn("mailbox=", output)
|
|
self.assertIn("tenant_id=", output)
|
|
self.assertIn("auth_method=Certificate", output)
|
|
self.assertIn("status=403", output)
|
|
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": _sample_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
|
|
clear ERROR line identifying the mailbox/tenant/auth method and
|
|
the Graph request-id/client-request-id when available, instead of
|
|
a bare logger.exception() that hides the actual error."""
|
|
|
|
CERT_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[msgraph]
|
|
auth_method = Certificate
|
|
client_id = client-id-1234
|
|
tenant_id = tenant-id-5678
|
|
mailbox = shared@example.com
|
|
certificate_path = /tmp/msgraph-cert.pem
|
|
certificate_password = s3cret-cert-pass
|
|
"""
|
|
|
|
def setUp(self):
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (dep.level, list(dep.handlers), dep.propagate)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
|
|
self.addCleanup(restore)
|
|
|
|
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.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliLogsMsGraphConnectionAuthFailureContext(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""An auth failure during connection construction logs the
|
|
redacted context plus the actionable AADSTS code verbatim."""
|
|
mock_graph_connection.side_effect = ClientAuthenticationError(
|
|
"AADSTS7000215: Invalid client secret"
|
|
)
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
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("Microsoft Graph connection failed", output)
|
|
self.assertIn("mailbox=shared@example.com", output)
|
|
self.assertIn("tenant_id=tenant-id-5678", output)
|
|
self.assertIn("auth_method=Certificate", output)
|
|
self.assertIn("AADSTS7000215", output)
|
|
mock_get_mailbox_reports.assert_not_called()
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliLogsMsGraphMailboxFetchFailureWithRequestId(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""A mailbox-fetch failure (the most common real-world auth
|
|
failure point, since app-only auth defers token acquisition to
|
|
first use) surfaces both OData inner-error request ids."""
|
|
mock_get_mailbox_reports.side_effect = ODataError(
|
|
response_status_code=503,
|
|
error=MainError(
|
|
message="Service unavailable",
|
|
inner_error=InnerError(request_id="rid-2", client_request_id="crid-2"),
|
|
),
|
|
)
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
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("Microsoft Graph mailbox fetch failed", output)
|
|
self.assertIn("request-id=rid-2", output)
|
|
self.assertIn("client-request-id=crid-2", output)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliMsGraphErrorFallsBackToResponseHeaderRequestId(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""When the response body didn't deserialize into a proper OData
|
|
inner error, the request-id still surfaces from the raw
|
|
response headers."""
|
|
mock_get_mailbox_reports.side_effect = ODataError(
|
|
response_status_code=503,
|
|
response_headers={"request-id": "hdr-rid"},
|
|
error=None,
|
|
)
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
|
|
with self.assertRaises(SystemExit):
|
|
self._run_main(cfg_path)
|
|
|
|
output = "\n".join(cm.output)
|
|
self.assertIn("request-id=hdr-rid", output)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testCliMsGraphErrorOmitsRequestIdWhenAbsent(
|
|
self, mock_graph_connection, mock_get_mailbox_reports
|
|
):
|
|
"""When neither an inner error nor a response header carries a
|
|
request id, the ERROR line simply omits the suffix rather than
|
|
printing an empty/misleading id."""
|
|
mock_get_mailbox_reports.side_effect = ODataError(response_status_code=500)
|
|
cfg_path = self._write_config(self.CERT_CONFIG)
|
|
|
|
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.assertNotIn("request-id=", output)
|
|
|
|
@patch("parsedmarc.cli.watch_inbox", side_effect=httpx.ConnectError("dns failure"))
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.MSGraphConnection")
|
|
def testWatchModeLogsMsGraphErrorAndExits(
|
|
self, mock_graph_connection, mock_get_mailbox_reports, mock_watch_inbox
|
|
):
|
|
"""Before this fix, --watch had no catch-all at all for Graph
|
|
errors, so a token/cert expiry mid-watch crashed with a raw
|
|
uncaught traceback. It now gets the same single ERROR line."""
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_text = self.CERT_CONFIG + "\n[mailbox]\nwatch = true\n"
|
|
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("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."""
|
|
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
_BASE_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
watch = true
|
|
"""
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testSighupTriggersReloadAndWatchRestarts(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""SIGHUP causes watch to return, config is re-parsed, and watch restarts."""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
def parse_side_effect(config, opts):
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
return None
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
mock_init_clients.return_value = {}
|
|
|
|
call_count = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
# Simulate SIGHUP arriving while watch is running
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return # Normal return — reload loop will continue
|
|
else:
|
|
raise FileExistsError("stop-watch-loop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
parsedmarc.cli._main()
|
|
|
|
# Exited with code 1 (from FileExistsError handler)
|
|
self.assertEqual(cm.exception.code, 1)
|
|
# watch_inbox was called twice: initial run + after reload
|
|
self.assertEqual(mock_watch.call_count, 2)
|
|
# _parse_config called for initial load + reload
|
|
self.assertGreaterEqual(mock_parse_config.call_count, 2)
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testInvalidConfigOnReloadKeepsPreviousState(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""A failing reload leaves opts and clients unchanged."""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
# Initial parse sets required opts; reload parse raises
|
|
initial_map = {"prefix_": ["example.com"]}
|
|
call_count = [0]
|
|
|
|
def parse_side_effect(config, opts):
|
|
call_count[0] += 1
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
if call_count[0] == 1:
|
|
return initial_map
|
|
raise RuntimeError("bad config")
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
|
|
initial_clients = {"s3_client": MagicMock()}
|
|
mock_init_clients.return_value = initial_clients
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
else:
|
|
raise FileExistsError("stop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(cm.exception.code, 1)
|
|
# watch was still called twice (reload loop continued after failed reload)
|
|
self.assertEqual(mock_watch.call_count, 2)
|
|
# Old clients should NOT have been closed (reload failed before swap)
|
|
initial_clients["s3_client"].close.assert_not_called()
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testReloadPassesFreshDomainMapToOutputClients(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""The index migrations resolve their target index names from
|
|
index_prefix_domain_map, so a reload has to hand the *reloaded* map
|
|
to _init_output_clients() -- otherwise a tenant onboarded into the
|
|
map is not covered until the process restarts (issue #868)."""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
first_map = {"tenant_a": ["example.com"]}
|
|
second_map = {"tenant_a": ["example.com"], "tenant_b": ["example.net"]}
|
|
maps = [first_map, second_map]
|
|
|
|
def parse_side_effect(config, opts):
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
return maps.pop(0) if maps else second_map
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
mock_init_clients.return_value = {}
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
raise FileExistsError("stop-watch-loop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_init_clients.call_count, 2)
|
|
self.assertIs(
|
|
mock_init_clients.call_args_list[0].kwargs["index_prefix_domain_map"],
|
|
first_map,
|
|
)
|
|
self.assertIs(
|
|
mock_init_clients.call_args_list[1].kwargs["index_prefix_domain_map"],
|
|
second_map,
|
|
)
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testReloadClosesOldClients(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""Successful reload closes the old output clients before replacing them."""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
def parse_side_effect(config, opts):
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
return None
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
|
|
old_client = MagicMock()
|
|
new_client = MagicMock()
|
|
init_call = [0]
|
|
|
|
def init_side_effect(opts, index_prefix_domain_map=None):
|
|
init_call[0] += 1
|
|
if init_call[0] == 1:
|
|
return {"kafka_client": old_client}
|
|
return {"kafka_client": new_client}
|
|
|
|
mock_init_clients.side_effect = init_side_effect
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
else:
|
|
raise FileExistsError("stop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit):
|
|
parsedmarc.cli._main()
|
|
|
|
# Old client must have been closed when reload succeeded
|
|
old_client.close.assert_called_once()
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testRemovedConfigSectionTakesEffectOnReload(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_init_clients,
|
|
):
|
|
"""Removing a config section on reload resets that option to its default."""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_init_clients.return_value = {}
|
|
|
|
# First config sets kafka_hosts (with required topics); second removes it.
|
|
config_v1 = (
|
|
self._BASE_CONFIG
|
|
+ "\n[kafka]\nhosts = kafka.example.com:9092\n"
|
|
+ "aggregate_topic = dmarc_agg\n"
|
|
+ "forensic_topic = dmarc_forensic\n"
|
|
+ "smtp_tls_topic = smtp_tls\n"
|
|
)
|
|
config_v2 = self._BASE_CONFIG # no [kafka] section
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(config_v1)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
# Rewrite config to remove kafka before triggering reload
|
|
with open(cfg_path, "w") as f:
|
|
f.write(config_v2)
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
else:
|
|
raise FileExistsError("stop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
# Capture opts used on each _init_output_clients call
|
|
init_opts_captures = []
|
|
|
|
def init_side_effect(opts, index_prefix_domain_map=None):
|
|
from argparse import Namespace as NS
|
|
|
|
init_opts_captures.append(NS(**vars(opts)))
|
|
return {}
|
|
|
|
mock_init_clients.side_effect = init_side_effect
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit):
|
|
parsedmarc.cli._main()
|
|
|
|
# First init: kafka_hosts should be set from v1 config
|
|
self.assertIsNotNone(init_opts_captures[0].kafka_hosts)
|
|
# Second init (after reload with v2 config): kafka_hosts should be None
|
|
self.assertIsNone(init_opts_captures[1].kafka_hosts)
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testReloadRefreshesReverseDnsMap(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""SIGHUP reload repopulates the reverse DNS map so lookups still work."""
|
|
import signal as signal_module
|
|
|
|
from parsedmarc import REVERSE_DNS_MAP
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
def parse_side_effect(config, opts):
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
return None
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
mock_init_clients.return_value = {}
|
|
|
|
# Snapshot the map state after each watch_inbox call
|
|
map_snapshots = []
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
else:
|
|
# Capture the map state after reload, before we stop the loop
|
|
map_snapshots.append(dict(REVERSE_DNS_MAP))
|
|
raise FileExistsError("stop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
# Pre-populate the map so we can verify it gets refreshed
|
|
REVERSE_DNS_MAP.clear()
|
|
REVERSE_DNS_MAP["stale.example.com"] = {
|
|
"name": "Stale",
|
|
"type": "stale",
|
|
}
|
|
original_contents = dict(REVERSE_DNS_MAP)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_watch.call_count, 2)
|
|
# The map should have been repopulated (not empty, not the stale data)
|
|
self.assertEqual(len(map_snapshots), 1)
|
|
refreshed = map_snapshots[0]
|
|
self.assertGreater(len(refreshed), 0, "Map should not be empty after reload")
|
|
self.assertNotEqual(
|
|
refreshed,
|
|
original_contents,
|
|
"Map should have been refreshed, not kept stale data",
|
|
)
|
|
self.assertNotIn(
|
|
"stale.example.com",
|
|
refreshed,
|
|
"Stale entry should have been cleared by reload",
|
|
)
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def test_sighup_reload_rebuilds_parser_config(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""After a SIGHUP reload, the ParserConfig passed to watch_inbox as
|
|
``config=`` must reflect the reloaded ``[general] dns_timeout``, not
|
|
the value from the initial config load.
|
|
|
|
Guards against _build_parser_config(opts) being called only once at
|
|
startup: opts itself is correctly refreshed in place by the existing
|
|
``for k, v in vars(new_opts).items(): setattr(opts, k, v)`` loop, but
|
|
parser_config is a separate ParserConfig snapshot built from opts —
|
|
if the reload path forgot to rebuild it, watch_inbox would keep
|
|
receiving the stale pre-reload config object forever.
|
|
"""
|
|
import signal as signal_module
|
|
|
|
mock_imap.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
mock_load_config.return_value = ConfigParser()
|
|
|
|
parse_calls = [0]
|
|
|
|
def parse_side_effect(config, opts):
|
|
parse_calls[0] += 1
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
opts.dns_timeout = 5.0 if parse_calls[0] == 1 else 42.0
|
|
return None
|
|
|
|
mock_parse_config.side_effect = parse_side_effect
|
|
mock_init_clients.return_value = {}
|
|
|
|
watch_calls = [0]
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls[0] += 1
|
|
if watch_calls[0] == 1:
|
|
if hasattr(signal_module, "SIGHUP"):
|
|
import os
|
|
|
|
os.kill(os.getpid(), signal_module.SIGHUP)
|
|
return
|
|
else:
|
|
raise FileExistsError("stop-watch-loop")
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_watch.call_count, 2)
|
|
first_config = mock_watch.call_args_list[0].kwargs["config"]
|
|
second_config = mock_watch.call_args_list[1].kwargs["config"]
|
|
self.assertEqual(first_config.dns_timeout, 5.0)
|
|
self.assertEqual(second_config.dns_timeout, 42.0)
|
|
|
|
|
|
class _RaisingCloseClient:
|
|
"""An output client whose close() fails, e.g. a Kafka producer whose
|
|
broker connection is already gone. Unlike the search handles, a plain
|
|
client like this lets the error reach _close_output_clients(), which is
|
|
where the SIGHUP reload closes the configuration it just replaced."""
|
|
|
|
def __init__(self):
|
|
self.close_count = 0
|
|
|
|
def close(self):
|
|
self.close_count += 1
|
|
raise RuntimeError("broker connection already gone")
|
|
|
|
|
|
class _RaisingCloseFileHandler(logging.FileHandler):
|
|
"""A log FileHandler whose close() fails, the way flushing to a full
|
|
disk or a stale network mount does. Counts the attempts so a test can
|
|
tell "close() was tried and failed" from "close() was never called"."""
|
|
|
|
def __init__(self, filename):
|
|
super().__init__(filename, "a")
|
|
self.close_attempts = 0
|
|
|
|
def close(self):
|
|
self.close_attempts += 1
|
|
raise OSError("No space left on device")
|
|
|
|
|
|
@unittest.skipUnless(
|
|
hasattr(signal, "SIGHUP"),
|
|
"SIGHUP not available on this platform",
|
|
)
|
|
class TestSighupReloadAtomicity(unittest.TestCase):
|
|
"""A SIGHUP reload is all-or-nothing.
|
|
|
|
The reload builds the replacement output clients first and only then
|
|
re-reads the reverse DNS map, the PSL overrides and the IP database,
|
|
re-applies opts, and rebuilds the ParserConfig. Committing the clients
|
|
before those steps ran meant a failure in any of them logged "Config
|
|
reload failed, continuing with previous config" while the *new* clients
|
|
were already live, the old ones were closed and unrecoverable, and the
|
|
reverse DNS map had been emptied by the loader that then failed to
|
|
refill it. #906 made _init_output_clients() itself all-or-nothing; these
|
|
tests cover the same guarantee for the whole reload.
|
|
|
|
Each test drives the real _main() through one SIGHUP and reads the state
|
|
the *next* watch_inbox() call sees, which is the first point at which the
|
|
running configuration is observable from outside.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
self.addCleanup(self._stderr_patch.stop)
|
|
self.addCleanup(self._stdout_patch.stop)
|
|
|
|
# _main() sets the parsedmarc logger's level and swaps its
|
|
# FileHandlers, and _configure_dependency_logging() copies its
|
|
# handler list onto every dependency logger -- including the
|
|
# temporary handler assertLogs installs. Snapshot all of them here,
|
|
# before anything runs, so no test leaves a dead handler behind for
|
|
# the next one.
|
|
loggers = [parsedmarc.log.logger] + [
|
|
logging.getLogger(name) for name in parsedmarc.cli._DEPENDENCY_LOGGERS
|
|
]
|
|
saved_logging = [
|
|
(lg, lg.level, list(lg.handlers), lg.propagate, lg.disabled)
|
|
for lg in loggers
|
|
]
|
|
|
|
def _restore_logging():
|
|
for lg, level, handlers, propagate, disabled in saved_logging:
|
|
for stray in lg.handlers:
|
|
# A reload that changes log_file leaves an open
|
|
# FileHandler behind; close it rather than waiting for
|
|
# the garbage collector to release the descriptor.
|
|
if isinstance(stray, logging.FileHandler) and (
|
|
stray not in handlers
|
|
):
|
|
stray.close()
|
|
lg.setLevel(level)
|
|
lg.handlers[:] = handlers
|
|
lg.propagate = propagate
|
|
lg.disabled = disabled
|
|
|
|
self.addCleanup(_restore_logging)
|
|
parsedmarc.log.logger.disabled = False
|
|
|
|
# _main() installs SIGHUP/SIGTERM/SIGINT handlers that would
|
|
# otherwise outlive the test and answer signals raised by later
|
|
# ones. Registered before the first _main() call installs them.
|
|
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
|
|
self.addCleanup(signal.signal, sig, signal.getsignal(sig))
|
|
|
|
# Process-wide state a reload writes. Restored explicitly rather
|
|
# than through _utils_globals_snapshot()/_restore_utils_globals(),
|
|
# which are themselves under test here.
|
|
original_map = dict(parsedmarc.REVERSE_DNS_MAP)
|
|
|
|
def _restore_map():
|
|
parsedmarc.REVERSE_DNS_MAP.clear()
|
|
parsedmarc.REVERSE_DNS_MAP.update(original_map)
|
|
|
|
self.addCleanup(_restore_map)
|
|
|
|
saved_overrides = list(parsedmarc.utils.psl_overrides)
|
|
saved_ip_db_path = parsedmarc.utils._IP_DB_PATH
|
|
saved_ipinfo_token = parsedmarc.utils._IPINFO_API_TOKEN
|
|
|
|
def _restore_utils():
|
|
parsedmarc.utils.psl_overrides[:] = saved_overrides
|
|
parsedmarc.utils._IP_DB_PATH = saved_ip_db_path
|
|
parsedmarc.utils._IPINFO_API_TOKEN = saved_ipinfo_token
|
|
|
|
self.addCleanup(_restore_utils)
|
|
|
|
# A distinctive map entry, so "the map still holds what it held
|
|
# before the reload" is an assertion about this dict and not about
|
|
# whatever the bundled CSV happens to contain.
|
|
parsedmarc.REVERSE_DNS_MAP.clear()
|
|
parsedmarc.REVERSE_DNS_MAP["before.example.com"] = {
|
|
"name": "Before Reload",
|
|
"type": "test",
|
|
}
|
|
|
|
def _write_file(self, contents, suffix):
|
|
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False) as f:
|
|
f.write(contents)
|
|
path = f.name
|
|
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
|
|
return path
|
|
|
|
def _fake_search_clients(self, extra_old_clients=None):
|
|
"""Stand in for _init_output_clients() against the *real* search
|
|
connection registry.
|
|
|
|
The real thing calls ``elastic.set_hosts()``, which registers the
|
|
client it builds under the process-wide ``default`` alias the moment
|
|
it is constructed, and stores it in the returned dict wrapped in an
|
|
_ElasticsearchHandle. That registry and that handle are the seam the
|
|
reload's rollback is written against, so both are real here and only
|
|
the cluster connection is a stand-in.
|
|
|
|
Returns:
|
|
tuple: the side effect to give the _init_output_clients patch,
|
|
the old connection, and the new one.
|
|
"""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
new_conn = _FakeSearchConnection("new")
|
|
self._search_conns = (old_conn, new_conn)
|
|
calls = []
|
|
|
|
def init_clients(opts, index_prefix_domain_map=None):
|
|
conn = old_conn if not calls else new_conn
|
|
calls.append(conn)
|
|
connections.add_connection("default", cast(Elasticsearch, conn))
|
|
clients = {
|
|
"elasticsearch": parsedmarc.cli._ElasticsearchHandle(conn),
|
|
}
|
|
if conn is old_conn and extra_old_clients:
|
|
clients.update(extra_old_clients)
|
|
return clients
|
|
|
|
return init_clients, old_conn, new_conn
|
|
|
|
def _current_alias(self):
|
|
try:
|
|
return parsedmarc.elastic.connections.get_connection("default")
|
|
except KeyError:
|
|
return None
|
|
|
|
def _capture_state(self):
|
|
"""Everything the reload can write, as the running configuration
|
|
sees it. Read at the moment the reload is over rather than after
|
|
_main() returns, because the trailing _close_output_clients() would
|
|
otherwise blur which clients the reload itself closed."""
|
|
old_conn, new_conn = self._search_conns
|
|
return SimpleNamespace(
|
|
reverse_dns_map=dict(parsedmarc.REVERSE_DNS_MAP),
|
|
psl_overrides=list(parsedmarc.utils.psl_overrides),
|
|
ip_db_path=parsedmarc.utils._IP_DB_PATH,
|
|
ipinfo_api_token=parsedmarc.utils._IPINFO_API_TOKEN,
|
|
alias=self._current_alias(),
|
|
old_closed=old_conn.close_count,
|
|
new_closed=new_conn.close_count,
|
|
# Read here rather than after _main() returns: assertLogs()
|
|
# restores the logger's handler list on the way out, so the
|
|
# handlers a reload attached or detached are only visible from
|
|
# inside the run.
|
|
log_handlers=list(parsedmarc.log.logger.handlers),
|
|
)
|
|
|
|
def _drive_reload(
|
|
self, initial_config, reload_config, init_clients, before_signal=None
|
|
):
|
|
"""Run _main() through exactly one SIGHUP reload.
|
|
|
|
The first watch_inbox() call rewrites the config file the way an
|
|
operator would, runs *before_signal* if one was given -- the seam
|
|
for anything that has to be set up inside the run, after
|
|
assertLogs() has replaced the logger's handlers -- and signals
|
|
SIGHUP. The second records the state the reload left behind and
|
|
signals SIGTERM, so the watch loop breaks and _main() returns
|
|
normally -- which runs its trailing
|
|
_close_output_clients(clients). That trailing close is what makes
|
|
"``clients`` still names the old dict" observable: the clients it
|
|
closes are, by definition, the ones the run ended with.
|
|
|
|
Returns:
|
|
tuple: the captured state, the watch_inbox mock, and the
|
|
captured log output.
|
|
"""
|
|
cfg_path = self._write_file(initial_config, ".ini")
|
|
captured = {}
|
|
watch_calls = []
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
watch_calls.append(kwargs)
|
|
if len(watch_calls) == 1:
|
|
with open(cfg_path, "w") as f:
|
|
f.write(reload_config)
|
|
if before_signal is not None:
|
|
before_signal()
|
|
os.kill(os.getpid(), signal.SIGHUP)
|
|
return
|
|
captured["state"] = self._capture_state()
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
with (
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
return_value={
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
},
|
|
),
|
|
patch(
|
|
"parsedmarc.cli.watch_inbox", side_effect=watch_side_effect
|
|
) as mock_watch,
|
|
patch("parsedmarc.cli._init_output_clients", side_effect=init_clients),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]),
|
|
self.assertLogs(parsedmarc.log.logger, level="INFO") as logs,
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_watch.call_count, 2)
|
|
self.assertIn("state", captured)
|
|
return captured["state"], mock_watch, logs.output
|
|
|
|
_CONFIG_TEMPLATE = """[general]
|
|
debug = true
|
|
offline = true
|
|
dns_timeout = {dns_timeout}
|
|
{general_extra}
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
watch = true
|
|
batch_size = {batch_size}
|
|
"""
|
|
|
|
def _initial_config(self, general_extra=""):
|
|
"""The configuration the run starts with: batch_size 3 and a 5
|
|
second DNS timeout."""
|
|
return self._CONFIG_TEMPLATE.format(
|
|
dns_timeout="5.0", general_extra=general_extra, batch_size=3
|
|
)
|
|
|
|
def _reload_config(self, general_extra=""):
|
|
"""What the operator edits the file to before signalling: every
|
|
value here differs from the initial config, so committing half of
|
|
them would show."""
|
|
return self._CONFIG_TEMPLATE.format(
|
|
dns_timeout="42.0", general_extra=general_extra, batch_size=9
|
|
)
|
|
|
|
def _assert_previous_config_still_running(self, state, mock_watch, logs):
|
|
"""The claim "continuing with previous config" makes, item by
|
|
item."""
|
|
self.assertTrue(
|
|
any("continuing with previous config" in line for line in logs),
|
|
logs,
|
|
)
|
|
self.assertEqual(
|
|
state.reverse_dns_map,
|
|
{"before.example.com": {"name": "Before Reload", "type": "test"}},
|
|
)
|
|
second_call = mock_watch.call_args_list[1].kwargs
|
|
self.assertEqual(second_call["batch_size"], 3)
|
|
self.assertEqual(second_call["config"].dns_timeout, 5.0)
|
|
|
|
def testFailedReloadKeepsOldClientsWhenMapFileIsMissing(self):
|
|
"""A reverse DNS map path that does not exist fails the reload after
|
|
the replacement clients were built: the new clients must be closed
|
|
and the old ones left running and still registered under the
|
|
``default`` alias.
|
|
|
|
open() on the missing file is the injected failure -- the loader
|
|
catches httpx and CSV errors but not a missing local file, which is
|
|
exactly what a typo in ``reverse_dns_map_path`` produces.
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra="local_reverse_dns_map_path = /nonexistent/map.csv\n"
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
# The new clients were built, then closed and stripped of the alias.
|
|
self.assertEqual(state.new_closed, 1)
|
|
# The old ones are untouched and still hold the alias, so every save
|
|
# between now and the next reload reaches the cluster opts describes.
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
# _main()'s trailing close ran against the old clients, which is
|
|
# only possible if `clients` still named them -- and it did not
|
|
# close the discarded clients a second time.
|
|
self.assertEqual(old_conn.close_count, 1)
|
|
self.assertEqual(new_conn.close_count, 1)
|
|
|
|
def testFailedReloadRestoresPslOverrides(self):
|
|
"""load_psl_overrides() empties the module-level list before it
|
|
reads anything, so a missing overrides file leaves it empty -- and
|
|
load_reverse_dns_map() calls it first thing, which is why staging
|
|
the map into a fresh dict is not enough on its own.
|
|
|
|
The rollback must put the list back by value, or every later
|
|
get_base_domain() call would fold domains without the overrides.
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
overrides_before = list(parsedmarc.utils.psl_overrides)
|
|
self.assertGreater(len(overrides_before), 0)
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra="local_psl_overrides_path = /nonexistent/psl_overrides.txt\n"
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
self.assertEqual(state.psl_overrides, overrides_before)
|
|
self.assertEqual(state.new_closed, 1)
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
|
|
def testFailedReloadKeepsOldClientsWhenIpDatabaseIsUnavailable(self):
|
|
"""load_ip_db() falls back to the bundled MMDB when the configured
|
|
file, the download and the cache are all unavailable; an install
|
|
without that bundled resource makes the fallback itself raise.
|
|
|
|
This is the deepest point a reload can fail and still be rolled
|
|
back: only configure_ipinfo_api() runs after it, and its documented
|
|
failure (an invalid token) exits the process rather than returning
|
|
to the watch loop. By the time it fails, the replacement clients
|
|
hold the ``default`` alias, the reverse DNS map has been staged from
|
|
a different file, and load_psl_overrides() has already replaced the
|
|
live overrides list -- so this is also where the most restores are
|
|
load-bearing at once.
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
overrides_before = list(parsedmarc.utils.psl_overrides)
|
|
self.assertNotIn("reload-only.example", overrides_before)
|
|
# Files the reload names and the run must end up not using.
|
|
reload_map_path = self._write_file(
|
|
"base_reverse_dns,name,type\nafter.example.com,After Reload,test\n",
|
|
".csv",
|
|
)
|
|
reload_overrides_path = self._write_file("reload-only.example\n", ".txt")
|
|
|
|
# The IP database the run starts with, and keeps.
|
|
ip_db_path = self._write_file("not really an mmdb", ".mmdb")
|
|
# An empty cache directory, so load_ip_db() cannot answer from a
|
|
# copy some earlier run downloaded and must reach the bundled file.
|
|
cache_dir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, cache_dir, True)
|
|
|
|
real_files = parsedmarc.utils.files
|
|
|
|
def files_without_bundled_mmdb(package):
|
|
# The importlib.resources boundary: the maps package (the
|
|
# reverse DNS map and the PSL overrides, both loaded earlier in
|
|
# the same reload) still resolves normally.
|
|
if package is parsedmarc.resources.ipinfo:
|
|
raise ModuleNotFoundError("parsedmarc.resources.ipinfo")
|
|
return real_files(package)
|
|
|
|
with (
|
|
patch("parsedmarc.utils.files", side_effect=files_without_bundled_mmdb),
|
|
patch("parsedmarc.utils.tempfile.gettempdir", return_value=cache_dir),
|
|
):
|
|
state, mock_watch, logs = self._drive_reload(
|
|
# Startup resolves the local file directly and never reaches
|
|
# the bundled fallback, so only the reload -- which drops
|
|
# ip_db_path -- fails.
|
|
self._initial_config(f"ip_db_path = {ip_db_path}\n"),
|
|
self._reload_config(
|
|
general_extra=(
|
|
f"local_reverse_dns_map_path = {reload_map_path}\n"
|
|
f"local_psl_overrides_path = {reload_overrides_path}\n"
|
|
)
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
# The two restores that carry weight at this depth: the overrides
|
|
# list load_psl_overrides() had already replaced, and the map, which
|
|
# was staged into a dict that is simply dropped -- the live one
|
|
# still holds the pre-reload entry rather than the reload's file.
|
|
self.assertEqual(state.psl_overrides, overrides_before)
|
|
self.assertNotIn("after.example.com", state.reverse_dns_map)
|
|
# load_ip_db() assigns _IP_DB_PATH only as the last thing on each
|
|
# of its paths, so a raise from it leaves the previous selection in
|
|
# place rather than a half-applied one; that the snapshot can put a
|
|
# *changed* value back is covered by TestUtilsGlobalsSnapshot.
|
|
self.assertEqual(state.ip_db_path, ip_db_path)
|
|
self.assertEqual(state.new_closed, 1)
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
|
|
def testSuccessfulReloadCommitsEveryStagedValue(self):
|
|
"""The other half: when every step succeeds, the staged state is
|
|
what the run continues with -- new clients live and holding the
|
|
alias, old clients closed, the reverse DNS map replaced by the file
|
|
the new config names, and the new watch parameters and ParserConfig
|
|
in use.
|
|
|
|
(Passes against the pre-fix code too, necessarily: a reload with
|
|
nothing to roll back has to end in the same place either way. It is
|
|
here so that the failure tests above cannot be satisfied by a
|
|
reload that quietly stopped applying anything.)"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
map_path = self._write_file(
|
|
"base_reverse_dns,name,type\nafter.example.com,After Reload,test\n",
|
|
".csv",
|
|
)
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=f"local_reverse_dns_map_path = {map_path}\n"
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
self.assertTrue(
|
|
any("Configuration reloaded successfully" in line for line in logs),
|
|
logs,
|
|
)
|
|
self.assertEqual(
|
|
state.reverse_dns_map,
|
|
{"after.example.com": {"name": "After Reload", "type": "test"}},
|
|
)
|
|
self.assertIs(state.alias, new_conn)
|
|
self.assertEqual(state.old_closed, 1)
|
|
self.assertEqual(state.new_closed, 0)
|
|
second_call = mock_watch.call_args_list[1].kwargs
|
|
self.assertEqual(second_call["batch_size"], 9)
|
|
self.assertEqual(second_call["config"].dns_timeout, 42.0)
|
|
# The ParserConfig keeps the live map object, so the in-place
|
|
# refresh above has to reach the parser through it.
|
|
self.assertIs(second_call["config"].reverse_dns_map, parsedmarc.REVERSE_DNS_MAP)
|
|
# _main()'s trailing close ran against the new clients.
|
|
self.assertEqual(new_conn.close_count, 1)
|
|
|
|
def testOldClientCloseFailureDoesNotUndoTheReload(self):
|
|
"""Closing the replaced clients is the last step and is best effort:
|
|
a client whose close() raises is logged and the reloaded
|
|
configuration stays live.
|
|
|
|
(This one passes against the pre-fix code too -- there the same
|
|
close ran before the swap, and _close_output_clients() swallowed the
|
|
error either way. It guards the move: closing the old clients last
|
|
must not put the commit at risk.)
|
|
"""
|
|
raising_client = _RaisingCloseClient()
|
|
init_clients, old_conn, new_conn = self._fake_search_clients(
|
|
extra_old_clients={"kafka": raising_client}
|
|
)
|
|
map_path = self._write_file(
|
|
"base_reverse_dns,name,type\nafter.example.com,After Reload,test\n",
|
|
".csv",
|
|
)
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=f"local_reverse_dns_map_path = {map_path}\n"
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
self.assertEqual(raising_client.close_count, 1)
|
|
self.assertTrue(
|
|
any("Error closing kafka" in line for line in logs),
|
|
logs,
|
|
)
|
|
self.assertTrue(
|
|
any("Configuration reloaded successfully" in line for line in logs),
|
|
logs,
|
|
)
|
|
self.assertIs(state.alias, new_conn)
|
|
self.assertEqual(state.new_closed, 0)
|
|
self.assertEqual(state.old_closed, 1)
|
|
self.assertEqual(
|
|
state.reverse_dns_map,
|
|
{"after.example.com": {"name": "After Reload", "type": "test"}},
|
|
)
|
|
self.assertEqual(mock_watch.call_args_list[1].kwargs["batch_size"], 9)
|
|
|
|
def testLogFileCloseFailureDoesNotUndoTheReload(self):
|
|
"""Swapping the log file is the last thing the commit does, and
|
|
closing the replaced FileHandler can fail on a full disk or a stale
|
|
mount. Opening the replacement happens back in phase 1, so the close
|
|
is the only statement left in the commit that can raise, and it must
|
|
not take the reload -- which is already live by then -- with it."""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
map_path = self._write_file(
|
|
"base_reverse_dns,name,type\nafter.example.com,After Reload,test\n",
|
|
".csv",
|
|
)
|
|
log_path = self._write_file("", ".log")
|
|
stale_handler = _RaisingCloseFileHandler(self._write_file("", ".log"))
|
|
# The subclass's close() raises; the unbound FileHandler.close()
|
|
# is what actually releases the descriptor.
|
|
self.addCleanup(logging.FileHandler.close, stale_handler)
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=(
|
|
f"local_reverse_dns_map_path = {map_path}\nlog_file = {log_path}\n"
|
|
)
|
|
),
|
|
init_clients,
|
|
# Inside the run: assertLogs() replaces the logger's handler
|
|
# list on the way in, so a handler attached before _main()
|
|
# started would not be there for the reload to close.
|
|
before_signal=lambda: parsedmarc.log.logger.addHandler(stale_handler),
|
|
)
|
|
|
|
self.assertEqual(stale_handler.close_attempts, 1)
|
|
self.assertTrue(
|
|
any("Unable to close the log file" in line for line in logs), logs
|
|
)
|
|
self.assertNotIn(stale_handler, state.log_handlers)
|
|
# The commit stands: new clients live and holding the alias, old
|
|
# ones closed, map replaced, new watch parameters in use.
|
|
self.assertTrue(
|
|
any("Configuration reloaded successfully" in line for line in logs), logs
|
|
)
|
|
self.assertIs(state.alias, new_conn)
|
|
self.assertEqual(state.new_closed, 0)
|
|
self.assertEqual(state.old_closed, 1)
|
|
self.assertEqual(
|
|
state.reverse_dns_map,
|
|
{"after.example.com": {"name": "After Reload", "type": "test"}},
|
|
)
|
|
self.assertEqual(mock_watch.call_args_list[1].kwargs["batch_size"], 9)
|
|
|
|
def testUnwritableLogFileAbortsTheReload(self):
|
|
"""The replacement log file is opened in phase 1, with the previous
|
|
FileHandler still attached, so a ``log_file`` that cannot be opened
|
|
is a reload failure rather than a warning logged after the old
|
|
handler is already gone.
|
|
|
|
Everything else in this reload would have succeeded -- it names a
|
|
readable map file and a new batch size -- so the previous
|
|
configuration still running afterwards is the log file open's doing.
|
|
The old handler is still attached and still open, which is what
|
|
makes the "Config reload failed" record reach the log file the
|
|
operator is tailing, and no second FileHandler was left behind by
|
|
the open that failed.
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
map_path = self._write_file(
|
|
"base_reverse_dns,name,type\nafter.example.com,After Reload,test\n",
|
|
".csv",
|
|
)
|
|
# /proc has no subdirectories that can be created, so opening a file
|
|
# under a nonexistent one fails the way a bad ``log_file`` path
|
|
# does. Same trick as
|
|
# test_unwritable_log_file_logs_warning_does_not_raise.
|
|
unwritable_log = "/proc/nonexistent/parsedmarc-reload.log"
|
|
live_handler = logging.FileHandler(self._write_file("", ".log"), "a")
|
|
self.addCleanup(live_handler.close)
|
|
self.addCleanup(parsedmarc.log.logger.removeHandler, live_handler)
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=(
|
|
f"local_reverse_dns_map_path = {map_path}\n"
|
|
f"log_file = {unwritable_log}\n"
|
|
)
|
|
),
|
|
init_clients,
|
|
# Inside the run, for the same reason as the close-failure test:
|
|
# assertLogs() replaces the logger's handler list on the way in.
|
|
before_signal=lambda: parsedmarc.log.logger.addHandler(live_handler),
|
|
)
|
|
|
|
self.assertTrue(
|
|
any(
|
|
"Config reload failed, continuing with previous config" in line
|
|
for line in logs
|
|
),
|
|
logs,
|
|
)
|
|
self.assertFalse(
|
|
any("Configuration reloaded successfully" in line for line in logs),
|
|
logs,
|
|
)
|
|
# The log file the operator is tailing is still attached and still
|
|
# open, so the failure was reported into it -- and it is the only
|
|
# FileHandler on the logger, so nothing leaked from the failed open.
|
|
self.assertEqual(
|
|
[h for h in state.log_handlers if isinstance(h, logging.FileHandler)],
|
|
[live_handler],
|
|
)
|
|
self.assertTrue(
|
|
live_handler.stream is not None and not live_handler.stream.closed,
|
|
"the previous log file handler was closed",
|
|
)
|
|
# The rest of the reload was rolled back: the replacement clients
|
|
# are closed, the old ones still hold the alias, the map still holds
|
|
# its pre-reload entry, and the watch parameters are the originals.
|
|
self.assertEqual(state.new_closed, 1)
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self.assertNotIn("after.example.com", state.reverse_dns_map)
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
|
|
def testFailedReloadClosesTheStagedLogFileHandler(self):
|
|
"""A reload that opens the replacement log file and then fails on a
|
|
later step has to close the file it opened: the handler was never
|
|
attached to the logger, so closing it is the whole undo, and leaving
|
|
it open would leak a descriptor on every such reload.
|
|
|
|
The missing map path is the injected failure, as in
|
|
testFailedReloadKeepsOldClientsWhenMapFileIsMissing; this reload also
|
|
names a writable log file, so the staged handler exists by the time
|
|
that failure lands. Capturing it needs a real FileHandler subclass
|
|
rather than a mock, because the code the reload runs afterwards
|
|
checks handlers with isinstance().
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
log_path = self._write_file("", ".log")
|
|
opened: list[logging.FileHandler] = []
|
|
|
|
class _CapturingFileHandler(logging.FileHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
opened.append(self)
|
|
|
|
with patch("logging.FileHandler", _CapturingFileHandler):
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=(
|
|
"local_reverse_dns_map_path = /nonexistent/map.csv\n"
|
|
f"log_file = {log_path}\n"
|
|
)
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
# The initial config names no log file, so the staging step is the
|
|
# only thing in the run that opens one.
|
|
self.assertEqual(len(opened), 1)
|
|
staged = opened[0]
|
|
self.addCleanup(staged.close)
|
|
self.assertEqual(staged.baseFilename, os.path.abspath(log_path))
|
|
# FileHandler.close() drops the stream it owns, so a None stream is
|
|
# the rollback having closed this handler.
|
|
self.assertIsNone(staged.stream)
|
|
# It was never attached, so there was nothing else to undo -- and
|
|
# the logger is still writing wherever it was before the SIGHUP.
|
|
self.assertNotIn(staged, state.log_handlers)
|
|
self.assertEqual(state.new_closed, 1)
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
|
|
def testFailedReloadSurvivesAStagedLogFileHandlerThatCannotClose(self):
|
|
"""A rollback whose staged-handler close fails must still restore
|
|
the search alias and finish as "continuing with previous config",
|
|
instead of the close error escaping the handler and taking the
|
|
watcher down with the alias left unrestored.
|
|
|
|
Same missing-map injected failure as
|
|
testFailedReloadClosesTheStagedLogFileHandler, but the staged
|
|
handler here is a _RaisingCloseFileHandler, so the rollback's own
|
|
``staged_log_handler.close()`` call raises. Before the fix, that
|
|
raise propagated out of the ``except Exception:`` block, so
|
|
_restore_search_aliases() and _restore_utils_globals() below it
|
|
never ran and the exception itself replaced the "Config reload
|
|
failed" log message.
|
|
"""
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
log_path = self._write_file("", ".log")
|
|
opened: list[_RaisingCloseFileHandler] = []
|
|
|
|
class _CapturingRaisingCloseFileHandler(_RaisingCloseFileHandler):
|
|
# cli.py constructs the handler as FileHandler(path, "a"), and
|
|
# _RaisingCloseFileHandler always opens in append mode, so the
|
|
# mode argument only needs accepting.
|
|
def __init__(self, filename, mode="a"):
|
|
super().__init__(filename)
|
|
opened.append(self)
|
|
|
|
with patch("logging.FileHandler", _CapturingRaisingCloseFileHandler):
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(),
|
|
self._reload_config(
|
|
general_extra=(
|
|
"local_reverse_dns_map_path = /nonexistent/map.csv\n"
|
|
f"log_file = {log_path}\n"
|
|
)
|
|
),
|
|
init_clients,
|
|
)
|
|
|
|
# The initial config names no log file, so the staging step is the
|
|
# only thing in the run that opens one.
|
|
self.assertEqual(len(opened), 1)
|
|
self.addCleanup(logging.FileHandler.close, opened[0])
|
|
self.assertEqual(opened[0].close_attempts, 1)
|
|
self.assertTrue(
|
|
any(
|
|
"Unable to close the log file opened for the reload" in line
|
|
for line in logs
|
|
),
|
|
logs,
|
|
)
|
|
# The close error was logged and swallowed rather than escaping, so
|
|
# the rollback still finished and reported itself normally.
|
|
self._assert_previous_config_still_running(state, mock_watch, logs)
|
|
# ... and the two restores that come after the close in source order
|
|
# still ran: the new clients are closed, the old ones still hold the
|
|
# alias, and the staged handler was never attached to the logger.
|
|
self.assertEqual(state.new_closed, 1)
|
|
self.assertEqual(state.old_closed, 0)
|
|
self.assertIs(state.alias, old_conn)
|
|
self.assertNotIn(opened[0], state.log_handlers)
|
|
|
|
def testLogFileUnopenableAtStartupIsRetriedOnReload(self):
|
|
"""A ``log_file`` that could not be opened when parsedmarc started is
|
|
only a warning there -- startup has no previous configuration to
|
|
keep, so it logs "Unable to write to log file" and carries on. The
|
|
next reload has to retry that open even though the operator never
|
|
changed the setting, once the directory that was missing exists.
|
|
|
|
Before this fix, startup recorded the unopened path onto
|
|
``opts.active_log_file`` anyway, so the reload's
|
|
``old_log_file != new_log_file`` comparison saw the same path on
|
|
both sides and treated the log file as nothing to refresh -- file
|
|
logging stayed dead until the process was restarted, even after the
|
|
directory was created. Recording ``None`` when the open fails makes
|
|
the comparison true, so the reload opens it for real.
|
|
"""
|
|
tmpdir = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, tmpdir, True)
|
|
log_path = os.path.join(tmpdir, "later", "parsedmarc.log")
|
|
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(general_extra=f"log_file = {log_path}\n"),
|
|
self._reload_config(general_extra=f"log_file = {log_path}\n"),
|
|
init_clients,
|
|
# The operator fixes the missing directory before the SIGHUP
|
|
# that _drive_reload sends right after this runs.
|
|
before_signal=lambda: os.makedirs(os.path.dirname(log_path)),
|
|
)
|
|
|
|
self.assertTrue(
|
|
any("Unable to write to log file" in line for line in logs), logs
|
|
)
|
|
self.assertTrue(
|
|
any("Configuration reloaded successfully" in line for line in logs),
|
|
logs,
|
|
)
|
|
file_handlers = [
|
|
h for h in state.log_handlers if isinstance(h, logging.FileHandler)
|
|
]
|
|
self.assertEqual(len(file_handlers), 1)
|
|
handler = file_handlers[0]
|
|
# assertLogs() already restored the logger's own handler list by
|
|
# the time this runs, so this handler is not attached to anything
|
|
# -- closing it is enough, with no removeHandler needed alongside
|
|
# it (contrast testUnwritableLogFileAbortsTheReload's live_handler,
|
|
# which stays attached and needs both).
|
|
self.addCleanup(handler.close)
|
|
self.assertEqual(handler.baseFilename, os.path.abspath(log_path))
|
|
self.assertEqual(state.old_closed, 1)
|
|
self.assertEqual(state.new_closed, 0)
|
|
self.assertIs(state.alias, new_conn)
|
|
self.assertTrue(os.path.exists(log_path))
|
|
|
|
def testLogFileOpenedAtStartupIsNotReopenedByAnUnchangedReload(self):
|
|
"""The other half of testLogFileUnopenableAtStartupIsRetriedOnReload:
|
|
a ``log_file`` startup *can* open gets recorded as the active one, so
|
|
a reload that leaves the setting unchanged does not reopen it.
|
|
|
|
This is what guards the ``opts.active_log_file = opts.log_file``
|
|
assignment moving inside the ``try`` -- if startup stopped recording
|
|
a successful open, ``old_log_file`` would stay ``None`` and every
|
|
reload would treat an unchanged, already-open ``log_file`` as a
|
|
replacement to stage, opening it a second time for no reason.
|
|
"""
|
|
log_path = self._write_file("", ".log")
|
|
opened: list[logging.FileHandler] = []
|
|
|
|
class _CapturingFileHandler(logging.FileHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
opened.append(self)
|
|
|
|
init_clients, old_conn, new_conn = self._fake_search_clients()
|
|
|
|
with patch("logging.FileHandler", _CapturingFileHandler):
|
|
state, mock_watch, logs = self._drive_reload(
|
|
self._initial_config(general_extra=f"log_file = {log_path}\n"),
|
|
self._reload_config(general_extra=f"log_file = {log_path}\n"),
|
|
init_clients,
|
|
)
|
|
|
|
self.assertFalse(
|
|
any("Unable to write to log file" in line for line in logs), logs
|
|
)
|
|
self.assertTrue(
|
|
any("Configuration reloaded successfully" in line for line in logs),
|
|
logs,
|
|
)
|
|
# Startup opened the file once; the reload, seeing the same path as
|
|
# its own active_log_file, did not stage a second FileHandler.
|
|
self.assertEqual(len(opened), 1)
|
|
file_handlers = [
|
|
h for h in state.log_handlers if isinstance(h, logging.FileHandler)
|
|
]
|
|
self.assertEqual(len(file_handlers), 1)
|
|
self.assertIs(file_handlers[0], opened[0])
|
|
self.assertEqual(opened[0].baseFilename, os.path.abspath(log_path))
|
|
# assertLogs() already restored the logger's own handler list by the
|
|
# time this runs, so this handler is not attached to anything --
|
|
# closing it is enough, with no removeHandler needed alongside it.
|
|
self.addCleanup(opened[0].close)
|
|
self.assertEqual(state.old_closed, 1)
|
|
self.assertEqual(state.new_closed, 0)
|
|
self.assertIs(state.alias, new_conn)
|
|
|
|
|
|
class TestUtilsGlobalsSnapshot(unittest.TestCase):
|
|
"""_utils_globals_snapshot() / _restore_utils_globals() cover every
|
|
module global the config loaders the SIGHUP reload calls assign:
|
|
``psl_overrides`` (load_psl_overrides), ``_IP_DB_PATH`` (load_ip_db) and
|
|
``_IPINFO_API_TOKEN`` (configure_ipinfo_api)."""
|
|
|
|
def setUp(self):
|
|
saved_overrides = list(parsedmarc.utils.psl_overrides)
|
|
saved_ip_db_path = parsedmarc.utils._IP_DB_PATH
|
|
saved_token = parsedmarc.utils._IPINFO_API_TOKEN
|
|
|
|
def _restore():
|
|
parsedmarc.utils.psl_overrides[:] = saved_overrides
|
|
parsedmarc.utils._IP_DB_PATH = saved_ip_db_path
|
|
parsedmarc.utils._IPINFO_API_TOKEN = saved_token
|
|
|
|
self.addCleanup(_restore)
|
|
|
|
def testRestoresEveryLoaderGlobalAfterTheLoadersRanAgain(self):
|
|
parsedmarc.utils.psl_overrides[:] = ["example.co.uk"]
|
|
parsedmarc.utils._IP_DB_PATH = "/before/ipinfo_lite.mmdb"
|
|
parsedmarc.utils._IPINFO_API_TOKEN = "before-token"
|
|
overrides_object = parsedmarc.utils.psl_overrides
|
|
|
|
snapshot = parsedmarc.cli._utils_globals_snapshot()
|
|
|
|
# What the loaders do: the overrides list is cleared and refilled in
|
|
# place, and the other two are rebound.
|
|
parsedmarc.utils.psl_overrides.clear()
|
|
parsedmarc.utils.psl_overrides.append("example.com.au")
|
|
parsedmarc.utils._IP_DB_PATH = "/after/ipinfo_lite.mmdb"
|
|
parsedmarc.utils._IPINFO_API_TOKEN = "after-token"
|
|
|
|
parsedmarc.cli._restore_utils_globals(snapshot)
|
|
|
|
self.assertEqual(parsedmarc.utils.psl_overrides, ["example.co.uk"])
|
|
self.assertEqual(parsedmarc.utils._IP_DB_PATH, "/before/ipinfo_lite.mmdb")
|
|
self.assertEqual(parsedmarc.utils._IPINFO_API_TOKEN, "before-token")
|
|
# In place, not rebound: get_base_domain() reads the list through
|
|
# the module global, but the snapshot must not hand back a
|
|
# different object to anything holding a reference to this one.
|
|
self.assertIs(parsedmarc.utils.psl_overrides, overrides_object)
|
|
|
|
def testSnapshotIsNotAliasedToTheLiveOverridesList(self):
|
|
"""The overrides list is snapshotted by value: load_psl_overrides()
|
|
mutates the live object, so a snapshot that aliased it would record
|
|
the failed reload's contents instead of the previous ones."""
|
|
parsedmarc.utils.psl_overrides[:] = ["example.co.uk"]
|
|
|
|
snapshot = parsedmarc.cli._utils_globals_snapshot()
|
|
parsedmarc.utils.psl_overrides.clear()
|
|
|
|
self.assertEqual(snapshot["psl_overrides"], ["example.co.uk"])
|
|
|
|
|
|
class TestSigtermShutdown(unittest.TestCase):
|
|
"""Tests for graceful SIGTERM/SIGINT shutdown."""
|
|
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = True
|
|
self._stdout_patch = patch("sys.stdout", new_callable=io.StringIO)
|
|
self._stderr_patch = patch("sys.stderr", new_callable=io.StringIO)
|
|
self._stdout_patch.start()
|
|
self._stderr_patch.start()
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as _logger
|
|
|
|
_logger.disabled = False
|
|
self._stderr_patch.stop()
|
|
self._stdout_patch.stop()
|
|
|
|
_BASE_CONFIG = """[general]
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
watch = true
|
|
"""
|
|
|
|
def _write_config(self, body=None):
|
|
with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as cfg:
|
|
cfg.write(body if body is not None else self._BASE_CONFIG)
|
|
cfg_path = cfg.name
|
|
self.addCleanup(lambda: os.path.exists(cfg_path) and os.remove(cfg_path))
|
|
return cfg_path
|
|
|
|
@staticmethod
|
|
def _empty_reports():
|
|
return {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
|
|
@staticmethod
|
|
def _parse_config_side_effect(config, opts):
|
|
opts.imap_host = "imap.example.com"
|
|
opts.imap_user = "user"
|
|
opts.imap_password = "pass"
|
|
opts.mailbox_watch = True
|
|
return None
|
|
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testSigtermDuringWatchExitsCleanlyAndClosesClients(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""SIGTERM during watch: the backend polls config_reloading,
|
|
observes the flag, and returns at a safe boundary; _main breaks
|
|
the watch loop, returns normally, and closes every output client
|
|
that exposes a `.close()`."""
|
|
mock_imap.return_value = object()
|
|
mock_load_config.return_value = ConfigParser()
|
|
mock_parse_config.side_effect = self._parse_config_side_effect
|
|
mock_get_reports.return_value = self._empty_reports()
|
|
|
|
kafka_client = MagicMock(spec=["close"])
|
|
elasticsearch_client = MagicMock(spec=["close"])
|
|
no_close_client = MagicMock(spec=[]) # no `close` attr → skipped
|
|
mock_init_clients.return_value = {
|
|
"kafka": kafka_client,
|
|
"elasticsearch": elasticsearch_client,
|
|
"syslog": no_close_client,
|
|
}
|
|
|
|
observed = []
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
# SIGTERM lands while watching; the backend then polls
|
|
# config_reloading at its next safe boundary and returns.
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
observed.append(kwargs["config_reloading"]())
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
cfg_path = self._write_config()
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(mock_watch.call_count, 1)
|
|
self.assertEqual(observed, [True])
|
|
kafka_client.close.assert_called()
|
|
elasticsearch_client.close.assert_called()
|
|
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli._parse_config")
|
|
@patch("parsedmarc.cli._load_config")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testFirstSigintGracefulSecondSigintHardExits(
|
|
self,
|
|
mock_imap,
|
|
mock_watch,
|
|
mock_get_reports,
|
|
mock_load_config,
|
|
mock_parse_config,
|
|
mock_init_clients,
|
|
):
|
|
"""First SIGINT → graceful flag, second SIGINT → os._exit(130).
|
|
|
|
The installed handler is invoked directly via signal.getsignal()
|
|
because two POSIX SIGINTs sent in rapid succession from the same
|
|
process can be coalesced by the kernel (standard signals don't
|
|
queue)."""
|
|
mock_imap.return_value = object()
|
|
mock_load_config.return_value = ConfigParser()
|
|
mock_parse_config.side_effect = self._parse_config_side_effect
|
|
mock_get_reports.return_value = self._empty_reports()
|
|
mock_init_clients.return_value = {}
|
|
|
|
sentinel = SystemExit("os._exit was reached")
|
|
|
|
def fake_exit(code):
|
|
raise sentinel
|
|
|
|
def watch_side_effect(*args, **kwargs):
|
|
handler = signal.getsignal(signal.SIGINT)
|
|
# getsignal() can return SIG_DFL/SIG_IGN/None; narrow the type
|
|
# so the handler can be invoked directly.
|
|
assert callable(handler)
|
|
handler(signal.SIGINT, None) # first press: graceful flag
|
|
handler(signal.SIGINT, None) # second press: hits os._exit
|
|
|
|
mock_watch.side_effect = watch_side_effect
|
|
cfg_path = self._write_config()
|
|
|
|
with patch("parsedmarc.cli.os._exit", side_effect=fake_exit) as mock_exit:
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", cfg_path]):
|
|
with self.assertRaises(SystemExit) as cm:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertIs(cm.exception, sentinel)
|
|
mock_exit.assert_called_once_with(130)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mbox")
|
|
@patch("parsedmarc.cli.is_mbox", side_effect=lambda p: p.endswith(".mbox"))
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.parallel.ProcessPoolExecutor")
|
|
@patch("parsedmarc.cli.glob")
|
|
def testSigtermDuringOneShotStopsEarlyAndSkipsMbox(
|
|
self,
|
|
mock_glob,
|
|
mock_pool_cls,
|
|
mock_init_clients,
|
|
mock_is_mbox,
|
|
mock_get_mbox,
|
|
):
|
|
"""SIGTERM during one-shot processing: ``parallel_map``'s
|
|
``should_stop`` check (polled after each yielded result, see
|
|
``parsedmarc/parallel.py``) stops submitting new jobs once the flag
|
|
is set, and the subsequent mbox loop breaks on its first iteration
|
|
(the flag is already set). Output clients are still closed.
|
|
|
|
``ProcessPoolExecutor`` is patched at the stdlib boundary (mirrors
|
|
the old ``cli.Process`` patch) with a fake whose ``submit(fn, arg)``
|
|
runs ``fn(arg)`` inline and returns an already-completed
|
|
``Future``-like object, so no real subprocess is spawned and no
|
|
pickling occurs. SIGTERM is raised on the very first ``submit``
|
|
call, mirroring the old test's trigger on the first child's
|
|
``start()``.
|
|
|
|
``parallel_map`` primes its submission window up to
|
|
``2 * n_procs`` jobs *before* the first result is harvested and
|
|
``should_stop`` is ever checked (see ``parallel_map``'s docstring),
|
|
so with the default ``n_procs = 1`` the first two files are
|
|
submitted and run before the stop takes effect — the ``should_stop``
|
|
check only prevents a third submission. Four ``.xml`` files (more
|
|
than the window size) are supplied so that the stop is still
|
|
observable: exactly 2 of the 4 are submitted, not all of them. One
|
|
``.mbox`` file routes into ``mbox_paths`` so the mbox break is
|
|
exercised too. ``is_mbox`` is keyed by suffix so the fake filenames
|
|
don't trigger ``mailbox.mbox(path, create=True)``."""
|
|
mock_glob.return_value = ["a.xml", "b.xml", "c.xml", "d.xml", "e.mbox"]
|
|
|
|
kafka_client = MagicMock(spec=["close"])
|
|
mock_init_clients.return_value = {"kafka": kafka_client}
|
|
|
|
submitted = []
|
|
|
|
class FakeFuture:
|
|
def __init__(self, value):
|
|
self._value = value
|
|
|
|
def result(self, timeout=None):
|
|
return self._value
|
|
|
|
def cancelled(self):
|
|
return False
|
|
|
|
class FakeExecutor:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc_info):
|
|
return False
|
|
|
|
def submit(self, fn, arg):
|
|
if not submitted:
|
|
# Mirrors the old test's trigger on the first child's
|
|
# start(): SIGTERM arrives after the first job is
|
|
# dispatched but while the submission window is still
|
|
# being primed.
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
submitted.append(arg)
|
|
return FakeFuture(fn(arg))
|
|
|
|
def shutdown(self, cancel_futures=True):
|
|
return None
|
|
|
|
mock_pool_cls.side_effect = FakeExecutor
|
|
|
|
with patch.object(
|
|
sys, "argv", ["parsedmarc", "a.xml", "b.xml", "c.xml", "d.xml", "e.mbox"]
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
# The submission window (2 * n_procs, n_procs=1) primes 2 jobs
|
|
# before should_stop is first checked; the stop takes effect before
|
|
# a 3rd or 4th file is ever submitted.
|
|
self.assertEqual(len(submitted), 2)
|
|
mock_get_mbox.assert_not_called()
|
|
kafka_client.close.assert_called()
|
|
|
|
@patch("parsedmarc.cli._init_output_clients")
|
|
@patch("parsedmarc.cli.glob")
|
|
def testNormalOneShotExitClosesOutputClients(
|
|
self,
|
|
mock_glob,
|
|
mock_init_clients,
|
|
):
|
|
"""A successful one-shot run with no signal still closes its
|
|
output clients — regression for the long-standing leak where
|
|
_close_output_clients was only called inside the SIGHUP
|
|
reload path."""
|
|
mock_glob.return_value = []
|
|
kafka_client = MagicMock(spec=["close"])
|
|
es_client = MagicMock(spec=["close"])
|
|
mock_init_clients.return_value = {
|
|
"kafka": kafka_client,
|
|
"elasticsearch": es_client,
|
|
}
|
|
|
|
# No watch, no mailbox, no files → _main runs through with
|
|
# empty parsing_results and returns normally.
|
|
with patch.object(sys, "argv", ["parsedmarc", "nothing-here.xml"]):
|
|
parsedmarc.cli._main()
|
|
|
|
kafka_client.close.assert_called_once()
|
|
es_client.close.assert_called_once()
|
|
|
|
|
|
class _FakeSearchConnection:
|
|
"""Stand-in for an Elasticsearch/OpenSearch client held in the real
|
|
connection registry. Records close() calls and nothing else, so no
|
|
network is touched while the registry itself stays real -- it is the
|
|
SDK boundary these handles are written against."""
|
|
|
|
def __init__(self, name: str):
|
|
self.name = name
|
|
self.close_count = 0
|
|
|
|
def __repr__(self):
|
|
return f"<fake {self.name} connection, closed {self.close_count}x>"
|
|
|
|
def close(self):
|
|
self.close_count += 1
|
|
|
|
|
|
class _RaisingSearchConnection(_FakeSearchConnection):
|
|
"""A client whose close() fails, e.g. because the transport is already
|
|
broken. The handles treat closing as best-effort."""
|
|
|
|
def close(self):
|
|
super().close()
|
|
raise RuntimeError("transport already closed")
|
|
|
|
|
|
def _isolate_default_alias(test_case, connections):
|
|
"""Restore whatever the process-wide registry held under the ``default``
|
|
alias, so a test that registers a client into a real Elasticsearch or
|
|
OpenSearch connection registry cannot leak into other tests regardless of
|
|
ordering."""
|
|
try:
|
|
previous = connections.get_connection("default")
|
|
except KeyError:
|
|
previous = None
|
|
|
|
def restore():
|
|
try:
|
|
connections.remove_connection("default")
|
|
except KeyError:
|
|
# Already gone: the test under this cleanup removed the
|
|
# alias itself, which is the state we are restoring to.
|
|
pass
|
|
if previous is not None:
|
|
connections.add_connection("default", previous)
|
|
|
|
test_case.addCleanup(restore)
|
|
|
|
|
|
class TestSearchBackendHandles(unittest.TestCase):
|
|
"""_ElasticsearchHandle / _OpenSearchHandle must close the client they
|
|
were built for, not whatever holds the ``default`` alias at close time.
|
|
|
|
A SIGHUP reload calls _init_output_clients -- which re-registers the
|
|
``default`` alias with the replacement client -- before it calls
|
|
_close_output_clients on the old clients (the order is deliberate, so a
|
|
broken new config leaves the old clients running). A handle that
|
|
re-resolved the alias when closing therefore closed the *new* client and
|
|
then dropped the alias entirely: both SDKs' Connections.remove_connection()
|
|
deletes the alias from ``_conns`` and ``_kwargs`` outright, so every later
|
|
save raised KeyError("There is no connection with alias 'default'.") and
|
|
the original client was never closed.
|
|
"""
|
|
|
|
def testElasticsearchHandleClosesItsOwnConnectionAfterReload(self):
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
new_conn = _FakeSearchConnection("new")
|
|
|
|
connections.add_connection("default", cast(Elasticsearch, old_conn))
|
|
handle = parsedmarc.cli._ElasticsearchHandle(
|
|
connections.get_connection("default")
|
|
)
|
|
# What a reload does before the old handle is closed: the new
|
|
# client takes over the alias.
|
|
connections.add_connection("default", cast(Elasticsearch, new_conn))
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(old_conn.close_count, 1)
|
|
self.assertEqual(new_conn.close_count, 0)
|
|
self.assertIs(connections.get_connection("default"), new_conn)
|
|
|
|
def testElasticsearchHandleReleasesAliasOnShutdown(self):
|
|
"""The normal teardown path still frees the alias it owns."""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
conn = _FakeSearchConnection("only")
|
|
|
|
connections.add_connection("default", cast(Elasticsearch, conn))
|
|
handle = parsedmarc.cli._ElasticsearchHandle(
|
|
connections.get_connection("default")
|
|
)
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 1)
|
|
with self.assertRaises(KeyError):
|
|
connections.get_connection("default")
|
|
|
|
def testElasticsearchHandleCloseIsBestEffort(self):
|
|
"""A client whose close() raises must not keep the handle from
|
|
releasing its alias. A second close() still calls the client's
|
|
close() again -- which raises and is swallowed -- even though the
|
|
alias-release step is now a no-op, since get_connection() raises
|
|
KeyError with the alias already gone."""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
conn = _RaisingSearchConnection("raiser")
|
|
|
|
connections.add_connection("default", cast(Elasticsearch, conn))
|
|
handle = parsedmarc.cli._ElasticsearchHandle(
|
|
connections.get_connection("default")
|
|
)
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 1)
|
|
with self.assertRaises(KeyError):
|
|
connections.get_connection("default")
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 2)
|
|
|
|
def testOpenSearchHandleClosesItsOwnConnectionAfterReload(self):
|
|
connections = opensearch_module.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
new_conn = _FakeSearchConnection("new")
|
|
|
|
connections.add_connection("default", old_conn)
|
|
handle = parsedmarc.cli._OpenSearchHandle(connections.get_connection("default"))
|
|
connections.add_connection("default", new_conn)
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(old_conn.close_count, 1)
|
|
self.assertEqual(new_conn.close_count, 0)
|
|
self.assertIs(connections.get_connection("default"), new_conn)
|
|
|
|
def testOpenSearchHandleReleasesAliasOnShutdown(self):
|
|
"""The normal teardown path still frees the alias it owns."""
|
|
connections = opensearch_module.connections
|
|
_isolate_default_alias(self, connections)
|
|
conn = _FakeSearchConnection("only")
|
|
|
|
connections.add_connection("default", conn)
|
|
handle = parsedmarc.cli._OpenSearchHandle(connections.get_connection("default"))
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 1)
|
|
with self.assertRaises(KeyError):
|
|
connections.get_connection("default")
|
|
|
|
def testOpenSearchHandleCloseIsBestEffort(self):
|
|
"""A client whose close() raises must not keep the handle from
|
|
releasing its alias. A second close() still calls the client's
|
|
close() again -- which raises and is swallowed -- even though the
|
|
alias-release step is now a no-op, since get_connection() raises
|
|
KeyError with the alias already gone."""
|
|
connections = opensearch_module.connections
|
|
_isolate_default_alias(self, connections)
|
|
conn = _RaisingSearchConnection("raiser")
|
|
|
|
connections.add_connection("default", conn)
|
|
handle = parsedmarc.cli._OpenSearchHandle(connections.get_connection("default"))
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 1)
|
|
with self.assertRaises(KeyError):
|
|
connections.get_connection("default")
|
|
|
|
handle.close()
|
|
|
|
self.assertEqual(conn.close_count, 2)
|
|
|
|
|
|
# A host nothing listens on. Every test below patches the SDK transport
|
|
# method that would talk to it, so the client is only ever constructed --
|
|
# which opens no socket -- and no test can reach the network even if a
|
|
# patch is later removed by mistake.
|
|
_UNREACHABLE_HOSTS = ["127.0.0.1:9299"]
|
|
|
|
|
|
def _es_request_without_network(client, method, path, **kwargs):
|
|
"""Stand-in for ``elasticsearch.Elasticsearch.perform_request``.
|
|
|
|
Every cluster call ``migrate_indexes()`` makes -- through
|
|
``Index.exists()``, ``client.count()`` and ``client.update_by_query()``,
|
|
and through the namespaced clients, which delegate to the
|
|
``Elasticsearch`` instance's own ``perform_request`` -- funnels through
|
|
this one method, so it is the SDK boundary at which a migration can be
|
|
made to succeed without a cluster.
|
|
|
|
A ``HEAD`` (``Index.exists()``) answers "no such index", which skips the
|
|
legacy ``published_policy.fo`` migration; anything else answers the
|
|
backfill's ``count`` query with zero matching documents, which skips the
|
|
``update_by_query``. ``migrate_indexes()`` therefore returns having
|
|
logged nothing and changed nothing.
|
|
"""
|
|
if method == "HEAD":
|
|
return {}
|
|
return {"count": 0}
|
|
|
|
|
|
def _os_request_without_network(transport, method, url, *args, **kwargs):
|
|
"""Stand-in for ``opensearchpy.Transport.perform_request``: answers
|
|
"no such index" / zero documents like ``_es_request_without_network``."""
|
|
if method == "HEAD":
|
|
return {}
|
|
return {"count": 0}
|
|
|
|
|
|
class TestInitOutputClientsDoesNotLogCredentials(unittest.TestCase):
|
|
"""GHSA-hjr3-rx4c-24cv: with --debug, the "Initializing ... client"
|
|
lines must mask credentials embedded in configured URLs."""
|
|
|
|
SECRET = "s3cretPW"
|
|
|
|
def _init(self, opts):
|
|
with self.assertLogs("parsedmarc.log", level="DEBUG") as logs:
|
|
clients = parsedmarc.cli._init_output_clients(opts)
|
|
self.addCleanup(parsedmarc.cli._close_output_clients, clients)
|
|
return "\n".join(logs.output)
|
|
|
|
def test_hec_url_password_is_masked(self):
|
|
opts = _output_client_opts(
|
|
hec=f"https://hecuser:{self.SECRET}@hec.example:8088",
|
|
hec_token="tok",
|
|
hec_index="email",
|
|
)
|
|
output = self._init(opts)
|
|
self.assertNotIn(self.SECRET, output)
|
|
self.assertIn("url=https://hecuser:***@hec.example:8088", output)
|
|
|
|
def test_hec_malformed_url_password_is_masked(self):
|
|
"""A "/", "?" or "#" in the password ends the authority before the
|
|
"@", which left the --debug line unmasked; it is now masked through
|
|
the last "@"."""
|
|
for password in ("p4ssw0rd/extra", "p4ss?w0rd", "p4ss#w0rd"):
|
|
with self.subTest(password=password):
|
|
opts = _output_client_opts(
|
|
hec=f"https://hecuser:{password}@hec.example:8088",
|
|
hec_token="tok",
|
|
hec_index="email",
|
|
)
|
|
output = self._init(opts)
|
|
self.assertNotIn("p4ss", output)
|
|
self.assertIn("url=https://***@hec.example:8088", output)
|
|
|
|
def test_loki_url_password_is_masked(self):
|
|
opts = _output_client_opts(
|
|
loki_url=f"https://lokiuser:{self.SECRET}@loki.example:3100",
|
|
loki_timestamp="ingest",
|
|
loki_timeout=1.0,
|
|
loki_batch_size=100,
|
|
)
|
|
output = self._init(opts)
|
|
self.assertNotIn(self.SECRET, output)
|
|
self.assertIn("url=https://lokiuser:***@loki.example:3100", output)
|
|
|
|
def test_loki_malformed_url_error_has_no_password(self):
|
|
"""A password with an unencoded "/" ends the URL's authority early
|
|
(RFC 3986 section 3.2), so httpx raises ``InvalidURL("Invalid port:
|
|
'p4ssw0rd'")`` (httpx 0.28.1 _urlparse.py), whose text and chained
|
|
ValueError both repeat it. Neither the startup error nor the
|
|
``--debug`` "Initializing Loki client" line may show any part of
|
|
the password."""
|
|
malformed = (
|
|
("https://admin:p4ssw0rd/extra@loki.example:3100", "p4ssw0rd"),
|
|
("https://admin:p4ss?w0rd@loki.example:3100", "p4ss"),
|
|
("https://admin:p4ss#w0rd@loki.example:3100", "p4ss"),
|
|
)
|
|
for url, secret in malformed:
|
|
with self.subTest(url=url):
|
|
opts = _output_client_opts(
|
|
loki_url=url,
|
|
loki_timestamp="ingest",
|
|
loki_timeout=1.0,
|
|
loki_batch_size=100,
|
|
)
|
|
with (
|
|
self.assertRaises(RuntimeError) as ctx,
|
|
self.assertLogs("parsedmarc.log", level="DEBUG") as logs,
|
|
):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(
|
|
str(ctx.exception),
|
|
"Loki: Invalid Loki URL (details withheld because they may "
|
|
"include credentials)",
|
|
)
|
|
trace = "".join(traceback.format_exception(ctx.exception))
|
|
self.assertNotIn(secret, trace)
|
|
output = "\n".join(logs.output)
|
|
self.assertNotIn(secret, output)
|
|
self.assertIn("url=https://***@loki.example:3100", output)
|
|
|
|
def test_elasticsearch_hosts_password_is_masked(self):
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=[f"https://u:{self.SECRET}@127.0.0.1:9299"],
|
|
elasticsearch_timeout=1.0,
|
|
)
|
|
with (
|
|
patch.object(Elasticsearch, "perform_request", _es_request_without_network),
|
|
patch(
|
|
"parsedmarc.elastic.connections.create_connection",
|
|
wraps=connections.create_connection,
|
|
) as mock_conn,
|
|
):
|
|
output = self._init(opts)
|
|
self.assertNotIn(self.SECRET, output)
|
|
self.assertIn("Initializing Elasticsearch client", output)
|
|
# Redaction is log-only: the client still gets the real credentials
|
|
self.assertEqual(
|
|
mock_conn.call_args.kwargs["hosts"],
|
|
[f"https://u:{self.SECRET}@127.0.0.1:9299"],
|
|
)
|
|
self.assertIn("https://u:***@127.0.0.1:9299", output)
|
|
|
|
def test_opensearch_hosts_password_is_masked_in_cli_and_set_hosts(self):
|
|
connections = opensearch_module.connections
|
|
_isolate_default_alias(self, connections)
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
opensearch_hosts=[f"https://u:{self.SECRET}@127.0.0.1:9299"],
|
|
opensearch_timeout=1.0,
|
|
)
|
|
with patch.object(
|
|
opensearchpy.Transport, "perform_request", _os_request_without_network
|
|
):
|
|
output = self._init(opts)
|
|
self.assertNotIn(self.SECRET, output)
|
|
lines = output.splitlines()
|
|
for marker in ("Initializing OpenSearch client", "Connecting to OpenSearch"):
|
|
with self.subTest(marker=marker):
|
|
matching = [line for line in lines if marker in line]
|
|
self.assertEqual(len(matching), 1)
|
|
self.assertIn("https://u:***@127.0.0.1:9299", matching[0])
|
|
|
|
|
|
class TestHttpxLogRedaction(unittest.TestCase):
|
|
"""GHSA-hjr3-rx4c-24cv: httpx logs the full request URL at INFO."""
|
|
|
|
def setUp(self):
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (
|
|
dep.level,
|
|
list(dep.handlers),
|
|
dep.propagate,
|
|
list(dep.filters),
|
|
)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate, filters) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
dep.filters = filters
|
|
|
|
self.addCleanup(restore)
|
|
parsedmarc.cli._configure_dependency_logging(logging.INFO)
|
|
|
|
def _client(self, seen):
|
|
def handler(request):
|
|
seen.append(request)
|
|
return httpx.Response(200)
|
|
|
|
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
|
|
def test_userinfo_password_masked_but_auth_still_sent(self):
|
|
seen = []
|
|
with self._client(seen) as client:
|
|
with self.assertLogs("httpx", level="INFO") as logs:
|
|
client.post("https://user:secret@hook.example/x", content=b"{}")
|
|
output = "\n".join(logs.output)
|
|
self.assertNotIn("secret", output)
|
|
self.assertIn("https://user:***@hook.example/x", output)
|
|
self.assertTrue(seen[0].headers["Authorization"].startswith("Basic "))
|
|
|
|
def test_ipinfo_token_query_parameter_masked(self):
|
|
seen = []
|
|
with self._client(seen) as client:
|
|
with self.assertLogs("httpx", level="INFO") as logs:
|
|
client.get(
|
|
"https://api.ipinfo.io/lite/8.8.8.8", params={"token": "TOKSECRET"}
|
|
)
|
|
output = "\n".join(logs.output)
|
|
self.assertNotIn("TOKSECRET", output)
|
|
self.assertIn("api.ipinfo.io/lite/8.8.8.8", output)
|
|
self.assertIn("token=***", output)
|
|
self.assertEqual(seen[0].url.params["token"], "TOKSECRET")
|
|
|
|
def test_token_mask_leaves_literal_encoded_stars_in_path_alone(self):
|
|
"""A path containing a literal ``%2A%2A%2A`` is not rewritten; only
|
|
the token value is masked, with and without other query params and
|
|
a fragment."""
|
|
for url, expected in (
|
|
(
|
|
"https://h.example/a%2A%2A%2Ab?token=TOKSECRET",
|
|
"https://h.example/a%2A%2A%2Ab?token=***",
|
|
),
|
|
(
|
|
"https://h.example/a%2A%2A%2Ab?x=1&token=TOKSECRET",
|
|
"https://h.example/a%2A%2A%2Ab?x=1&token=***",
|
|
),
|
|
(
|
|
"https://h.example/a%2A%2A%2Ab?x=1&token=TOKSECRET#f",
|
|
"https://h.example/a%2A%2A%2Ab?x=1&token=***#f",
|
|
),
|
|
):
|
|
with self.subTest(url=url):
|
|
result = parsedmarc.cli._RedactUrlCredentialsFilter._redact(
|
|
httpx.URL(url)
|
|
)
|
|
self.assertNotIn("TOKSECRET", result)
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_reconfiguring_does_not_stack_filters(self):
|
|
parsedmarc.cli._configure_dependency_logging(logging.INFO)
|
|
parsedmarc.cli._configure_dependency_logging(logging.DEBUG)
|
|
installed = [
|
|
f
|
|
for f in logging.getLogger("httpx").filters
|
|
if isinstance(f, parsedmarc.cli._RedactUrlCredentialsFilter)
|
|
]
|
|
self.assertEqual(len(installed), 1)
|
|
|
|
def test_non_tuple_args_and_other_arg_types_pass_through(self):
|
|
record = logging.LogRecord("httpx", logging.INFO, "f", 1, "x %d", (5,), None)
|
|
self.assertTrue(parsedmarc.cli._RedactUrlCredentialsFilter().filter(record))
|
|
self.assertEqual(record.args, (5,))
|
|
record = logging.LogRecord("httpx", logging.INFO, "f", 1, "plain", None, None)
|
|
self.assertTrue(parsedmarc.cli._RedactUrlCredentialsFilter().filter(record))
|
|
self.assertIsNone(record.args)
|
|
|
|
|
|
@unittest.skipUnless(
|
|
importlib.util.find_spec("httpx2") is not None,
|
|
"httpx2 (dnspython 2.9+ DNS over HTTPS) is not installed",
|
|
)
|
|
class TestHttpx2LogRedaction(unittest.TestCase):
|
|
"""GHSA-hjr3-rx4c-24cv: dnspython 2.9+ does DNS over HTTPS through
|
|
httpx2, which logs the request URL under the ``httpx2`` logger with an
|
|
``httpx2.URL``, a different class from ``httpx.URL``."""
|
|
|
|
def setUp(self):
|
|
import httpx2 # pyright: ignore[reportMissingImports]
|
|
|
|
self.httpx2 = httpx2
|
|
saved = {}
|
|
for name in parsedmarc.cli._DEPENDENCY_LOGGERS:
|
|
dep = logging.getLogger(name)
|
|
saved[name] = (
|
|
dep.level,
|
|
list(dep.handlers),
|
|
dep.propagate,
|
|
list(dep.filters),
|
|
)
|
|
|
|
def restore():
|
|
for name, (level, handlers, propagate, filters) in saved.items():
|
|
dep = logging.getLogger(name)
|
|
dep.setLevel(level)
|
|
dep.handlers = handlers
|
|
dep.propagate = propagate
|
|
dep.filters = filters
|
|
|
|
self.addCleanup(restore)
|
|
parsedmarc.cli._configure_dependency_logging(logging.INFO)
|
|
|
|
def test_httpx2_client_request_line_masks_userinfo(self):
|
|
"""httpx2 itself emits the line; the credential still reaches the
|
|
transport."""
|
|
seen = []
|
|
|
|
def handler(request):
|
|
seen.append(request)
|
|
return self.httpx2.Response(200)
|
|
|
|
with self.httpx2.Client(transport=self.httpx2.MockTransport(handler)) as client:
|
|
with self.assertLogs("httpx2", level="INFO") as logs:
|
|
client.post("https://doh:S3CRET@doh.example/dns-query", content=b"{}")
|
|
output = "\n".join(logs.output)
|
|
self.assertNotIn("S3CRET", output)
|
|
self.assertIn("https://doh:***@doh.example/dns-query", output)
|
|
self.assertTrue(seen[0].headers["Authorization"].startswith("Basic "))
|
|
|
|
def test_httpx2_url_token_query_parameter_masked(self):
|
|
url = self.httpx2.URL("https://h.example/a?x=1&token=TOKSECRET#f")
|
|
result = parsedmarc.cli._RedactUrlCredentialsFilter._redact(url)
|
|
self.assertEqual(result, "https://h.example/a?x=1&token=***#f")
|
|
|
|
def test_httpx2_logger_record_args_masked_and_ints_untouched(self):
|
|
record = logging.LogRecord(
|
|
"httpx2",
|
|
logging.INFO,
|
|
"f",
|
|
1,
|
|
"%s %d",
|
|
(self.httpx2.URL("https://u:S3CRET@h.example/x"), 200),
|
|
None,
|
|
)
|
|
self.assertTrue(parsedmarc.cli._RedactUrlCredentialsFilter().filter(record))
|
|
self.assertEqual(record.args, ("https://u:***@h.example/x", 200))
|
|
|
|
def test_reconfiguring_attaches_one_filter_to_httpx2(self):
|
|
parsedmarc.cli._configure_dependency_logging(logging.DEBUG)
|
|
parsedmarc.cli._configure_dependency_logging(logging.INFO)
|
|
installed = [
|
|
f
|
|
for f in logging.getLogger("httpx2").filters
|
|
if isinstance(f, parsedmarc.cli._RedactUrlCredentialsFilter)
|
|
]
|
|
self.assertEqual(len(installed), 1)
|
|
|
|
|
|
class TestHttpx2UrlClassImport(unittest.TestCase):
|
|
"""GHSA-hjr3-rx4c-24cv: ``_HTTP_URL_CLASSES`` must recognize
|
|
``httpx2.URL`` whenever httpx2 is importable, not only when dnspython
|
|
imported it. dnspython 2.9 requires Python >= 3.11, so on Python 3.10
|
|
CI installs dnspython 2.8 (which never imports httpx2) while httpx2
|
|
still installs there (hatch in the ``[build]`` extra requires it), so
|
|
a constant taken from ``dns.query`` left ``httpx2.URL`` arguments on
|
|
the ``httpx2`` logger unmasked there."""
|
|
|
|
def _exec_fresh_cli(self):
|
|
"""Execute parsedmarc.cli's source into a throwaway module and
|
|
return it, leaving the canonical module untouched. Module import
|
|
adds a StreamHandler to the parsedmarc logger, so the handler list
|
|
is restored afterwards."""
|
|
spec = importlib.util.find_spec("parsedmarc.cli")
|
|
assert spec is not None and spec.loader is not None
|
|
mod = importlib.util.module_from_spec(spec)
|
|
saved = list(parsedmarc.log.logger.handlers)
|
|
try:
|
|
spec.loader.exec_module(mod)
|
|
finally:
|
|
parsedmarc.log.logger.handlers[:] = saved
|
|
return mod
|
|
|
|
def test_httpx2_url_masked_when_dnspython_did_not_import_httpx2(self):
|
|
"""Simulate dnspython 2.8 (no ``dns.query.httpx2``) with httpx2
|
|
installed anyway: the httpx2 URL must still be masked. The old
|
|
constant, built from ``getattr(dns.query, "httpx2", None)``, left
|
|
it unredacted."""
|
|
try:
|
|
import httpx2 # pyright: ignore[reportMissingImports]
|
|
except ModuleNotFoundError:
|
|
self.skipTest("httpx2 is not installed")
|
|
with patch.dict(dns.query.__dict__):
|
|
dns.query.__dict__.pop("httpx2", None)
|
|
fresh = self._exec_fresh_cli()
|
|
url = httpx2.URL("https://u:S3CRET@h.example/x?token=TOKSECRET")
|
|
self.assertEqual(
|
|
fresh._RedactUrlCredentialsFilter._redact(url),
|
|
"https://u:***@h.example/x?token=***",
|
|
)
|
|
|
|
def test_without_httpx2_only_httpx_urls_are_matched(self):
|
|
"""With httpx2 unimportable (a None ``sys.modules`` entry makes
|
|
``import httpx2`` raise ModuleNotFoundError), the module still
|
|
imports, ``httpx2`` is None, and httpx URLs are still masked.
|
|
This covers the fallback arm of the guard added for
|
|
GHSA-hjr3-rx4c-24cv."""
|
|
with patch.dict(sys.modules, {"httpx2": None}):
|
|
fresh = self._exec_fresh_cli()
|
|
self.assertIsNone(fresh.httpx2)
|
|
self.assertEqual(fresh._HTTP_URL_CLASSES, (httpx.URL,))
|
|
self.assertEqual(
|
|
fresh._RedactUrlCredentialsFilter._redact(
|
|
httpx.URL("https://u:S3CRET@h.example/x")
|
|
),
|
|
"https://u:***@h.example/x",
|
|
)
|
|
|
|
|
|
class TestInitOutputClientsRollback(unittest.TestCase):
|
|
"""_init_output_clients() is all-or-nothing against the search backends'
|
|
process-wide ``default`` connection alias.
|
|
|
|
``elastic.set_hosts()`` / ``opensearch.set_hosts()`` register the client
|
|
they build under that alias the moment it is constructed, but the rest of
|
|
the initialization -- the index migration, and every output configured
|
|
after them -- can still fail. The SIGHUP reload in _main() builds the
|
|
replacement clients *before* closing the old ones and keeps the old opts
|
|
when the build raises ("Config reload failed, continuing with previous
|
|
config"), so a mutated alias meant the save path -- every ``Search`` and
|
|
``Document.save()`` in parsedmarc/elastic.py, each of which resolves the
|
|
``default`` alias through its SDK -- reached the *new* cluster while
|
|
``opts`` and ``index_prefix_domain_map`` still described the old one:
|
|
reports written to a destination the operator
|
|
never successfully configured, with the log saying the previous config
|
|
was still in effect. The half-built client leaked with it, as did every
|
|
client built earlier in the same failed call.
|
|
"""
|
|
|
|
def _close_spy(self, cls):
|
|
"""Patch *cls*.close() with a spy that still closes, and return the
|
|
list it appends each closed client to.
|
|
|
|
Patching the SDK client class -- not any parsedmarc function -- is
|
|
what makes "the client was closed" observable, and the real close()
|
|
still runs, so a client that is closed twice would still have to
|
|
survive it.
|
|
"""
|
|
closed = []
|
|
real_close = cls.close
|
|
|
|
def spy(client):
|
|
closed.append(client)
|
|
real_close(client)
|
|
|
|
patcher = patch.object(cls, "close", spy)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
return closed
|
|
|
|
def test_interrupt_after_set_hosts_restores_the_previous_elasticsearch_client(self):
|
|
"""Failure inside the Elasticsearch block, before the handle exists.
|
|
|
|
``migrate_indexes()`` runs once ``set_hosts()`` has already handed the
|
|
``default`` alias to the new client, and ``clients["elasticsearch"]``
|
|
is assigned only after it returns -- so a failure here leaves a
|
|
registered client that no handle owns. It has to be a BaseException:
|
|
``elastic.migrate_indexes()`` wraps every cluster call in
|
|
``except Exception`` and logs a warning (parsedmarc/elastic.py), so
|
|
what escapes this block is a Ctrl-C landing in one of them, not a
|
|
connection error.
|
|
"""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
connections.add_connection("default", cast(Elasticsearch, old_conn))
|
|
closed = self._close_spy(Elasticsearch)
|
|
registered = []
|
|
|
|
def interrupt(client, *args, **kwargs):
|
|
# Whatever set_hosts() registered a moment ago: the client the
|
|
# rollback is responsible for.
|
|
registered.append(connections.get_connection("default"))
|
|
raise KeyboardInterrupt
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
)
|
|
with patch.object(Elasticsearch, "perform_request", interrupt):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
self.assertEqual(len(registered), 1)
|
|
new_conn = registered[0]
|
|
self.assertIsNot(new_conn, old_conn)
|
|
self.assertIs(connections.get_connection("default"), old_conn)
|
|
self.assertEqual(closed, [new_conn])
|
|
self.assertEqual(old_conn.close_count, 0)
|
|
|
|
def test_interrupt_after_set_hosts_restores_the_previous_opensearch_client(self):
|
|
"""The same for OpenSearch, whose alias lives in a second registry.
|
|
|
|
The rollback ranges over both backends, and each has its own module,
|
|
registry and client class; a rollback that snapshotted or restored
|
|
one of them twice would pass the Elasticsearch test above.
|
|
"""
|
|
connections = opensearch_module.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
connections.add_connection("default", old_conn)
|
|
closed = self._close_spy(opensearchpy.OpenSearch)
|
|
registered = []
|
|
|
|
def interrupt(transport, *args, **kwargs):
|
|
registered.append(connections.get_connection("default"))
|
|
raise KeyboardInterrupt
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
opensearch_hosts=_UNREACHABLE_HOSTS,
|
|
opensearch_timeout=1.0,
|
|
)
|
|
with patch.object(opensearchpy.Transport, "perform_request", interrupt):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
self.assertEqual(len(registered), 1)
|
|
new_conn = registered[0]
|
|
self.assertIsNot(new_conn, old_conn)
|
|
self.assertIs(connections.get_connection("default"), old_conn)
|
|
self.assertEqual(closed, [new_conn])
|
|
self.assertEqual(old_conn.close_count, 0)
|
|
|
|
def test_failure_with_no_previous_alias_leaves_the_alias_unset(self):
|
|
"""The startup case: there was no ``default`` alias to restore.
|
|
|
|
Rolling back to "unset" is not the same as leaving the discarded
|
|
client in place. parsedmarc's own retry loop calls
|
|
_init_output_clients() again after a failed start, and elastic.py's
|
|
save path resolves the alias on every write, so a leftover
|
|
registration is a client for a configuration that never finished
|
|
being built.
|
|
"""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
try:
|
|
connections.remove_connection("default")
|
|
except KeyError:
|
|
# Nothing was registered: the startup state this test needs.
|
|
pass
|
|
closed = self._close_spy(Elasticsearch)
|
|
registered = []
|
|
|
|
def interrupt(client, *args, **kwargs):
|
|
registered.append(connections.get_connection("default"))
|
|
raise KeyboardInterrupt
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
)
|
|
with patch.object(Elasticsearch, "perform_request", interrupt):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
with self.assertRaises(KeyError):
|
|
connections.get_connection("default")
|
|
self.assertEqual(closed, registered)
|
|
|
|
def test_failure_in_a_later_output_closes_everything_already_built(self):
|
|
"""Elasticsearch fully built, then a later step fails.
|
|
|
|
``[opensearch] auth_type = awssigv4`` with no ``aws_region`` is a real
|
|
config error, raised by ``opensearch.set_hosts()`` before it
|
|
constructs anything -- and OpenSearch is the only output initialized
|
|
after Elasticsearch, so this is the shape of the reload that leaves an
|
|
_ElasticsearchHandle in ``clients``, owning the client that now holds
|
|
the alias. The handle must be closed (that is what releases the alias
|
|
again), the Kafka client built earlier in the same call must be closed
|
|
too, and both aliases must end up naming what they named on entry.
|
|
|
|
The Elasticsearch client is closed *exactly* once: the teardown does
|
|
it through the handle, and the restore then finds the alias already
|
|
released rather than closing the same client a second time.
|
|
"""
|
|
es_connections = parsedmarc.elastic.connections
|
|
os_connections = opensearch_module.connections
|
|
_isolate_default_alias(self, es_connections)
|
|
_isolate_default_alias(self, os_connections)
|
|
old_es = _FakeSearchConnection("old es")
|
|
old_os = _FakeSearchConnection("old os")
|
|
es_connections.add_connection("default", cast(Elasticsearch, old_es))
|
|
os_connections.add_connection("default", old_os)
|
|
closed = self._close_spy(Elasticsearch)
|
|
built = []
|
|
|
|
def request(client, method, path, **kwargs):
|
|
# The client set_hosts() registered, recorded on its first
|
|
# cluster call, so the assertions below can name it.
|
|
if not built:
|
|
built.append(es_connections.get_connection("default"))
|
|
return _es_request_without_network(client, method, path, **kwargs)
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
kafka_hosts=["kafka.example.com:9092"],
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
opensearch_hosts=_UNREACHABLE_HOSTS,
|
|
opensearch_auth_type="awssigv4",
|
|
)
|
|
with (
|
|
patch("parsedmarc.kafkaclient.KafkaProducer") as mock_producer,
|
|
patch.object(Elasticsearch, "perform_request", request),
|
|
):
|
|
with self.assertRaises(RuntimeError) as context:
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
self.assertIn("aws_region", str(context.exception))
|
|
mock_producer.return_value.close.assert_called_once()
|
|
self.assertEqual(built, closed)
|
|
self.assertEqual(len(closed), 1)
|
|
self.assertIs(es_connections.get_connection("default"), old_es)
|
|
self.assertIs(os_connections.get_connection("default"), old_os)
|
|
self.assertEqual(old_es.close_count, 0)
|
|
self.assertEqual(old_os.close_count, 0)
|
|
|
|
def test_a_failed_build_does_not_leave_the_serverless_flag_flipped(self):
|
|
"""The alias is not the only module-level state ``set_hosts()`` writes.
|
|
|
|
``elastic.set_hosts()`` also assigns ``elastic._SERVERLESS``, which
|
|
``elastic.create_indexes()`` consults to decide whether to strip the
|
|
``number_of_shards``/``number_of_replicas`` settings Elastic Cloud
|
|
Serverless rejects with HTTP 400. A reload that turns
|
|
``[elasticsearch] serverless`` on and then fails would otherwise pair
|
|
the restored old client -- a normal cluster -- with the new flag, and
|
|
the next index created on it would silently lose its shard settings.
|
|
|
|
Both halves are observed: the flag really is flipped while the build
|
|
runs (otherwise the assertion after it would hold for the wrong
|
|
reason), and it is back to its old value once the rollback is done.
|
|
"""
|
|
es_connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, es_connections)
|
|
old_es = _FakeSearchConnection("old es")
|
|
es_connections.add_connection("default", cast(Elasticsearch, old_es))
|
|
self.addCleanup(
|
|
setattr, parsedmarc.elastic, "_SERVERLESS", parsedmarc.elastic._SERVERLESS
|
|
)
|
|
parsedmarc.elastic._SERVERLESS = False
|
|
during = []
|
|
|
|
def request(client, method, path, **kwargs):
|
|
# The first cluster call happens after set_hosts() has written
|
|
# both the alias and the flag.
|
|
if not during:
|
|
during.append(parsedmarc.elastic._SERVERLESS)
|
|
return _es_request_without_network(client, method, path, **kwargs)
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
elasticsearch_serverless=True,
|
|
opensearch_hosts=_UNREACHABLE_HOSTS,
|
|
opensearch_auth_type="awssigv4",
|
|
)
|
|
with patch.object(Elasticsearch, "perform_request", request):
|
|
with self.assertRaises(RuntimeError):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
self.assertEqual(during, [True])
|
|
self.assertIs(parsedmarc.elastic._SERVERLESS, False)
|
|
self.assertIs(es_connections.get_connection("default"), old_es)
|
|
|
|
def test_an_interrupt_during_teardown_still_restores_the_alias(self):
|
|
"""A second Ctrl-C, landing in the teardown of the first one's rollback.
|
|
|
|
The rollback exists because a KeyboardInterrupt can escape the
|
|
Elasticsearch block, and an operator holding the key sends more than
|
|
one. ``_close_output_clients()`` swallows only ``Exception``, and so
|
|
does ``_ElasticsearchHandle.close()``, so an interrupt raised by the
|
|
client's own ``close()`` propagates out of the teardown. Run as a
|
|
plain statement sequence, that would skip the hand-back and leave the
|
|
alias on the new cluster -- the very state the rollback is for -- so
|
|
the teardown is wrapped in ``try``/``finally`` and the discarded
|
|
client's ``close()`` swallows BaseException.
|
|
"""
|
|
es_connections = parsedmarc.elastic.connections
|
|
os_connections = opensearch_module.connections
|
|
_isolate_default_alias(self, es_connections)
|
|
_isolate_default_alias(self, os_connections)
|
|
old_es = _FakeSearchConnection("old es")
|
|
old_os = _FakeSearchConnection("old os")
|
|
es_connections.add_connection("default", cast(Elasticsearch, old_es))
|
|
os_connections.add_connection("default", old_os)
|
|
attempted = []
|
|
|
|
def interrupting_close(client):
|
|
attempted.append(client)
|
|
raise KeyboardInterrupt
|
|
|
|
# Elasticsearch is built, then [opensearch] fails to build its
|
|
# client -- the same later-step failure as the test above -- and the
|
|
# interrupt arrives while the Elasticsearch handle is being closed.
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
opensearch_hosts=_UNREACHABLE_HOSTS,
|
|
opensearch_auth_type="awssigv4",
|
|
)
|
|
with (
|
|
patch.object(Elasticsearch, "perform_request", _es_request_without_network),
|
|
patch.object(Elasticsearch, "close", interrupting_close),
|
|
):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
# Twice: once through the handle, and once more by the rollback,
|
|
# which still finds the alias unreleased because the handle's close()
|
|
# never got that far.
|
|
self.assertEqual(len(attempted), 2)
|
|
self.assertIs(es_connections.get_connection("default"), old_es)
|
|
self.assertIs(os_connections.get_connection("default"), old_os)
|
|
self.assertEqual(old_es.close_count, 0)
|
|
|
|
def test_a_discarded_client_that_cannot_be_closed_still_returns_the_alias(self):
|
|
"""Closing the discarded client is best-effort; the hand-back is not.
|
|
|
|
A client whose transport is already broken raises from ``close()``.
|
|
Restoring the alias is the half that keeps the configuration still in
|
|
force writing to the cluster it was configured for, so it must not be
|
|
skipped because the client being thrown away could not be closed.
|
|
"""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
old_conn = _FakeSearchConnection("old")
|
|
connections.add_connection("default", cast(Elasticsearch, old_conn))
|
|
attempted = []
|
|
|
|
def failing_close(client):
|
|
attempted.append(client)
|
|
raise RuntimeError("transport already closed")
|
|
|
|
def interrupt(client, *args, **kwargs):
|
|
raise KeyboardInterrupt
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
)
|
|
with (
|
|
patch.object(Elasticsearch, "perform_request", interrupt),
|
|
patch.object(Elasticsearch, "close", failing_close),
|
|
):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
|
|
self.assertEqual(len(attempted), 1)
|
|
self.assertIs(connections.get_connection("default"), old_conn)
|
|
|
|
def test_successful_init_hands_the_alias_to_the_new_client(self):
|
|
"""The negative half: a successful call rolls nothing back.
|
|
|
|
The alias must name the client ``set_hosts()`` just built -- it is
|
|
what the save path resolves -- the previous client must be left
|
|
untouched for _main() to close on its own terms, and the returned
|
|
handle must own the new client, which is what makes
|
|
_close_output_clients() close it later. Ownership is observed by
|
|
closing the handle and watching which client's close() runs, not by
|
|
reading the handle's attributes.
|
|
"""
|
|
connections = parsedmarc.elastic.connections
|
|
_isolate_default_alias(self, connections)
|
|
# set_hosts() assigns elastic._SERVERLESS, and on the success path
|
|
# nothing rolls it back, so restore it here or it leaks into the
|
|
# rest of the run.
|
|
self.addCleanup(
|
|
setattr, parsedmarc.elastic, "_SERVERLESS", parsedmarc.elastic._SERVERLESS
|
|
)
|
|
old_conn = _FakeSearchConnection("old")
|
|
connections.add_connection("default", cast(Elasticsearch, old_conn))
|
|
closed = self._close_spy(Elasticsearch)
|
|
|
|
opts = _output_client_opts(
|
|
save_aggregate=True,
|
|
elasticsearch_hosts=_UNREACHABLE_HOSTS,
|
|
elasticsearch_timeout=1.0,
|
|
)
|
|
with patch.object(
|
|
Elasticsearch, "perform_request", _es_request_without_network
|
|
):
|
|
clients = parsedmarc.cli._init_output_clients(opts)
|
|
new_conn = connections.get_connection("default")
|
|
|
|
self.assertIsNot(new_conn, old_conn)
|
|
self.assertEqual(closed, [])
|
|
self.assertEqual(old_conn.close_count, 0)
|
|
self.assertEqual(sorted(clients), ["elasticsearch"])
|
|
|
|
clients["elasticsearch"].close()
|
|
|
|
self.assertEqual(closed, [new_conn])
|
|
|
|
|
|
def _domain_map_tls_reports():
|
|
"""Four SMTP TLS reports for the index_prefix_domain_map tests: two
|
|
whose policy domains fold to the mapped base domain example.com (one of
|
|
them mixed-case), one for an unmapped domain, and one with an empty
|
|
policies list -- parse_smtp_tls_report_json() accepts those, and
|
|
get_index_prefix() must treat them as unmappable (dropped by the
|
|
filter) rather than crash on policies[0]."""
|
|
return [
|
|
{
|
|
"organization_name": "Allowed Org",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"report_id": "allowed-1",
|
|
"contact_info": "tls@allowed.example.com",
|
|
"policies": [
|
|
{
|
|
"policy_domain": "allowed.example.com",
|
|
"policy_type": "sts",
|
|
"successful_session_count": 1,
|
|
"failed_session_count": 0,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"organization_name": "Unmapped Org",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"report_id": "unmapped-1",
|
|
"contact_info": "tls@unmapped.example.net",
|
|
"policies": [
|
|
{
|
|
"policy_domain": "unmapped.example.net",
|
|
"policy_type": "sts",
|
|
"successful_session_count": 5,
|
|
"failed_session_count": 0,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"organization_name": "Mixed Case Org",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"report_id": "mixed-case-1",
|
|
"contact_info": "tls@mixedcase.example.com",
|
|
"policies": [
|
|
{
|
|
"policy_domain": "MixedCase.Example.Com",
|
|
"policy_type": "sts",
|
|
"successful_session_count": 2,
|
|
"failed_session_count": 0,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"organization_name": "No Policies Org",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"report_id": "no-policies-1",
|
|
"contact_info": "tls@nopolicies.example.org",
|
|
"policies": [],
|
|
},
|
|
]
|
|
|
|
|
|
class TestIndexPrefixDomainMapTlsFiltering(unittest.TestCase):
|
|
"""Tests that SMTP TLS reports for unmapped domains are filtered out
|
|
when index_prefix_domain_map is configured."""
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testTlsReportsFilteredByDomainMap(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
):
|
|
"""TLS reports for domains not in the map should be silently dropped."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": _domain_map_tls_reports(),
|
|
}
|
|
)
|
|
|
|
domain_map = {"tenant_a": ["example.com"]}
|
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as map_file:
|
|
import yaml
|
|
|
|
yaml.dump(domain_map, map_file)
|
|
map_path = map_file.name
|
|
self.addCleanup(lambda: os.path.exists(map_path) and os.remove(map_path))
|
|
|
|
config = f"""[general]
|
|
save_smtp_tls = true
|
|
silent = false
|
|
index_prefix_domain_map = {map_path}
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
"""
|
|
with NamedTemporaryFile("w", suffix=".ini", delete=False) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
captured = io.StringIO()
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
with patch("sys.stdout", captured):
|
|
parsedmarc.cli._main()
|
|
|
|
# A single JSON document, not two: a run whose only reports came
|
|
# from the mailbox saves them inside save_callback and skips the
|
|
# second, empty pass rather than printing another blob after it.
|
|
output = json.loads(captured.getvalue())
|
|
tls_reports = output["smtp_tls_reports"]
|
|
self.assertEqual(len(tls_reports), 2)
|
|
report_ids = {r["report_id"] for r in tls_reports}
|
|
self.assertIn("allowed-1", report_ids)
|
|
self.assertIn("mixed-case-1", report_ids)
|
|
self.assertNotIn("unmapped-1", report_ids)
|
|
# Unmappable (no policies -> no domain), dropped rather than an
|
|
# IndexError from get_index_prefix() on policies[0].
|
|
self.assertNotIn("no-policies-1", report_ids)
|
|
|
|
@patch("parsedmarc.cli.email_results")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testTlsReportsFilteredForEmailResults(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
mock_email_results,
|
|
):
|
|
"""The combined results handed to email_results() are filtered by
|
|
index_prefix_domain_map exactly like the batches that were saved.
|
|
|
|
Regression test: get_dmarc_reports_from_mailbox() builds its return
|
|
value from its own accumulated lists, not from the batch dict it
|
|
passes to save_callback, so the in-place filtering process_reports()
|
|
applies to each batch never reaches the combined dict. The mock
|
|
mirrors that by handing save_callback a copy while returning the
|
|
full, unfiltered set."""
|
|
mock_imap_connection.return_value = object()
|
|
reports_dict = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": _domain_map_tls_reports(),
|
|
}
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
reports_dict,
|
|
batch={
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": list(reports_dict["smtp_tls_reports"]),
|
|
},
|
|
)
|
|
|
|
domain_map = {"tenant_a": ["example.com"]}
|
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as map_file:
|
|
import yaml
|
|
|
|
yaml.dump(domain_map, map_file)
|
|
map_path = map_file.name
|
|
self.addCleanup(lambda: os.path.exists(map_path) and os.remove(map_path))
|
|
|
|
config = f"""[general]
|
|
save_smtp_tls = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
|
|
[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
|
|
"""
|
|
with NamedTemporaryFile("w", suffix=".ini", delete=False) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
mock_email_results.assert_called_once()
|
|
emailed_results = mock_email_results.call_args.args[0]
|
|
report_ids = {r["report_id"] for r in emailed_results["smtp_tls_reports"]}
|
|
self.assertEqual(report_ids, {"allowed-1", "mixed-case-1"})
|
|
|
|
|
|
class TestNormalizeIndexPrefix(unittest.TestCase):
|
|
"""_normalize_index_prefix() is the single definition of how an
|
|
index_prefix_domain_map key becomes an index name prefix. The save path
|
|
and the migration path both call it, so these assertions pin the exact
|
|
strings both sides must agree on."""
|
|
|
|
def test_lowercases_and_strips(self):
|
|
self.assertEqual(parsedmarc.cli._normalize_index_prefix(" Acme "), "acme_")
|
|
|
|
def test_replaces_spaces_and_hyphens(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._normalize_index_prefix("Acme Corp-2"), "acme_corp_2_"
|
|
)
|
|
|
|
def test_strips_surrounding_underscores(self):
|
|
self.assertEqual(parsedmarc.cli._normalize_index_prefix("_tenant_"), "tenant_")
|
|
|
|
def test_key_that_normalizes_to_nothing_yields_a_bare_underscore(self):
|
|
"""Documents rather than special-cases the degenerate result: the
|
|
save path writes such a report's documents to `_dmarc_aggregate-*`,
|
|
so the migration path has to target the same name."""
|
|
self.assertEqual(parsedmarc.cli._normalize_index_prefix("_"), "_")
|
|
self.assertEqual(parsedmarc.cli._normalize_index_prefix(" "), "_")
|
|
|
|
|
|
class TestMigrationIndexNames(unittest.TestCase):
|
|
"""_migration_index_names() resolves every index name an index
|
|
migration should target, widening both configurable axes (issue #868).
|
|
Each case asserts the whole list, in order, so it observes the names
|
|
that must be absent as well as the ones that must be present."""
|
|
|
|
def test_nothing_configured_is_unchanged(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names("dmarc_aggregate", None, None, None),
|
|
["dmarc_aggregate"],
|
|
)
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names("dmarc_aggregate", None, None, {}),
|
|
["dmarc_aggregate"],
|
|
)
|
|
|
|
def test_suffix_also_targets_the_unsuffixed_name(self):
|
|
"""`dmarc_aggregate_prod*` matches none of the operator's own
|
|
pre-suffix `dmarc_aggregate-*` indexes, so both are targeted."""
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate", "prod", None, None
|
|
),
|
|
["dmarc_aggregate_prod", "dmarc_aggregate"],
|
|
)
|
|
|
|
def test_empty_suffix_adds_no_duplicate(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names("dmarc_aggregate", "", None, None),
|
|
["dmarc_aggregate"],
|
|
)
|
|
|
|
def test_domain_map_fans_out_after_the_unprefixed_name(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate",
|
|
None,
|
|
None,
|
|
{"Acme Corp": ["acme.example"], "widgets-inc": ["widgets.example"]},
|
|
),
|
|
[
|
|
"dmarc_aggregate",
|
|
"acme_corp_dmarc_aggregate",
|
|
"widgets_inc_dmarc_aggregate",
|
|
],
|
|
)
|
|
|
|
def test_keys_that_normalize_identically_are_deduplicated(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate",
|
|
None,
|
|
None,
|
|
{"acme corp": ["a.example"], "Acme-Corp": ["b.example"]},
|
|
),
|
|
["dmarc_aggregate", "acme_corp_dmarc_aggregate"],
|
|
)
|
|
|
|
def test_configured_prefix_suppresses_the_domain_map_fan_out(self):
|
|
"""A deployment with its own index_prefix writes only under that
|
|
prefix; submitting an _update_by_query against map-derived patterns
|
|
would touch another deployment's data on a shared cluster. The
|
|
suffix axis still applies."""
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate", "prod", "corp_", {"acme": ["acme.example"]}
|
|
),
|
|
["corp_dmarc_aggregate_prod", "corp_dmarc_aggregate"],
|
|
)
|
|
|
|
def test_configured_prefix_is_used_verbatim(self):
|
|
"""The save path passes index_prefix through untouched, so the
|
|
migration path must not normalize it either."""
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate", None, "My-Prefix ", None
|
|
),
|
|
["My-Prefix dmarc_aggregate"],
|
|
)
|
|
|
|
def test_both_axes_compose_in_save_time_order(self):
|
|
"""Save time builds `{prefix}{base}_{suffix}-{date}`: the prefix
|
|
precedes the base name and the suffix follows it."""
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"smtp_tls", "prod", None, {"acme": ["acme.example"]}
|
|
),
|
|
["smtp_tls_prod", "smtp_tls", "acme_smtp_tls_prod", "acme_smtp_tls"],
|
|
)
|
|
|
|
def test_key_normalizing_to_an_underscore_is_targeted(self):
|
|
self.assertEqual(
|
|
parsedmarc.cli._migration_index_names(
|
|
"dmarc_aggregate", None, None, {"_": ["acme.example"]}
|
|
),
|
|
["dmarc_aggregate", "_dmarc_aggregate"],
|
|
)
|
|
|
|
|
|
class TestMigrateIndexesDomainMapWiring(unittest.TestCase):
|
|
"""Issue #868: the startup index migrations built their index names
|
|
from index_prefix/index_suffix alone, so a multi-tenant deployment
|
|
whose prefixes come from index_prefix_domain_map had its backfill
|
|
guard run against `dmarc_aggregate*`, which matches none of its real
|
|
`<tenant>_dmarc_aggregate-*` indexes -- a silent no-op. These tests
|
|
assert the index names actually handed to migrate_indexes()."""
|
|
|
|
def _write_config(self, config, domain_map=None):
|
|
paths = {}
|
|
if domain_map is not None:
|
|
import yaml
|
|
|
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as map_file:
|
|
yaml.dump(domain_map, map_file)
|
|
paths["map"] = map_file.name
|
|
self.addCleanup(
|
|
lambda: os.path.exists(paths["map"]) and os.remove(paths["map"])
|
|
)
|
|
config = config.format(map_path=paths["map"])
|
|
with NamedTemporaryFile("w", suffix=".ini", delete=False) as config_file:
|
|
config_file.write(config)
|
|
paths["config"] = config_file.name
|
|
self.addCleanup(
|
|
lambda: os.path.exists(paths["config"]) and os.remove(paths["config"])
|
|
)
|
|
return paths["config"]
|
|
|
|
_IMAP = """
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
"""
|
|
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testElasticsearchMigrationFansOutOverDomainMap(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
mock_migrate,
|
|
):
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
save_smtp_tls = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
"""
|
|
+ self._IMAP
|
|
+ """
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
""",
|
|
domain_map={"Tenant-A": ["example.com"], "tenant_b": ["example.net"]},
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
kwargs = mock_migrate.call_args.kwargs
|
|
self.assertEqual(
|
|
kwargs["aggregate_indexes"],
|
|
[
|
|
"dmarc_aggregate",
|
|
"tenant_a_dmarc_aggregate",
|
|
"tenant_b_dmarc_aggregate",
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
kwargs["failure_indexes"],
|
|
["dmarc_failure", "tenant_a_dmarc_failure", "tenant_b_dmarc_failure"],
|
|
)
|
|
self.assertEqual(
|
|
kwargs["smtp_tls_indexes"],
|
|
["smtp_tls", "tenant_a_smtp_tls", "tenant_b_smtp_tls"],
|
|
)
|
|
# The legacy fo migration does not fan out over the map: it
|
|
# deletes the index it reindexes, and no index it could apply to
|
|
# can carry a tenant prefix.
|
|
self.assertEqual(kwargs["legacy_fo_indexes"], ["dmarc_aggregate"])
|
|
|
|
@patch("parsedmarc.cli.opensearch.migrate_indexes")
|
|
@patch("parsedmarc.cli.opensearch.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testOpenSearchMigrationFansOutWithSuffix(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
mock_migrate,
|
|
):
|
|
"""The OpenSearch block carries its own copy of the fan-out, so it
|
|
is asserted separately. With an index_suffix set, the unsuffixed
|
|
name is targeted too, for documents indexed before the suffix was
|
|
configured."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
"""
|
|
+ self._IMAP
|
|
+ """
|
|
[opensearch]
|
|
hosts = localhost
|
|
index_suffix = prod
|
|
""",
|
|
domain_map={"Tenant-A": ["example.com"]},
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
kwargs = mock_migrate.call_args.kwargs
|
|
self.assertEqual(
|
|
kwargs["aggregate_indexes"],
|
|
[
|
|
"dmarc_aggregate_prod",
|
|
"dmarc_aggregate",
|
|
"tenant_a_dmarc_aggregate_prod",
|
|
"tenant_a_dmarc_aggregate",
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
kwargs["smtp_tls_indexes"],
|
|
[
|
|
"smtp_tls_prod",
|
|
"smtp_tls",
|
|
"tenant_a_smtp_tls_prod",
|
|
"tenant_a_smtp_tls",
|
|
],
|
|
)
|
|
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testConfiguredPrefixSuppressesDomainMapFanout(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
mock_migrate,
|
|
):
|
|
"""The negative half of the fan-out contract, and the one case here
|
|
that also passed before #868 was fixed: a deployment that sets its
|
|
own index_prefix must never submit an _update_by_query against a
|
|
map-derived index pattern it does not write to."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
"""
|
|
+ self._IMAP
|
|
+ """
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
index_prefix = corp_
|
|
""",
|
|
domain_map={"Tenant-A": ["example.com"]},
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
kwargs = mock_migrate.call_args.kwargs
|
|
self.assertEqual(kwargs["aggregate_indexes"], ["corp_dmarc_aggregate"])
|
|
self.assertEqual(kwargs["failure_indexes"], ["corp_dmarc_failure"])
|
|
self.assertEqual(kwargs["smtp_tls_indexes"], ["corp_smtp_tls"])
|
|
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testLegacyFoMigrationHonorsPrefixAndSuffixButNotTheDomainMap(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
mock_migrate,
|
|
):
|
|
"""The published_policy.fo migration takes exact index names, and
|
|
index_prefix/index_suffix both date back to 4.1.0, so an affected
|
|
index may carry either. It cannot carry a tenant prefix, though:
|
|
index_prefix_domain_map arrived in 8.19.0, long after 5.0.0 fixed
|
|
the mapping. Asserting the full list covers both halves -- the
|
|
configured prefix and suffix are applied, and no map-derived name
|
|
is, which matters because this migration deletes the index it
|
|
reindexes."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
"""
|
|
+ self._IMAP
|
|
+ """
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
index_prefix = corp_
|
|
index_suffix = prod
|
|
""",
|
|
domain_map={"Tenant-A": ["example.com"]},
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_migrate.call_args.kwargs["legacy_fo_indexes"],
|
|
["corp_dmarc_aggregate_prod", "corp_dmarc_aggregate"],
|
|
)
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testMigrationTargetsMatchSaveTimePrefix(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
mock_migrate,
|
|
mock_save_aggregate,
|
|
):
|
|
"""Both ends of the shared-normalization contract in one test: the
|
|
prefix a report is *saved* under has to appear among the index
|
|
names the migration targets, or the backfill misses that tenant's
|
|
data."""
|
|
mock_imap_connection.return_value = object()
|
|
report = _sample_aggregate_reports()[0]
|
|
report["policy_published"]["domain"] = "example.com"
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [report],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
)
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
index_prefix_domain_map = {map_path}
|
|
"""
|
|
+ self._IMAP
|
|
+ """
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
""",
|
|
domain_map={"Tenant-A": ["example.com"]},
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(
|
|
mock_save_aggregate.call_args.kwargs["index_prefix"], "tenant_a_"
|
|
)
|
|
self.assertIn(
|
|
"tenant_a_dmarc_aggregate",
|
|
mock_migrate.call_args.kwargs["aggregate_indexes"],
|
|
)
|
|
|
|
|
|
class TestMailboxSaveCallbackWiring(unittest.TestCase):
|
|
"""The CLI's end of the #242 contract: the callback it hands to
|
|
get_dmarc_reports_from_mailbox() (and to watch_inbox()) reports whether
|
|
a batch reached every configured output destination, so the library
|
|
knows whether archiving that batch is safe."""
|
|
|
|
def setUp(self):
|
|
# _main()'s file-parsing loop dedups aggregate reports against this
|
|
# process-wide cache, so a sample parsed here would be dropped from
|
|
# a later test's results (and vice versa).
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
|
|
def _write_config(self, text):
|
|
with NamedTemporaryFile("w", suffix=".ini", delete=False) as config_file:
|
|
config_file.write(text)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
return config_path
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testSaveCallbackIsFalseOnlyWhenAnOutputDestinationFailed(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
mock_save_aggregate,
|
|
):
|
|
"""The save_callback saves the batch it is given and returns a
|
|
truthy value only when every destination accepted it. That verdict
|
|
is the whole basis on which get_dmarc_reports_from_mailbox() decides
|
|
whether to archive or delete the batch's messages."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
"""
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
parsedmarc.cli._main()
|
|
|
|
save_callback = mock_get_reports.call_args.kwargs.get("save_callback")
|
|
self.assertTrue(callable(save_callback))
|
|
|
|
report = {"policy_published": {"domain": "example.com"}}
|
|
batch = {
|
|
"aggregate_reports": [report],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
self.assertTrue(save_callback(batch))
|
|
self.assertIs(mock_save_aggregate.call_args.args[0], report)
|
|
|
|
mock_save_aggregate.side_effect = parsedmarc.elastic.ElasticsearchError(
|
|
"simulated output failure"
|
|
)
|
|
self.assertFalse(save_callback(batch))
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.watch_inbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testWatchCallbackIsTheSameAdapterAndRetryCapReachesBothCallSites(
|
|
self, mock_imap_connection, mock_watch_inbox, mock_get_reports
|
|
):
|
|
"""watch_inbox's callback must be the same save-then-report adapter
|
|
the single-shot fetch gets, not process_reports directly -- watch
|
|
mode would otherwise still archive before confirming the save. The
|
|
configured max_unsaved_retries has to reach both call sites too, or
|
|
one of the two paths would silently use the default cap."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
mock_watch_inbox.side_effect = FileExistsError("stop-watch-loop")
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = user
|
|
password = pass
|
|
|
|
[mailbox]
|
|
watch = true
|
|
max_unsaved_retries = 7
|
|
"""
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, 1)
|
|
single_shot_kwargs = mock_get_reports.call_args.kwargs
|
|
watch_kwargs = mock_watch_inbox.call_args.kwargs
|
|
self.assertTrue(callable(single_shot_kwargs.get("save_callback")))
|
|
self.assertIs(watch_kwargs.get("callback"), single_shot_kwargs["save_callback"])
|
|
self.assertEqual(single_shot_kwargs.get("max_unsaved_retries"), 7)
|
|
self.assertEqual(watch_kwargs.get("max_unsaved_retries"), 7)
|
|
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testFileOutputFailureIsRecordedAndReportsTheBatchUnsaved(
|
|
self, mock_imap_connection, mock_get_reports
|
|
):
|
|
"""A failed --output write is recorded like any other destination's
|
|
failure instead of escaping uncaught, so it blocks archiving too.
|
|
Pointing `output` at a regular file makes save_output() raise the
|
|
real ValueError it raises for a non-directory path."""
|
|
mock_imap_connection.return_value = object()
|
|
with NamedTemporaryFile("w", suffix=".txt", delete=False) as not_a_directory:
|
|
output_path = not_a_directory.name
|
|
self.addCleanup(lambda: os.path.exists(output_path) and os.remove(output_path))
|
|
|
|
reports_dict = {
|
|
"aggregate_reports": [{"policy_published": {"domain": "example.com"}}],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
verdicts = []
|
|
|
|
def _fetch_and_save(**kwargs):
|
|
verdicts.append(kwargs["save_callback"](reports_dict))
|
|
return reports_dict
|
|
|
|
mock_get_reports.side_effect = _fetch_and_save
|
|
config_path = self._write_config(
|
|
f"""[general]
|
|
silent = true
|
|
output = {output_path}
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
"""
|
|
)
|
|
|
|
with patch.object(sys, "argv", ["parsedmarc", "-c", config_path]):
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(verdicts, [False])
|
|
self.assertTrue(
|
|
any("File output Error" in line for line in cm.output), cm.output
|
|
)
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testMailboxAndFileReportsAreEachSavedExactlyOnce(
|
|
self,
|
|
mock_imap_connection,
|
|
mock_get_reports,
|
|
_mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
mock_save_aggregate,
|
|
):
|
|
"""A run combining a file argument and a mailbox saves in two passes:
|
|
the mailbox batch inside save_callback, the file-derived reports
|
|
afterward. The final pass must run on the file snapshot alone --
|
|
running it on the combined results would send every mailbox report
|
|
to every destination a second time."""
|
|
mock_imap_connection.return_value = object()
|
|
mailbox_report = {"policy_published": {"domain": "mailbox.example.com"}}
|
|
mock_get_reports.side_effect = _fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [mailbox_report],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
)
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
offline = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
"""
|
|
)
|
|
|
|
argv = ["parsedmarc", "-c", config_path, SAMPLE_AGGREGATE_REPORT_PATH]
|
|
with patch.object(sys, "argv", argv):
|
|
parsedmarc.cli._main()
|
|
|
|
saved_domains = [
|
|
call.args[0]["policy_published"]["domain"]
|
|
for call in mock_save_aggregate.call_args_list
|
|
]
|
|
self.assertEqual(saved_domains.count("mailbox.example.com"), 1)
|
|
self.assertEqual(len(saved_domains), 2)
|
|
|
|
@patch("parsedmarc.cli.elastic.save_aggregate_report_to_elasticsearch")
|
|
@patch("parsedmarc.cli.elastic.migrate_indexes")
|
|
@patch("parsedmarc.cli.elastic.set_hosts")
|
|
def testFailOnOutputErrorExitsForFileDerivedReports(
|
|
self,
|
|
_mock_set_hosts,
|
|
_mock_migrate_indexes,
|
|
mock_save_aggregate,
|
|
):
|
|
"""fail_on_output_error still exits non-zero for reports that came
|
|
from a file argument rather than a mailbox: with no mailbox
|
|
connection there is no save_callback to raise inside, so the failure
|
|
surfaces from the final pass over the file snapshot instead."""
|
|
mock_save_aggregate.side_effect = parsedmarc.elastic.ElasticsearchError(
|
|
"simulated output failure"
|
|
)
|
|
config_path = self._write_config(
|
|
"""[general]
|
|
save_aggregate = true
|
|
fail_on_output_error = true
|
|
silent = true
|
|
offline = true
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
"""
|
|
)
|
|
|
|
argv = ["parsedmarc", "-c", config_path, SAMPLE_AGGREGATE_REPORT_PATH]
|
|
with patch.object(sys, "argv", argv):
|
|
with self.assertRaises(SystemExit) as system_exit:
|
|
parsedmarc.cli._main()
|
|
|
|
self.assertEqual(system_exit.exception.code, 1)
|
|
mock_save_aggregate.assert_called_once()
|
|
|
|
|
|
class TestConfigAliases(unittest.TestCase):
|
|
"""Tests for config key aliases (env var friendly short names)."""
|
|
|
|
def test_maildir_create_alias(self):
|
|
"""[maildir] create works as alias for maildir_create."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
|
|
env = {
|
|
"PARSEDMARC_MAILDIR_CREATE": "true",
|
|
"PARSEDMARC_MAILDIR_PATH": "/tmp/test",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertTrue(opts.maildir_create)
|
|
|
|
def test_maildir_path_alias(self):
|
|
"""[maildir] path works as alias for maildir_path."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
|
|
env = {"PARSEDMARC_MAILDIR_PATH": "/var/mail/dmarc"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.maildir_path, "/var/mail/dmarc")
|
|
|
|
def test_msgraph_url_alias(self):
|
|
"""[msgraph] url works as alias for graph_url."""
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
from argparse import Namespace
|
|
|
|
env = {
|
|
"PARSEDMARC_MSGRAPH_AUTH_METHOD": "ClientSecret",
|
|
"PARSEDMARC_MSGRAPH_CLIENT_ID": "test-id",
|
|
"PARSEDMARC_MSGRAPH_CLIENT_SECRET": "test-secret",
|
|
"PARSEDMARC_MSGRAPH_TENANT_ID": "test-tenant",
|
|
"PARSEDMARC_MSGRAPH_MAILBOX": "test@example.com",
|
|
"PARSEDMARC_MSGRAPH_URL": "https://custom.graph.example.com",
|
|
}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.graph_url, "https://custom.graph.example.com")
|
|
|
|
def test_original_keys_still_work(self):
|
|
"""Original INI key names (maildir_create, maildir_path) still work."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
config = ConfigParser(interpolation=None)
|
|
config.add_section("maildir")
|
|
config.set("maildir", "maildir_path", "/original/path")
|
|
config.set("maildir", "maildir_create", "true")
|
|
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.maildir_path, "/original/path")
|
|
self.assertTrue(opts.maildir_create)
|
|
|
|
def test_ipinfo_url_option(self):
|
|
"""[general] ipinfo_url lands on opts.ipinfo_url."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
config = ConfigParser(interpolation=None)
|
|
config.add_section("general")
|
|
config.set("general", "ipinfo_url", "https://mirror.example/mmdb")
|
|
|
|
opts = Namespace()
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.ipinfo_url, "https://mirror.example/mmdb")
|
|
|
|
def test_ip_db_url_deprecated_alias(self):
|
|
"""[general] ip_db_url is accepted as an alias for ipinfo_url but
|
|
emits a deprecation warning."""
|
|
from argparse import Namespace
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
config = ConfigParser(interpolation=None)
|
|
config.add_section("general")
|
|
config.set("general", "ip_db_url", "https://old.example/mmdb")
|
|
|
|
opts = Namespace()
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.ipinfo_url, "https://old.example/mmdb")
|
|
self.assertTrue(
|
|
any("ip_db_url" in line and "deprecated" in line for line in cm.output),
|
|
f"expected deprecation warning, got: {cm.output}",
|
|
)
|
|
|
|
|
|
class TestExpandPath(unittest.TestCase):
|
|
"""Tests for _expand_path config path expansion."""
|
|
|
|
def test_expand_tilde(self):
|
|
from parsedmarc.cli import _expand_path
|
|
|
|
result = _expand_path("~/some/path")
|
|
self.assertFalse(result.startswith("~"))
|
|
self.assertTrue(result.endswith("/some/path"))
|
|
|
|
def test_expand_env_var(self):
|
|
from parsedmarc.cli import _expand_path
|
|
|
|
with patch.dict(os.environ, {"PARSEDMARC_TEST_DIR": "/opt/data"}):
|
|
result = _expand_path("$PARSEDMARC_TEST_DIR/tokens/.token")
|
|
self.assertEqual(result, "/opt/data/tokens/.token")
|
|
|
|
def test_expand_both(self):
|
|
from parsedmarc.cli import _expand_path
|
|
|
|
with patch.dict(os.environ, {"MY_APP": "parsedmarc"}):
|
|
result = _expand_path("~/$MY_APP/config")
|
|
self.assertNotIn("~", result)
|
|
self.assertIn("parsedmarc/config", result)
|
|
|
|
def test_no_expansion_needed(self):
|
|
from parsedmarc.cli import _expand_path
|
|
|
|
self.assertEqual(_expand_path("/absolute/path"), "/absolute/path")
|
|
self.assertEqual(_expand_path("relative/path"), "relative/path")
|
|
|
|
|
|
class TestArchiveSubdirForResult(unittest.TestCase):
|
|
"""Unit tests for _archive_subdir_for_result (issue #570): maps a
|
|
parsed report to its ``<year>/<month>/<Aggregate|Failure|SMTP-TLS>``
|
|
archive subdirectory, using minimal synthetic report dicts rather
|
|
than full sample parses."""
|
|
|
|
def test_aggregate_report_january_zero_pads_month(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(
|
|
ParsedReport,
|
|
{
|
|
"report_type": "aggregate",
|
|
"report": {
|
|
"report_metadata": {"begin_date": "2023-01-05 00:00:00"},
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
_archive_subdir_for_result(result),
|
|
os.path.join("2023", "01", "Aggregate"),
|
|
)
|
|
|
|
def test_failure_report_january_zero_pads_month(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(
|
|
ParsedReport,
|
|
{
|
|
"report_type": "failure",
|
|
"report": {"arrival_date_utc": "2023-01-05 12:00:00"},
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
_archive_subdir_for_result(result),
|
|
os.path.join("2023", "01", "Failure"),
|
|
)
|
|
|
|
def test_smtp_tls_report_uses_hyphenated_folder_name(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(
|
|
ParsedReport,
|
|
{
|
|
"report_type": "smtp_tls",
|
|
"report": {"begin_date": "2016-04-01T00:00:00Z"},
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
_archive_subdir_for_result(result),
|
|
os.path.join("2016", "04", "SMTP-TLS"),
|
|
)
|
|
|
|
def test_missing_date_key_returns_none(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(
|
|
ParsedReport,
|
|
{
|
|
"report_type": "aggregate",
|
|
"report": {"report_metadata": {}},
|
|
},
|
|
)
|
|
self.assertIsNone(_archive_subdir_for_result(result))
|
|
|
|
def test_unparseable_date_returns_none(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(
|
|
ParsedReport,
|
|
{
|
|
"report_type": "failure",
|
|
"report": {"arrival_date_utc": "not-a-real-date"},
|
|
},
|
|
)
|
|
self.assertIsNone(_archive_subdir_for_result(result))
|
|
|
|
def test_unknown_report_type_returns_none(self):
|
|
from parsedmarc.cli import _archive_subdir_for_result
|
|
|
|
result = cast(ParsedReport, {"report_type": "unknown", "report": {}})
|
|
self.assertIsNone(_archive_subdir_for_result(result))
|
|
|
|
|
|
class TestExcludeArchivedPaths(unittest.TestCase):
|
|
"""Unit tests for _exclude_archived_paths (issue #570): filters out
|
|
paths already inside the archive directory so a re-run doesn't
|
|
re-parse and re-archive its own previous output."""
|
|
|
|
def test_files_inside_archive_dir_are_excluded(self):
|
|
from parsedmarc.cli import _exclude_archived_paths
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
nested_dir = os.path.join(archive_dir, "2024", "01", "Aggregate")
|
|
os.makedirs(nested_dir)
|
|
inside_path = os.path.join(nested_dir, "report.xml")
|
|
with open(inside_path, "w") as f:
|
|
f.write("x")
|
|
outside_path = os.path.join(tmp_dir, "report.xml")
|
|
with open(outside_path, "w") as f:
|
|
f.write("x")
|
|
|
|
result = _exclude_archived_paths([inside_path, outside_path], archive_dir)
|
|
|
|
self.assertEqual(result, [outside_path])
|
|
|
|
def test_relative_paths_are_resolved_before_comparison(self):
|
|
from parsedmarc.cli import _exclude_archived_paths
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
os.makedirs(archive_dir)
|
|
inside_path = os.path.join(archive_dir, "report.xml")
|
|
with open(inside_path, "w") as f:
|
|
f.write("x")
|
|
|
|
cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_dir)
|
|
result = _exclude_archived_paths(
|
|
[os.path.join("archive", "report.xml")], "archive"
|
|
)
|
|
finally:
|
|
os.chdir(cwd)
|
|
|
|
self.assertEqual(result, [])
|
|
|
|
def test_files_outside_archive_dir_are_kept(self):
|
|
from parsedmarc.cli import _exclude_archived_paths
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
os.makedirs(archive_dir)
|
|
other_path = os.path.join(tmp_dir, "elsewhere", "report.xml")
|
|
os.makedirs(os.path.dirname(other_path))
|
|
with open(other_path, "w") as f:
|
|
f.write("x")
|
|
|
|
result = _exclude_archived_paths([other_path], archive_dir)
|
|
|
|
self.assertEqual(result, [other_path])
|
|
|
|
def test_symlinked_archive_dir_still_excludes_real_path(self):
|
|
"""A symlink doesn't defeat exclusion: if archive_directory is
|
|
configured through one spelling (e.g. a ``/data`` symlink) while
|
|
an input path is discovered through the real mount-point spelling
|
|
(e.g. ``/mnt/...``), ``os.path.realpath`` resolves both to the
|
|
same canonical path so the file already inside the archive is
|
|
still recognized and excluded rather than re-archived forever."""
|
|
from parsedmarc.cli import _exclude_archived_paths
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
real_archive_dir = os.path.join(tmp_dir, "real_archive")
|
|
os.makedirs(real_archive_dir)
|
|
symlinked_archive_dir = os.path.join(tmp_dir, "archive_link")
|
|
os.symlink(real_archive_dir, symlinked_archive_dir)
|
|
|
|
real_file_path = os.path.join(real_archive_dir, "report.xml")
|
|
with open(real_file_path, "w") as f:
|
|
f.write("x")
|
|
|
|
# archive_directory is configured via the symlinked spelling,
|
|
# while the file is discovered via the real spelling.
|
|
result = _exclude_archived_paths([real_file_path], symlinked_archive_dir)
|
|
|
|
self.assertEqual(result, [])
|
|
|
|
def test_non_comparable_paths_are_kept(self):
|
|
"""Per the Python docs, ``os.path.commonpath`` raises
|
|
``ValueError`` when the paths "are on the different drives"
|
|
(Windows) or mix absolute and relative pathnames
|
|
(https://docs.python.org/3/library/os.path.html#os.path.commonpath).
|
|
Both inputs here are realpath()-resolved so the mix can't occur
|
|
on POSIX, leaving the different-drives case unreachable on Linux
|
|
CI — hence the simulated raise. A path that can't be compared
|
|
with the archive root can't be inside it, so it must be kept
|
|
for parsing, and the ValueError must not propagate."""
|
|
from parsedmarc.cli import _exclude_archived_paths
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
os.makedirs(archive_dir)
|
|
report_path = os.path.join(tmp_dir, "report.xml")
|
|
with open(report_path, "w") as f:
|
|
f.write("x")
|
|
|
|
with patch(
|
|
"parsedmarc.cli.os.path.commonpath",
|
|
side_effect=ValueError("Paths don't have the same drive"),
|
|
):
|
|
result = _exclude_archived_paths([report_path], archive_dir)
|
|
|
|
self.assertEqual(result, [report_path])
|
|
|
|
|
|
class TestMoveFileToArchive(unittest.TestCase):
|
|
"""Unit tests for _move_file_to_archive (issue #570): the collision
|
|
loop that appends a numeric suffix rather than overwriting an
|
|
existing destination file."""
|
|
|
|
def test_no_collision_keeps_original_basename(self):
|
|
from parsedmarc.cli import _move_file_to_archive
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_dir = os.path.join(tmp_dir, "src")
|
|
os.makedirs(src_dir)
|
|
src_path = os.path.join(src_dir, "report.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("content")
|
|
dest_dir = os.path.join(tmp_dir, "dest")
|
|
|
|
dest_path = _move_file_to_archive(src_path, dest_dir)
|
|
|
|
self.assertEqual(dest_path, os.path.join(dest_dir, "report.xml"))
|
|
|
|
def test_collision_appends_dash_one(self):
|
|
from parsedmarc.cli import _move_file_to_archive
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_dir = os.path.join(tmp_dir, "src")
|
|
os.makedirs(src_dir)
|
|
src_path = os.path.join(src_dir, "report.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("new content")
|
|
dest_dir = os.path.join(tmp_dir, "dest")
|
|
os.makedirs(dest_dir)
|
|
existing_path = os.path.join(dest_dir, "report.xml")
|
|
with open(existing_path, "w") as f:
|
|
f.write("original content")
|
|
|
|
dest_path = _move_file_to_archive(src_path, dest_dir)
|
|
|
|
self.assertEqual(dest_path, os.path.join(dest_dir, "report-1.xml"))
|
|
with open(existing_path) as f:
|
|
self.assertEqual(f.read(), "original content")
|
|
with open(dest_path) as f:
|
|
self.assertEqual(f.read(), "new content")
|
|
|
|
def test_two_collisions_append_dash_two(self):
|
|
from parsedmarc.cli import _move_file_to_archive
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_dir = os.path.join(tmp_dir, "src")
|
|
os.makedirs(src_dir)
|
|
src_path = os.path.join(src_dir, "report.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("newest content")
|
|
dest_dir = os.path.join(tmp_dir, "dest")
|
|
os.makedirs(dest_dir)
|
|
for name in ("report.xml", "report-1.xml"):
|
|
with open(os.path.join(dest_dir, name), "w") as f:
|
|
f.write(f"original {name}")
|
|
|
|
dest_path = _move_file_to_archive(src_path, dest_dir)
|
|
|
|
self.assertEqual(dest_path, os.path.join(dest_dir, "report-2.xml"))
|
|
with open(os.path.join(dest_dir, "report.xml")) as f:
|
|
self.assertEqual(f.read(), "original report.xml")
|
|
with open(os.path.join(dest_dir, "report-1.xml")) as f:
|
|
self.assertEqual(f.read(), "original report-1.xml")
|
|
|
|
def test_failed_move_reraises_even_when_placeholder_cleanup_fails(self):
|
|
"""When the move fails and removing the placeholder also fails,
|
|
the move failure still propagates: the OSError from the
|
|
best-effort cleanup must be swallowed, not allowed to mask the
|
|
actionable error. The move error is deliberately a non-OSError
|
|
type so the assertion proves which of the two exceptions
|
|
escaped; the placeholder is left behind, as expected when its
|
|
cleanup fails."""
|
|
from parsedmarc.cli import _move_file_to_archive
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_path = os.path.join(tmp_dir, "report.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("content")
|
|
dest_dir = os.path.join(tmp_dir, "dest")
|
|
|
|
with (
|
|
patch(
|
|
"parsedmarc.cli.shutil.move",
|
|
side_effect=RuntimeError("move failed"),
|
|
),
|
|
patch(
|
|
"parsedmarc.cli.os.remove",
|
|
side_effect=OSError("remove failed"),
|
|
),
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
_move_file_to_archive(src_path, dest_dir)
|
|
|
|
# The source file is untouched and the zero-byte placeholder
|
|
# survives its failed cleanup.
|
|
self.assertTrue(os.path.isfile(src_path))
|
|
placeholder = os.path.join(dest_dir, "report.xml")
|
|
self.assertTrue(os.path.isfile(placeholder))
|
|
self.assertEqual(os.path.getsize(placeholder), 0)
|
|
# A leftover placeholder must never be executable or
|
|
# group/other-accessible: it's created with mode 0o600, not
|
|
# os.open's 0o777 default. The requested mode is masked by
|
|
# the process umask, which only clears bits, so asserting on
|
|
# the owner-exec and group/other bits is umask-independent.
|
|
placeholder_mode = stat.S_IMODE(os.stat(placeholder).st_mode)
|
|
self.assertEqual(placeholder_mode & 0o177, 0)
|
|
|
|
|
|
class TestArchiveProcessedFile(unittest.TestCase):
|
|
"""Unit tests for _archive_processed_file (issue #570): the
|
|
orchestrator that routes a processed file to Invalid/ on a parse
|
|
failure or the dated subdirectory on success, and never lets a move
|
|
failure abort the run."""
|
|
|
|
def test_exception_result_routes_to_invalid(self):
|
|
from parsedmarc.cli import _archive_processed_file
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_path = os.path.join(tmp_dir, "garbage.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("not a report")
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
|
|
_archive_processed_file(
|
|
src_path, archive_dir, parsedmarc.ParserError("boom")
|
|
)
|
|
|
|
self.assertTrue(
|
|
os.path.isfile(os.path.join(archive_dir, "Invalid", "garbage.xml"))
|
|
)
|
|
|
|
def test_move_failure_is_logged_and_does_not_raise(self):
|
|
"""A filesystem error while moving a file into the archive is
|
|
logged, not raised: the file has already been definitively
|
|
classified (parsed or failed), so a move error must not abort
|
|
the run and lose that work."""
|
|
from parsedmarc.cli import _archive_processed_file
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_path = os.path.join(tmp_dir, "garbage.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("not a report")
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
|
|
with patch("shutil.move", side_effect=OSError("disk full")):
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as log_ctx:
|
|
_archive_processed_file(
|
|
src_path, archive_dir, parsedmarc.ParserError("boom")
|
|
)
|
|
|
|
self.assertTrue(any("Error moving" in msg for msg in log_ctx.output))
|
|
# The move never completed, so the source file is untouched.
|
|
self.assertTrue(os.path.isfile(src_path))
|
|
|
|
def test_unresolvable_subdir_leaves_file_in_place(self):
|
|
"""When _archive_subdir_for_result can't determine a destination
|
|
(unknown report type or unparseable date, already warned about by
|
|
that helper), the file is left where it is rather than guessed
|
|
at."""
|
|
from parsedmarc.cli import _archive_processed_file
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_path = os.path.join(tmp_dir, "report.json")
|
|
with open(src_path, "w") as f:
|
|
f.write("{}")
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
|
|
result = cast(ParsedReport, {"report_type": "unknown", "report": {}})
|
|
_archive_processed_file(src_path, archive_dir, result)
|
|
|
|
self.assertTrue(os.path.isfile(src_path))
|
|
|
|
def test_non_parser_error_leaves_file_in_place(self):
|
|
"""Only a ParserError (every parse-failure exception, including
|
|
InvalidSMTPTLSReport, subclasses it) routes to Invalid/. A
|
|
transient OSError — e.g. from _parse_report_file_job's broad
|
|
catch, not a report-parsing failure — leaves the file in place
|
|
so a later run can retry it, and creates no Invalid/ directory
|
|
at all, since renaming it would permanently sideline a file that
|
|
was never actually shown to be an invalid report."""
|
|
from parsedmarc.cli import _archive_processed_file
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
src_path = os.path.join(tmp_dir, "report.xml")
|
|
with open(src_path, "w") as f:
|
|
f.write("<xml></xml>")
|
|
archive_dir = os.path.join(tmp_dir, "archive")
|
|
|
|
_archive_processed_file(src_path, archive_dir, OSError("transient"))
|
|
|
|
self.assertTrue(os.path.isfile(src_path))
|
|
self.assertFalse(os.path.isdir(archive_dir))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_config: per-section INI → opts mapping
|
|
#
|
|
# Each section of the INI is consumed by a different branch of
|
|
# _parse_config. The tests below build a minimal config for one
|
|
# section at a time and verify every documented key lands on the right
|
|
# opts attribute. A rename, typo, or dropped backwards-compat alias
|
|
# would be caught here.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StrToListTests(unittest.TestCase):
|
|
def test_str_to_list_strips_leading_whitespace_per_element(self):
|
|
from parsedmarc.cli import _str_to_list
|
|
|
|
self.assertEqual(_str_to_list("a, b ,c"), ["a", "b ", "c"])
|
|
|
|
def test_str_to_list_single_value(self):
|
|
from parsedmarc.cli import _str_to_list
|
|
|
|
self.assertEqual(_str_to_list("solo"), ["solo"])
|
|
|
|
|
|
def _opts():
|
|
"""A fresh Namespace with no attributes — _parse_config sets fields
|
|
via attribute assignment on whatever it's given."""
|
|
from argparse import Namespace
|
|
|
|
return Namespace()
|
|
|
|
|
|
def _config_with(section: str, settings: dict) -> "ConfigParser":
|
|
"""Build a ConfigParser holding exactly one section."""
|
|
from configparser import ConfigParser
|
|
|
|
cp = ConfigParser()
|
|
cp.add_section(section)
|
|
for k, v in settings.items():
|
|
cp.set(section, k, str(v))
|
|
return cp
|
|
|
|
|
|
class TestParseConfigMailbox(unittest.TestCase):
|
|
"""The [mailbox] section, including the per-report-type delete options
|
|
(issue #256). An option that is absent from the INI must be left alone
|
|
entirely: _main defaults the four per-type options to None so the library
|
|
inherits the overall ``delete`` value, and writing False for an absent key
|
|
would silently disable that inheritance."""
|
|
|
|
PER_TYPE_DELETE_OPTS = (
|
|
"mailbox_delete_aggregate",
|
|
"mailbox_delete_failure",
|
|
"mailbox_delete_smtp_tls",
|
|
"mailbox_delete_invalid",
|
|
)
|
|
|
|
def test_mailbox_full_section(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"mailbox",
|
|
{
|
|
"reports_folder": "Reports",
|
|
"archive_folder": "Processed",
|
|
"watch": "true",
|
|
"delete": "true",
|
|
"delete_aggregate": "true",
|
|
"delete_failure": "false",
|
|
"delete_smtp_tls": "true",
|
|
"delete_invalid": "false",
|
|
"test": "false",
|
|
"batch_size": "25",
|
|
"check_timeout": "60",
|
|
"max_unsaved_retries": "5",
|
|
"since": "3d",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.mailbox_reports_folder, "Reports")
|
|
self.assertEqual(opts.mailbox_archive_folder, "Processed")
|
|
self.assertIs(opts.mailbox_watch, True)
|
|
self.assertIs(opts.mailbox_delete, True)
|
|
self.assertIs(opts.mailbox_delete_aggregate, True)
|
|
# Explicitly false, not None: this type opts out of delete = true.
|
|
self.assertIs(opts.mailbox_delete_failure, False)
|
|
self.assertIs(opts.mailbox_delete_smtp_tls, True)
|
|
self.assertIs(opts.mailbox_delete_invalid, False)
|
|
self.assertIs(opts.mailbox_test, False)
|
|
self.assertEqual(opts.mailbox_batch_size, 25)
|
|
self.assertEqual(opts.mailbox_check_timeout, 60)
|
|
self.assertEqual(opts.mailbox_max_unsaved_retries, 5)
|
|
self.assertEqual(opts.mailbox_since, "3d")
|
|
|
|
def test_mailbox_max_unsaved_retries_from_env_var(self):
|
|
"""PARSEDMARC_MAILBOX_MAX_UNSAVED_RETRIES resolves to
|
|
[mailbox] max_unsaved_retries and parses as an int -- including 0,
|
|
which is a meaningful value ("never retry"), not an absent one."""
|
|
from parsedmarc.cli import _load_config, _parse_config
|
|
|
|
env = {"PARSEDMARC_MAILBOX_MAX_UNSAVED_RETRIES": "0"}
|
|
with patch.dict(os.environ, env, clear=False):
|
|
config = _load_config(None)
|
|
opts = _opts()
|
|
_parse_config(config, opts)
|
|
self.assertEqual(opts.mailbox_max_unsaved_retries, 0)
|
|
|
|
def test_mailbox_absent_per_type_delete_keys_are_not_set(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("mailbox", {"delete": "true"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertIs(opts.mailbox_delete, True)
|
|
for option in self.PER_TYPE_DELETE_OPTS:
|
|
with self.subTest(option=option):
|
|
self.assertFalse(hasattr(opts, option))
|
|
|
|
|
|
class TestParseConfigGeneral(unittest.TestCase):
|
|
"""The [general] section sets dozens of flags. Hit a representative
|
|
subset: filenames, save-toggles, DNS settings, output dir."""
|
|
|
|
def test_general_filenames_and_output(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"silent": "false",
|
|
"output": "/tmp/dmarc-out",
|
|
"aggregate_json_filename": "agg.json",
|
|
"failure_json_filename": "fail.json",
|
|
"smtp_tls_json_filename": "tls.json",
|
|
"aggregate_csv_filename": "agg.csv",
|
|
"failure_csv_filename": "fail.csv",
|
|
"smtp_tls_csv_filename": "tls.csv",
|
|
"save_aggregate": "true",
|
|
"save_failure": "true",
|
|
"save_smtp_tls": "true",
|
|
"debug": "false",
|
|
"verbose": "false",
|
|
"warnings": "false",
|
|
"fail_on_output_error": "false",
|
|
"offline": "true",
|
|
"strip_attachment_payloads": "true",
|
|
"n_procs": "4",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.output, "/tmp/dmarc-out")
|
|
self.assertEqual(opts.aggregate_json_filename, "agg.json")
|
|
self.assertEqual(opts.failure_json_filename, "fail.json")
|
|
self.assertEqual(opts.smtp_tls_csv_filename, "tls.csv")
|
|
self.assertTrue(opts.save_aggregate)
|
|
self.assertTrue(opts.save_failure)
|
|
self.assertTrue(opts.save_smtp_tls)
|
|
self.assertTrue(opts.offline)
|
|
self.assertTrue(opts.strip_attachment_payloads)
|
|
self.assertEqual(opts.n_procs, 4)
|
|
self.assertFalse(opts.silent)
|
|
self.assertFalse(opts.debug)
|
|
|
|
def test_general_save_forensic_alias_sets_save_failure(self):
|
|
"""Backwards compat: save_forensic in INI sets opts.save_failure."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("general", {"save_forensic": "true"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertTrue(opts.save_failure)
|
|
|
|
def test_general_forensic_filename_aliases_set_failure(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"forensic_json_filename": "fa.json",
|
|
"forensic_csv_filename": "fa.csv",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.failure_json_filename, "fa.json")
|
|
self.assertEqual(opts.failure_csv_filename, "fa.csv")
|
|
|
|
def _index_prefix_domain_map_config(self, yaml_text):
|
|
with NamedTemporaryFile("w", suffix=".yaml", delete=False) as map_file:
|
|
map_file.write(yaml_text)
|
|
map_path = map_file.name
|
|
self.addCleanup(lambda: os.path.exists(map_path) and os.remove(map_path))
|
|
return _config_with("general", {"index_prefix_domain_map": map_path})
|
|
|
|
def test_index_prefix_domain_map_accepts_a_mapping_of_lists(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config(
|
|
"tenant_a:\n - example.com\n - example.net\n"
|
|
)
|
|
self.assertEqual(
|
|
_parse_config(cp, _opts()),
|
|
{"tenant_a": ["example.com", "example.net"]},
|
|
)
|
|
|
|
def test_index_prefix_domain_map_empty_file_is_unset(self):
|
|
"""An empty file loads as None, which keeps multi-tenant prefixing
|
|
switched off rather than tripping the shape check."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config("")
|
|
self.assertIsNone(_parse_config(cp, _opts()))
|
|
|
|
def test_index_prefix_domain_map_rejects_a_non_mapping(self):
|
|
"""A list-shaped file has no tenant names, so the save path cannot
|
|
derive index prefixes from it and the migration path would resolve
|
|
garbage index names from its items."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config("- example.com\n- example.net\n")
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn("index_prefix_domain_map", str(ctx.exception))
|
|
|
|
def test_index_prefix_domain_map_rejects_a_scalar_domain_value(self):
|
|
"""The save path tests `domain in <value>`, and `in` on a str is a
|
|
substring test, not equality
|
|
(https://docs.python.org/3/reference/expressions.html
|
|
#membership-test-operations) -- so a scalar value silently claims
|
|
every domain that contains it, e.g. "example.co" matching
|
|
"example.com"."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config("tenant_a: example.co\n")
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn("list of domain names", str(ctx.exception))
|
|
|
|
def test_index_prefix_domain_map_rejects_a_non_string_domain(self):
|
|
"""A non-string list item never compares equal to the base domain
|
|
the save path looks up, so `tenant_a: [42]` would match nothing at
|
|
all -- the same silent-misbehavior class as a scalar value, and the
|
|
reason the check covers the list's items and not just its type."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config("tenant_a:\n - 42\n")
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn("all strings", str(ctx.exception))
|
|
|
|
def test_index_prefix_domain_map_rejects_a_non_string_key(self):
|
|
"""A non-string tenant name cannot be normalized into an index
|
|
prefix; it used to raise AttributeError mid-save instead."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = self._index_prefix_domain_map_config("42:\n - example.com\n")
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_general_dns_settings_with_defaults(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
# dns_timeout/dns_retries are typed via getfloat/getint which
|
|
# return non-None values for any valid input.
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_timeout": "5.0",
|
|
"dns_retries": "2",
|
|
"dns_test_address": "1.1.1.1",
|
|
"nameservers": "1.1.1.1, 8.8.8.8",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.dns_timeout, 5.0)
|
|
self.assertEqual(opts.dns_retries, 2)
|
|
self.assertEqual(opts.nameservers, ["1.1.1.1", "8.8.8.8"])
|
|
|
|
def test_general_nameservers_accept_doh_and_dot_entries(self):
|
|
"""A nameservers list may mix DNS over HTTPS URLs and DNS over TLS
|
|
entries with plain IP addresses (issue #880); _parse_config splits
|
|
and strips them like any other list value, and hands them to the DNS
|
|
pre-flight check (dns.query.https is mocked so no network is
|
|
needed)."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "5.0",
|
|
"nameservers": (
|
|
"https://cloudflare-dns.com/dns-query, tls://9.9.9.9#dns.quad9.net"
|
|
),
|
|
},
|
|
)
|
|
opts = _opts()
|
|
queried: list[tuple[str, str]] = []
|
|
|
|
def responder(request, where, *args, **kwargs):
|
|
queried.append((where, request.question[0].to_text()))
|
|
response = dns.message.make_response(request)
|
|
rrset = response.find_rrset(
|
|
response.answer,
|
|
request.question[0].name,
|
|
dns.rdataclass.IN,
|
|
dns.rdatatype.PTR,
|
|
create=True,
|
|
)
|
|
rrset.add(dns.rdata.from_text("IN", "PTR", "one.one.one.one."), 300)
|
|
return response
|
|
|
|
with patch("dns.query.https", side_effect=responder):
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(
|
|
opts.nameservers,
|
|
["https://cloudflare-dns.com/dns-query", "tls://9.9.9.9#dns.quad9.net"],
|
|
)
|
|
# The first (DoH) nameserver answered the pre-flight PTR query for
|
|
# dns_test_address, so the DoT one was never needed
|
|
self.assertEqual(
|
|
queried,
|
|
[
|
|
(
|
|
"https://cloudflare-dns.com/dns-query",
|
|
"1.1.1.1.in-addr.arpa. IN PTR",
|
|
)
|
|
],
|
|
)
|
|
|
|
def test_general_nameservers_pre_flight_reports_the_resolver_error(self):
|
|
"""A nameserver that fails every query is reported with the
|
|
resolver's own error, not as a missing PTR record. Issue #935: under
|
|
dnspython 2.9 every DoH query raised "session parameter must be an
|
|
httpx2.Client", and the pre-flight check said "no PTR record for
|
|
1.1.1.1", because get_reverse_dns() turns every DNS error into
|
|
None."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "0.2",
|
|
"nameservers": "https://dns.example/dns-query",
|
|
},
|
|
)
|
|
error = ValueError("session parameter must be an httpx2.Client")
|
|
with patch("dns.query.https", side_effect=error):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
message = str(ctx.exception)
|
|
self.assertIn("DNS pre-flight check failed", message)
|
|
self.assertIn("session parameter must be an httpx2.Client", message)
|
|
self.assertNotIn("no PTR record", message)
|
|
|
|
def test_general_nameservers_pre_flight_reports_a_missing_ptr_record(self):
|
|
"""When the nameserver answers but has no PTR record for
|
|
dns_test_address — NXDOMAIN, or NOERROR with an empty answer
|
|
section — the pre-flight error says so and names the nameservers."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
def nxdomain(request, *args, **kwargs):
|
|
response = dns.message.make_response(request)
|
|
response.set_rcode(dns.rcode.NXDOMAIN)
|
|
return response
|
|
|
|
def no_answer(request, *args, **kwargs):
|
|
return dns.message.make_response(request)
|
|
|
|
for name, responder in (("NXDOMAIN", nxdomain), ("NoAnswer", no_answer)):
|
|
with self.subTest(name):
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "2.0",
|
|
"nameservers": "https://dns.example/dns-query",
|
|
},
|
|
)
|
|
with patch("dns.query.https", side_effect=responder):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn(
|
|
"DNS pre-flight check failed: no PTR record for 1.1.1.1 "
|
|
"from ['https://dns.example/dns-query']",
|
|
str(ctx.exception),
|
|
)
|
|
|
|
def test_general_nameservers_pre_flight_errors_mask_url_credentials(self):
|
|
"""GHSA-hjr3-rx4c-24cv: the pre-flight error is logged at the default
|
|
level, so neither the "no PTR record ... from <nameservers>" list nor
|
|
a transport error that quotes the DoH request URL may carry the
|
|
nameserver URL's password, and the chained exception (printed on a
|
|
crash) must not repeat it."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
url = "https://doh:S3CRETPW@dns.example/dns-query"
|
|
|
|
def nxdomain(request, *args, **kwargs):
|
|
response = dns.message.make_response(request)
|
|
response.set_rcode(dns.rcode.NXDOMAIN)
|
|
return response
|
|
|
|
def transport_error(request, *args, **kwargs):
|
|
raise RuntimeError(f"Server error '502' for url '{url}'")
|
|
|
|
for name, responder, expected in (
|
|
("NXDOMAIN", nxdomain, "https://doh:***@dns.example/dns-query"),
|
|
("transport", transport_error, "https://doh:***@dns.example/dns-query"),
|
|
):
|
|
with self.subTest(name):
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "2.0",
|
|
"nameservers": url,
|
|
},
|
|
)
|
|
with patch("dns.query.https", side_effect=responder):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
trace = "".join(traceback.format_exception(ctx.exception))
|
|
self.assertNotIn("S3CRETPW", str(ctx.exception))
|
|
self.assertNotIn("S3CRETPW", trace)
|
|
self.assertIn(expected, str(ctx.exception))
|
|
|
|
def test_general_nameservers_pre_flight_masks_malformed_url_passwords(self):
|
|
"""GHSA-hjr3-rx4c-24cv: a password containing an unencoded ``/``,
|
|
``?`` or ``#`` ends the URL authority early (RFC 3986 section 3.2),
|
|
so ``urlsplit()`` reports no password and reads its start as the
|
|
port. dnspython 2.9.1 then raised ``ValueError("Port could not be
|
|
cast to integer value as 'p4ss'")`` from
|
|
``DoHNameserver.answer_port()`` (``dns/nameserver.py``), and
|
|
``_parse_dot_nameserver()`` quoted the same urlsplit message for a
|
|
``tls://`` entry, so the pre-flight error printed the start of the
|
|
password. Neither the password nor that prefix may reach the
|
|
message or the printed traceback. No query is sent: every entry is
|
|
rejected before dnspython contacts a server, which the patched
|
|
transports would turn into a different error."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
for url, expected in (
|
|
(
|
|
"https://doh:p4ss/w0rd@dns.example/dns-query",
|
|
"https://***@dns.example/dns-query",
|
|
),
|
|
(
|
|
"https://doh:p4ss?w0rd@dns.example/dns-query",
|
|
"https://***@dns.example/dns-query",
|
|
),
|
|
(
|
|
"https://doh:p4ss#w0rd@dns.example/dns-query",
|
|
"https://***@dns.example/dns-query",
|
|
),
|
|
("tls://dot:p4ss/w0rd@1.1.1.1", "tls://***@1.1.1.1"),
|
|
):
|
|
with self.subTest(url=url):
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "0.2",
|
|
"nameservers": url,
|
|
},
|
|
)
|
|
unreachable = OSError("no query may be sent")
|
|
with (
|
|
patch("dns.query.https", side_effect=unreachable),
|
|
patch("dns.query.tls", side_effect=unreachable),
|
|
):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
message = str(ctx.exception)
|
|
trace = "".join(traceback.format_exception(ctx.exception))
|
|
for secret in ("p4ss", "w0rd"):
|
|
self.assertNotIn(secret, message)
|
|
self.assertNotIn(secret, trace)
|
|
self.assertIn(expected, message)
|
|
self.assertIn("DNS pre-flight check failed: ValueError: ", message)
|
|
self.assertNotIn("no query may be sent", message)
|
|
|
|
def test_general_nameservers_pre_flight_rejects_invalid_test_address(self):
|
|
"""An unparseable dns_test_address is reported by setting name and
|
|
value (dns.reversename.from_address raises dns.exception.SyntaxError,
|
|
"Text input is malformed.", for non-IP text), and no DNS query is
|
|
attempted."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
for address in ("not-an-ip", "1.1.1"):
|
|
with self.subTest(address):
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": address,
|
|
"dns_timeout": "2.0",
|
|
"nameservers": "https://dns.example/dns-query",
|
|
},
|
|
)
|
|
with (
|
|
patch("dns.query.https") as https,
|
|
patch("dns.query.udp") as udp,
|
|
):
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertEqual(
|
|
f"DNS pre-flight check failed: dns_test_address {address!r} "
|
|
"is not a valid IP address",
|
|
str(ctx.exception),
|
|
)
|
|
https.assert_not_called()
|
|
udp.assert_not_called()
|
|
|
|
def test_general_nameservers_malformed_dot_entry_fails_pre_flight(self):
|
|
"""A malformed DNS over TLS nameservers entry raises a
|
|
ConfigurationError at startup, before any mailbox work begins. No
|
|
mocking is needed: the entry mapper rejects tls://not-an-ip with a
|
|
ValueError before any query is sent (the pre-flight path passes no
|
|
cache, so nothing can short-circuit it), and _parse_config wraps
|
|
any pre-flight failure in a ConfigurationError."""
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"general",
|
|
{
|
|
"dns_test_address": "1.1.1.1",
|
|
"dns_timeout": "2.0",
|
|
"nameservers": "tls://not-an-ip",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, opts)
|
|
self.assertIn("pre-flight", str(ctx.exception))
|
|
self.assertIn("tls://not-an-ip", str(ctx.exception))
|
|
|
|
def test_general_normalize_timespan_threshold(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("general", {"normalize_timespan_threshold_hours": "48"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.normalize_timespan_threshold_hours, 48.0)
|
|
|
|
def test_general_archive_directory_expands_env_var(self):
|
|
"""archive_directory goes through _expand_path, so a $VAR
|
|
reference in the INI value is expanded (issue #570)."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"general", {"archive_directory": "$SOME_ARCHIVE_VAR/reports-archive"}
|
|
)
|
|
opts = _opts()
|
|
with patch.dict(os.environ, {"SOME_ARCHIVE_VAR": "/opt/dmarc"}):
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.archive_directory, "/opt/dmarc/reports-archive")
|
|
|
|
def test_general_archive_directory_unset_leaves_attribute_absent(self):
|
|
"""When archive_directory is absent from the INI, _parse_config
|
|
never sets opts.archive_directory at all — asserted here as the
|
|
attribute staying absent from this test's bare Namespace. (In the
|
|
real CLI the attribute pre-exists with the Namespace default of
|
|
None, so _parse_config leaving it untouched is what keeps
|
|
archiving disabled.)"""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("general", {"silent": "false"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertFalse(hasattr(opts, "archive_directory"))
|
|
|
|
|
|
class TestParseConfigElasticsearch(unittest.TestCase):
|
|
def test_elasticsearch_basic(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"elasticsearch",
|
|
{
|
|
"hosts": "es1:9200, es2:9200",
|
|
"timeout": "30.0",
|
|
"number_of_shards": "3",
|
|
"number_of_replicas": "1",
|
|
"index_suffix": "tenant_a",
|
|
"index_prefix": "cust_",
|
|
"monthly_indexes": "true",
|
|
"ssl": "true",
|
|
"cert_path": "/etc/ca.pem",
|
|
"skip_certificate_verification": "true",
|
|
"user": "alice",
|
|
"password": "secret",
|
|
"api_key": "base64key",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.elasticsearch_hosts, ["es1:9200", "es2:9200"])
|
|
self.assertEqual(opts.elasticsearch_timeout, 30.0)
|
|
self.assertEqual(opts.elasticsearch_number_of_shards, 3)
|
|
self.assertEqual(opts.elasticsearch_number_of_replicas, 1)
|
|
self.assertEqual(opts.elasticsearch_index_suffix, "tenant_a")
|
|
self.assertEqual(opts.elasticsearch_index_prefix, "cust_")
|
|
self.assertTrue(opts.elasticsearch_monthly_indexes)
|
|
self.assertTrue(opts.elasticsearch_ssl)
|
|
self.assertEqual(opts.elasticsearch_ssl_cert_path, "/etc/ca.pem")
|
|
self.assertTrue(opts.elasticsearch_skip_certificate_verification)
|
|
self.assertEqual(opts.elasticsearch_username, "alice")
|
|
self.assertEqual(opts.elasticsearch_password, "secret")
|
|
self.assertEqual(opts.elasticsearch_api_key, "base64key")
|
|
|
|
def test_elasticsearch_number_of_replicas_without_shards(self):
|
|
"""Regression test: a [elasticsearch] section that sets only
|
|
number_of_replicas (no number_of_shards) must still set
|
|
opts.elasticsearch_number_of_replicas.
|
|
|
|
Before this fix, the number_of_replicas check was nested inside the
|
|
number_of_shards ``if`` block, so a replicas-only config was
|
|
silently ignored -- docs/source/usage.md lists number_of_shards and
|
|
number_of_replicas as independent options with no stated
|
|
dependency, and elastic.py's save functions accept them as
|
|
independent parameters with independent defaults, shards=1 and
|
|
replicas=0, but the accidental nesting made replicas silently
|
|
depend on shards also being set.
|
|
"""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"elasticsearch",
|
|
{"hosts": "es:9200", "number_of_replicas": "2"},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.elasticsearch_number_of_replicas, 2)
|
|
self.assertFalse(hasattr(opts, "elasticsearch_number_of_shards"))
|
|
|
|
def test_elasticsearch_apikey_camelcase_alias_pre_8_20(self):
|
|
"""`apiKey` (camelCase) is the legacy 8.20-and-earlier name."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("elasticsearch", {"hosts": "es:9200", "apiKey": "legacy"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.elasticsearch_api_key, "legacy")
|
|
|
|
def test_elasticsearch_missing_hosts_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("elasticsearch", {"timeout": "30"})
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn("hosts", str(ctx.exception))
|
|
|
|
def test_elasticsearch_serverless_flag(self):
|
|
"""``[elasticsearch] serverless = true`` flips ``opts.elasticsearch_serverless``."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("elasticsearch", {"hosts": "es:9200", "serverless": "true"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertIs(opts.elasticsearch_serverless, True)
|
|
|
|
def test_elasticsearch_serverless_passed_to_set_hosts(self):
|
|
"""End-to-end: a Serverless config reaches ``elastic.set_hosts(serverless=True)``.
|
|
|
|
Regression guard: catches anyone who later parses the flag but forgets
|
|
to plumb it through to ``set_hosts`` (or vice-versa).
|
|
"""
|
|
config = """[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
serverless = true
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with (
|
|
patch("parsedmarc.cli.elastic.migrate_indexes"),
|
|
patch("parsedmarc.cli.elastic.set_hosts") as mock_set_hosts,
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
return_value={
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
},
|
|
),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
mock_set_hosts.assert_called_once()
|
|
self.assertIs(mock_set_hosts.call_args.kwargs.get("serverless"), True)
|
|
|
|
|
|
class TestParseConfigOpenSearch(unittest.TestCase):
|
|
def test_opensearch_basic(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"opensearch",
|
|
{
|
|
"hosts": "os1:9200",
|
|
"timeout": "45.0",
|
|
"number_of_shards": "2",
|
|
"number_of_replicas": "0",
|
|
"index_suffix": "x",
|
|
"index_prefix": "y_",
|
|
"monthly_indexes": "true",
|
|
"ssl": "true",
|
|
"cert_path": "/etc/ca.pem",
|
|
"skip_certificate_verification": "true",
|
|
"user": "u",
|
|
"password": "p",
|
|
"api_key": "k",
|
|
"auth_type": "BASIC",
|
|
"aws_region": "us-east-1",
|
|
"aws_service": "es",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.opensearch_hosts, ["os1:9200"])
|
|
self.assertEqual(opts.opensearch_timeout, 45.0)
|
|
self.assertEqual(opts.opensearch_number_of_shards, 2)
|
|
self.assertEqual(opts.opensearch_number_of_replicas, 0)
|
|
self.assertEqual(opts.opensearch_index_suffix, "x")
|
|
self.assertEqual(opts.opensearch_index_prefix, "y_")
|
|
self.assertTrue(opts.opensearch_monthly_indexes)
|
|
self.assertTrue(opts.opensearch_ssl)
|
|
self.assertEqual(opts.opensearch_ssl_cert_path, "/etc/ca.pem")
|
|
self.assertTrue(opts.opensearch_skip_certificate_verification)
|
|
self.assertEqual(opts.opensearch_username, "u")
|
|
self.assertEqual(opts.opensearch_password, "p")
|
|
self.assertEqual(opts.opensearch_api_key, "k")
|
|
# auth_type is lowercased/stripped.
|
|
self.assertEqual(opts.opensearch_auth_type, "basic")
|
|
self.assertEqual(opts.opensearch_aws_region, "us-east-1")
|
|
self.assertEqual(opts.opensearch_aws_service, "es")
|
|
|
|
def test_opensearch_number_of_replicas_without_shards(self):
|
|
"""Regression test: a [opensearch] section that sets only
|
|
number_of_replicas (no number_of_shards) must still set
|
|
opts.opensearch_number_of_replicas.
|
|
|
|
Before this fix, the number_of_replicas check was nested inside the
|
|
number_of_shards ``if`` block, so a replicas-only config was
|
|
silently ignored -- docs/source/usage.md lists number_of_shards and
|
|
number_of_replicas as independent options with no stated
|
|
dependency, and opensearch.py's save functions accept them as
|
|
independent parameters with independent defaults, shards=1 and
|
|
replicas=0, but the accidental nesting made replicas silently
|
|
depend on shards also being set.
|
|
"""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"opensearch",
|
|
{"hosts": "os:9200", "number_of_replicas": "3"},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.opensearch_number_of_replicas, 3)
|
|
self.assertFalse(hasattr(opts, "opensearch_number_of_shards"))
|
|
|
|
def test_opensearch_authentication_type_legacy_alias(self):
|
|
"""`authentication_type` is the legacy spelling of `auth_type`."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"opensearch",
|
|
{"hosts": "os:9200", "authentication_type": "AWSSigV4"},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.opensearch_auth_type, "awssigv4")
|
|
|
|
def test_opensearch_apikey_camelcase_alias(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("opensearch", {"hosts": "os:9200", "apiKey": "legacy"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.opensearch_api_key, "legacy")
|
|
|
|
def test_opensearch_missing_hosts_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("opensearch", {"timeout": "30"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigUseDataStreams(unittest.TestCase):
|
|
"""``use_data_streams`` in [elasticsearch] and [opensearch] (issue
|
|
#842), and the warning that ``monthly_indexes`` is ignored with it."""
|
|
|
|
SECTIONS = ("elasticsearch", "opensearch")
|
|
|
|
def test_option_parsed_for_each_section(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
for section in self.SECTIONS:
|
|
for raw, expected in (("true", True), ("false", False)):
|
|
with self.subTest(section=section, value=raw):
|
|
cp = _config_with(
|
|
section, {"hosts": "h:9200", "use_data_streams": raw}
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertIs(
|
|
getattr(opts, f"{section}_use_data_streams"), expected
|
|
)
|
|
|
|
def test_option_absent_leaves_attribute_unset(self):
|
|
"""The default comes from the CLI's Namespace defaults, which the
|
|
real CLI builds before _parse_config runs."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
for section in self.SECTIONS:
|
|
with self.subTest(section=section):
|
|
cp = _config_with(section, {"hosts": "h:9200"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertFalse(hasattr(opts, f"{section}_use_data_streams"))
|
|
|
|
def test_monthly_indexes_with_data_streams_warns(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
for section in self.SECTIONS:
|
|
with self.subTest(section=section):
|
|
cp = _config_with(
|
|
section,
|
|
{
|
|
"hosts": "h:9200",
|
|
"monthly_indexes": "true",
|
|
"use_data_streams": "true",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(len(cm.records), 1)
|
|
self.assertIn(f"[{section}] monthly_indexes", cm.output[0])
|
|
self.assertIn("use_data_streams", cm.output[0])
|
|
# Still parsed; the save functions ignore it in stream mode.
|
|
self.assertTrue(getattr(opts, f"{section}_monthly_indexes"))
|
|
|
|
def test_no_warning_unless_both_are_enabled(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
combinations = (
|
|
{"monthly_indexes": "true"},
|
|
{"use_data_streams": "true"},
|
|
{"monthly_indexes": "true", "use_data_streams": "false"},
|
|
{"monthly_indexes": "false", "use_data_streams": "true"},
|
|
)
|
|
for section in self.SECTIONS:
|
|
for settings in combinations:
|
|
with self.subTest(section=section, settings=settings):
|
|
cp = _config_with(section, {"hosts": "h:9200", **settings})
|
|
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestUseDataStreamsPassthrough(unittest.TestCase):
|
|
"""Each backend's parsed option reaches that backend's three save
|
|
calls, and only those: enabling it in one section leaves the other
|
|
backend's saves False, and it is False when absent from both."""
|
|
|
|
def _run(self, es_extra: str = "", os_extra: str = "") -> dict[str, MagicMock]:
|
|
names = (
|
|
"elastic.save_aggregate_report_to_elasticsearch",
|
|
"elastic.save_failure_report_to_elasticsearch",
|
|
"elastic.save_smtp_tls_report_to_elasticsearch",
|
|
"opensearch.save_aggregate_report_to_opensearch",
|
|
"opensearch.save_failure_report_to_opensearch",
|
|
"opensearch.save_smtp_tls_report_to_opensearch",
|
|
)
|
|
config = f"""[general]
|
|
save_aggregate = true
|
|
save_failure = true
|
|
save_smtp_tls = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[elasticsearch]
|
|
hosts = localhost
|
|
{es_extra}
|
|
|
|
[opensearch]
|
|
hosts = localhost
|
|
{os_extra}
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
saves = {name: MagicMock() for name in names}
|
|
patches = [patch(f"parsedmarc.cli.{name}", saves[name]) for name in names]
|
|
patches += [
|
|
patch("parsedmarc.cli.elastic.set_hosts"),
|
|
patch("parsedmarc.cli.elastic.migrate_indexes"),
|
|
patch("parsedmarc.cli.opensearch.set_hosts"),
|
|
patch("parsedmarc.cli.opensearch.migrate_indexes"),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
side_effect=_fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [
|
|
{"policy_published": {"domain": "example.com"}}
|
|
],
|
|
"failure_reports": [{"reported_domain": "example.com"}],
|
|
"smtp_tls_reports": [
|
|
{"policies": [{"policy_domain": "example.com"}]}
|
|
],
|
|
}
|
|
),
|
|
),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
self.addCleanup(p.stop)
|
|
parsedmarc.cli._main()
|
|
return saves
|
|
|
|
def _assert_flags(
|
|
self, saves: dict[str, MagicMock], elastic: bool, opensearch: bool
|
|
) -> None:
|
|
for name, save in saves.items():
|
|
expected = elastic if name.startswith("elastic.") else opensearch
|
|
with self.subTest(save=name):
|
|
save.assert_called_once()
|
|
self.assertIs(save.call_args.kwargs["use_data_streams"], expected)
|
|
|
|
def test_enabled_in_both_sections_reaches_every_save_call(self):
|
|
enabled = "use_data_streams = true"
|
|
saves = self._run(es_extra=enabled, os_extra=enabled)
|
|
self._assert_flags(saves, elastic=True, opensearch=True)
|
|
|
|
def test_elasticsearch_section_only_reaches_elasticsearch_saves(self):
|
|
saves = self._run(es_extra="use_data_streams = true")
|
|
self._assert_flags(saves, elastic=True, opensearch=False)
|
|
|
|
def test_opensearch_section_only_reaches_opensearch_saves(self):
|
|
saves = self._run(os_extra="use_data_streams = true")
|
|
self._assert_flags(saves, elastic=False, opensearch=True)
|
|
|
|
def test_absent_defaults_to_false(self):
|
|
saves = self._run()
|
|
self._assert_flags(saves, elastic=False, opensearch=False)
|
|
|
|
|
|
class TestParseConfigSplunkHec(unittest.TestCase):
|
|
def test_splunk_hec_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"splunk_hec",
|
|
{
|
|
"url": "https://splunk:8088",
|
|
"token": "abc-token",
|
|
"index": "dmarc",
|
|
"skip_certificate_verification": "true",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.hec, "https://splunk:8088")
|
|
self.assertEqual(opts.hec_token, "abc-token")
|
|
self.assertEqual(opts.hec_index, "dmarc")
|
|
self.assertTrue(opts.hec_skip_certificate_verification)
|
|
|
|
def test_splunk_hec_missing_url_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("splunk_hec", {"token": "t", "index": "i"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_splunk_hec_missing_token_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("splunk_hec", {"url": "https://splunk:8088", "index": "i"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_splunk_hec_missing_index_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("splunk_hec", {"url": "https://splunk:8088", "token": "t"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigKafka(unittest.TestCase):
|
|
def test_kafka_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{
|
|
"hosts": "kafka1:9092, kafka2:9092",
|
|
"user": "u",
|
|
"password": "p",
|
|
"ssl": "true",
|
|
"skip_certificate_verification": "true",
|
|
"aggregate_topic": "dmarc-aggregate",
|
|
"failure_topic": "dmarc-failure",
|
|
"smtp_tls_topic": "smtp-tls",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.kafka_hosts, ["kafka1:9092", "kafka2:9092"])
|
|
self.assertEqual(opts.kafka_username, "u")
|
|
self.assertEqual(opts.kafka_password, "p")
|
|
self.assertTrue(opts.kafka_ssl)
|
|
self.assertTrue(opts.kafka_skip_certificate_verification)
|
|
self.assertEqual(opts.kafka_aggregate_topic, "dmarc-aggregate")
|
|
self.assertEqual(opts.kafka_failure_topic, "dmarc-failure")
|
|
self.assertEqual(opts.kafka_smtp_tls_topic, "smtp-tls")
|
|
|
|
def test_kafka_forensic_topic_alias_sets_failure_topic(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{
|
|
"hosts": "k:9092",
|
|
"aggregate_topic": "agg",
|
|
"forensic_topic": "old-fail",
|
|
"smtp_tls_topic": "tls",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.kafka_failure_topic, "old-fail")
|
|
|
|
def test_kafka_missing_hosts_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{
|
|
"aggregate_topic": "a",
|
|
"failure_topic": "f",
|
|
"smtp_tls_topic": "t",
|
|
},
|
|
)
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_kafka_missing_aggregate_topic_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{"hosts": "k:9092", "failure_topic": "f", "smtp_tls_topic": "t"},
|
|
)
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_kafka_missing_failure_topic_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{"hosts": "k:9092", "aggregate_topic": "a", "smtp_tls_topic": "t"},
|
|
)
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_kafka_missing_smtp_tls_topic_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with(
|
|
"kafka",
|
|
{"hosts": "k:9092", "aggregate_topic": "a", "failure_topic": "f"},
|
|
)
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigSmtp(unittest.TestCase):
|
|
def test_smtp_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"smtp",
|
|
{
|
|
"host": "smtp.example.com",
|
|
"port": "587",
|
|
"ssl": "true",
|
|
"skip_certificate_verification": "true",
|
|
"user": "u",
|
|
"password": "p",
|
|
"from": "dmarc@example.com",
|
|
"to": "admin@example.com, alert@example.com",
|
|
"subject": "DMARC Report",
|
|
"attachment": "/tmp/dmarc.zip",
|
|
"message": "See attached",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.smtp_host, "smtp.example.com")
|
|
self.assertEqual(opts.smtp_port, 587)
|
|
self.assertTrue(opts.smtp_ssl)
|
|
self.assertTrue(opts.smtp_skip_certificate_verification)
|
|
self.assertEqual(opts.smtp_user, "u")
|
|
self.assertEqual(opts.smtp_password, "p")
|
|
self.assertEqual(opts.smtp_from, "dmarc@example.com")
|
|
self.assertEqual(opts.smtp_to, ["admin@example.com", "alert@example.com"])
|
|
self.assertEqual(opts.smtp_subject, "DMARC Report")
|
|
self.assertEqual(opts.smtp_attachment, "/tmp/dmarc.zip")
|
|
self.assertEqual(opts.smtp_message, "See attached")
|
|
|
|
def test_smtp_missing_host_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("smtp", {"user": "u", "password": "p"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_smtp_missing_user_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("smtp", {"host": "smtp.example.com", "password": "p"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_smtp_missing_password_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("smtp", {"host": "smtp.example.com", "user": "u"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_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": _sample_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": _sample_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 TestSkipsResultsEmailWhenNoReportsParsed(unittest.TestCase):
|
|
"""#200: a run that parses no reports at all (e.g. an empty inbox,
|
|
or a mailbox where every message failed to parse) must not send a
|
|
results email with headers-only, data-free CSVs. The email step is
|
|
now skipped, with an INFO log line, whenever aggregate_reports,
|
|
failure_reports, and smtp_tls_reports are all empty."""
|
|
|
|
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.send_email")
|
|
@patch("parsedmarc.cli.get_dmarc_reports_from_mailbox")
|
|
@patch("parsedmarc.cli.IMAPConnection")
|
|
def testSkipsResultsEmailWhenNothingWasParsed(
|
|
self, mock_imap_connection, mock_get_mailbox_reports, mock_send_email
|
|
):
|
|
"""Regression test for #200: with an empty inbox (no aggregate,
|
|
failure, or SMTP TLS reports), the SMTP results email must not
|
|
be sent, and an INFO log line should explain why it was
|
|
skipped."""
|
|
mock_imap_connection.return_value = object()
|
|
mock_get_mailbox_reports.return_value = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
config_text = """[general]
|
|
silent = true
|
|
verbose = 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)
|
|
|
|
with self.assertLogs("parsedmarc.log", level="INFO") as logs:
|
|
self._run_main(cfg_path)
|
|
|
|
mock_send_email.assert_not_called()
|
|
self.assertTrue(
|
|
any("skipping the results email" in line for line in logs.output)
|
|
)
|
|
|
|
@patch("parsedmarc.send_email")
|
|
def testSendsResultsEmailWithRealPayloadWhenReportsExist(self, mock_send_email):
|
|
"""When at least one report is parsed, the results email is
|
|
still sent, and the attached zip's aggregate.csv contains actual
|
|
data rows (not just a header row)."""
|
|
config_text = """[general]
|
|
silent = true
|
|
offline = true
|
|
|
|
[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)
|
|
|
|
with patch.object(
|
|
sys,
|
|
"argv",
|
|
["parsedmarc", "-c", cfg_path, SAMPLE_AGGREGATE_REPORT_PATH],
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
mock_send_email.assert_called_once()
|
|
call_kwargs = mock_send_email.call_args.kwargs
|
|
attachments = call_kwargs["attachments"]
|
|
filename, zip_bytes = attachments[0]
|
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
|
csv_text = zf.read("aggregate.csv").decode("utf-8")
|
|
|
|
self.assertGreater(len(csv_text.strip().splitlines()), 1)
|
|
|
|
|
|
class TestParseConfigS3(unittest.TestCase):
|
|
def test_s3_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"s3",
|
|
{
|
|
"bucket": "my-bucket",
|
|
"path": "/dmarc/",
|
|
"region_name": "us-east-1",
|
|
"endpoint_url": "https://s3.example.com",
|
|
"access_key_id": "AKIA-x",
|
|
"secret_access_key": "secret",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.s3_bucket, "my-bucket")
|
|
# Leading and trailing slashes are stripped.
|
|
self.assertEqual(opts.s3_path, "dmarc")
|
|
self.assertEqual(opts.s3_region_name, "us-east-1")
|
|
self.assertEqual(opts.s3_endpoint_url, "https://s3.example.com")
|
|
self.assertEqual(opts.s3_access_key_id, "AKIA-x")
|
|
self.assertEqual(opts.s3_secret_access_key, "secret")
|
|
|
|
def test_s3_default_path_is_empty(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("s3", {"bucket": "b"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.s3_path, "")
|
|
|
|
def test_s3_missing_bucket_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("s3", {"path": "x"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigPostgreSQL(unittest.TestCase):
|
|
def test_postgresql_individual_params(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"postgresql",
|
|
{
|
|
"host": "db.example.com",
|
|
"port": "6543",
|
|
"user": "pmarc",
|
|
"password": "secret",
|
|
"database": "dmarc",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.postgresql_host, "db.example.com")
|
|
self.assertEqual(opts.postgresql_port, 6543)
|
|
self.assertEqual(opts.postgresql_user, "pmarc")
|
|
self.assertEqual(opts.postgresql_password, "secret")
|
|
self.assertEqual(opts.postgresql_database, "dmarc")
|
|
|
|
def test_postgresql_connection_string_takes_precedence(self):
|
|
"""connection_string is read and host parsing is skipped."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"postgresql",
|
|
{
|
|
"connection_string": "postgresql://u:p@h/db",
|
|
"host": "ignored.example.com",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.postgresql_connection_string, "postgresql://u:p@h/db")
|
|
# The host branch is skipped entirely when a connection_string is set.
|
|
self.assertFalse(hasattr(opts, "postgresql_host"))
|
|
|
|
def test_postgresql_missing_host_and_dsn_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("postgresql", {"port": "5432"})
|
|
with self.assertRaises(ConfigurationError) as ctx:
|
|
_parse_config(cp, _opts())
|
|
self.assertIn("postgresql", str(ctx.exception))
|
|
|
|
|
|
class TestPostgreSQLCliWiring(unittest.TestCase):
|
|
"""End-to-end: a [postgresql] config reaches PostgreSQLClient + create_tables.
|
|
|
|
Regression guard so the config parse, the Namespace defaults, and the
|
|
_init_output_clients wiring can't drift apart.
|
|
"""
|
|
|
|
def setUp(self):
|
|
# These tests simulate "[postgresql] configured and the SDK
|
|
# available", mocking PostgreSQLClient at the SDK boundary. CI's
|
|
# unit-test job deliberately omits the postgresql extra, so the
|
|
# module-level psycopg handle must be patched present too --
|
|
# otherwise _init_output_clients' presence check correctly
|
|
# reports the missing extra and _main exits 1 before any of the
|
|
# wiring under test runs.
|
|
patcher = patch.object(parsedmarc.cli.postgres, "psycopg", MagicMock())
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def test_postgresql_config_constructs_client_and_creates_tables(self):
|
|
config = """[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[postgresql]
|
|
host = db.example.com
|
|
port = 6543
|
|
user = pmarc
|
|
password = secret
|
|
database = dmarc
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with (
|
|
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
return_value={
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
},
|
|
),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
mock_client_cls.assert_called_once()
|
|
kwargs = mock_client_cls.call_args.kwargs
|
|
self.assertEqual(kwargs.get("host"), "db.example.com")
|
|
self.assertEqual(kwargs.get("port"), 6543)
|
|
self.assertEqual(kwargs.get("user"), "pmarc")
|
|
self.assertEqual(kwargs.get("database"), "dmarc")
|
|
mock_client_cls.return_value.create_tables.assert_called_once()
|
|
|
|
def test_postgresql_aggregate_report_is_saved(self):
|
|
"""An aggregate report reaches the client's save method via the loop."""
|
|
config = """[general]
|
|
save_aggregate = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[postgresql]
|
|
host = db.example.com
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
report = {"policy_published": {"domain": "example.com"}, "records": []}
|
|
with (
|
|
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
side_effect=_fetch_invoking_save_callback(
|
|
{
|
|
"aggregate_reports": [report],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
),
|
|
),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
):
|
|
parsedmarc.cli._main()
|
|
|
|
pg_client = mock_client_cls.return_value
|
|
pg_client.save_aggregate_report_to_postgresql.assert_called_once_with(report)
|
|
|
|
def _run_main(self, reports, save_side_effect=None):
|
|
"""Run _main with all save flags on and PostgreSQLClient mocked.
|
|
|
|
Returns the mocked client instance for assertions. *save_side_effect*,
|
|
if given, is applied to every save_* method so error-handling branches
|
|
can be exercised.
|
|
"""
|
|
config = """[general]
|
|
save_aggregate = true
|
|
save_failure = true
|
|
save_smtp_tls = true
|
|
silent = true
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[postgresql]
|
|
host = db.example.com
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with (
|
|
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
side_effect=_fetch_invoking_save_callback(reports),
|
|
),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
):
|
|
client = mock_client_cls.return_value
|
|
if save_side_effect is not None:
|
|
for m in (
|
|
"save_aggregate_report_to_postgresql",
|
|
"save_failure_report_to_postgresql",
|
|
"save_smtp_tls_report_to_postgresql",
|
|
):
|
|
getattr(client, m).side_effect = save_side_effect
|
|
parsedmarc.cli._main()
|
|
return client
|
|
|
|
def test_postgresql_all_report_types_saved(self):
|
|
"""Failure and SMTP-TLS reports also reach their save methods."""
|
|
agg = {"policy_published": {"domain": "example.com"}, "records": []}
|
|
fail = {"reported_domain": "example.com", "parsed_sample": {}}
|
|
tls = {"organization_name": "Org", "policies": [{"policy_domain": "d"}]}
|
|
client = self._run_main(
|
|
{
|
|
"aggregate_reports": [agg],
|
|
"failure_reports": [fail],
|
|
"smtp_tls_reports": [tls],
|
|
}
|
|
)
|
|
client.save_aggregate_report_to_postgresql.assert_called_once_with(agg)
|
|
client.save_failure_report_to_postgresql.assert_called_once_with(fail)
|
|
client.save_smtp_tls_report_to_postgresql.assert_called_once_with(tls)
|
|
|
|
def test_postgresql_already_saved_is_warned_not_fatal(self):
|
|
"""AlreadySaved from any save is swallowed (logged), not propagated."""
|
|
from parsedmarc import postgres
|
|
|
|
agg = {"policy_published": {"domain": "example.com"}, "records": []}
|
|
fail = {"reported_domain": "example.com", "parsed_sample": {}}
|
|
tls = {"organization_name": "Org", "policies": []}
|
|
# Should not raise despite every save raising AlreadySaved.
|
|
self._run_main(
|
|
{
|
|
"aggregate_reports": [agg],
|
|
"failure_reports": [fail],
|
|
"smtp_tls_reports": [tls],
|
|
},
|
|
save_side_effect=postgres.AlreadySaved("dup"),
|
|
)
|
|
|
|
def test_postgresql_error_is_logged_not_fatal(self):
|
|
"""PostgreSQLError from any save is logged, not propagated."""
|
|
from parsedmarc import postgres
|
|
|
|
agg = {"policy_published": {"domain": "example.com"}, "records": []}
|
|
fail = {"reported_domain": "example.com", "parsed_sample": {}}
|
|
tls = {"organization_name": "Org", "policies": []}
|
|
self._run_main(
|
|
{
|
|
"aggregate_reports": [agg],
|
|
"failure_reports": [fail],
|
|
"smtp_tls_reports": [tls],
|
|
},
|
|
save_side_effect=postgres.PostgreSQLError("boom"),
|
|
)
|
|
|
|
|
|
class TestParseConfigSyslog(unittest.TestCase):
|
|
def test_syslog_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"syslog",
|
|
{
|
|
"server": "syslog.example.com",
|
|
"port": "6514",
|
|
"protocol": "tls",
|
|
"cafile_path": "/etc/ca.pem",
|
|
"certfile_path": "/etc/c.pem",
|
|
"keyfile_path": "/etc/k.pem",
|
|
"timeout": "10.0",
|
|
"retry_attempts": "5",
|
|
"retry_delay": "2",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.syslog_server, "syslog.example.com")
|
|
self.assertEqual(opts.syslog_port, "6514")
|
|
self.assertEqual(opts.syslog_protocol, "tls")
|
|
self.assertEqual(opts.syslog_cafile_path, "/etc/ca.pem")
|
|
self.assertEqual(opts.syslog_certfile_path, "/etc/c.pem")
|
|
self.assertEqual(opts.syslog_keyfile_path, "/etc/k.pem")
|
|
self.assertEqual(opts.syslog_timeout, 10.0)
|
|
self.assertEqual(opts.syslog_retry_attempts, 5)
|
|
self.assertEqual(opts.syslog_retry_delay, 2)
|
|
|
|
def test_syslog_defaults(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("syslog", {"server": "s"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.syslog_port, 514)
|
|
self.assertEqual(opts.syslog_protocol, "udp")
|
|
self.assertEqual(opts.syslog_timeout, 5.0)
|
|
self.assertEqual(opts.syslog_retry_attempts, 3)
|
|
self.assertEqual(opts.syslog_retry_delay, 5)
|
|
|
|
def test_syslog_missing_server_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("syslog", {"port": "514"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigGmailApi(unittest.TestCase):
|
|
def test_gmail_api_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"gmail_api",
|
|
{
|
|
"credentials_file": "/etc/gmail-creds.json",
|
|
"token_file": "/var/lib/parsedmarc/gmail.token",
|
|
"include_spam_trash": "true",
|
|
"paginate_messages": "false",
|
|
"scopes": "https://www.googleapis.com/auth/gmail.readonly",
|
|
"oauth2_port": "8888",
|
|
"auth_mode": "device_code",
|
|
"service_account_user": "user@example.com",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.gmail_api_credentials_file, "/etc/gmail-creds.json")
|
|
self.assertEqual(opts.gmail_api_token_file, "/var/lib/parsedmarc/gmail.token")
|
|
self.assertTrue(opts.gmail_api_include_spam_trash)
|
|
self.assertFalse(opts.gmail_api_paginate_messages)
|
|
self.assertEqual(
|
|
opts.gmail_api_scopes,
|
|
["https://www.googleapis.com/auth/gmail.readonly"],
|
|
)
|
|
self.assertEqual(opts.gmail_api_oauth2_port, 8888)
|
|
self.assertEqual(opts.gmail_api_auth_mode, "device_code")
|
|
self.assertEqual(opts.gmail_api_service_account_user, "user@example.com")
|
|
|
|
def test_gmail_api_delegated_user_alias(self):
|
|
"""`delegated_user` is the legacy spelling of `service_account_user`."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"gmail_api",
|
|
{
|
|
"credentials_file": "/c",
|
|
"delegated_user": "legacy@example.com",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.gmail_api_service_account_user, "legacy@example.com")
|
|
|
|
def test_gmail_api_default_scope(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("gmail_api", {"credentials_file": "/c"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(
|
|
opts.gmail_api_scopes,
|
|
["https://www.googleapis.com/auth/gmail.modify"],
|
|
)
|
|
|
|
|
|
class TestParseConfigLogAnalytics(unittest.TestCase):
|
|
def test_log_analytics_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"log_analytics",
|
|
{
|
|
"client_id": "cid",
|
|
"client_secret": "csec",
|
|
"tenant_id": "tid",
|
|
"dce": "https://dce.example.com",
|
|
"dcr_immutable_id": "dcr-1",
|
|
"dcr_aggregate_stream": "Custom-Aggregate_CL",
|
|
"dcr_failure_stream": "Custom-Failure_CL",
|
|
"dcr_smtp_tls_stream": "Custom-SMTPTLS_CL",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.la_client_id, "cid")
|
|
self.assertEqual(opts.la_client_secret, "csec")
|
|
self.assertEqual(opts.la_tenant_id, "tid")
|
|
self.assertEqual(opts.la_dce, "https://dce.example.com")
|
|
self.assertEqual(opts.la_dcr_immutable_id, "dcr-1")
|
|
self.assertEqual(opts.la_dcr_aggregate_stream, "Custom-Aggregate_CL")
|
|
self.assertEqual(opts.la_dcr_failure_stream, "Custom-Failure_CL")
|
|
self.assertEqual(opts.la_dcr_smtp_tls_stream, "Custom-SMTPTLS_CL")
|
|
|
|
def test_log_analytics_forensic_stream_alias(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"log_analytics",
|
|
{
|
|
"client_id": "c",
|
|
"dcr_forensic_stream": "Old-Forensic_CL",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.la_dcr_failure_stream, "Old-Forensic_CL")
|
|
|
|
|
|
class TestParseConfigGelf(unittest.TestCase):
|
|
def test_gelf_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"gelf", {"host": "graylog.example.com", "port": "12201", "mode": "tls"}
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.gelf_host, "graylog.example.com")
|
|
self.assertEqual(opts.gelf_port, "12201")
|
|
self.assertEqual(opts.gelf_mode, "tls")
|
|
|
|
def test_gelf_missing_host_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("gelf", {"port": "12201", "mode": "udp"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_gelf_missing_port_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("gelf", {"host": "g", "mode": "udp"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_gelf_missing_mode_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("gelf", {"host": "g", "port": "12201"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
|
|
class TestParseConfigWebhook(unittest.TestCase):
|
|
def test_webhook_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"webhook",
|
|
{
|
|
"aggregate_url": "https://hooks.example.com/agg",
|
|
"failure_url": "https://hooks.example.com/fail",
|
|
"smtp_tls_url": "https://hooks.example.com/tls",
|
|
"timeout": "30",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.webhook_aggregate_url, "https://hooks.example.com/agg")
|
|
self.assertEqual(opts.webhook_failure_url, "https://hooks.example.com/fail")
|
|
self.assertEqual(opts.webhook_smtp_tls_url, "https://hooks.example.com/tls")
|
|
self.assertEqual(opts.webhook_timeout, 30)
|
|
|
|
def test_webhook_forensic_url_alias_sets_failure_url(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("webhook", {"forensic_url": "https://old.example.com/fail"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.webhook_failure_url, "https://old.example.com/fail")
|
|
|
|
|
|
class TestConfigureLogging(unittest.TestCase):
|
|
"""cli._configure_logging is a thin wrapper around
|
|
parsedmarc.log.configure_logging (the parallel-parsing worker
|
|
initializer in parsedmarc/parallel.py calls configure_logging
|
|
directly) — if this wrapper stops attaching a handler, any remaining
|
|
caller's log output goes dark."""
|
|
|
|
def setUp(self):
|
|
from parsedmarc.log import logger as plog
|
|
|
|
self._saved_handlers = list(plog.handlers)
|
|
self._saved_level = plog.level
|
|
|
|
def tearDown(self):
|
|
from parsedmarc.log import logger as plog
|
|
|
|
plog.handlers[:] = self._saved_handlers
|
|
plog.setLevel(self._saved_level)
|
|
|
|
def test_sets_log_level(self):
|
|
import logging as _logging
|
|
from parsedmarc.cli import _configure_logging
|
|
from parsedmarc.log import logger as plog
|
|
|
|
_configure_logging(_logging.DEBUG)
|
|
self.assertEqual(plog.level, _logging.DEBUG)
|
|
|
|
def test_adds_stream_handler_when_none_present(self):
|
|
import logging as _logging
|
|
from parsedmarc.cli import _configure_logging
|
|
from parsedmarc.log import logger as plog
|
|
|
|
# Clear any existing StreamHandler so we know addHandler runs.
|
|
plog.handlers[:] = [
|
|
h for h in plog.handlers if type(h) is not _logging.StreamHandler
|
|
]
|
|
_configure_logging(_logging.INFO)
|
|
self.assertTrue(any(type(h) is _logging.StreamHandler for h in plog.handlers))
|
|
|
|
def test_does_not_duplicate_stream_handler(self):
|
|
import logging as _logging
|
|
from parsedmarc.cli import _configure_logging
|
|
from parsedmarc.log import logger as plog
|
|
|
|
# Start with a single StreamHandler attached.
|
|
plog.handlers[:] = [_logging.StreamHandler()]
|
|
before = len(plog.handlers)
|
|
_configure_logging(_logging.INFO)
|
|
after = len(plog.handlers)
|
|
self.assertEqual(before, after)
|
|
|
|
def test_adds_file_handler_when_log_file_given(self):
|
|
import logging as _logging
|
|
import tempfile
|
|
from parsedmarc.cli import _configure_logging
|
|
from parsedmarc.log import logger as plog
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as tf:
|
|
path = tf.name
|
|
try:
|
|
_configure_logging(_logging.INFO, log_file=path)
|
|
self.assertTrue(
|
|
any(isinstance(h, _logging.FileHandler) for h in plog.handlers)
|
|
)
|
|
finally:
|
|
for h in list(plog.handlers):
|
|
if isinstance(h, _logging.FileHandler):
|
|
plog.removeHandler(h)
|
|
h.close()
|
|
os.remove(path)
|
|
|
|
def test_unwritable_log_file_logs_warning_does_not_raise(self):
|
|
"""If the log file can't be opened, we warn and continue. A
|
|
regression that raised would crash the whole parse pipeline."""
|
|
import logging as _logging
|
|
from parsedmarc.cli import _configure_logging
|
|
|
|
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
|
|
_configure_logging(_logging.INFO, log_file="/proc/nonexistent/x.log")
|
|
self.assertTrue(any("Unable to write to log file" in m for m in cm.output))
|
|
|
|
|
|
# Top-level module roots pulled in by parsedmarc's optional output and
|
|
# mailbox extras. Blocking all of them makes an install with no extras
|
|
# at all -- the new default (#883) -- look the way it does to the import
|
|
# system.
|
|
OPTIONAL_SDK_ROOTS = (
|
|
"azure",
|
|
"boto3",
|
|
"botocore",
|
|
"elastic_transport",
|
|
"elasticsearch",
|
|
"google",
|
|
"google_auth_oauthlib",
|
|
"googleapiclient",
|
|
"kafka",
|
|
"kiota_abstractions",
|
|
"kiota_authentication_azure",
|
|
"msgraph",
|
|
"msgraph_core",
|
|
"opensearchpy",
|
|
"pygelf",
|
|
)
|
|
|
|
# The parsedmarc submodules cli.py imports behind the extras guard, in
|
|
# the order their guarded import blocks appear in cli.py.
|
|
GUARDED_CLI_MODULES = (
|
|
"elastic",
|
|
"gelf",
|
|
"kafkaclient",
|
|
"loganalytics",
|
|
"opensearch",
|
|
"s3",
|
|
)
|
|
|
|
|
|
class _ImportBlocker(importlib.abc.MetaPathFinder):
|
|
"""A meta path finder that makes chosen module roots unimportable.
|
|
|
|
Installed at the head of ``sys.meta_path``, it raises
|
|
``ModuleNotFoundError`` for any module whose top-level root is
|
|
blocked — the exact exception a genuinely absent package produces,
|
|
and the only one cli.py's import guards catch, so a broken-but-
|
|
present SDK still fails loudly instead of masquerading as a missing
|
|
extra.
|
|
"""
|
|
|
|
def __init__(self, roots: frozenset[str]) -> None:
|
|
self.roots = roots
|
|
|
|
def find_spec(
|
|
self,
|
|
fullname: str,
|
|
path: Sequence[str] | None = None,
|
|
target: ModuleType | None = None,
|
|
) -> importlib.machinery.ModuleSpec | None:
|
|
if fullname.split(".")[0] in self.roots:
|
|
raise ModuleNotFoundError(f"blocked for test: {fullname}")
|
|
return None
|
|
|
|
|
|
def _is_affected(name: str) -> bool:
|
|
"""Is *name* a module that has to be reloaded while the SDKs are blocked?
|
|
|
|
The blocked roots themselves, the whole ``mailsuite`` tree (its
|
|
``mailbox`` package caches the ``gmail``/``graph`` submodules as
|
|
attributes once loaded, so a stale entry would satisfy
|
|
``parsedmarc.mail``'s guarded imports without ever reaching a blocked
|
|
root), and the ``parsedmarc`` submodules that import the blocked SDKs.
|
|
"""
|
|
root = name.split(".")[0]
|
|
if root in OPTIONAL_SDK_ROOTS or root == "mailsuite":
|
|
return True
|
|
return name in {f"parsedmarc.{m}" for m in (*GUARDED_CLI_MODULES, "mail")}
|
|
|
|
|
|
@contextmanager
|
|
def _cli_without_optional_sdks() -> Iterator[ModuleType]:
|
|
"""Reload :mod:`parsedmarc.cli` with every optional SDK unimportable.
|
|
|
|
Yields the reloaded module -- the same object as before, since
|
|
``importlib.reload`` re-executes in place. On exit, including when
|
|
the body raises, the finder is removed, anything imported while
|
|
blocked is discarded, the snapshots are put back, and
|
|
:mod:`parsedmarc.cli` is reloaded once more so later tests see the
|
|
real modules again. ``parsedmarc``'s own submodule attributes are
|
|
snapshotted and deleted too: ``from parsedmarc import elastic``
|
|
resolves against the package's attributes first, so leaving them in
|
|
place would satisfy the import without consulting the blocker.
|
|
"""
|
|
blocker = _ImportBlocker(frozenset(OPTIONAL_SDK_ROOTS))
|
|
removed_modules: dict[str, ModuleType] = {}
|
|
for name in list(sys.modules):
|
|
if _is_affected(name):
|
|
removed_modules[name] = sys.modules.pop(name)
|
|
removed_attrs: dict[str, ModuleType] = {}
|
|
for attr in (*GUARDED_CLI_MODULES, "mail"):
|
|
if attr in vars(parsedmarc):
|
|
removed_attrs[attr] = getattr(parsedmarc, attr)
|
|
delattr(parsedmarc, attr)
|
|
# Every reload re-runs cli.py's module level, which adds a stream
|
|
# handler to the parsedmarc logger; restore the original list so the
|
|
# handlers do not accumulate across tests.
|
|
handlers = list(parsedmarc.log.logger.handlers)
|
|
sys.meta_path.insert(0, blocker)
|
|
try:
|
|
importlib.invalidate_caches()
|
|
importlib.reload(parsedmarc.cli)
|
|
yield parsedmarc.cli
|
|
finally:
|
|
sys.meta_path.remove(blocker)
|
|
for name in list(sys.modules):
|
|
if _is_affected(name):
|
|
del sys.modules[name]
|
|
sys.modules.update(removed_modules)
|
|
for attr, value in removed_attrs.items():
|
|
setattr(parsedmarc, attr, value)
|
|
importlib.invalidate_caches()
|
|
importlib.reload(parsedmarc.cli)
|
|
parsedmarc.log.logger.handlers[:] = handlers
|
|
|
|
|
|
def _output_client_opts(**overrides) -> SimpleNamespace:
|
|
"""Build the opts namespace ``_init_output_clients`` reads.
|
|
|
|
Every option defaults to ``None`` -- falsy, so each output's ``if``
|
|
guard is skipped -- and *overrides* turn on exactly one output.
|
|
"""
|
|
names = (
|
|
"elasticsearch_api_key",
|
|
"elasticsearch_hosts",
|
|
"elasticsearch_index_prefix",
|
|
"elasticsearch_index_suffix",
|
|
"elasticsearch_password",
|
|
"elasticsearch_serverless",
|
|
"elasticsearch_skip_certificate_verification",
|
|
"elasticsearch_ssl",
|
|
"elasticsearch_ssl_cert_path",
|
|
"elasticsearch_timeout",
|
|
"elasticsearch_username",
|
|
"gelf_host",
|
|
"gelf_mode",
|
|
"gelf_port",
|
|
"hec",
|
|
"hec_index",
|
|
"hec_skip_certificate_verification",
|
|
"hec_token",
|
|
"kafka_hosts",
|
|
"kafka_password",
|
|
"kafka_skip_certificate_verification",
|
|
"kafka_username",
|
|
"la_dce",
|
|
"loki_batch_size",
|
|
"loki_label_fields",
|
|
"loki_labels",
|
|
"loki_password",
|
|
"loki_skip_certificate_verification",
|
|
"loki_tenant_id",
|
|
"loki_timeout",
|
|
"loki_timestamp",
|
|
"loki_token",
|
|
"loki_url",
|
|
"loki_username",
|
|
"opensearch_api_key",
|
|
"opensearch_auth_type",
|
|
"opensearch_aws_region",
|
|
"opensearch_aws_service",
|
|
"opensearch_hosts",
|
|
"opensearch_index_prefix",
|
|
"opensearch_index_suffix",
|
|
"opensearch_password",
|
|
"opensearch_skip_certificate_verification",
|
|
"opensearch_ssl",
|
|
"opensearch_ssl_cert_path",
|
|
"opensearch_timeout",
|
|
"opensearch_username",
|
|
"postgresql_connection_string",
|
|
"postgresql_database",
|
|
"postgresql_host",
|
|
"postgresql_password",
|
|
"postgresql_port",
|
|
"postgresql_user",
|
|
"s3_access_key_id",
|
|
"s3_bucket",
|
|
"s3_endpoint_url",
|
|
"s3_path",
|
|
"s3_region_name",
|
|
"s3_secret_access_key",
|
|
"save_aggregate",
|
|
"save_failure",
|
|
"save_smtp_tls",
|
|
"syslog_cafile_path",
|
|
"syslog_certfile_path",
|
|
"syslog_keyfile_path",
|
|
"syslog_port",
|
|
"syslog_protocol",
|
|
"syslog_retry_attempts",
|
|
"syslog_retry_delay",
|
|
"syslog_server",
|
|
"syslog_timeout",
|
|
"webhook_aggregate_url",
|
|
"webhook_failure_url",
|
|
"webhook_smtp_tls_url",
|
|
"webhook_timeout",
|
|
)
|
|
unknown = set(overrides) - set(names)
|
|
assert not unknown, f"unknown opts: {sorted(unknown)}"
|
|
values: dict[str, object] = {name: None for name in names}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
class TestOptionalIntegrationExtras(unittest.TestCase):
|
|
"""The optional-extras packaging split (#883).
|
|
|
|
``pip install parsedmarc`` no longer ships the Elasticsearch,
|
|
OpenSearch, Kafka, AWS, Azure, Gmail, or Microsoft Graph SDKs, so
|
|
``parsedmarc.cli`` must import without any of them, and a config
|
|
section whose extra is missing must fail with an actionable
|
|
``ConfigurationError`` rather than an ``ImportError`` traceback or an
|
|
``AttributeError`` on ``None``.
|
|
"""
|
|
|
|
# (cli.py module global, INI section name, extra name, opts that
|
|
# select that output, the key _init_output_clients() adds for it --
|
|
# None for Log Analytics, whose client is built per batch in
|
|
# process_reports() instead)
|
|
GUARDED_OUTPUTS = (
|
|
(
|
|
"elastic",
|
|
"elasticsearch",
|
|
"elastic",
|
|
{"elasticsearch_hosts": ["host:9200"]},
|
|
"elasticsearch",
|
|
),
|
|
(
|
|
"opensearch",
|
|
"opensearch",
|
|
"opensearch",
|
|
{"opensearch_hosts": ["host:9200"]},
|
|
"opensearch",
|
|
),
|
|
(
|
|
"kafkaclient",
|
|
"kafka",
|
|
"kafka",
|
|
{"kafka_hosts": ["host:9092"]},
|
|
"kafka_client",
|
|
),
|
|
("s3", "s3", "s3", {"s3_bucket": "reports"}, "s3_client"),
|
|
(
|
|
"gelf",
|
|
"gelf",
|
|
"gelf",
|
|
{"gelf_host": "logger", "gelf_port": 12201},
|
|
"gelf_client",
|
|
),
|
|
(
|
|
"loganalytics",
|
|
"log_analytics",
|
|
"loganalytics",
|
|
{"la_dce": "https://dce.example.com"},
|
|
None,
|
|
),
|
|
)
|
|
|
|
def test_cli_imports_with_no_optional_sdk_installed(self):
|
|
"""parsedmarc.cli imports with every optional SDK absent.
|
|
|
|
This is the base-install guarantee: the core CLI (file, IMAP,
|
|
Maildir, and mbox input; CSV/JSON, Splunk HEC, webhook, and
|
|
syslog output) works with none of the extras installed. Each
|
|
guarded module becomes ``None``; the eagerly imported outputs,
|
|
which need only httpx or the standard library, stay real.
|
|
"""
|
|
with _cli_without_optional_sdks() as blocked_cli:
|
|
self.assertIs(blocked_cli, sys.modules["parsedmarc.cli"])
|
|
for name in GUARDED_CLI_MODULES:
|
|
with self.subTest(module=name):
|
|
self.assertIsNone(getattr(blocked_cli, name))
|
|
for name in ("postgres", "splunk", "syslog", "webhook"):
|
|
with self.subTest(module=name):
|
|
self.assertIsInstance(getattr(blocked_cli, name), ModuleType)
|
|
|
|
# The other half: once the SDKs are back, so are the modules --
|
|
# a guard that left them None would break every later test.
|
|
for name in GUARDED_CLI_MODULES:
|
|
with self.subTest(module=name):
|
|
self.assertIsInstance(getattr(parsedmarc.cli, name), ModuleType)
|
|
|
|
def test_graph_error_types_fall_back_to_sentinel_classes(self):
|
|
"""The Graph error handling still works with the msgraph extra absent.
|
|
|
|
``ClientAuthenticationError`` and ``APIError`` appear only in
|
|
``except`` tuples and one ``isinstance`` check. Without the extra
|
|
a Graph connection cannot even be constructed -- its
|
|
``parsedmarc.mail`` placeholder raises the extra's ImportError --
|
|
so those handlers are unreachable, and the placeholders exist
|
|
purely to keep the module importable. They must be exception
|
|
classes distinct from the real ones, must never match an
|
|
unrelated exception, and must leave ``_log_msgraph_failure``'s
|
|
non-APIError branch intact.
|
|
"""
|
|
with _cli_without_optional_sdks() as blocked_cli:
|
|
for name in ("ClientAuthenticationError", "APIError"):
|
|
with self.subTest(name=name):
|
|
sentinel = getattr(blocked_cli, name)
|
|
self.assertTrue(issubclass(sentinel, Exception))
|
|
self.assertIsNot(sentinel, globals()[name])
|
|
|
|
# A real error raised where Graph errors are handled is not
|
|
# swallowed by the sentinels.
|
|
with self.assertRaises(ValueError):
|
|
try:
|
|
raise ValueError("boom")
|
|
except (
|
|
blocked_cli.ClientAuthenticationError,
|
|
blocked_cli.APIError,
|
|
httpx.HTTPError,
|
|
):
|
|
self.fail("a sentinel caught an unrelated exception")
|
|
|
|
with self.assertLogs("parsedmarc.log", level="ERROR") as cm:
|
|
blocked_cli._log_msgraph_failure(
|
|
ValueError("no token"),
|
|
stage="connection",
|
|
mailbox="reports@example.com",
|
|
tenant_id="tenant",
|
|
auth_method="ClientSecret",
|
|
)
|
|
self.assertIn("ValueError: no token", cm.output[0])
|
|
|
|
self.assertIs(
|
|
parsedmarc.cli.ClientAuthenticationError, ClientAuthenticationError
|
|
)
|
|
self.assertIs(parsedmarc.cli.APIError, APIError)
|
|
|
|
def test_missing_extra_raises_configuration_error_with_install_hint(self):
|
|
"""A configured output whose module is None names its extra.
|
|
|
|
``None`` is exactly what the import guard leaves behind when the
|
|
extra is not installed, so patching the module global reproduces
|
|
that state without reloading. The message must name the INI
|
|
section and the pip command, and it must be a
|
|
``ConfigurationError``: _main() reports those to the user
|
|
directly, while the ``RuntimeError`` wrappers around each
|
|
constructor would bury the hint in a traceback.
|
|
"""
|
|
for module, section, extra, options, _key in self.GUARDED_OUTPUTS:
|
|
with self.subTest(extra=extra):
|
|
opts = _output_client_opts(save_aggregate=True, **options)
|
|
with patch.object(parsedmarc.cli, module, None):
|
|
with self.assertRaises(
|
|
parsedmarc.cli.ConfigurationError
|
|
) as context:
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(
|
|
str(context.exception),
|
|
f"The [{section}] configuration section requires the {extra} "
|
|
f"extra: pip install parsedmarc[{extra}]",
|
|
)
|
|
|
|
def test_present_extra_initializes_without_the_hint(self):
|
|
"""The same config is accepted when the module is importable.
|
|
|
|
The negative half of the test above: the check must key on the
|
|
module being absent, not on the section being configured. The
|
|
module is replaced by a mock so no client opens a real
|
|
connection, and the client the section asks for is the only one
|
|
built.
|
|
"""
|
|
for module, _section, extra, options, key in self.GUARDED_OUTPUTS:
|
|
with self.subTest(extra=extra):
|
|
opts = _output_client_opts(save_aggregate=True, **options)
|
|
with patch.object(parsedmarc.cli, module, MagicMock()):
|
|
clients = parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(sorted(clients), [key] if key else [])
|
|
|
|
def test_log_analytics_extra_is_checked_before_reports_are_parsed(self):
|
|
"""The Log Analytics client is built per batch, checked at startup.
|
|
|
|
``process_reports()`` constructs ``LogAnalyticsClient`` for every
|
|
batch under ``opts.la_dce``, long after reports have been fetched
|
|
and parsed. The presence check therefore lives in
|
|
``_init_output_clients()``, which runs before any mailbox is
|
|
opened and again on a SIGHUP reload, so a missing extra is
|
|
reported immediately instead of once there is data to lose. With
|
|
no ``la_dce`` configured, a missing module is not an error at all.
|
|
"""
|
|
with patch.object(parsedmarc.cli, "loganalytics", None):
|
|
self.assertEqual(
|
|
parsedmarc.cli._init_output_clients(_output_client_opts()), {}
|
|
)
|
|
|
|
def test_missing_postgresql_extra_raises_configuration_error(self):
|
|
"""A configured [postgresql] section without psycopg fails fast.
|
|
|
|
postgres.py guards its own psycopg import, so the module is
|
|
always importable and ``postgres.psycopg is None`` is exactly the
|
|
missing-extra state. Without the presence check the constructor's
|
|
PostgreSQLError became a RuntimeError that the startup retry loop
|
|
retried for over a minute before exiting; a ConfigurationError
|
|
exits immediately with the install hint. The negative half:
|
|
with psycopg present, the same opts build the client.
|
|
"""
|
|
opts = _output_client_opts(postgresql_host="db.example.com")
|
|
with patch.object(parsedmarc.cli.postgres, "psycopg", None):
|
|
with self.assertRaises(parsedmarc.cli.ConfigurationError) as context:
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(
|
|
str(context.exception),
|
|
"The [postgresql] configuration section requires the postgresql "
|
|
"extra: pip install parsedmarc[postgresql]",
|
|
)
|
|
with (
|
|
patch.object(parsedmarc.cli.postgres, "psycopg", MagicMock()),
|
|
patch.object(parsedmarc.cli.postgres, "PostgreSQLClient") as mock_client,
|
|
):
|
|
clients = parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(sorted(clients), ["postgresql_client"])
|
|
self.assertIs(clients["postgresql_client"], mock_client.return_value)
|
|
|
|
def test_missing_mailbox_extras_fail_fast_at_config_time(self):
|
|
"""[msgraph]/[gmail_api] sections without their extra fail in
|
|
_parse_config with parsedmarc's own install hint.
|
|
|
|
Without the extra, parsedmarc.mail binds placeholder connection
|
|
classes outside the MailboxConnection hierarchy whose
|
|
construction raises mailsuite's ImportError — which named
|
|
mailsuite's extra, not parsedmarc's, and surfaced as a logged
|
|
traceback long after config parsing. The subclass check turns
|
|
that into an immediate ConfigurationError naming the parsedmarc
|
|
extra. The check reads parsedmarc.mail's own attributes — the
|
|
authoritative placeholder state — not cli's rebound names, which
|
|
other tests replace with MagicMock instances that issubclass()
|
|
would reject. The negative half — real classes pass the check —
|
|
is covered by every existing msgraph/gmail_api _parse_config
|
|
test, which runs with the real mailsuite classes imported.
|
|
"""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
class _Placeholder:
|
|
"""Stand-in for the parsedmarc.mail missing-extra placeholder."""
|
|
|
|
cases = (
|
|
("MSGraphConnection", "msgraph", "msgraph"),
|
|
("GmailConnection", "gmail_api", "gmail"),
|
|
)
|
|
for class_name, section, extra in cases:
|
|
with self.subTest(section=section):
|
|
cp = _config_with(section, {})
|
|
with patch.object(parsedmarc.mail, class_name, _Placeholder):
|
|
with self.assertRaises(
|
|
parsedmarc.cli.ConfigurationError
|
|
) as context:
|
|
_parse_config(cp, _opts())
|
|
self.assertEqual(
|
|
str(context.exception),
|
|
f"The [{section}] configuration section requires the {extra} "
|
|
f"extra: pip install parsedmarc[{extra}]",
|
|
)
|
|
|
|
|
|
class TestParseConfigLoki(unittest.TestCase):
|
|
def test_loki_complete(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"loki",
|
|
{
|
|
"url": "https://loki.example.com",
|
|
"labels": "host=mail2.example.com, env = prod",
|
|
"label_fields": "report_type, domain, org_name",
|
|
"timestamp": "report_begin",
|
|
"username": "u",
|
|
"password": "p",
|
|
"tenant_id": "fosdem",
|
|
"skip_certificate_verification": "true",
|
|
"timeout": "12.5",
|
|
"batch_size": "500",
|
|
},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.loki_url, "https://loki.example.com")
|
|
self.assertEqual(opts.loki_labels, {"host": "mail2.example.com", "env": "prod"})
|
|
self.assertEqual(opts.loki_label_fields, ["report_type", "domain", "org_name"])
|
|
self.assertEqual(opts.loki_timestamp, "report_begin")
|
|
self.assertEqual(opts.loki_username, "u")
|
|
self.assertEqual(opts.loki_password, "p")
|
|
self.assertEqual(opts.loki_tenant_id, "fosdem")
|
|
self.assertTrue(opts.loki_skip_certificate_verification)
|
|
self.assertEqual(opts.loki_timeout, 12.5)
|
|
self.assertEqual(opts.loki_batch_size, 500)
|
|
|
|
def test_loki_token(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("loki", {"url": "http://l:3100", "token": "t"})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.loki_token, "t")
|
|
|
|
def test_loki_empty_label_fields_means_no_dynamic_labels(self):
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with("loki", {"url": "http://l:3100", "label_fields": ""})
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.loki_label_fields, [])
|
|
|
|
def test_loki_label_fields_strips_whitespace_around_each_field(self):
|
|
"""Padding around commas must not leak into label names (it made
|
|
LokiClient raise ``Invalid Loki label name 'report_type '``)."""
|
|
from parsedmarc.cli import _parse_config
|
|
|
|
cp = _config_with(
|
|
"loki",
|
|
{"url": "http://l:3100", "label_fields": "report_type , domain ,org_name "},
|
|
)
|
|
opts = _opts()
|
|
_parse_config(cp, opts)
|
|
self.assertEqual(opts.loki_label_fields, ["report_type", "domain", "org_name"])
|
|
|
|
def test_loki_missing_url_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("loki", {"labels": "a=b"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_loki_malformed_labels_raise(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("loki", {"url": "http://l:3100", "labels": "host"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_loki_invalid_timestamp_raises(self):
|
|
from parsedmarc.cli import ConfigurationError, _parse_config
|
|
|
|
cp = _config_with("loki", {"url": "http://l:3100", "timestamp": "never"})
|
|
with self.assertRaises(ConfigurationError):
|
|
_parse_config(cp, _opts())
|
|
|
|
def test_loki_env_section_resolves(self):
|
|
from parsedmarc.cli import _resolve_section_key
|
|
|
|
self.assertEqual(_resolve_section_key("LOKI_URL"), ("loki", "url"))
|
|
self.assertEqual(_resolve_section_key("LOKI_PASSWORD"), ("loki", "password"))
|
|
|
|
|
|
class TestLokiCliWiring(unittest.TestCase):
|
|
"""End-to-end: a [loki] config reaches LokiClient, every report type
|
|
reaches its save method, a failed push counts as an output error
|
|
(so mailbox messages are held back instead of archived), and a config
|
|
LokiClient rejects fails client initialization under the Loki name."""
|
|
|
|
def _run_main(self, reports, extra_general="", save_side_effect=None):
|
|
config = f"""[general]
|
|
save_aggregate = true
|
|
save_failure = true
|
|
save_smtp_tls = true
|
|
silent = true
|
|
{extra_general}
|
|
|
|
[imap]
|
|
host = imap.example.com
|
|
user = test-user
|
|
password = test-password
|
|
|
|
[loki]
|
|
url = http://loki.example.com:3100
|
|
labels = host=mail2.example.com
|
|
timestamp = report_begin
|
|
tenant_id = fosdem
|
|
"""
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", suffix=".ini", delete=False
|
|
) as config_file:
|
|
config_file.write(config)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
|
|
with (
|
|
patch("parsedmarc.cli.loki.LokiClient") as mock_client_cls,
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
side_effect=_fetch_invoking_save_callback(reports),
|
|
),
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
|
|
):
|
|
client = mock_client_cls.return_value
|
|
if save_side_effect is not None:
|
|
for m in (
|
|
"save_aggregate_report_to_loki",
|
|
"save_failure_report_to_loki",
|
|
"save_smtp_tls_report_to_loki",
|
|
):
|
|
getattr(client, m).side_effect = save_side_effect
|
|
parsedmarc.cli._main()
|
|
return mock_client_cls
|
|
|
|
def _reports(self):
|
|
return {
|
|
"aggregate_reports": [{"policy_published": {"domain": "example.com"}}],
|
|
"failure_reports": [{"reported_domain": "example.com"}],
|
|
"smtp_tls_reports": [{"organization_name": "Org", "policies": []}],
|
|
}
|
|
|
|
def test_client_constructed_from_config(self):
|
|
cls = self._run_main(self._reports())
|
|
cls.assert_called_once()
|
|
self.assertEqual(cls.call_args.args, ("http://loki.example.com:3100",))
|
|
kwargs = cls.call_args.kwargs
|
|
self.assertEqual(kwargs["labels"], {"host": "mail2.example.com"})
|
|
self.assertIsNone(kwargs["label_fields"])
|
|
self.assertEqual(kwargs["timestamp"], "report_begin")
|
|
self.assertEqual(kwargs["tenant_id"], "fosdem")
|
|
self.assertTrue(kwargs["verify"])
|
|
self.assertEqual(kwargs["timeout"], 60)
|
|
self.assertEqual(kwargs["batch_size"], 1000)
|
|
|
|
def test_all_report_types_saved_and_client_closed(self):
|
|
reports = self._reports()
|
|
client = self._run_main(reports).return_value
|
|
client.save_aggregate_report_to_loki.assert_called_once_with(
|
|
reports["aggregate_reports"][0]
|
|
)
|
|
client.save_failure_report_to_loki.assert_called_once_with(
|
|
reports["failure_reports"][0]
|
|
)
|
|
client.save_smtp_tls_report_to_loki.assert_called_once_with(
|
|
reports["smtp_tls_reports"][0]
|
|
)
|
|
client.close.assert_called()
|
|
|
|
def test_loki_error_is_an_output_error(self):
|
|
from parsedmarc.loki import LokiError
|
|
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
self._run_main(
|
|
self._reports(),
|
|
extra_general="fail_on_output_error = true",
|
|
save_side_effect=LokiError("HTTP 503"),
|
|
)
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
|
|
def test_rejected_client_config_names_the_loki_section(self):
|
|
"""A [loki] config that parses but that LokiClient rejects is
|
|
reported as a ``Loki:`` RuntimeError.
|
|
|
|
_parse_config accepts ``token`` alongside ``username``/``password``;
|
|
only the real LokiClient constructor enforces that they are
|
|
exclusive, so this is the path a misconfigured section takes at
|
|
startup and on a SIGHUP reload.
|
|
"""
|
|
config = _config_with(
|
|
"loki",
|
|
{"url": "http://loki.example.com:3100", "token": "t", "username": "u"},
|
|
)
|
|
# The non-None Namespace defaults _main() gives the loki_* options.
|
|
opts = _output_client_opts(
|
|
loki_timestamp="ingest",
|
|
loki_skip_certificate_verification=False,
|
|
loki_timeout=60,
|
|
loki_batch_size=1000,
|
|
)
|
|
parsedmarc.cli._parse_config(config, opts)
|
|
with self.assertRaises(RuntimeError) as context:
|
|
parsedmarc.cli._init_output_clients(opts)
|
|
self.assertEqual(
|
|
str(context.exception),
|
|
"Loki: Loki token and username/password are exclusive",
|
|
)
|
|
self.assertIsInstance(context.exception.__cause__, ValueError)
|
|
|
|
|
|
class TestOutputDestinationExceptionsMarkTheBatchUnsaved(unittest.TestCase):
|
|
"""GHSA-4272-4r8g-6c5h: the failure and SMTP TLS Elasticsearch and
|
|
OpenSearch blocks of process_reports(), every Splunk HEC and PostgreSQL
|
|
block, and the file output caught only their own client's error. Any
|
|
other exception a report made a saver raise (an IndexError on an empty
|
|
From: list, a TypeError in the CSV writer) escaped through the save
|
|
callback, and _main logged "Mailbox Error" and exited 1 on every run,
|
|
leaving the message in place. Each block now ends with the catch-all the
|
|
aggregate Elasticsearch block has: the error is recorded through
|
|
log_output_error() and the batch is reported unsaved."""
|
|
|
|
_AGGREGATE = {"policy_published": {"domain": "example.com"}}
|
|
_FAILURE = {"reported_domain": "example.com"}
|
|
_SMTP_TLS = {"policies": [{"policy_domain": "example.com"}]}
|
|
|
|
def setUp(self):
|
|
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
|
|
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
|
|
|
|
def _run(self, sections, batch, *patches, general="", silent=True):
|
|
"""Run _main() on one mailbox batch.
|
|
|
|
Returns the save callback's verdicts, the ERROR log lines, the exit
|
|
code (``None`` when _main returned) and the mocks ``patches`` made.
|
|
"""
|
|
with NamedTemporaryFile("w", suffix=".ini", delete=False) as config_file:
|
|
config_file.write(
|
|
"[general]\n"
|
|
"save_aggregate = true\n"
|
|
"save_failure = true\n"
|
|
"save_smtp_tls = true\n"
|
|
f"silent = {str(silent).lower()}\n"
|
|
f"{general}\n"
|
|
"[imap]\n"
|
|
"host = imap.example.com\n"
|
|
"user = test-user\n"
|
|
"password = test-password\n\n"
|
|
f"{sections}"
|
|
)
|
|
config_path = config_file.name
|
|
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
|
|
full_batch = {
|
|
"aggregate_reports": [],
|
|
"failure_reports": [],
|
|
"smtp_tls_reports": [],
|
|
}
|
|
full_batch.update(batch)
|
|
verdicts = []
|
|
|
|
def _fetch_and_save(**kwargs):
|
|
verdicts.append(kwargs["save_callback"](full_batch))
|
|
return full_batch
|
|
|
|
with ExitStack() as stack:
|
|
stack.enter_context(
|
|
patch(
|
|
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
|
|
side_effect=_fetch_and_save,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch("parsedmarc.cli.IMAPConnection", return_value=object())
|
|
)
|
|
mocks = [stack.enter_context(patcher) for patcher in patches]
|
|
stack.enter_context(
|
|
patch.object(sys, "argv", ["parsedmarc", "-c", config_path])
|
|
)
|
|
cm = stack.enter_context(self.assertLogs("parsedmarc.log", level="ERROR"))
|
|
exit_code = None
|
|
try:
|
|
parsedmarc.cli._main()
|
|
except SystemExit as system_exit:
|
|
exit_code = system_exit.code
|
|
return verdicts, cm.output, exit_code, mocks
|
|
|
|
def _assert_unsaved(self, result, destination, message):
|
|
verdicts, output, exit_code, _mocks = result
|
|
self.assertIsNone(exit_code)
|
|
self.assertEqual(verdicts, [False])
|
|
expected = f"{destination} exception Error: {message}"
|
|
self.assertTrue(any(expected in line for line in output), output)
|
|
self.assertFalse(any("Mailbox Error" in line for line in output), output)
|
|
|
|
def _search_backend_patches(self, module, saver, error):
|
|
return (
|
|
patch(f"parsedmarc.cli.{module}.set_hosts"),
|
|
patch(f"parsedmarc.cli.{module}.migrate_indexes"),
|
|
patch(f"parsedmarc.cli.{module}.{saver}", side_effect=error),
|
|
)
|
|
|
|
def testStandardOutputEncodingErrorIsRecorded(self):
|
|
"""A report string the console cannot encode (a lone surrogate) made
|
|
print() in process_reports() raise UnicodeEncodeError outside every
|
|
try."""
|
|
report = {
|
|
"organization_name": "Comp\ud800any",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"contact_info": "tls@attacker.example",
|
|
"report_id": "poc",
|
|
"policies": [],
|
|
}
|
|
stdout = io.TextIOWrapper(io.BytesIO(), encoding="utf-8")
|
|
with patch.object(sys, "stdout", stdout):
|
|
result = self._run("", {"smtp_tls_reports": [report]}, silent=False)
|
|
self._assert_unsaved(
|
|
result, "Standard output", "'utf-8' codec can't encode character"
|
|
)
|
|
|
|
def testStandardOutputClosedPipeIsRecorded(self):
|
|
"""A closed pipe on standard output (BrokenPipeError) is recorded the
|
|
same way as an encoding error."""
|
|
|
|
class _ClosedPipe(io.StringIO):
|
|
def write(self, text):
|
|
raise BrokenPipeError(32, "Broken pipe")
|
|
|
|
report = {
|
|
"organization_name": "Reporter Inc.",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"contact_info": "tls@reporter.example",
|
|
"report_id": "pipe",
|
|
"policies": [],
|
|
}
|
|
with patch.object(sys, "stdout", _ClosedPipe()):
|
|
result = self._run("", {"smtp_tls_reports": [report]}, silent=False)
|
|
self._assert_unsaved(result, "Standard output", "[Errno 32] Broken pipe")
|
|
|
|
def testFileOutputExceptionIsRecorded(self):
|
|
"""A real TypeError from the SMTP TLS CSV writer (a policy_strings
|
|
list holding an integer), not a mock."""
|
|
output_dir = tempfile.mkdtemp()
|
|
self.addCleanup(lambda: shutil.rmtree(output_dir, ignore_errors=True))
|
|
report = {
|
|
"organization_name": "Attacker Inc.",
|
|
"begin_date": "2024-01-01T00:00:00Z",
|
|
"end_date": "2024-01-01T23:59:59Z",
|
|
"contact_info": "tls@attacker.example",
|
|
"report_id": "poc",
|
|
"policies": [
|
|
{
|
|
"policy_domain": "example.com",
|
|
"policy_type": "sts",
|
|
"policy_strings": [1],
|
|
"successful_session_count": 1,
|
|
"failed_session_count": 0,
|
|
}
|
|
],
|
|
}
|
|
result = self._run(
|
|
"", {"smtp_tls_reports": [report]}, general=f"output = {output_dir}"
|
|
)
|
|
self._assert_unsaved(
|
|
result,
|
|
"File output",
|
|
"sequence item 0: expected str instance, int found",
|
|
)
|
|
|
|
def testSearchBackendFailureAndSmtpTlsExceptionsAreRecorded(self):
|
|
cases = (
|
|
("elastic", "Elasticsearch", "elasticsearch"),
|
|
("opensearch", "OpenSearch", "opensearch"),
|
|
)
|
|
report_types = (
|
|
("failure", "failure_reports", self._FAILURE),
|
|
("smtp_tls", "smtp_tls_reports", self._SMTP_TLS),
|
|
)
|
|
for module, destination, section in cases:
|
|
for report_type, batch_key, report in report_types:
|
|
saver = f"save_{report_type}_report_to_{section}"
|
|
with self.subTest(saver=saver):
|
|
result = self._run(
|
|
f"[{section}]\nhosts = localhost\n",
|
|
{batch_key: [report]},
|
|
*self._search_backend_patches(
|
|
module, saver, IndexError("list index out of range")
|
|
),
|
|
)
|
|
self.assertIs(result[3][2].call_args.args[0], report)
|
|
self._assert_unsaved(result, destination, "list index out of range")
|
|
|
|
def testPostgreSQLExceptionsAreRecorded(self):
|
|
report_types = (
|
|
("aggregate", "aggregate_reports", self._AGGREGATE),
|
|
("failure", "failure_reports", self._FAILURE),
|
|
("smtp_tls", "smtp_tls_reports", self._SMTP_TLS),
|
|
)
|
|
for report_type, batch_key, report in report_types:
|
|
saver = f"save_{report_type}_report_to_postgresql"
|
|
message = "argument of type 'int' is not a container or iterable"
|
|
client_cls = MagicMock()
|
|
getattr(client_cls.return_value, saver).side_effect = TypeError(message)
|
|
with self.subTest(saver=saver):
|
|
result = self._run(
|
|
"[postgresql]\nhost = db.example.com\n",
|
|
{batch_key: [report]},
|
|
patch("parsedmarc.cli.postgres.PostgreSQLClient", client_cls),
|
|
)
|
|
client = client_cls.return_value
|
|
getattr(client, saver).assert_called_once_with(report)
|
|
self._assert_unsaved(result, "PostgreSQL", message)
|
|
|
|
def testSplunkHecExceptionsAreRecorded(self):
|
|
report_types = (
|
|
("aggregate", "aggregate_reports", self._AGGREGATE),
|
|
("failure", "failure_reports", self._FAILURE),
|
|
("smtp_tls", "smtp_tls_reports", self._SMTP_TLS),
|
|
)
|
|
for report_type, batch_key, report in report_types:
|
|
saver = f"save_{report_type}_reports_to_splunk"
|
|
message = "'int' object has no attribute 'replace'"
|
|
client_cls = MagicMock()
|
|
getattr(client_cls.return_value, saver).side_effect = AttributeError(
|
|
message
|
|
)
|
|
with self.subTest(saver=saver):
|
|
result = self._run(
|
|
"[splunk_hec]\n"
|
|
"url = https://splunk.example.com:8088\n"
|
|
"token = test-token\n"
|
|
"index = dmarc\n",
|
|
{batch_key: [report]},
|
|
patch("parsedmarc.cli.splunk.HECClient", client_cls),
|
|
)
|
|
client = client_cls.return_value
|
|
getattr(client, saver).assert_called_once_with([report])
|
|
self._assert_unsaved(result, "Splunk HEC", message)
|
|
|
|
def testFailOnOutputErrorStillExitsThroughParserError(self):
|
|
"""With fail_on_output_error the recorded exception is raised as
|
|
ParserError, as for any other output error, so _main exits 1 with
|
|
the output-error message rather than as a mailbox error."""
|
|
verdicts, output, exit_code, _mocks = self._run(
|
|
"[elasticsearch]\nhosts = localhost\n",
|
|
{"failure_reports": [self._FAILURE]},
|
|
*self._search_backend_patches(
|
|
"elastic",
|
|
"save_failure_report_to_elasticsearch",
|
|
IndexError("list index out of range"),
|
|
),
|
|
general="fail_on_output_error = true",
|
|
)
|
|
self.assertEqual(exit_code, 1)
|
|
self.assertEqual(verdicts, [])
|
|
self.assertTrue(
|
|
any(
|
|
"Output destination failures detected" in line
|
|
and "Elasticsearch exception Error: list index out of range" in line
|
|
for line in output
|
|
),
|
|
output,
|
|
)
|
|
self.assertFalse(any("Mailbox Error" in line for line in output), output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|