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 (plans/panel-notifications.md): 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, _ := 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"}} 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 both 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) } // 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"`, `DNS records of the server's own name`, `
sp2026`, `value="sp2026._domainkey.mail.example.org"`,
`