aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_init.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-08 14:00:39 +0200
committerDanilo M. <danix@danix.xyz>2026-09-08 14:00:39 +0200
commit7a281821d4b631ab5782d95f4f9380631b993d99 (patch)
tree373ebf2e69f8738e4e97d228998ba44e653ef8ee /tests/test_init.py
parent0f5b1e38f448b554b13e6f6189cb8b7fbd22a95e (diff)
downloadabusectl-7a281821d4b631ab5782d95f4f9380631b993d99.tar.gz
abusectl-7a281821d4b631ab5782d95f4f9380631b993d99.zip
feat: first-run config builder
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
Diffstat (limited to 'tests/test_init.py')
-rw-r--r--tests/test_init.py185
1 files changed, 185 insertions, 0 deletions
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 0000000..619d6ba
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,185 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# 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()