hub v0.71.0: paired recovery mails (F11), prefs seeding at claim + empty-email no-clobber (F12), priority headers + operator test leg (F14-light)
This commit is contained in:
@@ -1904,13 +1904,25 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents, payload.CooldownHours); err != nil {
|
||||
// Empty-email no-clobber guard (v0.71.0, audit F12): a controller push with an empty email
|
||||
// (e.g. an unconfigured box) must never wipe a stored non-empty address — the seeded/edited
|
||||
// email is the customer's alert lifeline. Events + cooldown from the push still apply; a push
|
||||
// with a non-empty email updates everything (customer edits keep working).
|
||||
saveEmail := payload.Email
|
||||
if saveEmail == "" {
|
||||
if existing, err := h.store.GetNotificationPrefs(payload.CustomerID); err == nil && existing != nil && existing.Email != "" {
|
||||
saveEmail = existing.Email
|
||||
h.logger.Printf("[INFO] Notification prefs push for %s had empty email — preserving stored address", payload.CustomerID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.store.SaveNotificationPrefs(payload.CustomerID, saveEmail, payload.EnabledEvents, payload.CooldownHours); err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents)
|
||||
h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, saveEmail, payload.EnabledEvents)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSavePreferences_EmptyEmailCannotClobber (v0.71.0 F12, Scenario E): a controller push with an
|
||||
// empty email must preserve a stored non-empty address while still applying events + cooldown.
|
||||
// Companion red-proof: removing the guard in handleSavePreferences makes this fail.
|
||||
func TestSavePreferences_EmptyEmailCannotClobber(t *testing.T) {
|
||||
h, st := newEventTestHandler(t)
|
||||
if err := st.SaveNotificationPrefs("c1", "seeded@example.hu", []string{"node_down", "backup_failed"}, 6); err != nil {
|
||||
t.Fatalf("stored prefs: %v", err)
|
||||
}
|
||||
|
||||
rr := do(h, http.MethodPost, "/preferences", "ckey",
|
||||
`{"customer_id":"c1","email":"","enabled_events":["node_down"],"cooldown_hours":12}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
prefs, err := st.GetNotificationPrefs("c1")
|
||||
if err != nil || prefs == nil {
|
||||
t.Fatalf("prefs: %+v err=%v", prefs, err)
|
||||
}
|
||||
if prefs.Email != "seeded@example.hu" {
|
||||
t.Fatalf("empty-email push CLOBBERED the stored address: email=%q", prefs.Email)
|
||||
}
|
||||
if len(prefs.EnabledEvents) != 1 || prefs.EnabledEvents[0] != "node_down" || prefs.CooldownHours != 12 {
|
||||
t.Fatalf("events/cooldown from the push must still apply: %+v", prefs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavePreferences_NonEmptyEmailStillUpdates: a push with a real email updates everything —
|
||||
// customer edits keep working (the guard must not freeze the address forever).
|
||||
func TestSavePreferences_NonEmptyEmailStillUpdates(t *testing.T) {
|
||||
h, st := newEventTestHandler(t)
|
||||
if err := st.SaveNotificationPrefs("c1", "old@example.hu", []string{"node_down"}, 6); err != nil {
|
||||
t.Fatalf("stored prefs: %v", err)
|
||||
}
|
||||
|
||||
rr := do(h, http.MethodPost, "/preferences", "ckey",
|
||||
`{"customer_id":"c1","email":"new@example.hu","enabled_events":["backup_failed"],"cooldown_hours":3}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
prefs, _ := st.GetNotificationPrefs("c1")
|
||||
if prefs.Email != "new@example.hu" || len(prefs.EnabledEvents) != 1 || prefs.EnabledEvents[0] != "backup_failed" || prefs.CooldownHours != 3 {
|
||||
t.Fatalf("non-empty push must update everything: %+v", prefs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavePreferences_EmptyEmailNoStoredRow: an empty-email push with no stored row behaves as
|
||||
// before (row created with empty email — the all-off case stays legitimate).
|
||||
func TestSavePreferences_EmptyEmailNoStoredRow(t *testing.T) {
|
||||
h, st := newEventTestHandler(t)
|
||||
rr := do(h, http.MethodPost, "/preferences", "ckey",
|
||||
`{"customer_id":"c1","email":"","enabled_events":[],"cooldown_hours":6}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
prefs, _ := st.GetNotificationPrefs("c1")
|
||||
if prefs == nil || prefs.Email != "" {
|
||||
t.Fatalf("all-off push must store the empty row unchanged, got %+v", prefs)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user