Files
felhom-controller/controller/internal/web/r922_email_cleared_test.go
T

188 lines
6.5 KiB
Go

package web
// R-922 (operator ruling 2026-10-09 11:19, option A): when the household clears its notification address on
// the dashboard, the hub deletes the stored address. The hub cannot tell that apart from an unconfigured box
// pushing an empty address (its no-clobber guard, audit F12) — so the controller SAYS so: the push carries
// `"email_cleared": true`, and ONLY after a deliberate clear. Asserted on the WIRE (a real Notifier against an
// httptest hub), not on a mock's call count.
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
type prefsHub struct {
mu sync.Mutex
bodies []map[string]json.RawMessage
}
func (h *prefsHub) last(t *testing.T) map[string]json.RawMessage {
t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if len(h.bodies) == 0 {
t.Fatal("no preferences push reached the hub")
}
return h.bodies[len(h.bodies)-1]
}
func (h *prefsHub) count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.bodies)
}
// r922Server is notifyGuardServer with a REAL notifier aimed at a capturing hub.
func r922Server(t *testing.T) (*Server, *settings.Settings, *prefsHub) {
t.Helper()
s, sett := notifyGuardServer(t)
hub := &prefsHub{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/preferences" {
w.WriteHeader(http.StatusOK)
return
}
raw, _ := io.ReadAll(r.Body)
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Errorf("preferences push is not a JSON object: %v", err)
}
hub.mu.Lock()
hub.bodies = append(hub.bodies, m)
hub.mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
s.notifier = notify.New(srv.URL, "test-key", "c1", sett, log.New(io.Discard, "", 0), false)
if !s.notifier.IsEnabled() {
t.Fatal("test notifier not enabled")
}
return s, sett, hub
}
func clearForm() url.Values {
return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}} // no event_* boxes
}
func flagOf(t *testing.T, body map[string]json.RawMessage) (present bool, value bool) {
t.Helper()
raw, ok := body["email_cleared"]
if !ok {
return false, false
}
var v bool
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("email_cleared is not a boolean: %s", raw)
}
return true, v
}
func emailOf(t *testing.T, body map[string]json.RawMessage) string {
t.Helper()
var e string
_ = json.Unmarshal(body["email"], &e)
return e
}
// RED TEST. A household that had an address and clears it: the push carries email_cleared:true with an empty
// address. Today's push carries no flag, so the hub keeps the old address (seen on Tester 1, 2026-10-09).
// The flag also rides the NEXT push (the debug resync reads the stored prefs) and a second empty save —
// it stays while the address stays empty, so a push the hub missed is repaired by the next one.
func TestR922_DeliberateClearSendsEmailCleared(t *testing.T) {
s, sett, hub := r922Server(t)
if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{
Email: "household@example.hu", EnabledEvents: []string{"backup_failed"}, CooldownHours: 6,
}); err != nil {
t.Fatal(err)
}
postNotifications(t, s, clearForm())
body := hub.last(t)
if present, v := flagOf(t, body); !present || !v {
t.Fatalf("R-922: the push after a deliberate clear carries no email_cleared:true (present=%v value=%v) — the hub keeps the old address; body keys=%v", present, v, keysOf(body))
}
if e := emailOf(t, body); e != "" {
t.Fatalf("email = %q, want empty beside the clear flag", e)
}
// The next push from the STORED prefs (the debug resync) carries it too.
before := hub.count()
s.debugPreferencesSync(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/debug/preferences/sync", nil))
if hub.count() != before+1 {
t.Fatalf("the resync did not push")
}
if present, v := flagOf(t, hub.last(t)); !present || !v {
t.Fatalf("the stored clear marker did not ride the next push (present=%v value=%v)", present, v)
}
// A second empty save keeps it (the stored address is already empty; the household's clear still stands).
postNotifications(t, s, clearForm())
if present, v := flagOf(t, hub.last(t)); !present || !v {
t.Fatalf("a second empty save dropped the clear flag (present=%v value=%v)", present, v)
}
}
// A box that never had an address must NOT send the flag: its empty push is exactly the case the hub's
// no-clobber guard protects (a provisioning-seeded address must survive an unconfigured box).
func TestR922_NeverConfiguredBoxSendsNoFlag(t *testing.T) {
s, _, hub := r922Server(t)
postNotifications(t, s, clearForm())
if present, _ := flagOf(t, hub.last(t)); present {
t.Fatalf("a never-configured box sent email_cleared — the hub would delete a seeded address; body keys=%v", keysOf(hub.last(t)))
}
s.debugPreferencesSync(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/debug/preferences/sync", nil))
if present, _ := flagOf(t, hub.last(t)); present {
t.Fatalf("the resync of a never-configured box sent email_cleared")
}
}
// Setting a new address after a clear drops the flag: the push carries the new address and no flag, and the
// later pushes stay clean.
func TestR922_NewAddressDropsFlag(t *testing.T) {
s, sett, hub := r922Server(t)
if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{
Email: "old@example.hu", EnabledEvents: []string{"backup_failed"}, CooldownHours: 6,
}); err != nil {
t.Fatal(err)
}
postNotifications(t, s, clearForm())
if present, v := flagOf(t, hub.last(t)); !present || !v {
t.Fatalf("precondition: the clear did not send the flag (present=%v value=%v)", present, v)
}
postNotifications(t, s, url.Values{
"notification_email": {"new@example.hu"},
"event_backup_failed": {"on"},
"cooldown_hours": {"6"},
})
body := hub.last(t)
if present, _ := flagOf(t, body); present {
t.Fatalf("the push with a new address still carries email_cleared; body keys=%v", keysOf(body))
}
if e := emailOf(t, body); e != "new@example.hu" {
t.Fatalf("email = %q, want new@example.hu", e)
}
s.debugPreferencesSync(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/debug/preferences/sync", nil))
if present, _ := flagOf(t, hub.last(t)); present {
t.Fatalf("the stored marker survived a new address — the next push still carries email_cleared")
}
}
func keysOf(m map[string]json.RawMessage) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}