aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-09 19:12:41 +0200
committerDanilo M. <danix@danix.xyz>2026-09-09 19:12:41 +0200
commit7275db9f81e9995318441807aa0a80022c84d55f (patch)
treee814cffcc90ddda0082ee64ce3a57d4600c2b74b /tests
parentf17a12003563330b1a57c245ebbd435e97aa73cc (diff)
downloadabusectl-7275db9f81e9995318441807aa0a80022c84d55f.tar.gz
abusectl-7275db9f81e9995318441807aa0a80022c84d55f.zip
fix: derive a destination id from its address, not its position
A positional id names a slot in one run's list, and report may run again on a case whose contacts have changed since. A newly resolved indicator whose desk sorts ahead renumbers every desk after it, so bodies/<id>.xarf on disk comes to belong to a different desk than the manifest entry sharing that id. The body hash recorded per destination would then compare one desk's body against another's, reporting an edit nobody made or missing one that was. Hashing the address makes an id follow the desk. It hashes the same normalised form the grouping uses, so two spellings of one desk share an id rather than letting whichever spelling RDAP published first decide a body's filename. Eight hex chars is a deliberate ceiling: collision probability is about 1e-8 at ten desks, and a short id keeps a case directory readable to the person reviewing it. Also strengthens two tests that passed against trivially wrong code: the no-address case now asserts alongside a contact that does resolve, so returning nothing at all is no longer a passing answer, and the two-address case asserts the ioc lists and distinct ids rather than the sorted targets alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
Diffstat (limited to 'tests')
-rw-r--r--tests/test_report.py112
1 files changed, 93 insertions, 19 deletions
diff --git a/tests/test_report.py b/tests/test_report.py
index 6a0dc51..c72974e 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -22,17 +22,36 @@ class Grouping(unittest.TestCase):
"abuse": ["a@host.invalid", "b@host.invalid"], "source": "rdap"},
]
destinations = report.email_destinations(contacts)
- self.assertEqual(
- sorted(d["target"] for d in destinations),
- ["a@host.invalid", "b@host.invalid"],
- )
+ self.assertEqual([d["target"] for d in destinations],
+ ["a@host.invalid", "b@host.invalid"])
+ # Both desks carry the indicator, and each gets its own id: a
+ # destination that reached only one desk, or two rows sharing an
+ # id, would pass an assertion on the sorted targets alone.
+ self.assertEqual([d["iocs"] for d in destinations],
+ [["ioc-1"], ["ioc-1"]])
+ self.assertEqual(len({d["id"] for d in destinations}), 2)
+ for destination in destinations:
+ self.assertEqual(destination["id"],
+ report.email_destination_id(
+ destination["target"]))
def test_a_contact_with_no_address_creates_no_destination(self):
+ """The contact that resolved must still produce its destination.
+
+ Asserted alongside one that DOES resolve, because "no destination
+ for this contact" is also what returning nothing at all looks
+ like, and that is not the behaviour being described.
+ """
contacts = [
{"iocs": ["ioc-1"], "query": "example.invalid", "abuse": [],
"source": "rdap", "error": "no abuse role published"},
+ {"iocs": ["ioc-2"], "query": "198.51.100.7",
+ "abuse": ["abuse@host.invalid"], "source": "rdap"},
]
- self.assertEqual(report.email_destinations(contacts), [])
+ destinations = report.email_destinations(contacts)
+ self.assertEqual([d["target"] for d in destinations],
+ ["abuse@host.invalid"])
+ self.assertEqual(destinations[0]["iocs"], ["ioc-2"])
def test_destinations_carry_stable_ids_and_pending_status(self):
contacts = [
@@ -40,17 +59,26 @@ class Grouping(unittest.TestCase):
"abuse": ["abuse@host.invalid"], "source": "rdap"},
]
destination = report.email_destinations(contacts)[0]
- self.assertEqual(destination["id"], "email-1")
+ self.assertEqual(destination["id"],
+ report.email_destination_id("abuse@host.invalid"))
self.assertEqual(destination["kind"], "email")
self.assertEqual(destination["status"], "pending")
- def test_ids_are_numbered_per_destination_not_per_contact(self):
- """A skipped contact must not leave a hole in the numbering.
+ def test_an_id_is_the_literal_shape_a_reviewer_will_read(self):
+ """Pin the shape, since it becomes a filename in bodies/.
+
+ Computed by hand rather than by calling the code under test, so
+ this fails if the derivation changes rather than following it.
+ """
+ self.assertEqual(report.email_destination_id("abuse@host.invalid"),
+ "email-bc50e369")
+
+ def test_ids_are_derived_per_destination_not_per_contact(self):
+ """A contact that resolved to no desk must not shift another's id.
- The obvious implementation enumerates the contacts, and a contact
- with no abuse address then burns an id: the desks come back as
- "email-2" and "email-4", which reads to a reviewer as two reports
- having gone missing.
+ The obvious implementation numbers destinations by position, and a
+ skipped contact then either burns an id or renumbers the rest.
+ Both are wrong for the same reason: an id names a desk.
"""
contacts = [
{"iocs": ["ioc-1"], "query": "example.invalid", "abuse": [],
@@ -62,7 +90,8 @@ class Grouping(unittest.TestCase):
]
destinations = report.email_destinations(contacts)
self.assertEqual([d["id"] for d in destinations],
- ["email-1", "email-2"])
+ [report.email_destination_id("a@host.invalid"),
+ report.email_destination_id("b@host.invalid")])
self.assertEqual([d["target"] for d in destinations],
["a@host.invalid", "b@host.invalid"])
@@ -95,6 +124,16 @@ class Grouping(unittest.TestCase):
self.assertEqual(destinations[0]["iocs"], ["ioc-1", "ioc-2"])
self.assertEqual(destinations[0]["target"], "abuse@Host.Invalid")
+ def test_two_spellings_of_one_desk_share_an_id(self):
+ """The id derives from the same normalised form the grouping uses.
+
+ Otherwise the spelling RDAP happened to publish first would decide
+ a body's filename, and a re-run that saw the other spelling first
+ would look like a different desk.
+ """
+ self.assertEqual(report.email_destination_id("abuse@Host.Invalid"),
+ report.email_destination_id("abuse@host.invalid"))
+
def test_two_local_part_cases_stay_two_destinations(self):
"""Only the receiving host knows whether its local parts fold.
@@ -109,6 +148,7 @@ class Grouping(unittest.TestCase):
destinations = report.email_destinations(contacts)
self.assertEqual(sorted(d["target"] for d in destinations),
["Abuse@host.invalid", "abuse@host.invalid"])
+ self.assertNotEqual(destinations[0]["id"], destinations[1]["id"])
def test_a_destination_starts_with_no_body(self):
contacts = [
@@ -118,12 +158,7 @@ class Grouping(unittest.TestCase):
self.assertIsNone(report.email_destinations(contacts)[0]["body"])
def test_the_same_contacts_produce_the_same_ids_twice(self):
- """Ids must not depend on dict iteration luck or set ordering.
-
- Task 8 writes each body to bodies/<id>.xarf and records its hash
- against that id, so an id that moved between two runs over the
- same input would compare one desk's body against another's.
- """
+ """Ids must not depend on dict iteration luck or set ordering."""
contacts = [
{"iocs": ["ioc-1"], "query": "198.51.100.7",
"abuse": ["b@host.invalid", "a@host.invalid"], "source": "rdap"},
@@ -138,6 +173,45 @@ class Grouping(unittest.TestCase):
["b@host.invalid", "a@host.invalid",
"c@host.invalid"])
+ def test_a_desks_id_survives_another_desk_appearing(self):
+ """An id names a DESK, not a position in this run's list.
+
+ Task 8 writes each body to bodies/<id>.xarf and records its hash
+ against that id. With a positional id, re-running contacts on a
+ case that gained an indicator renumbers every desk after the new
+ one, so bodies/<id>.xarf on disk belongs to a different desk than
+ the manifest's entry of that id, and the edit check compares one
+ desk's body against another's.
+ """
+ established = {"iocs": ["ioc-1"], "query": "198.51.100.7",
+ "abuse": ["b@host.invalid"], "source": "rdap"}
+ first = report.email_destinations([established])
+
+ # A later contacts run finds an indicator whose desk sorts ahead.
+ newcomer = {"iocs": ["ioc-2"], "query": "example.invalid",
+ "abuse": ["a@new.invalid"], "source": "rdap"}
+ second = report.email_destinations([newcomer, established])
+
+ by_target = {d["target"]: d["id"] for d in second}
+ self.assertEqual(by_target["b@host.invalid"], first[0]["id"])
+ self.assertNotEqual(by_target["a@new.invalid"], first[0]["id"])
+
+ def test_an_ids_position_does_not_leak_into_it(self):
+ """The same desk alone and third in a list gets one id."""
+ alone = report.email_destinations([
+ {"iocs": ["ioc-1"], "query": "198.51.100.7",
+ "abuse": ["desk@host.invalid"], "source": "rdap"},
+ ])
+ crowded = report.email_destinations([
+ {"iocs": ["ioc-2"], "query": "198.51.100.8",
+ "abuse": ["one@host.invalid", "two@host.invalid"],
+ "source": "rdap"},
+ {"iocs": ["ioc-1"], "query": "198.51.100.7",
+ "abuse": ["desk@host.invalid"], "source": "rdap"},
+ ])
+ self.assertEqual(crowded[2]["target"], "desk@host.invalid")
+ self.assertEqual(crowded[2]["id"], alone[0]["id"])
+
if __name__ == "__main__":
unittest.main()