aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_report.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 10:13:04 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 10:13:04 +0200
commit2cdd4dba93b26691a85cd0977294687d5afe0670 (patch)
tree6027db6b599b43138a7b1f81a08296f7e91cfa5e /tests/test_report.py
parent57dc93c3069ccac583533f4f2d4f9cbc2720c73e (diff)
downloadabusectl-2cdd4dba93b26691a85cd0977294687d5afe0670.tar.gz
abusectl-2cdd4dba93b26691a85cd0977294687d5afe0670.zip
feat: freeze a reported case and detect edited bodies
Content hash rather than mtime, because mtime is wrong in both directions. Any sent destination freezes the whole case with no override: two desks holding contradictory accounts of one incident is worse than a stale body. The marker is tested for PRESENCE rather than truthiness. It is write-once and monotonic, so an empty or malformed value is a half-written record of a case a desk may already hold, and the safe direction is to refuse; only an explicit null counts as absent. A non-mapping marker refuses too rather than raising AttributeError out of a .get, since cli turns Frozen into a message and a traceback into a bug report. The hash pins utf-8 rather than leaving the encoding to the locale: it is compared against a body read back off disk, possibly under a different LANG, and for a sent destination it is the record of what was disclosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
Diffstat (limited to 'tests/test_report.py')
-rw-r--r--tests/test_report.py110
1 files changed, 110 insertions, 0 deletions
diff --git a/tests/test_report.py b/tests/test_report.py
index 6a340c9..b36252c 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,6 +1,7 @@
import copy
import email
import email.policy
+import hashlib
import json
import unittest
@@ -1596,5 +1597,114 @@ class DocumentIdentity(unittest.TestCase):
self.assertEqual(addresses[0].display_name, name)
+class Freeze(unittest.TestCase):
+ """A case that has reached a desk is an evidence record, not a draft."""
+
+ def _frozen(self, marker):
+ manifest = copy.deepcopy(MANIFEST)
+ manifest["frozen"] = marker
+ return manifest
+
+ def test_a_frozen_case_refuses(self):
+ manifest = self._frozen({"at": "2026-09-07T10:00:00Z",
+ "by": "abusedb"})
+ with self.assertRaises(report.Frozen) as caught:
+ report.check_regenerable(manifest, modified=[])
+ self.assertIn("abusedb", str(caught.exception))
+
+ def test_a_frozen_case_refuses_even_when_forced(self):
+ manifest = self._frozen({"at": "2026-09-07T10:00:00Z",
+ "by": "abusedb"})
+ with self.assertRaises(report.Frozen):
+ report.check_regenerable(manifest, modified=[], force=True)
+
+ def test_a_frozen_case_refuses_before_the_modified_check(self):
+ """Frozen wins over force for EVERY shape of the second argument.
+
+ The two rules are not independent: an edited body on a frozen case
+ must still refuse, and must refuse as Frozen rather than as
+ Modified, because Modified is the one --force can clear.
+ """
+ manifest = self._frozen({"at": "2026-09-07T10:00:00Z",
+ "by": "abusedb"})
+ with self.assertRaises(report.Frozen):
+ report.check_regenerable(manifest, modified=["email-1"])
+ with self.assertRaises(report.Frozen):
+ report.check_regenerable(manifest, modified=["email-1"],
+ force=True)
+
+ def test_a_present_but_empty_marker_still_freezes(self):
+ """Present means frozen. The marker is write-once and monotonic, so
+ a shape that is present and says nothing useful is a half-written or
+ corrupted record, and the safe direction is to refuse. Truthiness is
+ the wrong test: `{}` is present and falsy, and treating it as absent
+ silently unfreezes a case a desk may already hold.
+ """
+ for marker in ({}, "", 0, []):
+ with self.subTest(marker=marker):
+ with self.assertRaises(report.Frozen):
+ report.check_regenerable(self._frozen(marker),
+ modified=[], force=True)
+
+ def test_a_malformed_marker_refuses_rather_than_crashing(self):
+ """A marker that is not a mapping is still a refusal, not an
+ AttributeError. cli turns Frozen into a message and a traceback into
+ a bug report.
+ """
+ for marker in ("2026-09-07T10:00:00Z", 1, ["abusedb"]):
+ with self.subTest(marker=marker):
+ with self.assertRaises(report.Frozen):
+ report.check_regenerable(self._frozen(marker),
+ modified=[])
+
+ def test_an_explicit_null_marker_is_absent(self):
+ """JSON has no way to omit a key it wrote as null, and null is the
+ one present value that unambiguously carries no record.
+ """
+ manifest = self._frozen(None)
+ report.check_regenerable(manifest, modified=[])
+
+ def test_a_modified_body_refuses_without_force(self):
+ with self.assertRaises(report.Modified) as caught:
+ report.check_regenerable(MANIFEST, modified=["email-1"])
+ self.assertIn("email-1", str(caught.exception))
+
+ def test_a_modified_body_is_allowed_with_force(self):
+ report.check_regenerable(MANIFEST, modified=["email-1"], force=True)
+
+ def test_an_untouched_case_regenerates(self):
+ report.check_regenerable(MANIFEST, modified=[])
+
+ def test_it_does_not_mutate_the_manifest(self):
+ """It is a check, not a step. case.py is the only writer."""
+ manifest = copy.deepcopy(MANIFEST)
+ before = copy.deepcopy(manifest)
+ report.check_regenerable(manifest, modified=[])
+ self.assertEqual(manifest, before)
+
+
+class Hashes(unittest.TestCase):
+ def test_the_hash_detects_a_changed_body(self):
+ first = report.body_hash("one")
+ self.assertNotEqual(first, report.body_hash("two"))
+ self.assertEqual(first, report.body_hash("one"))
+
+ def test_it_is_sha256_of_the_utf8_bytes(self):
+ """The encoding is pinned, not incidental: the hash is compared
+ against a body read back off disk, and for a sent destination it is
+ the record of what was disclosed.
+ """
+ text = "a body with a non-ascii org name: Örg\n"
+ self.assertEqual(
+ report.body_hash(text),
+ hashlib.sha256(text.encode("utf-8")).hexdigest(),
+ )
+
+ def test_it_hashes_a_real_generated_body(self):
+ destination = report.email_destinations(MANIFEST["contacts"])[0]
+ text = report.build(MANIFEST, destination, IDENTITY)
+ self.assertEqual(len(report.body_hash(text)), 64)
+
+
if __name__ == "__main__":
unittest.main()