Migration 0004_notifications.sql, DDL only: users.notify, a user's own
switch, on by default; notify_state, one row per subject the panel has
written about (dns:<domain>, cert, queue, limit:<domain>:<login>), so that
each thing is said once and a restart says nothing again.
Store: the instance switch as a settings key, on unless turned off;
SetUserNotify; NotifyRecipients - global users for everything, domain users
for the domains on their list or all of them, only with an e-mail and their
switch on, a shared mailbox once; the state's get, set, delete and list by
prefix; LimitRefusalsSince, the mail refused under a level-2 limit per domain
and application.
Nothing is sent yet. Checked on Windows: go vet clean, go test
./internal/store passes with the schema test at user_version 4. The page
guards fail from the design commit 87fa0a4 until the pages are built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
5.8 KiB
Go
167 lines
5.8 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The instance switch is on until someone turns it off: a database that never
|
|
// saw the setting sends notifications.
|
|
func TestNotificationsEnabledByDefault(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if on, err := s.NotificationsEnabled(); err != nil || !on {
|
|
t.Fatalf("a fresh database: enabled = %v, %v; want on", on, err)
|
|
}
|
|
if err := s.SetNotificationsEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if on, err := s.NotificationsEnabled(); err != nil || on {
|
|
t.Errorf("after turning off: enabled = %v, %v", on, err)
|
|
}
|
|
if err := s.SetNotificationsEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if on, err := s.NotificationsEnabled(); err != nil || !on {
|
|
t.Errorf("after turning on again: enabled = %v, %v", on, err)
|
|
}
|
|
}
|
|
|
|
// Who is written to: every global user about everything, a domain user about
|
|
// the domains on their list or all of them - and nobody without an e-mail or
|
|
// with their own switch off.
|
|
func TestNotifyRecipients(t *testing.T) {
|
|
s := openTestStore(t)
|
|
mine, err := s.AddDomain("mine.example.com", "sp")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other, err := s.AddDomain("other.example.com", "sp")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
user := func(name string, role Role, reach Reach, email string, notify bool) {
|
|
t.Helper()
|
|
id, err := s.CreateUser(name, "hash", role, reach)
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
if err := s.UpdateUser(id, name, "hash", email); err != nil {
|
|
t.Fatalf("update %s: %v", name, err)
|
|
}
|
|
if u, err := s.GetUser(id); err != nil || !u.Notify {
|
|
t.Fatalf("%s: a new user's switch is %v, %v; want on", name, u.Notify, err)
|
|
}
|
|
if !notify {
|
|
if err := s.SetUserNotify(id, false); err != nil {
|
|
t.Fatalf("switch off %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
user("root", RoleGlobal, Reach{}, "root@example.org", true)
|
|
user("root2", RoleGlobal, Reach{}, "root@example.org", true) // the same mailbox
|
|
user("quiet", RoleGlobal, Reach{}, "quiet@example.org", false)
|
|
user("nomail", RoleGlobal, Reach{}, "", true)
|
|
user("mine", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mine@example.org", true)
|
|
user("theirs", RoleDomain, Reach{DomainIDs: []int64{other.ID}}, "theirs@example.org", true)
|
|
user("every", RoleDomain, Reach{AllDomains: true}, "every@example.org", true)
|
|
user("mineoff", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mineoff@example.org", false)
|
|
|
|
for _, c := range []struct{ domain, want string }{
|
|
{"", "root@example.org"},
|
|
{"mine.example.com", "every@example.org mine@example.org root@example.org"},
|
|
{"other.example.com", "every@example.org root@example.org theirs@example.org"},
|
|
{"gone.example.com", "every@example.org root@example.org"},
|
|
} {
|
|
got, err := s.NotifyRecipients(c.domain)
|
|
if err != nil {
|
|
t.Fatalf("recipients for %q: %v", c.domain, err)
|
|
}
|
|
if strings.Join(got, " ") != c.want {
|
|
t.Errorf("recipients for %q = %q, want %q", c.domain, strings.Join(got, " "), c.want)
|
|
}
|
|
}
|
|
|
|
if err := s.SetUserNotify(99999, true); err != ErrUserNotFound {
|
|
t.Errorf("switch of a user that does not exist: %v", err)
|
|
}
|
|
}
|
|
|
|
// What has been said is remembered by key, replaced whole, and forgotten.
|
|
func TestNotifyStateRoundTrip(t *testing.T) {
|
|
s := openTestStore(t)
|
|
if _, ok, err := s.GetNotifyState("dns:example.com"); err != nil || ok {
|
|
t.Fatalf("no state yet: ok = %v, %v", ok, err)
|
|
}
|
|
since := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
|
|
st := NotifyState{Key: "dns:example.com", Phase: "pending", Count: 1, Since: since}
|
|
if err := s.SetNotifyState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, ok, err := s.GetNotifyState("dns:example.com")
|
|
if err != nil || !ok || got.Phase != "pending" || got.Count != 1 || !got.Since.Equal(since) || !got.SentAt.IsZero() {
|
|
t.Fatalf("state = %+v, %v, %v", got, ok, err)
|
|
}
|
|
|
|
st.Phase, st.Count, st.Mark, st.SentAt = "open", 0, "x", since.Add(time.Hour)
|
|
if err := s.SetNotifyState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _, _ = s.GetNotifyState("dns:example.com")
|
|
if got.Phase != "open" || got.Mark != "x" || !got.SentAt.Equal(since.Add(time.Hour)) {
|
|
t.Errorf("replaced state = %+v", got)
|
|
}
|
|
|
|
for _, key := range []string{"dns:other.example.com", "queue", "limit:example.com:app"} {
|
|
if err := s.SetNotifyState(NotifyState{Key: key, Phase: "open", Since: since}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
keys, err := s.ListNotifyStateKeys("dns:")
|
|
if err != nil || strings.Join(keys, " ") != "dns:example.com dns:other.example.com" {
|
|
t.Errorf("dns keys = %v, %v", keys, err)
|
|
}
|
|
if err := s.DeleteNotifyState("dns:example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok, _ := s.GetNotifyState("dns:example.com"); ok {
|
|
t.Error("state still there after delete")
|
|
}
|
|
}
|
|
|
|
// Refused mail is counted per domain and application from a point in time;
|
|
// delivered mail and older refusals are not.
|
|
func TestLimitRefusalsSince(t *testing.T) {
|
|
s := openTestStore(t)
|
|
for _, e := range []SendLogEntry{
|
|
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
|
|
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
|
|
{Domain: "example.com", AppLogin: "app2", From: "b@example.com"},
|
|
} {
|
|
if err := s.InsertRejected(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := s.InsertQueued(SendLogEntry{QueueID: "ABC123", Domain: "example.com", AppLogin: "app3", To: "x@example.net"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.InsertRejected(SendLogEntry{Domain: "old.example.com", AppLogin: "old"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE send_log SET created_at = '2026-01-01T00:00:00Z' WHERE app_login = 'old'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.LimitRefusalsSince(time.Now().UTC().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 || got[0].AppLogin != "app1" || got[0].Count != 2 || got[1].AppLogin != "app2" || got[1].Count != 1 {
|
|
t.Fatalf("refusals = %+v", got)
|
|
}
|
|
if got[0].Domain != "example.com" || got[0].First.IsZero() {
|
|
t.Errorf("first refusal = %+v", got[0])
|
|
}
|
|
}
|