"""Tests for parsedmarc.config""" import dataclasses import pickle import time import unittest from collections import OrderedDict from unittest import mock import parsedmarc import parsedmarc.config as parsedmarc_config class TestParserConfigCaches(unittest.TestCase): """Covers per-instance cache isolation for `ParserConfig`.""" def test_each_instance_gets_isolated_caches(self): """Two independently constructed ParserConfig instances must never share cache objects with each other or with the module defaults, and mutating one instance's caches must not leak into the others.""" cfg_a = parsedmarc_config.ParserConfig() cfg_b = parsedmarc_config.ParserConfig() # All distinct objects. self.assertIsNot(cfg_a.ip_address_cache, cfg_b.ip_address_cache) self.assertIsNot( cfg_a.seen_aggregate_report_ids, cfg_b.seen_aggregate_report_ids ) self.assertIsNot(cfg_a.reverse_dns_map, cfg_b.reverse_dns_map) self.assertIsNot(cfg_a.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE) self.assertIsNot( cfg_a.seen_aggregate_report_ids, parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, ) self.assertIsNot(cfg_a.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP) self.assertIsNot(cfg_b.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE) self.assertIsNot( cfg_b.seen_aggregate_report_ids, parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, ) self.assertIsNot(cfg_b.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP) # Mutating one instance's caches must not affect the other, nor the # module defaults. cfg_a.ip_address_cache["1.2.3.4"] = {"ip_address": "1.2.3.4"} cfg_a.seen_aggregate_report_ids["report-id-a"] = True cfg_a.reverse_dns_map["example.com"] = {"name": "Example", "type": None} self.assertNotIn("1.2.3.4", cfg_b.ip_address_cache) self.assertNotIn("report-id-a", cfg_b.seen_aggregate_report_ids) self.assertNotIn("example.com", cfg_b.reverse_dns_map) self.assertNotIn("1.2.3.4", parsedmarc_config.IP_ADDRESS_CACHE) self.assertNotIn("report-id-a", parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS) self.assertNotIn("example.com", parsedmarc_config.REVERSE_DNS_MAP) def test_module_default_caches_are_the_public_globals(self): """The three module-default caches in `parsedmarc.config` must be the very same objects re-exported as `parsedmarc.IP_ADDRESS_CACHE`, `parsedmarc.SEEN_AGGREGATE_REPORT_IDS`, and `parsedmarc.REVERSE_DNS_MAP`, so that pre-refactor callers who imported these names directly from the top-level package keep observing the same cache objects as code that goes through ParserConfig. """ self.assertIs(parsedmarc.IP_ADDRESS_CACHE, parsedmarc_config.IP_ADDRESS_CACHE) self.assertIs( parsedmarc.SEEN_AGGREGATE_REPORT_IDS, parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, ) self.assertIs(parsedmarc.REVERSE_DNS_MAP, parsedmarc_config.REVERSE_DNS_MAP) class TestSeenReportCache(unittest.TestCase): """Covers `SeenReportCache`, the seen-aggregate-report-ID cache that drops expired entries on insert (GHSA-9fw6-mwqr-p43m).""" def test_expired_entry_is_removed_when_another_is_added(self): """An entry older than ``max_age`` is removed from storage when a different key is added, not only when its own key is read again. On a plain ``ExpiringDict`` the same sequence leaves both entries stored (``len == 2``, measured against expiringdict 1.2.2), which is how every distinct report stayed in memory for the life of a watch process. ``len()`` and ``OrderedDict.keys()`` look at storage directly; ``in`` would hide the stale entry by expiring it on read. """ cache = parsedmarc_config.SeenReportCache(max_len=100, max_age_seconds=3600) cache.__setitem__("old", True, set_time=time.time() - 7200) cache["new"] = True self.assertEqual(len(cache), 1) self.assertEqual(list(OrderedDict.keys(cache)), ["new"]) def test_unexpired_entry_survives_the_next_insert(self): """The sweep stops at the first entry that has not expired, so an entry still inside ``max_age`` is kept and still reads as seen.""" cache = parsedmarc_config.SeenReportCache(max_len=100, max_age_seconds=3600) cache.__setitem__("recent", True, set_time=time.time() - 60) cache["new"] = True self.assertEqual(len(cache), 2) self.assertEqual(list(OrderedDict.keys(cache)), ["recent", "new"]) self.assertIn("recent", cache) def test_re_added_key_moves_to_the_tail(self): """A key set again while still stored moves to the tail, so an expired entry behind it is still swept on the next insert. A is added, B a second later, A again 30 minutes later (still unexpired, so the sweep keeps it), and C once B is over an hour old but A is not. Without the move, ``OrderedDict.__setitem__`` refreshed A in place at the head, the sweep stopped there, and B survived: storage held ``['A', 'B', 'C']`` (measured on c8b3c26 with this same fake clock). """ clock = [1_000_000.0] with mock.patch("time.time", side_effect=lambda: clock[0]): cache = parsedmarc_config.SeenReportCache(max_len=100, max_age_seconds=3600) cache["A"] = True clock[0] += 1 cache["B"] = True clock[0] += 1800 cache["A"] = True clock[0] += 1900 cache["C"] = True self.assertEqual(list(OrderedDict.keys(cache)), ["A", "C"]) self.assertIn("A", cache) def test_default_seen_report_cache_is_bounded(self): """The factory builds a `SeenReportCache` with a one-hour expiry and a hard ceiling of 100,000 entries (it was 100 million).""" cache = parsedmarc_config._new_seen_aggregate_report_ids() self.assertIsInstance(cache, parsedmarc_config.SeenReportCache) self.assertEqual(cache.max_len, 100_000) self.assertEqual(cache.max_age, 3600) self.assertIsInstance( parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, parsedmarc_config.SeenReportCache, ) class TestParserConfigPickling(unittest.TestCase): """Covers the pickle strategy documented on `ParserConfig`: option fields round-trip, but cache contents never cross process boundaries and the unpickled object's caches rebind to the unpickling process's module defaults.""" def test_pickle_drops_cache_contents_and_binds_process_defaults(self): """Pickling and unpickling a ParserConfig must preserve option fields, but must NOT carry cache contents across the round-trip, and must leave the unpickled instance's caches bound to this module's (the "unpickling process's") default cache objects rather than fresh, empty ones. Fresh-per-unpickle caches would silently defeat per-worker caching, since a functools.partial payload carrying a ParserConfig is re-pickled for every task submitted to a multiprocessing worker.""" cfg = parsedmarc_config.ParserConfig( dns_timeout=9.5, nameservers=["192.0.2.53"] ) cfg.ip_address_cache["sentinel-ip"] = "sentinel-ip-value" cfg.seen_aggregate_report_ids["sentinel-report-id"] = True cfg.reverse_dns_map["sentinel.example"] = { "name": "Sentinel", "type": None, } restored = pickle.loads(pickle.dumps(cfg)) # Option fields compare equal (cache fields are compare=False). self.assertEqual(cfg, restored) self.assertEqual(restored.dns_timeout, 9.5) self.assertEqual(restored.nameservers, ["192.0.2.53"]) # Caches rebind to this process's module defaults, not fresh copies. self.assertIs(restored.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE) self.assertIs( restored.seen_aggregate_report_ids, parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, ) self.assertIs(restored.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP) # Sentinel entries placed on the original instance's caches must NOT # have crossed into the module defaults. self.assertNotIn("sentinel-ip", parsedmarc_config.IP_ADDRESS_CACHE) self.assertNotIn( "sentinel-report-id", parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS ) self.assertNotIn("sentinel.example", parsedmarc_config.REVERSE_DNS_MAP) def test_setstate_defaults_fields_missing_from_older_pickles(self): """__setstate__ must initialize every non-cache field to its class default before applying the pickled state, so a ParserConfig serialized by an older parsedmarc version (whose state lacks fields added since) unpickles with the newer fields at their defaults instead of unset — __init__ never runs during unpickling, so an absent field would otherwise raise AttributeError on first access. Simulated by calling __setstate__ directly with a partial state dict, exactly what pickle.loads does with an old payload.""" restored = object.__new__(parsedmarc_config.ParserConfig) restored.__setstate__({"offline": True, "dns_timeout": 7.5}) self.assertTrue(restored.offline) self.assertEqual(restored.dns_timeout, 7.5) # Fields absent from the old state get their class defaults. self.assertIsNone(restored.ip_db_path) self.assertIsNone(restored.nameservers) self.assertEqual(restored.normalize_timespan_threshold_hours, 24.0) # Caches still rebind to the module defaults. self.assertIs(restored.ip_address_cache, parsedmarc_config.IP_ADDRESS_CACHE) self.assertIs( restored.seen_aggregate_report_ids, parsedmarc_config.SEEN_AGGREGATE_REPORT_IDS, ) self.assertIs(restored.reverse_dns_map, parsedmarc_config.REVERSE_DNS_MAP) class TestParserConfigFrozenAndReplace(unittest.TestCase): """Covers frozen-dataclass immutability and the `dataclasses.replace` escape hatch for deriving variants that keep sharing caches.""" def test_frozen_and_replace_shares_caches(self): """ParserConfig is frozen, so attribute assignment must raise FrozenInstanceError. `dataclasses.replace(cfg, ...)` must return a new config that shares the SAME cache objects as the source (all fields, including the three cache fields, are init fields), since that is the documented way to derive a variant that keeps warm caches.""" cfg = parsedmarc_config.ParserConfig() with self.assertRaises(dataclasses.FrozenInstanceError): cfg.offline = True # type: ignore[misc] variant = dataclasses.replace(cfg, offline=True) self.assertTrue(variant.offline) self.assertIs(variant.ip_address_cache, cfg.ip_address_cache) self.assertIs(variant.seen_aggregate_report_ids, cfg.seen_aggregate_report_ids) self.assertIs(variant.reverse_dns_map, cfg.reverse_dns_map)