2c0e43e0d0
v0.78.0 asserted in a comment that a type with no customerMessages entry 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 — pure configuration. A customer with whole_guest_backup_failed enabled would have been emailed raw English operator text about a backup they cannot act on. The new test proves it against the v0.78.0 shape. operatorOnlyEvents is now an explicit register checked before prefs, logging a skipped/operator_only row so the skip is visible. NOT implemented as 'missing customerMessages blocks delivery' — several types rely on that fallback on purpose. The handler comment now names the real mechanism.
143 lines
4.7 KiB
Go
143 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|