controller (unreleased): R-921 check-first pre-check (no stop while another tier's job is in flight); R-922 email_cleared on a household's deliberate clear
gates / gates (push) Successful in 1m0s
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
This commit is contained in:
@@ -616,7 +616,7 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
prefs := s.settings.GetNotificationPrefs()
|
||||
if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil {
|
||||
if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2957,10 +2957,15 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// R-922 (operator ruling 2026-10-09, option A): an empty address where one was stored is the household's
|
||||
// deliberate clear — persisted, so this push AND every later one carry `email_cleared` while the address
|
||||
// stays empty, and the hub deletes the address it holds. A box that never had one does not set it.
|
||||
emailCleared := s.settings.GetNotificationPrefs().ClearedBy(email)
|
||||
prefs := &settings.NotificationPrefs{
|
||||
Email: email,
|
||||
EnabledEvents: enabledEvents,
|
||||
CooldownHours: cooldownHours,
|
||||
EmailCleared: emailCleared,
|
||||
}
|
||||
|
||||
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
|
||||
@@ -2971,13 +2976,13 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
||||
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v, email_cleared=%t", email, enabledEvents, emailCleared)
|
||||
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
|
||||
|
||||
// Sync preferences to hub
|
||||
data := s.notificationsPageData()
|
||||
if s.notifier != nil && s.notifier.IsEnabled() {
|
||||
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
|
||||
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours, emailCleared); err != nil {
|
||||
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
|
||||
data["NotificationSuccess"] = s.msg(r, "settings.notify_saved_sync_failed", err)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user