diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_config.py | 170 | ||||
| -rw-r--r-- | tests/test_report.py | 26 |
2 files changed, 195 insertions, 1 deletions
diff --git a/tests/test_config.py b/tests/test_config.py index 6fa8619..1815993 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,7 +20,25 @@ import tempfile import unittest from unittest import mock -from abusectl import config +from abusectl import config, report + +# Minimal, kept local rather than imported from test_report: these tests are +# about the shape config produces, and they must not start failing because a +# report fixture grew a field. +_MANIFEST = { + "format": 1, + "case_id": "2026-09-07-aaaa", + "iocs": [ + {"id": "ioc-1", "type": "ipv4", "value": "203.0.113.42", + "origin": "received-chain", "confidence": "boundary-hop"}, + ], + "headers": [("From", '"Example Bank" <phish@sender.invalid>')], + "contacts": [ + {"iocs": ["ioc-1"], "query": "203.0.113.42", + "abuse": ["abuse@host.invalid"], "source": "rdap"}, + ], +} +_DESTINATION = {"id": "d1", "target": "abuse@host.invalid", "iocs": ["ioc-1"]} class TestConfig(unittest.TestCase): @@ -105,6 +123,156 @@ class TestConfig(unittest.TestCase): with self.assertRaises(ValueError): config.load(path) + def test_the_reporter_identity_is_read(self): + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'name = "A Reporter"\n' + 'org = "Example Consulting"\n' + 'email = "reporter@example.org"\n' + ) + loaded = config.load(path) + self.assertEqual(loaded.reporter["name"], "A Reporter") + self.assertEqual(loaded.reporter["org"], "Example Consulting") + self.assertEqual(loaded.reporter["email"], "reporter@example.org") + + def test_an_absent_reporter_section_is_an_empty_dict_not_a_crash(self): + path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n') + self.assertEqual(config.load(path).reporter, {}) + + def test_an_empty_value_is_treated_as_absent(self): + # Same rule as cases and as every key init writes: skipped is ABSENT, + # never "". An empty org must not reach a report as a stray comma. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'name = "A Reporter"\n' + 'org = ""\n' + 'email = "reporter@example.org"\n' + ) + reporter = config.load(path).reporter + self.assertNotIn("org", reporter) + self.assertEqual(reporter["name"], "A Reporter") + + def test_a_whitespace_only_value_is_treated_as_absent(self): + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'org = " "\n' + 'email = "reporter@example.org"\n' + ) + self.assertNotIn("org", config.load(path).reporter) + + def test_a_reporter_value_is_stored_stripped(self): + # The name becomes a From display name. Leading and trailing space + # survives into the header verbatim, which is sloppy at best and + # affects folding at worst. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'name = " A Reporter "\n' + 'email = " reporter@example.org "\n' + ) + reporter = config.load(path).reporter + self.assertEqual(reporter["name"], "A Reporter") + self.assertEqual(reporter["email"], "reporter@example.org") + + def test_a_reporter_value_that_is_not_a_string_is_rejected_clearly(self): + # Not dropped. Dropping reads as not-configured, and this is the one + # identity the tool discloses deliberately: a typo that silently + # removes the reply address must be reported against the file that + # holds it, the same way a non-string trusted_relays entry is. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + "name = 42\n" + ) + with self.assertRaises(ValueError) as caught: + config.load(path) + self.assertIn("must be a string", str(caught.exception)) + self.assertIn("name", str(caught.exception)) + + def test_a_non_string_email_is_rejected_rather_than_dropped(self): + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'email = ["reporter@example.org"]\n' + ) + with self.assertRaises(ValueError): + config.load(path) + + def test_a_string_reporter_section_is_rejected_clearly(self): + # reporter = "A Reporter" is valid TOML and would otherwise raise + # AttributeError naming nothing the user can find in their file. It + # has to precede [general]: a bare key written after a table header + # belongs to that table, not to the document. + path = self._write( + 'reporter = "me"\n\n[general]\ntrusted_relays = ["192.0.2.0/24"]\n' + ) + with self.assertRaises(ValueError) as caught: + config.load(path) + self.assertIn("must be a table", str(caught.exception)) + + def test_an_unknown_reporter_key_is_ignored(self): + # Ignored rather than refused: unlike a manifest format, an unknown + # key here loses nothing. Keeping only the three the spec names is + # what stops it reaching a report body. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'email = "reporter@example.org"\n' + 'phone = "+1 555 0100"\n' + ) + reporter = config.load(path).reporter + self.assertNotIn("phone", reporter) + self.assertEqual(reporter["email"], "reporter@example.org") + + def test_a_configured_identity_builds_a_report_body(self): + # The round trip that only shows up much later otherwise: the dict + # config produces must be the shape report.build() consumes. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'name = "A Reporter"\n' + 'org = "Example Consulting"\n' + 'email = "reporter@example.org"\n' + ) + body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter) + self.assertIn("A Reporter <reporter@example.org>", body) + self.assertIn( + "Reported by: A Reporter, Example Consulting " + "<reporter@example.org>", body) + + def test_an_identity_with_only_an_email_still_builds_a_body(self): + # Every key is individually skippable per the spec, so config drops + # the skipped ones and build() must survive their absence rather + # than raising KeyError on a case that parsed fine. + path = self._write( + "[general]\n" + 'trusted_relays = ["192.0.2.0/24"]\n' + "\n" + "[reporter]\n" + 'email = "reporter@example.org"\n' + ) + body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter) + self.assertIn("reporter@example.org", body) + def test_the_config_path_follows_xdg_config_home(self): with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg-probe"}): self.assertEqual( diff --git a/tests/test_report.py b/tests/test_report.py index e87956f..9159dc7 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -640,6 +640,32 @@ class TextPart(unittest.TestCase): self.assertLessEqual(len(line), 72, line) self.assertIn(long_subject, report.unwrap(text)) + def test_an_identity_missing_a_name_or_org_still_builds(self): + """Each [reporter] key is individually skippable. + + config drops a skipped one rather than storing "", so a partial + identity is the normal shape here, not a malformed one. Subscripting + it raised KeyError on a case that had parsed perfectly. + """ + destination = report.email_destinations(MANIFEST["contacts"])[0] + + text = report.text_part(MANIFEST, destination, + {"email": "reporter@example.org"}) + self.assertIn("Reported by: <reporter@example.org>", text) + self.assertNotIn(",", report.unwrap(text).split("Reported by:")[1] + .splitlines()[0]) + + text = report.text_part(MANIFEST, destination, + {"name": "A Reporter", + "email": "reporter@example.org"}) + self.assertIn("Reported by: A Reporter <reporter@example.org>", text) + + text = report.text_part(MANIFEST, destination, + {"org": "Example Consulting", + "email": "reporter@example.org"}) + self.assertIn("Reported by: Example Consulting " + "<reporter@example.org>", text) + def test_a_long_reporter_identity_is_not_truncated(self): """The identity is the one thing disclosed deliberately. |
