package notify import ( "strings" "testing" "time" ) // R-87 Group C — the ALARM. // // It reuses `notifierAgainstHub` / `awaitEvent` from backup_target_notify_test.go rather than adding // a second harness: the question is identical (what actually went over the WIRE), and a second // recorder is how two tests come to disagree about the same encoding. // // The severity vocabulary is `{info, warning, error, critical}` and anything else is silently coerced // to `info` and mailed to NOBODY — that shipped twice (R-328 on `disk_health_degraded`, R-329 on // `app_start_failed`: 91 events stored, zero delivered). // TestR87_FailureEmitsExactlyOneEventWithAValidSeverity — C1. func TestR87_FailureEmitsExactlyOneEventWithAValidSeverity(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyOffsiteProofEmpty( "A(z) kimai legutobbi tavoli mentese olvashato, de nem tartalmazza az alkalmazas adatait.", "snapshot a07c36a1, reason database_expected_none_captured") ev := awaitEvent(t, got) if ev.EventType != "offsite_proof_empty" { t.Fatalf("event_type = %q, want offsite_proof_empty — an unallowlisted type is 400'd by the hub and VANISHES", ev.EventType) } valid := map[string]bool{"info": true, "warning": true, "error": true, "critical": true} if !valid[ev.Severity] { t.Fatalf("severity %q is outside the hub's vocabulary — it would be coerced to info and mailed to nobody", ev.Severity) } if ev.Severity != "error" { t.Fatalf("severity = %q, want error: a backup holding none of the customer's data is not a warning", ev.Severity) } // EXACTLY ONE. A second event for the same fact is how an operator learns to skim. select { case extra := <-got: t.Fatalf("a failing proof must emit exactly ONE event; a second arrived: %+v", extra) case <-time.After(300 * time.Millisecond): } } // TestR87_MessageSaysIntactButEmptyNotCorrupt — C3. // // ASCII-ONLY FRAGMENTS WITH A NEGATIVE CONTROL (R-364): an accented grep has returned 0 for strings // that were there, so every fragment below is ASCII and one deliberately-absent fragment proves the // search can fail. func TestR87_MessageSaysIntactButEmptyNotCorrupt(t *testing.T) { n, got := notifierAgainstHub(t) msg := "A(z) kimai legutobbi tavoli mentese olvashato, de nem tartalmazza az alkalmazas adatait. " + "A tarolo nem serult - a mentes keszult el uresen." n.NotifyOffsiteProofEmpty(msg, "snapshot a07c36a1") ev := awaitEvent(t, got) // POSITIVE: the store is readable AND the content is absent — both halves, or the customer reads // this as R-359's damaged store, which has a different cause and a different action. for _, frag := range []string{"olvashato", "nem tartalmazza", "nem serult"} { if !strings.Contains(ev.Message, frag) { t.Fatalf("the message must carry %q; got %q", frag, ev.Message) } } // NEGATIVE CONTROL: prove the search above can fail on this same string. if strings.Contains(ev.Message, "ZZZ-NOT-IN-THE-MESSAGE") { t.Fatal("negative control matched — the fragment search is not discriminating, so the positives above prove nothing") } // It must NOT claim damage. These are R-359's own words for the other fault. for _, forbidden := range []string{"hibat talalt", "serult lehet"} { if strings.Contains(ev.Message, forbidden) { t.Fatalf("the message must not say the store is damaged; %q appears in %q", forbidden, ev.Message) } } } // TestR87_ProofEventCarriesNoPathAndNoRepositoryURL — units carry portable secrets and the repository // URL is a credential-adjacent string. Neither may ride out on an event. func TestR87_ProofEventCarriesNoPathAndNoRepositoryURL(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyOffsiteProofEmpty("A(z) kimai mentese ures.", "snapshot a07c36a1, reason database_expected_none_captured, expected: db") ev := awaitEvent(t, got) for _, forbidden := range []string{"/mnt/", "sftp:", "RESTIC_PASSWORD", "ssh_key"} { if strings.Contains(ev.Message, forbidden) { t.Fatalf("%q must never appear in a customer/operator event; got %q", forbidden, ev.Message) } } } // TestR87_PassEmitsNothing — C2, asserted as a NON-EFFECT. The job-level half — that the pass branch // never reaches the notifier at all — is D1's AST walk over main.go. func TestR87_PassEmitsNothing(t *testing.T) { _, got := notifierAgainstHub(t) select { case ev := <-got: t.Fatalf("nothing was called, so nothing may arrive; got %+v", ev) case <-time.After(300 * time.Millisecond): } } // TestR87_NoPerAppCooldownEntryWasAdded — C4, asserted from the controller side. // // The register lives in the hub (`notify.perAppCooldownEvents`) and adding an entry there is a FENCED // act (08 §6.2): the backup family's cooldown is coarse ON PURPOSE so that one full disk produces one // mail rather than twenty. This job proves ONE app per night, so the coarse hourly operator cooldown // is already the right grain. // // The controller's half of that contract is asserted here: the event must NOT carry a `stack_name` // detail field, because that is the payload shape the hub's per-app keying reads. The app is named in // the MESSAGE instead. This fails if someone later routes the type through the per-app path. func TestR87_NoPerAppCooldownEntryWasAdded(t *testing.T) { n, got := notifierAgainstHub(t) n.NotifyOffsiteProofEmpty("A(z) kimai mentese ures.", "snapshot a07c36a1") ev := awaitEvent(t, got) if strings.Contains(ev.Message, "stack_name") { t.Fatalf("offsite_proof_empty must not carry stack_name — that is the field the hub per-app cooldown keys on, and this family is coarse by design; got %s", ev.Message) } }