Files
felhom.eu/hub/internal/notify/operator_only_test.go
T
admin 2c0e43e0d0 hub v0.79.0 — R-97c: make the operator-only claim true
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.
2026-07-27 17:54:47 +02:00

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")
}
}