# Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Tests for reading and validating ~/.config/abusectl/config.toml.""" import os import pathlib import tempfile import unittest from unittest import mock 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" ')], "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): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.root = pathlib.Path(self._tmp.name) def tearDown(self): self._tmp.cleanup() def _write(self, text: str) -> pathlib.Path: path = self.root / "config.toml" path.write_text(text, encoding="utf-8") return path def test_trusted_relays_and_cases_are_read(self): path = self._write( "[general]\n" 'cases = "~/cases"\n' 'trusted_relays = ["192.0.2.0/24"]\n' ) loaded = config.load(path) self.assertEqual(loaded.trusted_relays, ["192.0.2.0/24"]) self.assertEqual(loaded.cases, pathlib.Path.home() / "cases") def test_a_missing_file_is_reported_as_not_configured(self): with self.assertRaises(config.NotConfigured): config.load(self.root / "absent.toml") def test_an_empty_relay_list_is_not_configured(self): # Present but empty is the same as absent: parse must refuse either # way rather than guess, so they are one error. path = self._write("[general]\ntrusted_relays = []\n") with self.assertRaises(config.NotConfigured): config.load(path) def test_a_malformed_cidr_is_rejected_at_load(self): # Reported against the file that holds the typo, not later against a # message that did nothing wrong. path = self._write('[general]\ntrusted_relays = ["not-a-network"]\n') with self.assertRaises(ValueError): config.load(path) def test_the_cases_path_has_a_default(self): path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n') self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) def test_malformed_toml_is_not_reported_as_not_configured(self): # A syntax error is a broken file, which is a different problem from # an absent one and must not be answered with "run abusectl init". path = self._write("[general\ntrusted_relays = [") with self.assertRaises(Exception) as caught: config.load(path) self.assertNotIsInstance(caught.exception, config.NotConfigured) def test_an_empty_cases_value_falls_back_to_the_default(self): # Empty is the same as absent, per this module's own rule: writing # Path("") would put evidence in whatever directory the command # happened to run from. path = self._write( '[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = ""\n' ) self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) def test_a_whitespace_cases_value_falls_back_to_the_default(self): path = self._write( '[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = " "\n' ) self.assertEqual(config.load(path).cases, config.DEFAULT_CASES) def test_a_string_trusted_relays_is_rejected_clearly(self): # Easy to write by hand without the brackets. Iterating the string # validates single characters and reports an error naming nothing # the user can find in their file. path = self._write('[general]\ntrusted_relays = "192.0.2.0/24"\n') with self.assertRaises(ValueError) as caught: config.load(path) self.assertIn("must be a list", str(caught.exception)) def test_a_relay_entry_that_is_not_a_string_is_rejected_clearly(self): path = self._write("[general]\ntrusted_relays = [42]\n") 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 ", body) self.assertIn( "Reported by: A Reporter, Example Consulting " "", 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( config.path(), pathlib.Path("/tmp/xdg-probe/abusectl/config.toml"), ) if __name__ == "__main__": unittest.main()