Files
felhom-controller/controller/internal/web/r922_email_cleared_test.go
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00

246 lines
9.1 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"
"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
}