# 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 the first-run config builder in abusectl.init.""" import ipaddress import pathlib import tempfile import tomllib import unittest from abusectl import config, init class TestBuildConfig(unittest.TestCase): def test_the_answers_become_readable_toml(self): text = init.build({"trusted_relays": ["192.0.2.0/24"], "cases": "~/c"}) parsed = tomllib.loads(text) self.assertEqual(parsed["general"]["trusted_relays"], ["192.0.2.0/24"]) self.assertEqual(parsed["general"]["cases"], "~/c") def test_a_skipped_answer_is_absent_not_empty(self): # An empty string reads as configured-and-broken later on. text = init.build({"trusted_relays": ["192.0.2.0/24"], "cases": ""}) self.assertNotIn("cases", tomllib.loads(text)["general"]) def test_a_malformed_relay_is_rejected(self): with self.assertRaises(ValueError): init.build({"trusted_relays": ["nonsense"]}) def test_no_relays_at_all_is_rejected(self): with self.assertRaises(ValueError): init.build({"trusted_relays": []}) def test_the_result_loads_back_through_config(self): with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text( init.build({"trusted_relays": ["192.0.2.0/24"]}), encoding="utf-8" ) self.assertEqual(config.load(path).trusted_relays, ["192.0.2.0/24"]) def test_a_quote_in_an_answer_cannot_break_out_of_the_toml(self): # The cases path reaches the file as a quoted string, so a value # containing a quote must not be able to close it early. with self.assertRaises(ValueError): init.build({"trusted_relays": ["192.0.2.0/24"], "cases": 'x" \ntrusted_relays = ["0.0.0.0/0"]\n#'}) class TestProviderTable(unittest.TestCase): def test_a_known_provider_resolves_to_ranges(self): self.assertTrue(init.provider_relays("gmail")) def test_lookup_is_case_insensitive(self): self.assertEqual(init.provider_relays("Gmail"), init.provider_relays("gmail")) def test_an_unknown_provider_returns_nothing(self): self.assertEqual(init.provider_relays("nosuchprovider"), []) def test_every_shipped_range_is_a_valid_network(self): for name, ranges in init.PROVIDERS.items(): self.assertTrue(ranges, f"{name} has no ranges") for entry in ranges: ipaddress.ip_network(entry, strict=False) def test_a_provider_result_survives_the_builder(self): # The table feeds the builder directly, so its entries must satisfy # the same validation a typed answer does. text = init.build({"trusted_relays": init.provider_relays("gmail")}) self.assertIn("74.125.0.0/16", text) class TestSampleChain(unittest.TestCase): def test_hops_are_offered_for_picking(self): raw = ( pathlib.Path(__file__).parent / "fixtures" / "simple.eml" ).read_bytes() hops = init.hops_from_sample(raw) self.assertEqual(hops, ["192.0.2.11", "203.0.113.42"]) class TestWriteGuard(unittest.TestCase): def test_writing_over_an_existing_config_refuses_without_force(self): with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text("[general]\n", encoding="utf-8") with self.assertRaises(FileExistsError): init.write(path, {"trusted_relays": ["192.0.2.0/24"]}) def test_force_overwrites_and_leaves_a_backup(self): with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text('[general]\ntrusted_relays = ["10.0.0.0/8"]\n', encoding="utf-8") init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) self.assertIn("192.0.2.0/24", path.read_text()) backups = list(pathlib.Path(tmp).glob("config.toml.bak-*")) self.assertEqual(len(backups), 1) self.assertIn("10.0.0.0/8", backups[0].read_text()) def test_a_backup_is_not_world_readable_either(self): # It holds the same secrets the config does. with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text("[general]\n", encoding="utf-8") init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) backup = next(pathlib.Path(tmp).glob("config.toml.bak-*")) self.assertEqual(backup.stat().st_mode & 0o077, 0) def test_an_unsupplied_section_survives_a_rewrite(self): # Once the config holds a MISP key, an init that only sets the relays # must not silently discard it. The backup makes that recoverable; # not losing it is better. with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text( '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' '[misp]\nurl = "https://misp.example.invalid"\n' 'api_key = "kept"\n', encoding="utf-8", ) init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) rewritten = path.read_text() self.assertIn("192.0.2.0/24", rewritten) self.assertIn("[misp]", rewritten) self.assertIn("kept", rewritten) def test_a_rewrite_still_loads(self): # Preserving sections verbatim must not produce a file the reader # then chokes on. with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text( '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' '[misp]\napi_key = "kept"\n', encoding="utf-8", ) init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) self.assertEqual(config.load(path).trusted_relays, ["192.0.2.0/24"]) def test_a_written_config_is_not_world_readable(self): # It will hold API keys as later parts land. with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" init.write(path, {"trusted_relays": ["192.0.2.0/24"]}) self.assertEqual(path.stat().st_mode & 0o077, 0) def test_a_first_write_creates_no_backup(self): with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" init.write(path, {"trusted_relays": ["192.0.2.0/24"]}) self.assertEqual(list(pathlib.Path(tmp).glob("*.bak-*")), []) def test_existing_summary_names_sections_without_showing_values(self): # The file holds API keys. Echoing a secret to the terminal to ask # about overwriting it is a poor trade. with tempfile.TemporaryDirectory() as tmp: path = pathlib.Path(tmp) / "config.toml" path.write_text( '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' '[misp]\napi_key = "topsecret"\n', encoding="utf-8", ) summary = init.existing_summary(path) self.assertIn("misp", summary) self.assertIn("api_key", summary) self.assertNotIn("topsecret", summary) if __name__ == "__main__": unittest.main()