package main import ( "context" "errors" "path/filepath" "regexp" "strings" "testing" "time" "github.com/gorilla/mux" "github.com/meshcore-analyzer/mailer" "github.com/meshcore-analyzer/users" ) type fakeNotifySource struct { isReady bool hs HealthThresholds low int nodeMap map[string]notifyNode heard map[string]time.Time relayed map[string]time.Time obs []notifyObserver newestAt func() time.Time // newest packet in the store; nil means none lock time.Duration // what lastHeard reports as its lock time panicNext bool heardAsked []string foreignAsked int observersAsked int } func (f *fakeNotifySource) ready() bool { return f.isReady } func (f *fakeNotifySource) health() HealthThresholds { return f.hs } func (f *fakeNotifySource) lowBatteryMv() int { return f.low } func (f *fakeNotifySource) nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) { if f.panicNext { f.panicNext = false panic("fake source panic") } out := map[string]notifyNode{} for _, pk := range pubkeys { if n, ok := f.nodeMap[pk]; ok { out[pk] = n } } if withForeign { f.foreignAsked++ for pk, n := range f.nodeMap { if n.Foreign { out[pk] = n } } } return out, nil } func (f *fakeNotifySource) lastHeard(pks []string) (map[string]time.Time, time.Duration) { f.heardAsked = append([]string(nil), pks...) return f.heard, f.lock } func (f *fakeNotifySource) lastRelayed([]string) map[string]time.Time { return f.relayed } func (f *fakeNotifySource) newestPacket() time.Time { if f.newestAt == nil { return time.Time{} } return f.newestAt() } func (f *fakeNotifySource) observers() ([]notifyObserver, error) { f.observersAsked++ return f.obs, nil } type notifyClock struct{ t time.Time } func (c *notifyClock) Now() time.Time { return c.t } func (c *notifyClock) Advance(d time.Duration) { c.t = c.t.Add(d) } func defaultNotifySettings() notifySettings { return notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 300, maxWatchesPerUser: 50} } type notifyFixture struct { *authFixture n *notifier src *fakeNotifySource clk *notifyClock } // newNotifyFixture is newAuthFixture with notifications set to ns, a fake // analyzer source and one fixed clock for the notifier and users.db. // admin@example.org is a config admin. func newNotifyFixture(t *testing.T, ns notifySettings) *notifyFixture { t.Helper() a, fake := newTestAuthService(t, "admin@example.org") a.set.notify = ns clk := ¬ifyClock{t: time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)} a.st.SetClock(clk.Now) src := &fakeNotifySource{isReady: true, hs: (&Config{}).GetHealthThresholds(), low: 3300, nodeMap: map[string]notifyNode{}, heard: map[string]time.Time{}, relayed: map[string]time.Time{}, newestAt: clk.Now} if ns.enabled { a.notify = newNotifier(a, src, clk.Now) } srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats(), auth: a} r := mux.NewRouter() srv.registerAuthRoutes(r) return ¬ifyFixture{authFixture: &authFixture{srv: srv, router: r, fake: fake, st: a.st}, n: a.notify, src: src, clk: clk} } // watcher registers and activates a user with default prefs watching pubkeys. func (f *notifyFixture) watcher(t *testing.T, email, name string, pubkeys ...string) *client { t.Helper() c := f.registerAndActivate(t, email, name, pw) if _, err := f.st.NotifyPrefsFor(c.me.ID); err != nil { t.Fatal(err) } if _, _, _, err := f.st.AddWatches(c.me.ID, pubkeys, 0); err != nil { t.Fatal(err) } return c } // setNode puts a node in the fake analyzer, last seen lastSeen before now. func (f *notifyFixture) setNode(pk, name, role string, lastSeen time.Duration, battery *int) { f.src.nodeMap[pk] = notifyNode{Pubkey: pk, Name: name, Role: role, LastSeen: f.clk.t.Add(-lastSeen).Format(time.RFC3339), BatteryMv: battery} } func (f *notifyFixture) tick() { f.n.tick(context.Background()) } func (f *notifyFixture) notifyMails() []mailer.Message { var out []mailer.Message for _, m := range f.fake.Sent() { if m.Tag == "notify" { out = append(out, m) } } return out } func TestNotifierMailsAChangeAfterTheSilentFirstTick(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) pat := f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("first tick mailed: %+v", m) } f.clk.Advance(25 * time.Hour) f.tick() m := f.notifyMails() if len(m) != 1 { t.Fatalf("mails = %d; want 1", len(m)) } p, _ := f.st.NotifyPrefsFor(pat.me.ID) if m[0].To != "pat@example.org" || m[0].Subject != "[CoreScope] 1 change on your watched nodes" || !strings.Contains(m[0].Text, "Alpha: offline, 2026-10-08 13:00 UTC") || !strings.Contains(m[0].Text, testBase+"/#/nodes/"+evPkA) || !strings.Contains(m[0].Text, testBase+"/#/account?section=notifications") || !strings.Contains(m[0].Text, testBase+"/#/account/unsubscribe?token="+p.UnsubToken) { t.Fatalf("mail = %+v", m[0]) } if m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" || m[0].Headers["List-Unsubscribe-Post"] != "List-Unsubscribe=One-Click" { t.Fatalf("headers = %v", m[0].Headers) } if total, per, err := f.st.NotifyMailCounts(f.clk.t.Add(-time.Hour)); err != nil || total != 1 || per[pat.me.ID] != 1 { t.Fatalf("mail_log notify rows = %d, %v, %v", total, per, err) } f.clk.Advance(5 * time.Minute) f.tick() if len(f.notifyMails()) != 1 { t.Fatal("an unchanged state was mailed again") } f.src.heard[evPkA] = f.clk.t.Add(-time.Minute) f.tick() if m := f.notifyMails(); len(m) != 2 || !strings.Contains(m[1].Text, "Alpha: back online") { t.Fatalf("back-online mail = %+v", m) } } func TestNotifierOneMailPerUserPerTick(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB) f.watcher(t, "quinn@example.org", "Quinn", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.setNode(evPkB, "Bravo", "companion", time.Hour, nil) f.tick() f.clk.Advance(25 * time.Hour) f.tick() m := f.notifyMails() if len(m) != 2 { t.Fatalf("mails = %d; want one per user", len(m)) } byTo := map[string]mailer.Message{m[0].To: m[0], m[1].To: m[1]} pat, quinn := byTo["pat@example.org"], byTo["quinn@example.org"] if pat.Subject != "[CoreScope] 2 changes on your watched nodes" || !strings.Contains(pat.Text, "Alpha: offline") || !strings.Contains(pat.Text, "Bravo: offline") { t.Fatalf("Pat's mail = %+v", pat) } if quinn.Subject != "[CoreScope] 1 change on your watched nodes" || strings.Contains(quinn.Text, "Bravo") { t.Fatalf("Quinn's mail = %+v", quinn) } } func TestNotifierSkipsAreNeverMailedLater(t *testing.T) { cases := []struct { name string tune func(*notifySettings) block func(*notifyFixture, int64) error lift func(*notifyFixture, int64) error }{ {"notifications off", nil, func(f *notifyFixture, uid int64) error { _, err := f.st.SetNotifyPrefs(uid, false, users.NodeNotifyEvents) return err }, func(f *notifyFixture, uid int64) error { _, err := f.st.SetNotifyPrefs(uid, true, users.NodeNotifyEvents) return err }}, {"address bounces", nil, func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, true) }, func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, false) }}, {"account disabled", nil, func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusDisabled) }, func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusActive) }}, {"per-user limit", func(ns *notifySettings) { ns.perUserPerDay = 1 }, func(f *notifyFixture, uid int64) error { _, err := f.st.LogMail(&uid, "pat@example.org", users.NotifyMailPurpose, "") return err }, func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }}, {"instance limit", func(ns *notifySettings) { ns.maxMailsPerDay = 1 }, func(f *notifyFixture, uid int64) error { _, err := f.st.LogMail(nil, "gone@example.org", users.NotifyMailPurpose, "") return err }, func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { ns := defaultNotifySettings() if c.tune != nil { c.tune(&ns) } f := newNotifyFixture(t, ns) pat := f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() // Advance first, so a blocking mail_log row lies inside the // rolling 24 hours of the next tick. f.clk.Advance(25 * time.Hour) if err := c.block(f, pat.me.ID); err != nil { t.Fatal(err) } f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed while blocked: %+v", m) } if err := c.lift(f, pat.me.ID); err != nil { t.Fatal(err) } f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("a skipped change was mailed later: %+v", m) } }) } } func TestNotifierRestartAndNewWatchStaySilent(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) pat := f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() f.clk.Advance(25 * time.Hour) f.tick() if len(f.notifyMails()) != 1 { t.Fatal("no mail for the change") } restarted := newNotifier(f.srv.auth, f.src, f.clk.Now) // the states live in users.db f.setNode(evPkB, "Bravo", "companion", 48*time.Hour, nil) if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkB}, 0); err != nil { t.Fatal(err) } f.clk.Advance(5 * time.Minute) restarted.tick(context.Background()) if m := f.notifyMails(); len(m) != 1 { t.Fatalf("the restart or the new watch mailed: %+v", m[1:]) } } func TestNotifierSendFailureIsNotRetried(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() f.fake.SetSendErr(errors.New("provider down")) f.clk.Advance(25 * time.Hour) f.tick() f.fake.SetSendErr(nil) f.clk.Advance(5 * time.Minute) f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("a failed send was retried: %+v", m) } if total, _, _ := f.st.NotifyMailCounts(f.clk.t.Add(-48 * time.Hour)); total != 0 { t.Fatalf("mail_log has %d notify rows for a mail that never left", total) } } func TestNotifierStateWriteFailureSendsNothing(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() f.execDB(t, `CREATE TRIGGER notify_state_fail BEFORE UPDATE ON notification_state BEGIN SELECT RAISE(ABORT, 'boom'); END`) f.clk.Advance(25 * time.Hour) f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed although the states were not written: %+v", m) } f.execDB(t, `DROP TRIGGER notify_state_fail`) f.tick() if m := f.notifyMails(); len(m) != 1 { t.Fatalf("mails after the write works again = %d; want 1", len(m)) } } func TestNotifierWaitsForTheAnalyzer(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.src.isReady = false f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 0 { t.Fatalf("evaluated while the store was loading: %+v", states) } f.src.isReady = true f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 1 { t.Fatalf("states once ready = %+v", states) } } func TestNotifierPanicIsRecovered(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.src.panicNext = true f.n.safeTick(context.Background()) f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 1 { t.Fatalf("the tick after the panic did not run: %+v", states) } } func TestNotifierReadsForeignAndObserversOnlyForAdmins(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() if f.src.foreignAsked != 0 || f.src.observersAsked != 0 { t.Fatalf("admin data read without an admin asking: foreign %d, observers %d", f.src.foreignAsked, f.src.observersAsked) } boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw) if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil { t.Fatal(err) } f.tick() if f.src.foreignAsked != 1 || f.src.observersAsked != 0 { t.Fatalf("foreign %d, observers %d; want 1, 0", f.src.foreignAsked, f.src.observersAsked) } if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil { t.Fatal(err) } f.tick() if f.src.foreignAsked != 1 { t.Fatal("foreign nodes read for a demoted admin") } } func TestNotifyMailEscapesNodeNames(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, `Bad & "co"`, "companion", time.Hour, nil) f.tick() f.clk.Advance(25 * time.Hour) f.tick() m := f.notifyMails() if len(m) != 1 || strings.Contains(m[0].HTML, "Bad") || !strings.Contains(m[0].HTML, "<b>Bad</b> & "co"") { t.Fatalf("HTML = %q", m[0].HTML) } } // Names come from whoever owns a node or runs an observer: a line break // must not forge a line in the mail, a bidi override must not reorder it. func TestNotifyMailSanitisesNames(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) admin := f.registerAndActivate(t, "admin@example.org", "Ada", pw) if _, err := f.st.SetNotifyPrefs(admin.me.ID, true, []string{users.NotifyObserverOffline}); err != nil { t.Fatal(err) } f.setNode(evPkA, "x\r\nAccount suspended, sign in: https://evil.example\t\u202egnp.exe", "companion", time.Hour, nil) seen := f.clk.t.Add(-time.Minute).Format(time.RFC3339) f.src.obs = []notifyObserver{{ID: "OBS1", Name: "Roof\n\u2066Fake line\u2069", LastSeen: seen}, {ID: "OBS2", Name: "\n\u202e", LastSeen: seen}} f.tick() f.clk.Advance(25 * time.Hour) f.tick() m := f.notifyMails() if len(m) != 2 { t.Fatalf("mails = %d, want 2", len(m)) } for _, msg := range m { for _, part := range []string{msg.Text, msg.HTML} { for _, bad := range []string{"\r", "\t", "\u202e", "\u2066", "\u2069", "\nAccount", "\nFake"} { if strings.Contains(part, bad) { t.Fatalf("mail part contains %q:\n%s", bad, part) } } } } body := m[0].Text + m[1].Text for _, want := range []string{"- x Account suspended, sign in: https://evil.example gnp.exe: offline,", "- Roof Fake line: observer offline,", "- OBS2: observer offline,"} { if !strings.Contains(body, want) { t.Fatalf("text lacks %q:\n%s", want, body) } } } func TestMailSafeText(t *testing.T) { cases := map[string]string{ "plain": "plain", " a \n\n b\r\tc ": "a b c", "a\u202eb\u2066c\u2069d": "a b c d", "a\u202ab\u202dc": "a b c", "a\u0085b\x7fc": "a b c", "\n\u202a": "", "Zo\u00eb \u00e9": "Zo\u00eb \u00e9", } for in, want := range cases { if got := mailSafeText(in); got != want { t.Errorf("mailSafeText(%q) = %q; want %q", in, got, want) } } } func TestNotifyChangeText(t *testing.T) { at := time.Date(2026, 10, 7, 9, 5, 0, 0, time.UTC) cases := []struct { c notifyChange want string }{ {notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyBad, At: at}, "A: offline, 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyGood, At: at}, "A: back online, 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyBad, BatteryMv: evMv(3250), At: at}, "A: battery low (3250 mV), 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyGood, BatteryMv: evMv(3410), At: at}, "A: battery recovered (3410 mV), 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyForeignNew, Name: "F", To: users.NotifyTold, At: at}, "F: new foreign node, 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyBad, At: at}, "O: observer offline, 2026-10-07 09:05 UTC"}, {notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyGood, At: at}, "O: observer back online, 2026-10-07 09:05 UTC"}, } for _, c := range cases { if got := notifyChangeText(c.c); got != c.want { t.Errorf("%q; want %q", got, c.want) } } if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyObserverOffline, Subject: "AB CD"}); got != "https://e.org/#/observers/AB%20CD" { t.Errorf("observer URL = %q", got) } if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyNodeOffline, Subject: evPkA}); got != "https://e.org/#/nodes/"+evPkA { t.Errorf("node URL = %q", got) } } func TestNotifySkipReason(t *testing.T) { ns := defaultNotifySettings() ok := users.User{Status: users.StatusActive} on := users.NotifyPrefs{Enabled: true} cases := []struct { u users.User p users.NotifyPrefs mine, total int want string }{ {ok, on, 0, 0, ""}, {users.User{Status: users.StatusDisabled}, on, 0, 0, "account not active"}, {users.User{Status: users.StatusActive, EmailBouncing: true}, on, 0, 0, "address bounces"}, {ok, users.NotifyPrefs{}, 0, 0, "notifications off"}, {ok, on, 20, 0, "daily limit for this user reached"}, {ok, on, 19, 300, "daily limit for this instance reached"}, {ok, on, 19, 299, ""}, } for _, c := range cases { if got := notifySkipReason(c.u, c.p, c.mine, c.total, ns); got != c.want { t.Errorf("%+v %+v %d %d = %q; want %q", c.u, c.p, c.mine, c.total, got, c.want) } } } func TestInitUserManagementStartsTheNotifierOnlyWhenEnabled(t *testing.T) { for _, on := range []bool{false, true} { um := &UserManagementConfig{Enabled: true, PublicBaseURL: testBase, Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"}} if on { um.Notifications = &NotificationsConfig{Enabled: true} } srv := &Server{cfg: &Config{UserManagement: um}} if err := srv.initUserManagement(filepath.Join(t.TempDir(), "meshcore.db")); err != nil { t.Fatal(err) } if (srv.auth.notify != nil) != on { t.Fatalf("notifications %v: notifier = %v", on, srv.auth.notify) } done := make(chan struct{}) go func() { srv.closeUserManagement(); close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("closeUserManagement did not stop the notifier") } } } func TestNotifierDeletesAdminStatesOfADemotedAdmin(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw) if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil { t.Fatal(err) } f.src.nodeMap[evPkB] = notifyNode{Pubkey: evPkB, Name: "Far", Foreign: true} f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 2 { t.Fatalf("baseline states = %+v; want the sentinel and Far", states) } if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil { t.Fatal(err) } f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 0 { t.Fatalf("states of a demoted admin = %+v; want none", states) } if err := f.st.SetRole(boss.me.ID, users.RoleAdmin); err != nil { t.Fatal(err) } f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("re-promotion mailed: %+v", m) } } func TestNotifierMailsAUserWithoutAPrefsRow(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw) if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil { t.Fatal(err) } f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() if states, _ := f.st.AllNotifyStates(); len(states) != 1 { t.Fatalf("states = %+v; want the default node.offline baseline", states) } if prefs, _ := f.st.AllNotifyPrefs(); len(prefs) != 0 { t.Fatalf("a silent tick created prefs: %+v", prefs) } f.clk.Advance(25 * time.Hour) f.tick() m := f.notifyMails() p, err := f.st.NotifyPrefsFor(pat.me.ID) if err != nil { t.Fatal(err) } if len(m) != 1 || p.UnsubToken == "" || !strings.Contains(m[0].Text, "unsubscribe?token="+p.UnsubToken) || m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" { t.Fatalf("mail = %+v, prefs = %+v", m, p) } } func TestNotifierNormalisesMixedCaseWatches(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) pat := f.watcher(t, "pat@example.org", "Pat", strings.ToUpper(evPkA)) if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil { t.Fatal(err) } f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) // keyed lowercase, like NotifyNodes f.src.heard[evPkA] = f.clk.t.Add(-time.Minute) f.tick() if len(f.src.heardAsked) != 1 || f.src.heardAsked[0] != evPkA { t.Fatalf("last heard asked for %v; want [%s]", f.src.heardAsked, evPkA) } states, _ := f.st.AllNotifyStates() if len(states) != 1 || states[0].Subject != evPkA || states[0].State != users.NotifyGood { t.Fatalf("states = %+v; want one good row under the lowercase key", states) } delete(f.src.heard, evPkA) f.clk.Advance(25 * time.Hour) f.tick() if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") || strings.Count(m[0].Text, "Alpha:") != 1 { t.Fatalf("mail = %+v; want one Alpha offline line", m) } } // cancelAfterSend cancels the tick's context once a mail went out, like a // shutdown arriving mid-delivery. type cancelAfterSend struct { mailer.Mailer cancel context.CancelFunc } func (c cancelAfterSend) Send(ctx context.Context, m mailer.Message) (string, error) { id, err := c.Mailer.Send(ctx, m) c.cancel() return id, err } func TestNotifierStopsDeliveringAfterShutdown(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.watcher(t, "quinn@example.org", "Quinn", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() f.clk.Advance(25 * time.Hour) ctx, cancel := context.WithCancel(context.Background()) defer cancel() f.n.a.mail = cancelAfterSend{Mailer: f.n.a.mail, cancel: cancel} f.n.tick(ctx) if m := f.notifyMails(); len(m) != 1 { t.Fatalf("mails after a shutdown mid-delivery = %d; want 1", len(m)) } } func TestNotifierPausesOfflineChecksWhileIngestIsStale(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() // baseline: online lastPacket := f.clk.t f.src.newestAt = func() time.Time { return lastPacket } logs := captureLog(func() { f.clk.Advance(25 * time.Hour) // feed down for a day: Alpha looks offline f.tick() f.clk.Advance(5 * time.Minute) f.tick() }) if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed %d while ingest is stale", len(m)) } paused := "[notify] ingest stale since " + lastPacket.UTC().Format(time.RFC3339) + "; offline checks paused" if n := strings.Count(logs, paused); n != 1 { t.Fatalf("pause logged %d times; want once:\n%s", n, logs) } f.src.newestAt = f.clk.Now // feed back, Alpha still silent logs = captureLog(f.tick) if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed %d on the first tick after recovery; offline waits one silent window", len(m)) } if n := strings.Count(logs, "[notify] ingest fresh again; offline checks resumed"); n != 1 { t.Fatalf("resume logged %d times; want once:\n%s", n, logs) } f.clk.Advance(23 * time.Hour) f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed %d inside the window after recovery", len(m)) } f.clk.Advance(time.Hour + time.Minute) // a full companion window after recovery f.tick() if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") { t.Fatalf("mails one window after recovery = %+v; want Alpha offline", m) } } // A store that is already stale at startup gets the same grace once fresh. func TestNotifierGraceWhenStaleAtStartup(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.tick() // baseline: online (states persist in users.db) f.n = newNotifier(f.n.a, f.src, f.clk.Now) // restart f.clk.Advance(25 * time.Hour) old := f.clk.t.Add(-25 * time.Hour) f.src.newestAt = func() time.Time { return old } f.tick() // stale at startup f.src.newestAt = f.clk.Now f.tick() if m := f.notifyMails(); len(m) != 0 { t.Fatalf("mailed %d right after a stale startup recovered", len(m)) } f.clk.Advance(24*time.Hour + time.Minute) f.tick() if m := f.notifyMails(); len(m) != 1 { t.Fatalf("mails one window after recovery = %d; want 1", len(m)) } } func TestNotifierTreatsAnEmptyStoreAsStale(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.src.newestAt = nil if logs := captureLog(f.tick); !strings.Contains(logs, "[notify] ingest stale since (no packets); offline checks paused") { t.Fatalf("log = %q", logs) } if s, err := f.st.AllNotifyStates(); err != nil || len(s) != 0 { t.Fatalf("states with no packets in the store = %+v, %v", s, err) } } func TestNotifierLogsOneLinePerTick(t *testing.T) { f := newNotifyFixture(t, defaultNotifySettings()) f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB) f.watcher(t, "quinn@example.org", "Quinn", evPkB) f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) f.setNode(evPkB, "Bravo", "companion", time.Hour, nil) f.src.lock = 1500 * time.Microsecond logs := captureLog(f.tick) if !regexp.MustCompile(`\[notify\] tick: users=2 changes=0 mails=0 took=\d+\.\d\dms lock=1\.50ms`).MatchString(logs) { t.Fatalf("first tick log = %q", logs) } f.clk.Advance(25 * time.Hour) f.src.newestAt = f.clk.Now logs = captureLog(f.tick) if n := strings.Count(logs, "[notify] tick:"); n != 1 || !strings.Contains(logs, "users=2 changes=3 mails=2 ") { t.Fatalf("second tick log (%d lines) = %q", n, logs) } }