aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_report.py
blob: 3b13d11b2900a0ea83dcc4316278c02d34ec2952 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
import copy
import json
import unittest

from abusectl import report


class Grouping(unittest.TestCase):
    def test_two_contacts_at_one_address_become_one_destination(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
            {"iocs": ["ioc-2"], "query": "example.invalid",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["target"], "abuse@host.invalid")
        self.assertEqual(destinations[0]["iocs"], ["ioc-1", "ioc-2"])

    def test_a_contact_with_two_addresses_reaches_both_desks(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["a@host.invalid", "b@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        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"},
        ]
        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 = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destination = report.email_destinations(contacts)[0]
        self.assertEqual(destination["id"],
                         report.email_destination_id("abuse@host.invalid"))
        self.assertEqual(destination["kind"], "email")
        self.assertEqual(destination["status"], "pending")

    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 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": [],
             "source": "rdap", "error": "no abuse role published"},
            {"iocs": ["ioc-2"], "query": "198.51.100.7",
             "abuse": ["a@host.invalid"], "source": "rdap"},
            {"iocs": ["ioc-3"], "query": "198.51.100.8",
             "abuse": ["b@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual([d["id"] for d in destinations],
                         [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"])

    def test_one_desk_listed_twice_by_one_contact_is_one_destination(self):
        """A duplicate in a contact's own abuse list must not duplicate a desk.

        RDAP jCards are attacker-adjacent data: an entity can publish the
        same address in two vcard rows, and one destination per ADDRESS is
        the rule regardless of how many rows produced it.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid", "abuse@host.invalid"],
             "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["iocs"], ["ioc-1"])

    def test_one_desk_spelled_with_two_domain_cases_is_one_destination(self):
        """A domain is case-insensitive, so two spellings are one desk."""
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@Host.Invalid"], "source": "rdap"},
            {"iocs": ["ioc-2"], "query": "example.invalid",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["iocs"], ["ioc-1", "ioc-2"])
        self.assertEqual(destinations[0]["target"], "abuse@Host.Invalid")

    def test_the_domain_folds_under_a_local_part_that_does_not(self):
        """Isolate the domain fold from the role fold.

        The version of this test that first shipped used a lowercase
        local part throughout, so it exercised only the domain and passed
        while a capitalised role name produced two destinations.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["J.Smith@Host.Invalid"], "source": "rdap"},
            {"iocs": ["ioc-2"], "query": "example.invalid",
             "abuse": ["J.Smith@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["iocs"], ["ioc-1", "ioc-2"])

    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_a_role_mailbox_folds_in_both_halves(self):
        """"Abuse@Host.Invalid" and "abuse@host.invalid" are one desk.

        The case that first shipped folded the domain only, so a jCard
        publishing the role name capitalised produced two destinations and
        two mails to one desk. RFC 2142 mandates the role mailboxes and
        requires them case-insensitive, so no host runs "Abuse@" and
        "abuse@" as different desks.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["Abuse@Host.Invalid"], "source": "rdap"},
            {"iocs": ["ioc-2"], "query": "example.invalid",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["iocs"], ["ioc-1", "ioc-2"])
        self.assertEqual(destinations[0]["target"], "Abuse@Host.Invalid")

    def test_one_contact_publishing_a_role_mailbox_twice_folds_it(self):
        """The same fold applies within one contact's own abuse list.

        rdap.abuse_addresses dedupes case-sensitively, so a jCard with two
        vcard rows spelling the role differently delivers both here.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["Abuse@host.invalid", "abuse@host.invalid"],
             "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(len(destinations), 1)
        self.assertEqual(destinations[0]["iocs"], ["ioc-1"])

    def test_every_rfc2142_role_this_tool_can_meet_folds(self):
        for role in ("abuse", "postmaster", "security", "noc", "hostmaster"):
            with self.subTest(role=role):
                self.assertEqual(
                    report.email_destination_id(f"{role.title()}@host.invalid"),
                    report.email_destination_id(f"{role}@host.invalid"))

    def test_a_personal_local_part_is_left_alone(self):
        """Only the receiving host knows whether ITS local parts fold.

        A named mailbox is not a standardised role, so folding it could
        silently merge two desks a host genuinely distinguishes and drop
        one of them. Two mails to one desk is the lesser failure, and the
        role names above are where the duplicate actually happens.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["J.Smith@host.invalid", "j.smith@host.invalid"],
             "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual(sorted(d["target"] for d in destinations),
                         ["J.Smith@host.invalid", "j.smith@host.invalid"])
        self.assertNotEqual(destinations[0]["id"], destinations[1]["id"])

    def test_a_destination_starts_with_no_body(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        self.assertIsNone(report.email_destinations(contacts)[0]["body"])

    def test_the_contacts_passed_in_are_not_modified(self):
        """The caller's contacts are the manifest's own array.

        case.py is the only writer of a manifest, so a grouping pass that
        edited what it was handed would write through it from outside.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        before = copy.deepcopy(contacts)
        report.email_destinations(contacts)
        self.assertEqual(contacts, before)

    def test_a_destinations_ioc_list_is_its_own(self):
        """Not aliased to the contact's list it was built from.

        Holds today because the grouping starts a fresh list, but nothing
        else pins it: an implementation that reused contact["iocs"] for a
        single-contact destination would pass every other test here and
        leave a destination and a contact sharing one list in a manifest
        about to be written.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destination = report.email_destinations(contacts)[0]
        destination["iocs"].append("ioc-2")
        self.assertEqual(contacts[0]["iocs"], ["ioc-1"])

    def test_the_same_contacts_produce_the_same_ids_twice(self):
        """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"},
            {"iocs": ["ioc-2"], "query": "example.invalid",
             "abuse": ["c@host.invalid"], "source": "rdap"},
        ]
        first = report.email_destinations(contacts)
        second = report.email_destinations(contacts)
        self.assertEqual([(d["id"], d["target"]) for d in first],
                         [(d["id"], d["target"]) for d in second])
        self.assertEqual([d["target"] for d in first],
                         ["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"])


class Unreportable(unittest.TestCase):
    def test_an_ioc_with_no_desk_is_listed_with_its_reason(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
            {"iocs": ["ioc-2", "ioc-3"], "query": "example.invalid",
             "abuse": [], "source": "rdap",
             "error": "no abuse role published"},
        ]
        self.assertEqual(
            report.unreportable(contacts),
            [
                {"ioc": "ioc-2", "reason": "no abuse role published"},
                {"ioc": "ioc-3", "reason": "no abuse role published"},
            ],
        )

    def test_a_missing_reason_still_produces_an_entry(self):
        contacts = [{"iocs": ["ioc-9"], "query": "x.invalid", "abuse": [],
                     "source": "rdap"}]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-9", "reason": "no abuse address resolved"}],
        )

    def test_an_empty_reason_does_not_read_as_no_reason(self):
        """`error: ""` must not be reported as the literal empty string.

        A contact entry is written by contacts.resolve, but a manifest is
        a file on disk that a user edits during review. An empty reason
        renders as a blank cell in the report the user reads, which says
        nothing at all; the default at least says what happened.
        """
        contacts = [{"iocs": ["ioc-9"], "query": "x.invalid", "abuse": [],
                     "source": "rdap", "error": ""}]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-9", "reason": "no abuse address resolved"}],
        )

    def test_nothing_unreportable_is_an_empty_list_not_an_error(self):
        contacts = [{"iocs": ["ioc-1"], "query": "198.51.100.7",
                     "abuse": ["abuse@host.invalid"], "source": "rdap"}]
        self.assertEqual(report.unreportable(contacts), [])

    def test_an_ioc_that_reached_a_desk_elsewhere_is_not_unreportable(self):
        """Hosts fold, so one IOC can sit in a resolved and an unresolved
        contact at once. It IS reportable, and listing it says otherwise.

        The plan's implementation listed it regardless, which puts an
        indicator in both the destination list and the "no desk found"
        list of one manifest. A reviewer reading the second acts on an
        indicator that is already on its way to a desk, and the whole
        point of the array is that it can be trusted without diffing.
        """
        contacts = [
            {"iocs": ["ioc-1", "ioc-2"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
            {"iocs": ["ioc-2", "ioc-3"], "query": "example.invalid",
             "abuse": [], "source": "rdap",
             "error": "no abuse role published"},
        ]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-3", "reason": "no abuse role published"}],
        )

    def test_one_ioc_unresolved_twice_is_listed_once(self):
        """Two contacts, both unresolved, one shared indicator.

        A duplicate row is a second line in the report about one
        indicator, and the reasons may differ, so which one wins has to
        be decided rather than left to whichever contact came last.
        First reason seen wins, matching the first-seen ordering the
        destinations use.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "example.invalid", "abuse": [],
             "source": "rdap", "error": "no abuse role published"},
            {"iocs": ["ioc-1"], "query": "198.51.100.7", "abuse": [],
             "source": "rdap", "error": "no rdap server for this tld, "
                                        "or no answer"},
        ]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-1", "reason": "no abuse role published"}],
        )

    def test_the_contacts_passed_in_are_not_modified(self):
        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"},
        ]
        before = copy.deepcopy(contacts)
        report.unreportable(contacts)
        self.assertEqual(contacts, before)


class MalformedAddresses(unittest.TestCase):
    """An abuse "address" with no @ cannot be mailed.

    RDAP jCard data is third-party and occasionally malformed, and a
    destination built from such a value carries an unsendable target with
    status "pending". That is the failure mode the unreportable array
    exists to prevent: the indicator appears reportable, no desk ever
    receives it, and nothing in the manifest says so.
    """

    def test_a_target_with_no_at_creates_no_destination(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["not-an-address"], "source": "rdap"},
            {"iocs": ["ioc-2"], "query": "198.51.100.7",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        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_an_ioc_whose_only_address_is_malformed_is_unreportable(self):
        contacts = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["not-an-address"], "source": "rdap"},
        ]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-1",
              "reason": "no usable abuse address published"}],
        )

    def test_a_usable_address_beside_a_malformed_one_still_reports(self):
        """The good half of a jCard must survive the bad half.

        Discarding the contact wholesale would lose a real desk over a
        neighbouring malformed row.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["not-an-address", "abuse@host.invalid"],
             "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        self.assertEqual([d["target"] for d in destinations],
                         ["abuse@host.invalid"])
        self.assertEqual(report.unreportable(contacts), [])

    def test_an_addresss_own_error_is_not_overwritten_by_the_default(self):
        """A contact that has both a reason and a malformed address.

        The contact's own error says more than "no usable address", so it
        wins; the default is only for a contact that offered no reason.
        """
        contacts = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["not-an-address"], "source": "rdap",
             "error": "no abuse role published"},
        ]
        self.assertEqual(
            report.unreportable(contacts),
            [{"ioc": "ioc-1", "reason": "no abuse role published"}],
        )

    def test_the_two_lists_partition_every_indicator(self):
        """The invariant the pair is for: each IOC is in exactly one.

        Every other test here pins one side. This pins the relationship,
        which is what a reviewer actually relies on: an indicator missing
        from both is silently unreported, and one in both is reported and
        also flagged as unreported. Both failures come from the two
        functions disagreeing about what counts as a desk, so they are
        asserted against one input that exercises every branch.
        """
        contacts = [
            {"iocs": ["ioc-1", "ioc-2"], "query": "198.51.100.7",
             "abuse": ["Abuse@Host.Invalid"], "source": "rdap"},
            {"iocs": ["ioc-2", "ioc-3"], "query": "example.invalid",
             "abuse": [], "source": "rdap",
             "error": "no abuse role published"},
            {"iocs": ["ioc-4"], "query": "other.invalid",
             "abuse": ["not-an-address"], "source": "rdap"},
            {"iocs": ["ioc-5"], "query": "mixed.invalid",
             "abuse": ["broken", "abuse@host.invalid"], "source": "rdap"},
        ]
        destinations = report.email_destinations(contacts)
        reported = {i for d in destinations for i in d["iocs"]}
        flagged = {e["ioc"] for e in report.unreportable(contacts)}
        every = {i for c in contacts for i in c["iocs"]}

        self.assertEqual(reported & flagged, set())
        self.assertEqual(reported | flagged, every)
        self.assertEqual(reported, {"ioc-1", "ioc-2", "ioc-5"})
        self.assertEqual(flagged, {"ioc-3", "ioc-4"})

    def test_an_empty_or_whitespace_target_is_not_a_desk(self):
        for value in ("", "   ", "@host.invalid", "abuse@"):
            with self.subTest(value=value):
                contacts = [{"iocs": ["ioc-1"], "query": "example.invalid",
                             "abuse": [value], "source": "rdap"}]
                self.assertEqual(report.email_destinations(contacts), [])
                self.assertEqual(
                    report.unreportable(contacts),
                    [{"ioc": "ioc-1",
                      "reason": "no usable abuse address published"}])


IDENTITY = {"name": "A Reporter", "org": "Example Consulting",
            "email": "reporter@example.org"}

MANIFEST = {
    "format": 1,
    "case_id": "2026-09-07-aaaa",
    "iocs": [
        {"id": "ioc-1", "type": "ipv4", "value": "203.0.113.42",
         "origin": "received-chain", "confidence": "boundary-hop"},
        {"id": "ioc-2", "type": "url",
         "value": "http://login.sender.invalid/verify?id=REDACTED",
         "origin": "body"},
    ],
    "auth": {"spf": "fail", "dkim": "none", "dmarc": "fail"},
    "headers": [
        ("From", '"Example Bank" <phish@sender.invalid>'),
        ("Subject", "Your account requires verification"),
        ("Date", "Mon, 07 Sep 2026 09:12:40 +0000"),
    ],
    "contacts": [
        {"iocs": ["ioc-1", "ioc-2"], "query": "203.0.113.42",
         "abuse": ["abuse@host.invalid"], "source": "rdap"},
    ],
}

# 120 characters, well past the 72-column wrap, built from .invalid only.
LONG_URL = ("http://very-long-host-name.example.invalid/a/rather/deep/path/"
            "segment/tree/verify?campaign=REDACTED&id=REDACTED")


class TextPart(unittest.TestCase):
    def setUp(self):
        destination = report.email_destinations(MANIFEST["contacts"])[0]
        self.text = report.text_part(MANIFEST, destination, IDENTITY)

    def test_the_redaction_note_is_always_present(self):
        self.assertIn("Recipient identifiers", self.text)

    def test_the_reporter_identity_appears(self):
        self.assertIn("A Reporter", self.text)
        self.assertIn("Example Consulting", self.text)
        self.assertIn("reporter@example.org", self.text)

    def test_the_destinations_own_indicators_appear(self):
        self.assertIn("203.0.113.42", self.text)
        self.assertIn("http://login.sender.invalid/verify?id=REDACTED",
                      self.text)

    def test_an_indicator_belonging_to_another_desk_does_not_appear(self):
        manifest = dict(MANIFEST)
        manifest["iocs"] = MANIFEST["iocs"] + [
            {"id": "ioc-9", "type": "ipv4", "value": "192.0.2.99",
             "origin": "received-chain"},
        ]
        destination = report.email_destinations(MANIFEST["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)
        self.assertNotIn("192.0.2.99", text)

    def test_no_line_exceeds_seventy_two_columns(self):
        for line in self.text.splitlines():
            self.assertLessEqual(len(line), 72, line)

    # --- the parts the plan got wrong -------------------------------------

    def _with(self, **fields) -> str:
        manifest = copy.deepcopy(MANIFEST)
        manifest.update(fields)
        destination = report.email_destinations(manifest["contacts"])[0]
        return report.text_part(manifest, destination, IDENTITY)

    def test_a_long_url_is_whole_and_still_within_seventy_two_columns(self):
        """A truncated URL is a WRONG indicator, not a shortened one.

        The plan wrapped three lines with a `[:72]` slice and left the
        indicator list unwrapped. Both halves are the same defect: a desk
        acting on a prefix acts on a resource that is not the one reported,
        and a prefix reads as complete because nothing says otherwise.

        So the value must survive intact, reassemblable by a reader, and
        every line must still fit. Asserting only "the URL is in the text"
        would pass on a long unwrapped line, and asserting only the column
        limit would pass on a truncation; the two together admit neither.
        """
        manifest = copy.deepcopy(MANIFEST)
        manifest["iocs"] = [
            {"id": "ioc-1", "type": "url", "value": LONG_URL,
             "origin": "body"},
        ]
        manifest["contacts"] = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)

        for line in text.splitlines():
            self.assertLessEqual(len(line), 72, line)
        # Rejoining the continuation lines must give back the exact value.
        self.assertIn(LONG_URL, report.unwrap(text))

    def test_a_long_header_value_is_not_truncated(self):
        """A header is what the message DECLARED, and a cut one misstates it.

        The subject here is attacker-controlled free text of a length no
        column limit accommodates. Truncating it publishes something the
        message did not say.
        """
        long_subject = ("Your account requires verification before "
                        "the end of the working day or it will be "
                        "suspended permanently")
        manifest = copy.deepcopy(MANIFEST)
        manifest["headers"] = [("Subject", long_subject)]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)

        for line in text.splitlines():
            self.assertLessEqual(len(line), 72, line)
        self.assertIn(long_subject, report.unwrap(text))

    def test_a_long_reporter_identity_is_not_truncated(self):
        """The identity is the one thing disclosed deliberately.

        A cut address is an address nobody can reply to, which defeats the
        line's only purpose. The plan sliced it at 72.
        """
        identity = {"name": "A Reporter With Rather A Long Name",
                    "org": "Example Consulting And Partners Limited",
                    "email": "a.reporter@consulting.example.org"}
        destination = report.email_destinations(MANIFEST["contacts"])[0]
        text = report.text_part(MANIFEST, destination, identity)

        for line in text.splitlines():
            self.assertLessEqual(len(line), 72, line)
        joined = report.unwrap(text)
        self.assertIn("A Reporter With Rather A Long Name", joined)
        self.assertIn("a.reporter@consulting.example.org", joined)

    def test_headers_survive_a_json_round_trip(self):
        """case.load() returns lists, not tuples: JSON has no tuple.

        The header block is the one place a pair is destructured, so it is
        the one place the round-trip shape can break the report.
        """
        manifest = json.loads(json.dumps(MANIFEST))
        self.assertIsInstance(manifest["headers"][0], list)
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)
        self.assertIn("Your account requires verification", text)

    def test_an_origin_reads_as_english_not_as_an_internal_token(self):
        """`header-list_unsubscribe` is a parser's word, not a desk's.

        A desk deciding whether to act needs to know where an indicator was
        seen. An internal token with an underscore in it reads as debug
        output and makes the whole report look machine-dumped.
        """
        manifest = copy.deepcopy(MANIFEST)
        manifest["iocs"] = [
            {"id": "ioc-1", "type": "url", "value": "http://a.invalid/x",
             "origin": "header-list_unsubscribe"},
        ]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)
        self.assertNotIn("header-list_unsubscribe", text)
        self.assertIn("List-Unsubscribe", text)

    def test_an_unknown_origin_is_shown_rather_than_dropped(self):
        """A newer parse.py may invent an origin this table does not know.

        Dropping it would silently lose the one line saying where an
        indicator came from, so an unknown token is shown as-is: ugly beats
        absent, and it is visible enough to get the table updated.
        """
        manifest = copy.deepcopy(MANIFEST)
        manifest["iocs"] = [
            {"id": "ioc-1", "type": "url", "value": "http://a.invalid/x",
             "origin": "some-future-origin"},
        ]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)
        self.assertIn("some-future-origin", text)

    def test_a_boundary_hop_is_described_as_the_sending_ip(self):
        self.assertIn("sending IP", self.text)

    def test_an_ioc_id_a_destination_names_but_the_manifest_lacks(self):
        """A manifest is a file the user edits, so the two can disagree.

        The report must still be produced for the indicators that do exist
        rather than raising, and must not invent a row for the missing one.
        """
        destination = {"id": "email-x", "kind": "email",
                       "target": "abuse@host.invalid",
                       "iocs": ["ioc-1", "ioc-404"], "body": None,
                       "status": "pending"}
        text = report.text_part(MANIFEST, destination, IDENTITY)
        self.assertIn("203.0.113.42", text)
        self.assertNotIn("ioc-404", text)

    def test_a_manifest_with_no_auth_or_headers_still_reports(self):
        """Both blocks are optional and an empty one must not print a
        heading with nothing under it."""
        text = self._with(auth={}, headers=[])
        self.assertIn("203.0.113.42", text)
        self.assertNotIn("Message as declared", text)
        self.assertNotIn("Authentication results", text)


class BackslashRoundTrip(unittest.TestCase):
    """A value's own backslash must never be read as a wrap marker.

    The continuation marker is a trailing "\\", and a URL path may legally
    end in one. Until this was fixed the two were indistinguishable, so an
    attacker who read this source could append a backslash and make their
    own indicator garble itself in the report an abuse desk reads. That is
    an adversarial trigger on attacker-supplied text, not an edge case.

    The property asserted throughout is the only one that closes it:
    unwrap(text_part(...)) contains the value EXACTLY, for every value,
    wrapped or not. Asserting "the value appears" without unwrap, or
    asserting only on long values, both leave the short case open, and the
    short case is the one that needs no wrapping to corrupt.
    """

    def _render_ioc(self, value: str) -> str:
        manifest = copy.deepcopy(MANIFEST)
        manifest["iocs"] = [{"id": "ioc-1", "type": "url", "value": value,
                             "origin": "body"}]
        manifest["contacts"] = [
            {"iocs": ["ioc-1"], "query": "example.invalid",
             "abuse": ["abuse@host.invalid"], "source": "rdap"},
        ]
        destination = report.email_destinations(manifest["contacts"])[0]
        return report.text_part(manifest, destination, IDENTITY)

    def _assert_round_trips(self, value: str) -> None:
        text = self._render_ioc(value)
        for line in text.splitlines():
            self.assertLessEqual(len(line), 72, line)
        self.assertIn(value, report.unwrap(text))

    def test_a_short_value_ending_in_a_backslash_survives(self):
        """The case that needs no wrapping at all to corrupt.

        Nothing is wrapped here, yet unwrap() used to eat the following
        line, merging the indicator with its own origin annotation and
        rendering "http://a.invalid/xseen in a link in the message body".
        One corrupt line where there were two, and a wrong indicator.
        """
        self._assert_round_trips("http://a.invalid/x\\")

    def test_a_long_value_ending_in_a_backslash_survives(self):
        self._assert_round_trips("http://a.invalid/" + "b" * 90 + "\\")

    def test_a_value_with_an_interior_backslash_survives(self):
        self._assert_round_trips("http://a.invalid/x\\y/z")

    def test_a_value_ending_in_two_backslashes_survives(self):
        """Whatever escaping is chosen must not have its own off-by-one.

        Doubling every backslash makes a trailing pair into four, and a
        decoder that consumes them greedily or in the wrong order gives
        back one backslash or three. This is the test that catches that.
        """
        self._assert_round_trips("http://a.invalid/x\\\\")

    def test_adversarial_values_round_trip_exactly(self):
        """A handful of shapes chosen to sit on the seams.

        The two boundary values matter most: a value that exactly fills a
        line and one a single character over it are where an off-by-one in
        the wrap arithmetic lives, and a backslash landing exactly on the
        break column is where escaping and wrapping interact.
        """
        indent = 2
        room = 72 - indent
        values = [
            "http://a.invalid/x\\",
            "http://a.invalid/x\\y/z",
            "http://a.invalid/x\\\\",
            "\\" + "a" * 40,
            "a" * 40 + "\\",
            "http://a.invalid/" + "b" * 90 + "\\",
            "a" * room,                      # exactly fills the line
            "a" * (room + 1),                # one character over
            "a" * (room - 1) + "\\",         # backslash at the break
            "a" * room + "\\",
            "\\\\" + "c" * 80 + "\\\\",
        ]
        for value in values:
            with self.subTest(value=value):
                self._assert_round_trips(value)

    def test_a_backslash_landing_on_the_break_column_survives(self):
        """The case that a passing suite still missed.

        Escaping doubles each backslash, and a break falling BETWEEN the
        two halves of a pair splits the run unwrap() counts the parity of.
        Both halves are then misread, a real marker reads as content, the
        continuation line is orphaned and the tail of the value is silently
        dropped. It needs a backslash at exactly the break column, so no
        hand-written case found it; a randomised sweep failed 454 of 3538.

        Walking the backslash across every position around the boundary is
        what makes this deterministic rather than luck.
        """
        room = 72 - 2  # indent is two spaces for an indicator line
        for offset in range(-4, 5):
            position = room + offset
            if position < 1:
                continue
            value = "a" * position + "\\" + "b" * 30
            with self.subTest(offset=offset):
                self._assert_round_trips(value)

    def test_a_run_of_backslashes_across_the_break_survives(self):
        """A run is where an off-by-one in the back-off hides.

        Backing off one character is correct only if the character it lands
        on is the first half of a pair; a run of three or four exercises
        whether the parity test looks at the run rather than at one
        character.
        """
        room = 72 - 2
        for length in range(1, 6):
            for offset in range(-3, 4):
                position = room + offset
                if position < 1:
                    continue
                value = "a" * position + "\\" * length + "b" * 20
                with self.subTest(length=length, offset=offset):
                    self._assert_round_trips(value)

    def test_a_value_that_is_entirely_backslashes_survives(self):
        """Escaping doubles the length, so this is the worst case for both
        the wrap arithmetic and the parity test at once."""
        for length in (1, 2, 3, 34, 35, 36, 70, 71):
            with self.subTest(length=length):
                self._assert_round_trips("\\" * length)

    def test_an_attacker_subject_cannot_corrupt_the_header_block(self):
        """Subject is attacker-controlled and sits beside headers it can eat.

        This is worse than the URL case: a trailing backslash on Subject
        used to swallow the following line, rendering
        "Subject: Verify nowDate: Mon, 07 Sep 2026 09:12:40 +0000". The
        attacker's own text destroys a DIFFERENT field's value, so the
        block misstates what the message declared, which is the one thing
        that block exists to report faithfully.
        """
        subject = "Verify now\\"
        manifest = copy.deepcopy(MANIFEST)
        manifest["headers"] = [
            ("Subject", subject),
            ("Date", "Mon, 07 Sep 2026 09:12:40 +0000"),
        ]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)

        for line in text.splitlines():
            self.assertLessEqual(len(line), 72, line)
        joined = report.unwrap(text)
        self.assertIn(f"Subject: {subject}", joined)
        # The Date must survive intact rather than being absorbed.
        self.assertIn("Date: Mon, 07 Sep 2026 09:12:40 +0000", joined)

    def test_a_display_name_ending_in_a_backslash_survives(self):
        """The From display name is attacker-controlled too, and a sweep
        has already found a spoofed one."""
        value = '"Example Bank\\" <phish@sender.invalid>'
        manifest = copy.deepcopy(MANIFEST)
        manifest["headers"] = [("From", value),
                               ("Subject", "Your account requires check")]
        destination = report.email_destinations(manifest["contacts"])[0]
        text = report.text_part(manifest, destination, IDENTITY)

        joined = report.unwrap(text)
        self.assertIn(f"From: {value}", joined)
        self.assertIn("Subject: Your account requires check", joined)

    def test_unwrap_reads_a_marker_by_parity_not_by_a_trailing_backslash(self):
        """unwrap() is PUBLIC, so its input is not only our own output.

        A case manifest is a file the user edits and a desk may script
        against the text part, so unwrap() must decide correctly on a line
        it did not generate. Inside generated text the wrap back-off means
        a marker always follows an even run, so parity and a plain
        endswith() agree and neither is distinguishable by a round-trip
        test. They disagree here, on a line ending in an escaped pair and
        nothing else: that is content, and the following line must NOT be
        absorbed into it.
        """
        # "a\\" escaped is a value ending in one literal backslash, whole
        # on its line. endswith() reads the second half as a marker.
        self.assertEqual(report.unwrap("  a\\\\\n  next"), "  a\\\n  next")
        # An odd run IS a marker: two escaped halves plus the marker.
        self.assertEqual(report.unwrap("  a\\\\\\\n  next"), "  a\\next")

    def test_an_identity_containing_a_backslash_survives(self):
        identity = {"name": "A Reporter\\", "org": "Example Consulting",
                    "email": "reporter@example.org"}
        destination = report.email_destinations(MANIFEST["contacts"])[0]
        text = report.text_part(MANIFEST, destination, identity)
        self.assertIn("A Reporter\\", report.unwrap(text))
        self.assertIn("Generated by abusectl.", report.unwrap(text))


if __name__ == "__main__":
    unittest.main()