diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 10:28:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 10:28:07 +0200 |
| commit | 5f9bbeb509773e5280995b39b44cf9be9c29ece2 (patch) | |
| tree | b5670ea4ef424921407ce1049d0ab2bc235561a9 /tests/test_config.py | |
| parent | be392fe195f8f15dbe263b4c39d609bce95a8615 (diff) | |
| download | abusectl-5f9bbeb509773e5280995b39b44cf9be9c29ece2.tar.gz abusectl-5f9bbeb509773e5280995b39b44cf9be9c29ece2.zip | |
feat: read the reporter identity from config
The reporter's identity is the one identifier this tool discloses
deliberately, so it comes from config only and parse never supplies it. An
empty value is absent, the same rule the rest of the config follows.
Three departures from the plan, each a defect in its code:
A non-string value is REJECTED, not dropped. The plan filtered on
isinstance(value, str), so name = 42 or email = ["a@b"] silently vanished
and read back as not-configured. This file already learned that lesson from
ipaddress.ip_network(42) returning a valid-looking 0.0.0.42/32: a wrong
value that reads as plausible is worse than an error. Dropping the email
would strip the reply address from every report while the user believed
they were identified, so the typo is reported against the file that holds
it, the way a non-string trusted_relays entry already is.
The value is stored STRIPPED. The plan tested value.strip() for truthiness
but stored the original, so name = " A Reporter " reached the From
display name as "From: A Reporter <...>", verbatim and unquoted.
Only the three keys the spec names are carried across, and text_part no
longer subscripts them. Each key is individually skippable and config drops
a skipped one, so a partial identity is the normal shape, yet text_part
read identity['name'] and identity['org'] directly: a config naming only an
email raised KeyError on a case that had parsed perfectly. The "Reported
by:" line is now joined from the parts present, so a missing org leaves no
stray comma. A wholly unconfigured identity still raises in build(); how to
refuse that belongs to the cli task, not here.
The dataclass field defaults to an empty dict rather than editing every
construction site, and the mutable-dict-in-a-frozen-dataclass is left as
is: report only reads it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
Diffstat (limited to 'tests/test_config.py')
| -rw-r--r-- | tests/test_config.py | 170 |
1 files changed, 169 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( |
