mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 03:19:58 +00:00
Part E of #2128: a logged-in user watches nodes and gets one mail when a watched node goes offline, comes back, or reports a low battery. Admins can add instance events: a new foreign node, and an observer going offline or back. This covers the per-user mail part of #775. Stacked on #2139 (part D, which builds on #2138). Review the commits after that branch. ## The situation A sysop learns that a repeater went silent, or that its battery is running down, only by opening CoreScope. #775 asks for notifications. Accounts (part A) now give a verified address to mail and a mailer with delivery status. ## What this PR adds **Events.** They use the instance thresholds, so a mail does not disagree with the node page: - `node.offline`: silent for the role's `healthThresholds` window. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (#1598). - `node.battery`: advert telemetry below `batteryThresholds.lowMv`, recovered at `lowMv + 100`. - Admins only: `foreign.new` (once per node) and `observer.offline`. **Store.** Schema v5 with `notification_prefs`, `notification_watches` and `notification_state`. **Server** (only with `userManagement.notifications.enabled`) - A notifier next to the janitor, every `intervalMinutes` (5). The evaluator is a pure function of watches, prefs, stored states and node snapshots. - No burst after a restart or a new watch: a subject's first evaluation stores its state without mailing. The loop waits for the startup load. While ingest is stale (newest packet older than 30 minutes) the offline checks pause, and after recovery they wait one silent window. - One mail per user per check. Limits: 20 per user and 100 per instance per rolling 24 hours, 50 watches per user. A change over a limit is recorded and never mailed later. - Every mail has a one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`). The GET only redirects to a confirm page, so link scanners cannot unsubscribe anyone. Node names are cleaned of control and bidi characters before they go into a mail. - Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE /api/account/notifications/watches/{pubkey}`, `POST /api/account/notifications/watch-my-nodes` (copies the synced "my nodes" list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message` gains `Headers`. **Frontend.** A "Notify me" toggle on the node pages, a Notifications section on the account page (`#/account?section=notifications`), an unsubscribe page, and the notification mail count on the admin overview. Spec: [`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md). ## Performance Each check reads watches, prefs and states from `users.db` (one query each), the watched nodes from the analyzer DB in chunks of 500, and last-heard times from the packet store. | Measurement | Result | |---|---| | One check on our staging instance, 130,000 packets in memory, 1 watcher | 6.9 ms, of which 0.93 ms holding the store read lock | | Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes | 1.75 ms | No work is added to ingest, broadcast or any request path. ## Verification - `internal/users` and `internal/mailer` with `-race`; the `cmd/server` suite plus `-race` on the notifier tests; 74 new Go tests. The same Windows-only failure as noted in #2138 applies. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 26 of 26 steps locally. With notifications off the new steps fail, so they do test the flag. - On our staging and production instance since 7 October 2026. ## Not in this PR - Other channels (Discord, Telegram, webhooks). Detection is separate from delivery, so one can be added. - The topology, RF and anomaly alerts of #775. - If the whole server starts after a feed outage that already ended, the grace window is unknown and a watcher can get one wrong offline mail. The user guide says so. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
728 lines
26 KiB
Go
728 lines
26 KiB
Go
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, `<b>Bad</b> & "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, "<b>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)
|
|
}
|
|
}
|