fd28d70bff
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
246 lines
9.1 KiB
Go
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
|
|
}
|