ec9b997151
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
188 lines
6.5 KiB
Go
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
|
|
}
|