diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 11:10:08 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 11:10:08 +0200 |
| commit | 45a68fbbe8da80a7bdf399b2e042f1982ca6a69d (patch) | |
| tree | d56a1f42cc1507b185cbfcc60f2aee63a8ddf1d5 /tests | |
| parent | fb45dbc03650bd9ed7f5629fd01fffdaa951f109 (diff) | |
| download | abusectl-45a68fbbe8da80a7bdf399b2e042f1982ca6a69d.tar.gz abusectl-45a68fbbe8da80a7bdf399b2e042f1982ca6a69d.zip | |
feat: add the report subcommand
Refuses without a configured reporter identity rather than filing a report
with no reply address, and turns a frozen or edited case into an error
message rather than a traceback.
The identity rule is EMAIL REQUIRED, NAME AND ORG OPTIONAL, not the plan's
"all three required". Each [reporter] key is individually skippable at init
and config drops a skipped one rather than storing "", so a partial identity
is a normal shape; text_part() already renders whatever subset is present.
Requiring all three would refuse a config init itself writes without
complaint. The address is different in kind because it becomes the From.
That closes backlog item 5: report.build() read identity["email"] directly
and raised a bare KeyError on a name-without-address identity, which is
exactly what skipping one init prompt while answering another produces. The
check lives in the command rather than the builder, since that is what
decides whether the user sees an exit code and a sentence or a traceback.
Two further defects in the plan's code, both fixed here:
- It called config.load() with no argument, so the global --config option
was silently ignored for this subcommand alone. It now resolves the path
the way _cmd_parse does. Reporting against the wrong identity is the
failure that would have caused.
- Its summary printed counts and no path. Nothing sends these bodies yet, so
reviewing them by hand is the entire point of the command, and the case's
bodies directory is now named in the output.
case.py stays the only writer of the manifest: generate() returns it and
case.save() writes it atomically. Bodies are written before that save, so a
failed save leaves bodies the manifest does not record; the next run finds no
recorded hash, treats them as regenerable rather than hand-edited and
overwrites them, and nothing has been sent, so no machinery is warranted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
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() |
