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 }