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>
377 lines
14 KiB
Go
377 lines
14 KiB
Go
package users
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// cmd/ingestor/approved_channels.go reads the approved names with this exact
|
|
// literal (it does not import this package). Its test checks the literal is
|
|
// still in this file; TestApprovedSubjects checks it still agrees with
|
|
// ApprovedSubjects.
|
|
const ingestorApprovedQuery = `SELECT subject FROM proposals WHERE kind = 'hashtag_channel' AND status = 'approved' ORDER BY decided_at, id LIMIT ?`
|
|
|
|
func mustPropose(t *testing.T, st *Store, subject string, uid int64) *Proposal {
|
|
t.Helper()
|
|
p, err := st.Propose(KindHashtagChannel, subject, uid, ProposalLimits{})
|
|
if err != nil {
|
|
t.Fatalf("Propose(%s): %v", subject, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func mustDecide(t *testing.T, st *Store, id int64, a ProposalAction, reviewer int64) *Proposal {
|
|
t.Helper()
|
|
p, err := st.Decide(id, a, reviewer, "", 0)
|
|
if err != nil {
|
|
t.Fatalf("Decide(%d, %s): %v", id, a, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// reach moves a fresh pending proposal to status.
|
|
func reach(t *testing.T, st *Store, id int64, status ProposalStatus, reviewer int64) {
|
|
t.Helper()
|
|
switch status {
|
|
case ProposalApproved:
|
|
mustDecide(t, st, id, ProposalApprove, reviewer)
|
|
case ProposalRejected:
|
|
mustDecide(t, st, id, ProposalReject, reviewer)
|
|
case ProposalRevoked:
|
|
mustDecide(t, st, id, ProposalApprove, reviewer)
|
|
mustDecide(t, st, id, ProposalRevoke, reviewer)
|
|
}
|
|
}
|
|
|
|
func TestProposeCreatesPending(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
p := mustPropose(t, st, "#mycity", u.ID)
|
|
if p.ID == 0 || p.Kind != KindHashtagChannel || p.Subject != "#mycity" || p.Status != ProposalPending ||
|
|
p.ProposerID == nil || *p.ProposerID != u.ID || p.ReviewerID != nil || p.Note != "" ||
|
|
p.DecidedAt != nil || !p.CreatedAt.Equal(clk.Now()) {
|
|
t.Fatalf("unexpected proposal: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestProposeRefusesDuplicatesByState(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
a := mustPropose(t, st, "#a", u.ID)
|
|
if _, err := st.Propose(KindHashtagChannel, "#a", admin.ID, ProposalLimits{}); !errors.Is(err, ErrProposalPending) {
|
|
t.Fatalf("pending duplicate err = %v; want ErrProposalPending", err)
|
|
}
|
|
mustDecide(t, st, a.ID, ProposalApprove, admin.ID)
|
|
if _, err := st.Propose(KindHashtagChannel, "#a", admin.ID, ProposalLimits{}); !errors.Is(err, ErrProposalApproved) {
|
|
t.Fatalf("approved duplicate err = %v; want ErrProposalApproved", err)
|
|
}
|
|
b := mustPropose(t, st, "#b", u.ID)
|
|
mustDecide(t, st, b.ID, ProposalReject, admin.ID)
|
|
if _, err := st.Propose(KindHashtagChannel, "#b", u.ID, ProposalLimits{}); !errors.Is(err, ErrProposalRejected) {
|
|
t.Fatalf("rejected re-proposal err = %v; want ErrProposalRejected", err)
|
|
}
|
|
// The same subject under another kind is another row.
|
|
if _, err := st.Propose("other_kind", "#a", u.ID, ProposalLimits{}); err != nil {
|
|
t.Fatalf("other kind: %v", err)
|
|
}
|
|
// Case matters: the key is derived from the exact name.
|
|
if _, err := st.Propose(KindHashtagChannel, "#A", u.ID, ProposalLimits{}); err != nil {
|
|
t.Fatalf("#A next to #a: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokedCanBeProposedAgain(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
v := mustCreate(t, st, "val@example.org", "Val")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
p := mustPropose(t, st, "#a", u.ID)
|
|
clk.Advance(time.Hour)
|
|
mustDecide(t, st, p.ID, ProposalApprove, admin.ID)
|
|
if _, err := st.Decide(p.ID, ProposalRevoke, admin.ID, "spam", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clk.Advance(time.Hour)
|
|
again, err := st.Propose(KindHashtagChannel, "#a", v.ID, ProposalLimits{})
|
|
if err != nil || again.ID != p.ID || again.Status != ProposalPending || again.ProposerID == nil || *again.ProposerID != v.ID ||
|
|
again.ReviewerID != nil || again.Note != "" || again.DecidedAt != nil || !again.CreatedAt.Equal(clk.Now()) {
|
|
t.Fatalf("re-proposal = %+v, %v; want the same row pending under Val", again, err)
|
|
}
|
|
}
|
|
|
|
func TestDecideTransitions(t *testing.T) {
|
|
cases := []struct {
|
|
from ProposalStatus
|
|
action ProposalAction
|
|
to ProposalStatus // "" = refused
|
|
}{
|
|
{ProposalPending, ProposalApprove, ProposalApproved},
|
|
{ProposalPending, ProposalReject, ProposalRejected},
|
|
{ProposalPending, ProposalRevoke, ""},
|
|
{ProposalApproved, ProposalRevoke, ProposalRevoked},
|
|
{ProposalApproved, ProposalApprove, ""},
|
|
{ProposalApproved, ProposalReject, ""},
|
|
{ProposalRejected, ProposalApprove, ""},
|
|
{ProposalRejected, ProposalRevoke, ""},
|
|
{ProposalRevoked, ProposalApprove, ""},
|
|
{ProposalRevoked, ProposalReject, ""},
|
|
}
|
|
for _, c := range cases {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
p := mustPropose(t, st, "#x", u.ID)
|
|
reach(t, st, p.ID, c.from, admin.ID)
|
|
clk.Advance(time.Minute)
|
|
got, err := st.Decide(p.ID, c.action, admin.ID, "because", 0)
|
|
if c.to == "" {
|
|
if !errors.Is(err, ErrProposalTransition) {
|
|
t.Errorf("%s from %s: err = %v; want ErrProposalTransition", c.action, c.from, err)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil || got.Status != c.to || got.ReviewerID == nil || *got.ReviewerID != admin.ID ||
|
|
got.Note != "because" || got.DecidedAt == nil || !got.DecidedAt.Equal(clk.Now()) {
|
|
t.Errorf("%s from %s = %+v, %v; want %s", c.action, c.from, got, err, c.to)
|
|
continue
|
|
}
|
|
list, err := st.ListProposals(ProposalFilter{})
|
|
if err != nil || len(list) != 1 || list[0].Status != c.to || list[0].Note != "because" {
|
|
t.Errorf("%s from %s: stored %+v, %v", c.action, c.from, list, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecideUnknownID(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
if _, err := st.Decide(999, ProposalApprove, admin.ID, "", 0); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Decide(missing) err = %v; want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestDecideApproveRespectsMaxApproved(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
for _, s := range []string{"#a", "#b"} {
|
|
mustDecide(t, st, mustPropose(t, st, s, u.ID).ID, ProposalApprove, admin.ID)
|
|
}
|
|
c := mustPropose(t, st, "#c", u.ID)
|
|
if _, err := st.Decide(c.ID, ProposalApprove, admin.ID, "", 2); !errors.Is(err, ErrProposalApprovedLimit) {
|
|
t.Fatalf("third approve err = %v; want ErrProposalApprovedLimit", err)
|
|
}
|
|
if got, _ := st.ListProposals(ProposalFilter{Status: ProposalPending}); len(got) != 1 || got[0].ID != c.ID {
|
|
t.Fatalf("refused approve changed the row: %+v", got)
|
|
}
|
|
// Other kinds do not count, and reject is never limited.
|
|
o, err := st.Propose("other_kind", "#a", u.ID, ProposalLimits{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.Decide(o.ID, ProposalApprove, admin.ID, "", 2); err != nil {
|
|
t.Fatalf("other kind approve: %v", err)
|
|
}
|
|
if _, err := st.Decide(c.ID, ProposalReject, admin.ID, "", 2); err != nil {
|
|
t.Fatalf("reject at the cap: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProposeLimits(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
v := mustCreate(t, st, "val@example.org", "Val")
|
|
day := ProposalLimits{PerUserPerDay: 2}
|
|
for _, s := range []string{"#a", "#b"} {
|
|
if _, err := st.Propose(KindHashtagChannel, s, u.ID, day); err != nil {
|
|
t.Fatalf("Propose(%s): %v", s, err)
|
|
}
|
|
}
|
|
if _, err := st.Propose(KindHashtagChannel, "#c", u.ID, day); !errors.Is(err, ErrProposalUserLimit) {
|
|
t.Fatalf("third in a day err = %v; want ErrProposalUserLimit", err)
|
|
}
|
|
if _, err := st.Propose(KindHashtagChannel, "#c", v.ID, day); err != nil {
|
|
t.Fatalf("another user has its own budget: %v", err)
|
|
}
|
|
clk.Advance(24*time.Hour + time.Second)
|
|
if _, err := st.Propose(KindHashtagChannel, "#d", u.ID, day); err != nil {
|
|
t.Fatalf("next day: %v", err)
|
|
}
|
|
// Four pending now: #a #b #c #d.
|
|
capped := ProposalLimits{MaxPending: 4}
|
|
if _, err := st.Propose(KindHashtagChannel, "#e", v.ID, capped); !errors.Is(err, ErrProposalPendingLimit) {
|
|
t.Fatalf("pending cap err = %v; want ErrProposalPendingLimit", err)
|
|
}
|
|
// A duplicate is reported as such, not as a limit.
|
|
if _, err := st.Propose(KindHashtagChannel, "#a", v.ID, capped); !errors.Is(err, ErrProposalPending) {
|
|
t.Fatalf("duplicate at the cap err = %v; want ErrProposalPending", err)
|
|
}
|
|
}
|
|
|
|
func TestListProposalsFilterAndOrder(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
a := mustPropose(t, st, "#a", u.ID)
|
|
clk.Advance(time.Minute)
|
|
b := mustPropose(t, st, "#b", u.ID)
|
|
clk.Advance(time.Minute)
|
|
mustDecide(t, st, a.ID, ProposalApprove, admin.ID)
|
|
all, err := st.ListProposals(ProposalFilter{})
|
|
if err != nil || len(all) != 2 || all[0].ID != b.ID || all[1].ID != a.ID {
|
|
t.Fatalf("ListProposals(all) = %+v, %v; want #b then #a", all, err)
|
|
}
|
|
if pend, _ := st.ListProposals(ProposalFilter{Status: ProposalPending}); len(pend) != 1 || pend[0].ID != b.ID {
|
|
t.Fatalf("pending = %+v", pend)
|
|
}
|
|
if other, _ := st.ListProposals(ProposalFilter{Kind: "other_kind"}); len(other) != 0 {
|
|
t.Fatalf("other kind = %+v", other)
|
|
}
|
|
}
|
|
|
|
func TestProposalsByUser(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
v := mustCreate(t, st, "val@example.org", "Val")
|
|
a := mustPropose(t, st, "#a", u.ID)
|
|
clk.Advance(time.Minute)
|
|
b := mustPropose(t, st, "#b", u.ID)
|
|
mustPropose(t, st, "#c", v.ID)
|
|
list, err := st.ProposalsByUser(u.ID)
|
|
if err != nil || len(list) != 2 || list[0].ID != b.ID || list[1].ID != a.ID {
|
|
t.Fatalf("ProposalsByUser = %+v, %v", list, err)
|
|
}
|
|
}
|
|
|
|
func TestApprovedSubjects(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
for _, s := range []string{"#c", "#a", "#b"} {
|
|
p := mustPropose(t, st, s, u.ID)
|
|
clk.Advance(time.Minute)
|
|
mustDecide(t, st, p.ID, ProposalApprove, admin.ID)
|
|
}
|
|
mustPropose(t, st, "#pending", u.ID)
|
|
o, err := st.Propose("other_kind", "#x", u.ID, ProposalLimits{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustDecide(t, st, o.ID, ProposalApprove, admin.ID)
|
|
|
|
got, err := st.ApprovedSubjects(KindHashtagChannel, 0)
|
|
if err != nil || strings.Join(got, ",") != "#c,#a,#b" {
|
|
t.Fatalf("ApprovedSubjects = %v, %v; want decision order #c,#a,#b", got, err)
|
|
}
|
|
if capped, _ := st.ApprovedSubjects(KindHashtagChannel, 2); strings.Join(capped, ",") != "#c,#a" {
|
|
t.Fatalf("capped = %v", capped)
|
|
}
|
|
if empty, err := st.ApprovedSubjects("nothing", 0); err != nil || empty == nil || len(empty) != 0 {
|
|
t.Fatalf("no rows = %#v, %v; want a non-nil empty slice", empty, err)
|
|
}
|
|
rows, err := st.db.Query(ingestorApprovedQuery, 2)
|
|
if err != nil {
|
|
t.Fatalf("ingestor query: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var viaIngestor []string
|
|
for rows.Next() {
|
|
var s string
|
|
if err := rows.Scan(&s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
viaIngestor = append(viaIngestor, s)
|
|
}
|
|
if strings.Join(viaIngestor, ",") != "#c,#a" {
|
|
t.Fatalf("ingestor query = %v; want #c,#a (same order and cap as ApprovedSubjects)", viaIngestor)
|
|
}
|
|
}
|
|
|
|
func TestPruneProposals(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
admin := mustCreate(t, st, "ada@example.org", "Ada")
|
|
old := map[string]ProposalStatus{"#rej": ProposalRejected, "#rev": ProposalRevoked, "#app": ProposalApproved, "#pen": ProposalPending}
|
|
for s, status := range old {
|
|
reach(t, st, mustPropose(t, st, s, u.ID).ID, status, admin.ID)
|
|
}
|
|
clk.Advance(91 * 24 * time.Hour)
|
|
reach(t, st, mustPropose(t, st, "#young", u.ID).ID, ProposalRejected, admin.ID)
|
|
n, err := st.PruneProposals(90 * 24 * time.Hour)
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("PruneProposals = %d, %v; want 2", n, err)
|
|
}
|
|
left, _ := st.ListProposals(ProposalFilter{})
|
|
got := map[string]bool{}
|
|
for _, p := range left {
|
|
got[p.Subject] = true
|
|
}
|
|
if len(left) != 3 || !got["#app"] || !got["#pen"] || !got["#young"] {
|
|
t.Fatalf("left after prune = %v", got)
|
|
}
|
|
// A pruned rejection can be proposed again.
|
|
if _, err := st.Propose(KindHashtagChannel, "#rej", u.ID, ProposalLimits{}); err != nil {
|
|
t.Fatalf("re-propose after prune: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProposalSurvivesProposerDeletion(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
u := mustCreate(t, st, "pat@example.org", "Pat")
|
|
mustPropose(t, st, "#a", u.ID)
|
|
if err := st.Delete(u.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err := st.ListProposals(ProposalFilter{})
|
|
if err != nil || len(list) != 1 || list[0].ProposerID != nil {
|
|
t.Fatalf("after deleting the proposer = %+v, %v; want the row with no proposer", list, err)
|
|
}
|
|
}
|
|
|
|
// A users.db written by a v3 binary gains the proposals table and keeps its rows.
|
|
func TestMigrateV3DatabaseToV4(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "users.db")
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stmts := []string{
|
|
`CREATE TABLE schema_version (version INTEGER NOT NULL)`,
|
|
`INSERT INTO schema_version (version) VALUES (3)`,
|
|
}
|
|
for _, m := range migrations[:3] {
|
|
stmts = append(stmts, m...)
|
|
}
|
|
stmts = append(stmts, `INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`)
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
t.Fatalf("build v3 db: %v", err)
|
|
}
|
|
}
|
|
db.Close()
|
|
|
|
st, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open v3 db: %v", err)
|
|
}
|
|
defer st.Close()
|
|
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
|
|
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
|
|
}
|
|
if !hasIndex(t, st, "proposals_kind_subject") || !hasIndex(t, st, "proposals_status") {
|
|
t.Fatal("proposals indexes missing after migration")
|
|
}
|
|
u, err := st.GetByEmail("old@example.org")
|
|
if err != nil {
|
|
t.Fatalf("v3 user lost: %v", err)
|
|
}
|
|
if _, err := st.Propose(KindHashtagChannel, "#after", u.ID, ProposalLimits{}); err != nil {
|
|
t.Fatalf("Propose after migration: %v", err)
|
|
}
|
|
}
|