Files
selfpost/internal/web/handlers/handlers_notifications_test.go
T
mixandClaude Opus 5.5 30fcf978e0
test / test (push) Waiting to run
docs: clear shipped plans and closed work out of the documents
Owner, 2026-10-18: "Надо почистить документы от устаревшего и сделанного."

- Deleted, shipped: plans/delivery-log-storage, from-header-binding,
  inbound-antispam-panel, inbound-upstream-result, panel-dkim-signing,
  panel-notifications, and the own-mta-builds analysis (its three rows
  shipped as 2.6.1, 2.7.0, 2.8.0). What outlived them moved first: when a
  filter-list counter is worth a migration, and the notifications' "when"
  table, into architecture.md; the sender check's own-process trigger into
  security.md. Code comments that cited them point at architecture.md or
  security.md; CHANGELOG keeps their names, unlinked.
- Kept, marked closed: audit-2026-10-12, assumptions-2026-10-14 and
  security-audit-2026-10-17 - the code cites their finding ids in 50-84
  files. Their file:line refs are of the tree each audited.
- roadmap.md: open work only - stage H of the security audit (the owner's),
  go-milter-upstream (the owner posts), and queue-table, which the
  redesign left undecided and only its own shipped section still named;
  Dropped and Ideas as they were. 2.x versioning instead of 1.x.
- development.md: the 47-row planned-version-cuts table (history, in the
  CHANGELOG) gone; a stage's last step now removes its roadmap item and
  plan. Docs map lists the closed audits.
- panel-redesign.md: the done checklist out; Room for roadmap items says
  which drawn screens are unbuilt and why.
- Stale facts fixed: schema-migrations.md and architecture.md at schema 14
  (0013's users columns and user_links, 0014's secure TLS mode);
  security.md's accepted risk "profile saved without the password" - the
  e-mail asks for it since 2.14.0.

Tests: go build, go vet; go test of cmd and the touched internal packages
pass except logtail's three Windows-bound rotation tests (rename of an open
file; CI is Linux). Only comments changed in Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:37:22 +03:00

348 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/web/auth"
)
// The panel pages of notifications (docs/architecture.md § Notifications): the user's
// checkbox on Account, the instance's switch and the DNS records of the server's
// own name on Server › Settings, and the state of those records on Server ›
// Health. What is sent and when is internal/notify's and tested there.
const notifyHost = "mail.example.org"
// ---- Account
// The checkbox under the e-mail is the user's own switch: the page shows what is
// stored, Save profile writes it, and an unchecked box is a form without the
// field, which means off. Saving it changes nothing else of the profile.
func TestAccountNotifyCheckbox(t *testing.T) {
h, password := settingsServer(t)
notify := func() bool {
u, _ := h.store.GetUser(globalPrincipal.ID)
return u.Notify
}
save := func(extra url.Values) {
t.Helper()
form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}, "current_password": {password}}
for k, v := range extra {
form[k] = v
}
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusSeeOther {
t.Fatalf("POST /account/profile %v = %d:\n%s", extra, rec.Code, rec.Body.String())
}
}
save(url.Values{"notify": {"on"}})
if !notify() {
t.Error("a ticked box did not turn notifications on")
}
if out := getBody(t, h.HandleAccount, "/account"); !strings.Contains(out, `name="notify" checked`) {
t.Errorf("a user who has them on is shown an unticked box:\n%s", out)
}
save(nil)
if notify() {
t.Error("a form without the field did not turn notifications off")
}
if out := getBody(t, h.HandleAccount, "/account"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
t.Errorf("a user who has them off is shown a ticked box, or none:\n%s", out)
}
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "mix@example.org" {
t.Errorf("saving the box lost the e-mail: %q", u.Email)
}
}
// A profile that is refused saves nothing, the box included, and shows the form
// again as it was submitted.
func TestAccountNotifyIsNotSavedWithARefusedProfile(t *testing.T) {
h, _ := settingsServer(t)
if err := h.store.SetUserNotify(globalPrincipal.ID, false); err != nil {
t.Fatal(err)
}
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"admin"}, "email": {"not-an-address"}, "notify": {"on"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /account/profile with a bad e-mail = %d, want 400", rec.Code)
}
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Notify {
t.Error("a refused profile turned notifications on")
}
if !strings.Contains(rec.Body.String(), `name="notify" checked`) {
t.Error("the form shown again with the error lost the box the user ticked")
}
}
// ---- Server › Settings
// The switch is saved with the rest of the page and only a global user can save
// it: a domain administrator is told the page does not exist, and nothing moves.
func TestSettingsNotificationsSwitch(t *testing.T) {
h, _ := settingsServer(t)
on := func() bool {
v, err := h.store.NotificationsEnabled()
if err != nil {
t.Fatal(err)
}
return v
}
save := func(p auth.Principal, notify bool) *httptest.ResponseRecorder {
form := url.Values{"send_log_retention_days": {"60"}}
if notify {
form.Set("notify", "on")
}
return postFormAs(h.HandleServerSettings, p, "/server/settings", nil, form)
}
if !on() {
t.Fatal("a fresh instance has notifications off")
}
if rec := save(globalPrincipal, false); rec.Code != http.StatusSeeOther || on() {
t.Errorf("an unticked box = %d, switch on: %v; want 303 and off", rec.Code, on())
}
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
t.Errorf("the page shows the switch on while it is off:\n%s", out)
}
if rec := save(globalPrincipal, true); rec.Code != http.StatusSeeOther || !on() {
t.Errorf("a ticked box = %d, switch on: %v; want 303 and on", rec.Code, on())
}
if out := getBody(t, h.HandleServerSettings, "/server/settings"); !strings.Contains(out, `name="notify" checked`) {
t.Errorf("the page shows the switch off while it is on:\n%s", out)
}
d, err := h.store.AddDomain("example.com", "mail")
if err != nil {
t.Fatal(err)
}
ops := domainAdmin(t, h.store, "ops", d.ID)
if rec := save(ops, false); rec.Code != http.StatusNotFound || !on() {
t.Errorf("a domain administrator turning the switch off = %d, switch on: %v; want 404 and on", rec.Code, on())
}
}
// A page that is refused (here: a retention out of range) leaves the switch as
// it was, like every other setting on it.
func TestSettingsRefusalLeavesTheNotificationsSwitch(t *testing.T) {
h, _ := settingsServer(t)
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /server/settings = %d, want 400", rec.Code)
}
if on, _ := h.store.NotificationsEnabled(); !on {
t.Error("a refused page turned notifications off")
}
}
// serverMailStand is a panel that knows its own name, has the key notifications
// are signed with, and asks a DNS server that answers from a table: the address
// of the server's name is there, everything else is for the test to publish.
func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
t.Helper()
h, _ := settingsServer(t)
zone, addr := newFakeZone(t)
zone.publishA(notifyHost, "192.0.2.10")
h.dns = dnscheck.New([]string{addr})
h.cfg.Hostname = notifyHost
h.machine = &health.MachineSampler{}
h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "sp2026")
h.domains.SetServerName(notifyHost)
mail, err := h.domains.ServerMail()
if err != nil {
t.Fatalf("ServerMail: %v", err)
}
return h, zone, mail
}
// publishMail puts the three records of the server's own name into the zone.
func publishMail(zone *fakeZone, mail domain.ServerMail) {
zone.publishTXT(mail.Record.Name, mail.Record.Value)
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF)
zone.publishTXT(dnscheck.DMARCRecordName(notifyHost), dnscheck.ServerMailDMARC)
}
// With both records in DNS, Settings gives each with the value to publish and
// the state "published", and no warning about what is missing.
func TestSettingsMailRecordsPublished(t *testing.T) {
h, zone, mail := serverMailStand(t)
publishMail(zone, mail)
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`id="notification-mail"`, `The server&#39;s own name · checked just now`,
`<h3>DKIM</h3><span class="tag is-success is-light">published</span>`,
`selector <code>sp2026</code>`, `value="sp2026._domainkey.mail.example.org"`,
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
`For the server&#39;s own name, not for your domains`,
`value="v=spf1 a -all"`,
`<h3>DMARC</h3><span class="tag is-success is-light">published</span>`,
`value="_dmarc.mail.example.org"`, `value="v=DMARC1; p=reject"`,
`<span class="sp-mono">postmaster@mail.example.org</span>`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "may file them as spam") || strings.Contains(out, "does not refuse mail forged") {
t.Error("the note about a missing SPF or DMARC record is shown while it is published")
}
}
// With nothing published the records are the same and the states say so, as a
// warning and not as the red of a domain: notifications are sent without them.
// The page says what that costs under SPF and what is missing under DKIM.
func TestSettingsMailRecordsMissing(t *testing.T) {
h, _, _ := serverMailStand(t)
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
`<h3>DMARC</h3><span class="tag is-warning is-light">missing</span>`,
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`, `value="v=DMARC1; p=reject"`,
`Without it a receiver does not refuse mail forged in the server&#39;s name`,
`Notifications are sent without it, and a receiver may file them as spam.`,
`No TXT record is published at sp2026._domainkey.mail.example.org.`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `is-danger is-light">missing`) {
t.Error("a record of the server's own name that is missing is drawn as an error")
}
}
// A DKIM record that holds some other key is a mismatch, and the page shows what
// DNS holds next to what to publish.
func TestSettingsMailDKIMWithAnotherKey(t *testing.T) {
h, zone, mail := serverMailStand(t)
zone.publishTXT(mail.Record.Name, "v=DKIM1; k=rsa; p=AAAAB3NzaC1yc2EAAAADAQABAAABAQDhWrongKeyEntirely")
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{`<span class="tag is-warning is-light">mismatch</span>`, `In DNS now`, `WrongKeyEntirely`} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q:\n%s", want, out)
}
}
}
// If the key of the server's own name cannot be read the page is still there:
// the DKIM record is left out and SPF is shown unchecked.
func TestSettingsWithoutAServerKeyStillRenders(t *testing.T) {
h, _ := settingsServer(t) // its domain service does not know the server's name
out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{`id="notification-mail"`, `<h3>SPF</h3><span class="tag is-light">not checked</span>`, `name="notify"`} {
if !strings.Contains(out, want) {
t.Errorf("settings page is missing %q", want)
}
}
if strings.Contains(out, "<h3>DKIM</h3>") {
t.Error("a DKIM record is shown for a key that could not be read")
}
}
// ---- Server › Health
// Health has the card with the state of each record, the host under its name,
// and the way to the values on Settings. A record that is missing makes the
// card a warning.
func TestHealthNotificationMailCard(t *testing.T) {
h, zone, mail := serverMailStand(t)
zone.publishTXT(mail.Record.Name, mail.Record.Value) // DKIM only
out := getBody(t, h.HandleHealth, "/server/health")
for _, want := range []string{
`id="notification-mail"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`DMARC<br><span class="sp-muted sp-mono sp-small">_dmarc.mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
`href="/server/settings#notification-mail"`,
} {
if !strings.Contains(out, want) {
t.Errorf("health page is missing %q:\n%s", want, out)
}
}
}
// With both records in place the card is green. The card is part of the polled
// fragment, swapped in by its id.
func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
h, zone, mail := serverMailStand(t)
publishMail(zone, mail)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/server/health/fragment", nil)
h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
out := rec.Body.String()
for _, want := range []string{
`id="notification-mail" hx-swap-oob="true"`,
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div>`,
`<td><span class="tag is-success is-light">published</span></td>`,
} {
if !strings.Contains(out, want) {
t.Errorf("health fragment is missing %q:\n%s", want, out)
}
}
if strings.Contains(out[strings.Index(out, `id="notification-mail"`):], "is-warning") {
t.Error("the card warns while both records are published")
}
}
// The DNS answers are cached for a few minutes; Re-check DNS on Health asks for
// the records of the server's own name again as well as for its address.
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Contains(out, `is-success is-light">published`) {
t.Fatal("the cached answer was not used: the test cannot tell a re-check from a fresh read")
}
rec := postFormAs(h.HandleHealthRecheck, globalPrincipal, "/server/health/recheck", nil, url.Values{})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code)
}
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 3 {
t.Errorf("after Re-check DNS the three records should be published:\n%s", out)
}
}
// Re-check DNS on Settings asks for the same records again and returns to
// Settings, where a record that has just been published comes back folded.
func TestSettingsRecheckAsksForNotificationMailAgain(t *testing.T) {
h, zone, mail := serverMailStand(t)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Count(out, `is-warning is-light">missing`) != 3 {
t.Fatalf("before publishing, the three records should be missing:\n%s", out)
}
publishMail(zone, mail)
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Contains(out, `is-success is-light">published`) {
t.Fatal("the cached answer was not used: the test cannot tell a re-check from a fresh read")
}
rec := postFormAs(h.HandleSettingsDNSRecheck, globalPrincipal, "/server/settings/dns-recheck", nil, url.Values{})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?rechecked=1" {
t.Fatalf("POST /server/settings/dns-recheck = %d to %q, want 303 to /server/settings?rechecked=1", rec.Code, rec.Header().Get("Location"))
}
out := getBody(t, h.HandleServerSettings, "/server/settings?rechecked=1")
if n := strings.Count(out, `<details class="sp-record">`); n != 3 {
t.Errorf("after Re-check DNS the three records should be published and folded, %d are:\n%s", n, out)
}
if !strings.Contains(out, "DNS re-checked.") || !strings.Contains(out, "· checked just now") {
t.Errorf("Settings does not say the records were just re-checked:\n%s", out)
}
}