diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 17:26:17 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 17:26:17 +0200 |
| commit | a6ead5cb8904cd46d3f3c6f6e4c4b3bc9bc2bb0b (patch) | |
| tree | dab9115f1c4ee9113690094a459c4e4d668276ad /tests | |
| parent | fa9d51612259c484448815162bba7a199bca60c7 (diff) | |
| download | abusectl-a6ead5cb8904cd46d3f3c6f6e4c4b3bc9bc2bb0b.tar.gz abusectl-a6ead5cb8904cd46d3f3c6f6e4c4b3bc9bc2bb0b.zip | |
Skipping a destination at init left the previous one in the file. Sections
a run does not render are preserved verbatim, which is what stops an init
setting only the relays from discarding a key set earlier, but a skip is a
deliberate "do not use this" and the two rules collided. Declining MISP
re-enabled the instance from an earlier run, and MISP is the gate for
everything irreversible, so report would have submitted to an instance the
user had just said no to. Both vendors behaved the same way.
Found by hand-testing the prompts, which is why they are hand-tested: no
unit test covers a sequence of runs, and the suite was green throughout.
write() gains `drop`, the sections the user was ASKED about and skipped,
which join the set _preserved_sections already drops. An unasked section is
still preserved, so the non-interactive path and the existing carry-across
tests are unchanged. The prompt only fires when a section BOTH exists and
was skipped, and Enter keeps it: removal is the destructive answer and has
to be typed.
DESTINATION_ANSWER_KEYS is derived from _DESTINATION_FIELDS rather than
written out again, so a destination added later cannot be forgotten here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176FYdVfpzUq8S9jecqQqL6
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_init.py | 139 |
1 files changed, 138 insertions, 1 deletions
diff --git a/tests/test_init.py b/tests/test_init.py index 798ed26..2e2ebd5 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -14,13 +14,15 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Tests for the first-run config builder in abusectl.init.""" +import contextlib +import io import ipaddress import pathlib import tempfile import tomllib import unittest -from abusectl import config, init +from abusectl import cli, config, init class TestBuildConfig(unittest.TestCase): @@ -363,6 +365,141 @@ class BuildsDestinationSections(unittest.TestCase): {"misp", "abusedb", "urlhaus"}) +class DropsSkippedSections(unittest.TestCase): + """A section the user was ASKED about and skipped must not survive. + + Sections this run does not render are preserved verbatim, which is what + stops an init setting only the relays from discarding a key set + earlier. That rule and a deliberate skip collide: declining MISP at the + prompt left the previous instance in the file, and report would then + submit to an instance the user had just said no to. `drop` is how write + tells the two apart. + """ + + ANSWERS = {"trusted_relays": ["192.0.2.0/24"]} + + def _existing(self, tmp): + path = pathlib.Path(tmp) / "config.toml" + init.write(path, {**self.ANSWERS, + "misp_url": "https://old.example.invalid", + "misp_api_key": "old", + "abusedb_api_key": "olda"}) + return path + + def test_a_dropped_section_is_removed(self): + with tempfile.TemporaryDirectory() as tmp: + path = self._existing(tmp) + init.write(path, self.ANSWERS, force=True, drop=frozenset({"misp"})) + data = tomllib.loads(path.read_text()) + self.assertNotIn("misp", data) + + def test_dropping_one_leaves_the_others(self): + with tempfile.TemporaryDirectory() as tmp: + path = self._existing(tmp) + init.write(path, self.ANSWERS, force=True, drop=frozenset({"misp"})) + data = tomllib.loads(path.read_text()) + self.assertEqual(data["abusedb"]["api_key"], "olda") + + def test_without_drop_a_section_is_still_preserved(self): + # The rule drop exists to qualify, not to replace. A non-interactive + # caller passes no drop and must behave exactly as before. + with tempfile.TemporaryDirectory() as tmp: + path = self._existing(tmp) + init.write(path, self.ANSWERS, force=True) + data = tomllib.loads(path.read_text()) + self.assertEqual(data["misp"]["api_key"], "old") + + def test_a_dropped_section_that_is_answered_is_still_written(self): + # drop names what was skipped. An answered section renders normally + # and must not be removed by a stale drop entry. + with tempfile.TemporaryDirectory() as tmp: + path = self._existing(tmp) + init.write(path, {**self.ANSWERS, + "misp_url": "https://new.example.invalid", + "misp_api_key": "new"}, + force=True, drop=frozenset({"misp"})) + data = tomllib.loads(path.read_text()) + self.assertEqual(data["misp"]["url"], "https://new.example.invalid") + + def test_the_result_still_parses_and_loads(self): + with tempfile.TemporaryDirectory() as tmp: + path = self._existing(tmp) + init.write(path, self.ANSWERS, force=True, + drop=frozenset({"misp", "abusedb"})) + self.assertEqual(config.load(path).destinations, set()) + + +class AsksBeforeKeepingASkippedSection(unittest.TestCase): + """The prompt half, driven directly rather than through stdin.""" + + SKIPPED = {"misp_url": "", "misp_api_key": "", "abusedb_api_key": ""} + + def _answer(self, replies): + replies = iter(replies) + cli._ask = lambda question: next(replies) + + def setUp(self): + self._real_ask = cli._ask + + def tearDown(self): + cli._ask = self._real_ask + + def test_declining_drops_the_section(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + init.write(path, {"trusted_relays": ["192.0.2.0/24"], + "misp_url": "https://old.example.invalid", + "misp_api_key": "old"}) + self._answer(["n"]) + with contextlib.redirect_stdout(io.StringIO()): + dropped = cli._confirm_dropped(path, self.SKIPPED) + self.assertEqual(dropped, frozenset({"misp"})) + + def test_the_default_keeps_it(self): + # Enter means keep: removal is the destructive answer and must be + # typed, not fallen into. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + init.write(path, {"trusted_relays": ["192.0.2.0/24"], + "misp_url": "https://old.example.invalid", + "misp_api_key": "old"}) + self._answer([""]) + with contextlib.redirect_stdout(io.StringIO()): + dropped = cli._confirm_dropped(path, self.SKIPPED) + self.assertEqual(dropped, frozenset()) + + def test_an_answered_section_is_never_asked_about(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + init.write(path, {"trusted_relays": ["192.0.2.0/24"], + "misp_url": "https://old.example.invalid", + "misp_api_key": "old"}) + # No replies queued: asking anything raises StopIteration. + self._answer([]) + with contextlib.redirect_stdout(io.StringIO()): + dropped = cli._confirm_dropped( + path, {"misp_url": "https://new.example.invalid", + "misp_api_key": "new"}) + self.assertEqual(dropped, frozenset()) + + def test_a_first_run_asks_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + self._answer([]) + dropped = cli._confirm_dropped( + pathlib.Path(tmp) / "absent.toml", self.SKIPPED) + self.assertEqual(dropped, frozenset()) + + def test_an_unreadable_config_asks_nothing(self): + # Not a file to make removal decisions from, and write() backs it + # up regardless. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text("not [valid toml", encoding="utf-8") + self._answer([]) + dropped = cli._confirm_dropped(path, self.SKIPPED) + self.assertEqual(dropped, frozenset()) + + if __name__ == "__main__": unittest.main() |
