d55c590c5a
gates / gates (push) Successful in 5m25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
144 lines
5.6 KiB
Go
144 lines
5.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// MAIL-HOLD — a restored hub starts quiet. Evidence: the 2026-10-09 restore drill
|
|
// (documentation/audits/dooplex-survival-2026-10-09/partD-05-checks.txt): a hub started on a restored database mailed
|
|
// households pending kernel notices within a minute, and `notifications.operator_enabled: false` did not stop it.
|
|
//
|
|
// COMPANION RED-PROOF (REPORT): make deliver() skip the mailHold.Check call (the pre-fix shape: sendEmailFn is reached
|
|
// directly) → TestMailHold_DispatcherSendsNothingWhileHeld fails with every path's mail SENT.
|
|
|
|
type holdRecorder struct {
|
|
mu sync.Mutex
|
|
sent []string // subjects
|
|
}
|
|
|
|
func (r *holdRecorder) fn(to, subject, body string, headers map[string]string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sent = append(r.sent, subject)
|
|
return nil
|
|
}
|
|
|
|
func (r *holdRecorder) count() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.sent)
|
|
}
|
|
|
|
// holdDispatcher returns a dispatcher whose household c1 has a registered address, notification prefs with
|
|
// backup_failed on, the operator channel ON, the MAIL-HOLD marker present, and a log captured in buf.
|
|
func holdDispatcher(t *testing.T) (*Dispatcher, *store.Store, *holdRecorder, *mailhold.Hold, *bytes.Buffer) {
|
|
t.Helper()
|
|
st := newDispStore(t)
|
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p", Email: "household@example.hu"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SaveNotificationPrefs("c1", "household@example.hu", []string{"backup_failed"}, 6); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
logger := log.New(buf, "", 0)
|
|
d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, logger)
|
|
rec := &holdRecorder{}
|
|
d.sendEmailFn = rec.fn
|
|
d.afterFn = func(time.Duration, func()) {} // a retry must never fire in these tests
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, mailhold.FileName), nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hold := mailhold.New(dir, logger)
|
|
d.SetMailHold(hold)
|
|
return d, st, rec, hold, buf
|
|
}
|
|
|
|
// The consequence asserted: with the marker present, NOT ONE mail reaches the sender, on any dispatcher path —
|
|
// household event, operator event, recovery, test mail, kernel notice, claim code, self-bind link.
|
|
func TestMailHold_DispatcherSendsNothingWhileHeld(t *testing.T) {
|
|
d, st, rec, _, buf := holdDispatcher(t)
|
|
|
|
d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box") // operator + household
|
|
d.ProcessEvent("c1", "test", "info", "test", "", "operator") // test mail, both channels
|
|
if _, err := d.SendKernelNotice("c1", "6.8.12-1"); err == nil {
|
|
t.Errorf("kernel notice: a held mail must report an error (the caller then runs no kernel step)")
|
|
}
|
|
if err := d.SendClaimEmail("claim", "c1", "household@example.hu", "c1.example", "CODE-1234"); err == nil {
|
|
t.Errorf("claim mail: a held mail must report an error")
|
|
}
|
|
if err := d.SendSelfBindEmail("c1", "household@example.hu", "https://hub.example/bind/x"); err == nil {
|
|
t.Errorf("self-bind mail: a held mail must report an error")
|
|
}
|
|
|
|
if n := rec.count(); n != 0 {
|
|
t.Fatalf("MAIL-HOLD present but %d mail(s) were SENT: %v", n, rec.sent)
|
|
}
|
|
out := buf.String()
|
|
if got := strings.Count(out, "[WARN] MAIL-HOLD: not sending"); got < 7 {
|
|
t.Errorf("each held mail is logged once: want >= 7 MAIL-HOLD lines, got %d\n%s", got, out)
|
|
}
|
|
if strings.Contains(out, "household@example.hu") || strings.Contains(out, "op@felhom.eu") {
|
|
t.Errorf("the hold's log must carry no address:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "CODE-1234") {
|
|
t.Errorf("the hold's log must carry no mail body")
|
|
}
|
|
// The record says what happened: held, not sent and not failed.
|
|
if _, ok, _ := st.LastCustomerSentAt("c1", []string{"backup_failed"}); ok {
|
|
t.Errorf("a held household mail was recorded as SENT")
|
|
}
|
|
}
|
|
|
|
// After the release the hub mails again — and the very same event key mails at once: the cooldown a held mail armed is
|
|
// given back, so the first real alarm after the release is not silenced by a mail that was never sent.
|
|
func TestMailHold_ReleaseResumesSends(t *testing.T) {
|
|
d, _, rec, hold, _ := holdDispatcher(t)
|
|
|
|
d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box")
|
|
if rec.count() != 0 {
|
|
t.Fatalf("held: %d mail(s) sent", rec.count())
|
|
}
|
|
if err := hold.Release(); err != nil {
|
|
t.Fatalf("release: %v", err)
|
|
}
|
|
if hold.Held() {
|
|
t.Fatalf("the marker is still there after Release")
|
|
}
|
|
d.ProcessEvent("c1", "backup_failed", "error", "Backup failed", "", "box")
|
|
if n := rec.count(); n != 2 {
|
|
t.Fatalf("after the release the operator AND the household mail must go out: sent=%d %v", n, rec.sent)
|
|
}
|
|
// Held mails are DROPPED, not re-sent: exactly the two fresh ones, no backlog.
|
|
}
|
|
|
|
// Belt for the "one gate" claim: in dispatcher.go the sender seam is called in exactly ONE place, inside deliver().
|
|
// A new send path that calls sendEmailFn directly would bypass the hold; this fails when one appears.
|
|
func TestMailHold_EverySendPathIsGated(t *testing.T) {
|
|
src, err := os.ReadFile("dispatcher.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := regexp.MustCompile(`d\.sendEmailFn\(`).FindAllIndex(src, -1)
|
|
if len(calls) != 1 {
|
|
t.Fatalf("dispatcher.go calls d.sendEmailFn( %d times; every send must go through deliver() (the MAIL-HOLD gate)", len(calls))
|
|
}
|
|
body := string(src)
|
|
i := strings.Index(body, "func (d *Dispatcher) deliver(")
|
|
if i < 0 || calls[0][0] < i || calls[0][0] > i+600 {
|
|
t.Fatalf("the one d.sendEmailFn( call is not inside deliver()")
|
|
}
|
|
}
|