package notify import ( "io" "log" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-97c — the operator-only claim must be TRUE, not asserted. // // v0.78.0 committed a comment saying a type with no `customerMessages` entry "structurally cannot" // reach a customer. It can: templates.go falls back to the raw message when the entry is missing, and // the only customer gate is `prefs.EnabledEvents` — configuration. This suite runs under exactly the // configuration that would break it. func opOnlyStore(t *testing.T) *store.Store { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "oo.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"}); err != nil { t.Fatal(err) } return st } // sentTo records every address the dispatcher actually tried to email. type sentTo struct{ to []string } func opOnlyDispatcher(t *testing.T, st *store.Store, rec *sentTo) *Dispatcher { t.Helper() d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0)) d.sendEmailFn = func(to, subject, body string, headers map[string]string) error { rec.to = append(rec.to, to) return nil } return d } // SCENARIO E — the breaking configuration: the customer HAS the event enabled and HAS an email. // // COMPANION RED-PROOF (observed): delete the operatorOnlyEvents check from processCustomer (the // v0.78.0 shape) and this fails with // // "R-97c: a customer was emailed an OPERATOR-ONLY event (customer@example.com) — enabled_events // must not be able to opt in" // // Restored. func TestOperatorOnly_CustomerCannotOptIn(t *testing.T) { st := opOnlyStore(t) // THE BREAKING CONFIG — not today's safe one. if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"whole_guest_backup_failed", "backup_failed"}, 6); err != nil { t.Fatalf("SaveNotificationPrefs: %v", err) } rec := &sentTo{} d := opOnlyDispatcher(t, st, rec) d.ProcessEvent("c1", "whole_guest_backup_failed", "error", "Whole-guest backup FAILED on the felhom-pbs tier", `{"tier":"felhom-pbs"}`, "controller") for _, to := range rec.to { if to == "customer@example.com" { t.Fatalf("R-97c: a customer was emailed an OPERATOR-ONLY event (%s) — "+ "enabled_events must not be able to opt in", to) } } // The operator MUST still get it — the guard must not silence the signal entirely. gotOperator := false for _, to := range rec.to { if to == "operator@felhom.eu" { gotOperator = true } } if !gotOperator { t.Fatal("the operator must still be notified; the guard is customer-only") } } // The skip must be VISIBLE — a silent drop is indistinguishable from a delivery that never happened. func TestOperatorOnly_SkipIsLogged(t *testing.T) { st := opOnlyStore(t) if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"whole_guest_backup_failed"}, 6); err != nil { t.Fatal(err) } rec := &sentTo{} d := opOnlyDispatcher(t, st, rec) d.ProcessEvent("c1", "whole_guest_backup_failed", "error", "boom", `{"tier":"local"}`, "controller") logs, err := st.GetRecentNotifications("c1", 20) if err != nil { t.Fatalf("GetNotificationLog: %v", err) } found := false for _, l := range logs { if l.Channel == "customer" && l.Status == "skipped" && strings.Contains(l.ErrorMessage, "operator_only") { found = true } } if !found { t.Fatalf("the customer skip must be logged as skipped/operator_only so it is visible; got %d row(s)", len(logs)) } } // A NORMAL customer event must be unaffected — the register is narrow, not a blanket mute. func TestOperatorOnly_NormalCustomerEventStillDelivered(t *testing.T) { st := opOnlyStore(t) if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"backup_failed"}, 6); err != nil { t.Fatal(err) } rec := &sentTo{} d := opOnlyDispatcher(t, st, rec) d.ProcessEvent("c1", "backup_failed", "error", "app-data backup failed", "{}", "controller") got := false for _, to := range rec.to { if to == "customer@example.com" { got = true } } if !got { t.Fatal("a normal customer-facing event must still be delivered — the register must not be a blanket mute") } } // Pin the membership: both R-97a types, by name. func TestOperatorOnly_RegisterContents(t *testing.T) { for _, et := range []string{"whole_guest_backup_failed", "whole_guest_backup_recovered"} { if !operatorOnlyEvents[et] { t.Fatalf("%s must be operator-only; a customer can take no action on it", et) } } if operatorOnlyEvents["backup_failed"] { t.Fatal("backup_failed is the APP-DATA tier and IS customer-facing — do not mute it") } }