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" "strings" "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 } // clearForm is a CONFIRMED clear: the household pressed the warning's own button (confirm_clear=1). func clearForm() url.Values { return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}, "confirm_clear": {"1"}} // no event_* boxes } // R-922 warning (S1, `09` §3 decision 196): clearing a stored address is WARNED first. The first press // saves nothing and sends nothing; the page says „no more notification mails" in the reader's language and // offers the confirm button. Only the confirm clears. // // RED-PROOF (REPORT.md): delete the warning block in settingsNotificationsHandler → the first press clears // the address and pushes to the hub, and this test fails on "the address was cleared before the warning". func TestR922_ClearWarnsFirst(t *testing.T) { for _, tc := range []struct{ lang, fragment string }{ {"hu", "nem kapsz t"}, // „nem kapsz több értesítő levelet" — ASCII fragment {"en", "you will get no more notification e-mails"}, } { s, sett, hub := r922Server(t) if err := sett.SetLanguage(tc.lang); err != nil { t.Fatal(err) } if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{ Email: "household@example.hu", EnabledEvents: []string{"backup_failed"}, CooldownHours: 6, }); err != nil { t.Fatal(err) } s.loadTemplates() rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}}) if p := sett.GetNotificationPrefs(); p.Email != "household@example.hu" { t.Fatalf("%s: the address was cleared before the warning (stored %q)", tc.lang, p.Email) } if hub.count() != 0 { t.Fatalf("%s: a push reached the hub before the household confirmed", tc.lang) } body := rr.Body.String() if !strings.Contains(body, `id="notification-clear-warning"`) || !strings.Contains(body, tc.fragment) { t.Fatalf("%s: the warning is not on the page (want %q)", tc.lang, tc.fragment) } if !strings.Contains(body, `name="confirm_clear" value="1"`) { t.Fatalf("%s: the warning offers no confirm button", tc.lang) } postNotifications(t, s, clearForm()) if p := sett.GetNotificationPrefs(); p.Email != "" { t.Fatalf("%s: the confirmed clear did not clear (stored %q)", tc.lang, p.Email) } if present, v := flagOf(t, hub.last(t)); !present || !v { t.Fatalf("%s: the confirmed clear did not send email_cleared", tc.lang) } } // A box with no stored address is not warned: there is nothing to lose. s, sett, _ := r922Server(t) s.loadTemplates() rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}}) if strings.Contains(rr.Body.String(), `id="notification-clear-warning"`) { t.Error("a box with no stored address was warned") } _ = sett } 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 }