diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_cli.py | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/tests/test_cli.py b/tests/test_cli.py index b8fffea..89da6b0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -259,5 +259,205 @@ class ContactsCommand(unittest.TestCase): self.assertEqual(code, cli.EXIT_ERROR) +class ReportCommand(unittest.TestCase): + """The report command's dispatch: exit codes, and what it refuses. + + The identity rule this asserts is EMAIL REQUIRED, NAME AND ORG OPTIONAL, + which is narrower than the plan's "all three". Every [reporter] key is + individually skippable by init and config drops a skipped one, so + demanding all three would refuse a config init itself is happy to write. + report.text_part() renders whatever subset is present, and build() puts + the address in the From, so only the address is load-bearing: a report + with no reply address is one an abuse desk cannot answer. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self._tmp.name) + self.config = self.root / "config.toml" + + def tearDown(self): + self._tmp.cleanup() + + def _write_config(self, reporter: str) -> None: + self.config.write_text( + '[general]\ntrusted_relays = ["192.0.2.0/24"]\n' + reporter, + encoding="utf-8", + ) + + def _make_case(self): + from abusectl import case + + created = case.create( + self.root / "cases", b"From: sender@example.invalid\r\n\r\nbody\r\n" + ) + manifest = case.load(created.path) + manifest["iocs"] = [ + {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7", + "origin": "received-chain"} + ] + manifest["contacts"] = [{ + "iocs": ["ioc-1"], "query": "198.51.100.7", + "abuse": ["abuse@example.invalid"], "source": "rdap", + }] + case.save(created.path, manifest) + return created.path + + def _run(self, *args): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = cli.main(list(args)) + return code, out.getvalue(), err.getvalue() + + def test_a_missing_case_is_an_error_not_a_traceback(self): + self._write_config('[reporter]\nemail = "r@example.org"\n') + code, _, err = self._run( + "--config", str(self.config), "report", "/nonexistent/case" + ) + self.assertEqual(code, cli.EXIT_ERROR) + self.assertIn("/nonexistent/case", err) + + def test_a_case_with_no_reporter_configured_says_so(self): + # An unconfigured identity is not-configured, not a crash: the + # report would otherwise be filed with no reply address. + self._write_config("") + case_path = self._make_case() + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_NOT_CONFIGURED) + self.assertIn("abusectl init", err) + # Nothing was written. A refusal must leave the case as it was. + self.assertEqual(list((case_path / "bodies").iterdir()), []) + + def test_an_identity_with_no_email_is_refused_rather_than_crashing(self): + # Backlog item 5: build() reads identity["email"] directly, so this + # shape raised KeyError. A name without an address is exactly what + # skipping one init prompt and answering another produces. + self._write_config('[reporter]\nname = "A Reporter"\n') + case_path = self._make_case() + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_NOT_CONFIGURED) + self.assertIn("email", err) + self.assertEqual(list((case_path / "bodies").iterdir()), []) + + def test_an_email_alone_is_enough_to_build_a_report(self): + # name and org are genuinely optional, and text_part renders the + # subset that is present. Refusing here would reject a config init + # writes without complaint. + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + code, out, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_OK, err) + + from abusectl import case + + manifest = case.load(case_path) + self.assertEqual(len(manifest["destinations"]), 1) + body = case_path / manifest["destinations"][0]["body"] + text = body.read_bytes().decode("utf-8") + self.assertIn("From: r@example.org", text) + self.assertIn("Reported by: <r@example.org>", text) + # The summary tells the user where to look: nothing sends these yet, + # so review is the next step and it needs a path. + self.assertIn("1 destinations", out) + self.assertIn(str(case_path / "bodies"), out) + + def test_the_global_config_option_is_honoured(self): + # --config is global and every other subcommand honours it. Calling + # config.load() with no argument would read the user's real config + # and report against the wrong identity, or refuse a configured run. + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + code, _, err = self._run( + "--config", str(self.root / "absent.toml"), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_NOT_CONFIGURED) + self.assertIn("absent.toml", err) + + def test_a_frozen_case_is_an_error_not_a_traceback(self): + from abusectl import case + + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + manifest = case.load(case_path) + manifest["frozen"] = {"by": "abuse@example.invalid", "at": "2026-09-10"} + case.save(case_path, manifest) + + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_ERROR) + self.assertIn("cannot be regenerated", err) + + def test_an_edited_body_is_refused_and_force_clears_it(self): + from abusectl import case + + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + self._run("--config", str(self.config), "report", str(case_path)) + + body = case_path / case.load(case_path)["destinations"][0]["body"] + body.write_bytes(b"hand edited\n") + + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_ERROR) + self.assertIn("--force", err) + self.assertEqual(body.read_bytes(), b"hand edited\n") + + code, _, err = self._run( + "--config", str(self.config), "report", "--force", str(case_path) + ) + self.assertEqual(code, cli.EXIT_OK, err) + self.assertIn("From: r@example.org", body.read_bytes().decode("utf-8")) + backups = list((case_path / "bodies").glob("*.orig")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), b"hand edited\n") + + def test_a_second_run_on_an_untouched_case_succeeds(self): + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + self._run("--config", str(self.config), "report", str(case_path)) + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_OK, err) + + def test_a_corrupt_manifest_is_an_error_not_a_traceback(self): + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + (case_path / "manifest.json").write_text("{not json") + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_ERROR) + + def test_a_case_with_no_contacts_reports_nothing_rather_than_failing(self): + # generate() always sets both keys, so the summary can index them. + from abusectl import case + + self._write_config('[reporter]\nemail = "r@example.org"\n') + case_path = self._make_case() + manifest = case.load(case_path) + manifest["contacts"] = [] + del manifest["destinations"] + case.save(case_path, manifest) + + code, out, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, cli.EXIT_OK, err) + self.assertIn("0 destinations", out) + written = case.load(case_path) + self.assertEqual(written["destinations"], []) + self.assertEqual(written["unreportable"], []) + + if __name__ == "__main__": unittest.main() |
