From 7fbd109ed8e00ca1184015ba50c30ee92cce1ad8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 9 Sep 2026 09:47:19 +0200 Subject: feat: add the contacts subcommand A re-run overwrites contacts[] wholesale rather than merging. A merge would let a contact resolved a week ago survive into a report filed today, which is the stale-address hazard the response caching policy already refuses, and overwriting makes a re-run always safe, which matters because a partial network failure makes re-running the natural next step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz --- tests/test_cli.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) (limited to 'tests/test_cli.py') diff --git a/tests/test_cli.py b/tests/test_cli.py index fc4a6e7..1c023c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -163,5 +163,78 @@ class TestParse(unittest.TestCase): self.assertIn("absent.eml", err) +class ContactsCommand(unittest.TestCase): + def test_contacts_rewrites_the_manifest(self): + from unittest import mock + + from abusectl import case + + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + created = case.create(root, 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"} + ] + case.save(created.path, manifest) + + fake_contacts = [{ + "iocs": ["ioc-1"], "query": "198.51.100.7", + "abuse": ["abuse@example.invalid"], "source": "rdap", + }] + + out = io.StringIO() + with mock.patch("abusectl.cli.contacts_module.resolve", + return_value=fake_contacts) as resolve, \ + mock.patch("abusectl.cli.rdap_module.bootstrap", + return_value={"services": []}), \ + redirect_stdout(out): + code = cli.main(["contacts", str(created.path)]) + + self.assertEqual(code, cli.EXIT_OK) + self.assertTrue(resolve.called) + written = case.load(created.path) + self.assertEqual(written["contacts"], fake_contacts) + + def test_a_missing_case_is_an_error_not_a_traceback(self): + err = io.StringIO() + with redirect_stderr(err): + code = cli.main(["contacts", "/nonexistent/case/path"]) + self.assertEqual(code, cli.EXIT_ERROR) + + def test_an_unavailable_bootstrap_is_an_error_not_a_traceback(self): + from unittest import mock + + from abusectl import case, rdap + + with tempfile.TemporaryDirectory() as tmp: + created = case.create( + pathlib.Path(tmp), b"From: sender@example.invalid\r\n\r\nbody\r\n" + ) + err = io.StringIO() + with mock.patch( + "abusectl.cli.rdap_module.bootstrap", + side_effect=rdap.BootstrapUnavailable("no bootstrap and no cache"), + ), redirect_stderr(err): + code = cli.main(["contacts", str(created.path)]) + + self.assertEqual(code, cli.EXIT_ERROR) + self.assertIn("no bootstrap and no cache", err.getvalue()) + + def test_a_corrupt_manifest_is_an_error_not_a_traceback(self): + from abusectl import case + + with tempfile.TemporaryDirectory() as tmp: + created = case.create( + pathlib.Path(tmp), b"From: sender@example.invalid\r\n\r\nbody\r\n" + ) + (created.path / "manifest.json").write_text("{not json") + err = io.StringIO() + with redirect_stderr(err): + code = cli.main(["contacts", str(created.path)]) + + self.assertEqual(code, cli.EXIT_ERROR) + + if __name__ == "__main__": unittest.main() -- cgit v1.2.3