From 37d723f2ffd55dcca141831ee2368b5b49618de8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 10 Sep 2026 10:38:10 +0200 Subject: feat: ask for the reporter identity during init Each answer is validated at the prompt that asked for it, and a skipped answer is absent from the file rather than an empty string. Two defects fixed beyond the plan, both in the carry-across rule. Sections build() does not produce are preserved verbatim, and that rule was written when [general] was the only section it produced. Emitting [reporter] as well made preservation emit it TWICE, and tomllib refuses a duplicate table outright, so the rewritten file became unreadable and took the preserved [misp] key with it. Dropping [reporter] unconditionally instead would have been the opposite defect: an init that skips all three questions would silently delete an identity set by hand. What is dropped is now read back off the rendered text, so it is what this run actually wrote rather than what it might have written, and a section added to the builder later cannot be forgotten here. The email check is deliberately not an RFC 5322 validator. What a typo costs is a report whose reply address bounces, and the answers that produce that are a name with no @ at all, a spelled-out "at", and a stray space from a copy-paste. Anything stricter starts rejecting addresses that work. Skipping the address is allowed but warned about at the prompt: it is the one field a report cannot be built without, since it becomes the From. report.build() raises a bare KeyError on that identity today, which the three prompts made reachable from a config file for the first time; logged as backlog item 5 rather than fixed here, because where the check belongs is the report subcommand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE --- tests/test_init.py | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) (limited to 'tests/test_init.py') diff --git a/tests/test_init.py b/tests/test_init.py index 619d6ba..46b1a26 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -59,6 +59,144 @@ class TestBuildConfig(unittest.TestCase): "cases": 'x" \ntrusted_relays = ["0.0.0.0/0"]\n#'}) +class TestReporterSection(unittest.TestCase): + def test_the_identity_becomes_a_reporter_table(self): + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "Example Ltd", + "reporter_email": "abuse@example.org", + }) + parsed = tomllib.loads(text) + self.assertEqual(parsed["reporter"], { + "name": "A Reporter", + "org": "Example Ltd", + "email": "abuse@example.org", + }) + + def test_a_fully_skipped_identity_emits_no_table_at_all(self): + # Not an empty [reporter]: an empty table reads as configured, and + # the reader would then report an identity of nothing rather than + # saying plainly that none is set. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "", + "reporter_org": "", + "reporter_email": "", + }) + self.assertNotIn("[reporter]", text) + self.assertNotIn("reporter", tomllib.loads(text)) + + def test_a_skipped_answer_is_absent_not_empty(self): + # Same rule as the cases path: "" reads as configured-and-broken. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "", + "reporter_email": " ", + }) + reporter = tomllib.loads(text)["reporter"] + self.assertEqual(reporter, {"name": "A Reporter"}) + + def test_values_are_written_stripped(self): + # config.load() strips on read, so writing unstripped would make the + # file disagree with what every consumer sees. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": " A Reporter ", + }) + self.assertEqual(tomllib.loads(text)["reporter"]["name"], "A Reporter") + + def test_a_quote_in_an_identity_cannot_break_out_of_the_toml(self): + with self.assertRaises(ValueError): + init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": 'x"\nemail = "attacker@example.invalid"', + }) + + def test_the_identity_loads_back_through_config(self): + # The seam report.build() consumes: what init writes must arrive as + # the dict shape the reader hands over, keys and all. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text(init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "Example Ltd", + "reporter_email": "abuse@example.org", + }), encoding="utf-8") + self.assertEqual(config.load(path).reporter, { + "name": "A Reporter", + "org": "Example Ltd", + "email": "abuse@example.org", + }) + + def test_a_partial_identity_loads_back_with_only_what_was_given(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text(init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_email": "abuse@example.org", + }), encoding="utf-8") + self.assertEqual(config.load(path).reporter, + {"email": "abuse@example.org"}) + + +class TestReporterCarryAcross(unittest.TestCase): + def test_an_identity_set_by_hand_survives_a_relays_only_rewrite(self): + # AGENTS.md: sections build() does not produce are carried across + # verbatim. Skipping all three answers must not delete an identity + # the user set earlier; there would be no warning that it went. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "A Reporter"\nemail = "abuse@example.org"\n', + encoding="utf-8", + ) + init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) + self.assertEqual(config.load(path).reporter, + {"name": "A Reporter", "email": "abuse@example.org"}) + + def test_a_new_identity_replaces_the_old_one_without_duplicating_it(self): + # A section the builder DOES produce must not also be carried across: + # two [reporter] tables in one file is not merely untidy, tomllib + # refuses the whole file and the config becomes unreadable. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "Old Name"\n', + encoding="utf-8", + ) + init.write(path, { + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "New Name", + }, force=True) + + self.assertEqual(path.read_text().count("[reporter]"), 1) + self.assertEqual(config.load(path).reporter, {"name": "New Name"}) + + def test_an_unrelated_section_still_survives_alongside_an_identity(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "Old Name"\n\n' + '[misp]\napi_key = "kept"\n', + encoding="utf-8", + ) + init.write(path, { + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "New Name", + }, force=True) + + rewritten = path.read_text() + self.assertIn("kept", rewritten) + self.assertNotIn("Old Name", rewritten) + self.assertEqual(config.load(path).reporter, {"name": "New Name"}) + + class TestProviderTable(unittest.TestCase): def test_a_known_provider_resolves_to_ranges(self): self.assertTrue(init.provider_relays("gmail")) -- cgit v1.2.3