aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_cli.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_cli.py')
-rw-r--r--tests/test_cli.py200
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()