Files
felhom.eu/hub/internal/notify/mailhold_test.go
T

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()")
}
}