panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):
- Account: "Send me notifications" in the Profile form, saved with the
profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
mail with the DKIM and SPF records of the server's own name, drawn with the
record partial of a domain's DNS page and checked in DNS. Sending rate
limits are box 02 in the left column. A record that is not in place is
tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
and a link to Settings, beside Configuration (07); part of the polled
fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
account, word for word with help.html.
If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.
e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeZone is a DNS server on the loopback that answers from a table, so that a
|
||||
// page can be rendered against records that are published, wrong or absent
|
||||
// without leaving the machine. Names are lower case without the trailing dot;
|
||||
// a name the zone does not hold is NXDOMAIN, which is how a record that is not
|
||||
// published looks to the checker.
|
||||
type fakeZone struct {
|
||||
mu sync.Mutex
|
||||
txt map[string][]string
|
||||
addr map[string]string // name -> IPv4 address
|
||||
}
|
||||
|
||||
// newFakeZone starts the server for the length of the test and returns the
|
||||
// resolver address to give dnscheck.New.
|
||||
func newFakeZone(t *testing.T) (*fakeZone, string) {
|
||||
t.Helper()
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("fake DNS: listen: %v", err)
|
||||
}
|
||||
z := &fakeZone{txt: map[string][]string{}, addr: map[string]string{}}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if resp := z.answer(buf[:n]); resp != nil {
|
||||
_, _ = conn.WriteTo(resp, from)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return z, conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
// publishTXT sets the TXT record of a name; publishA its IPv4 address.
|
||||
func (z *fakeZone) publishTXT(name, value string) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
z.txt[strings.ToLower(name)] = append(z.txt[strings.ToLower(name)], value)
|
||||
}
|
||||
|
||||
func (z *fakeZone) publishA(name, ip string) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
z.addr[strings.ToLower(name)] = ip
|
||||
}
|
||||
|
||||
// answer builds the reply to one query: the question is copied back, then the
|
||||
// records of the asked type if the zone has any. A name with nothing of the
|
||||
// asked type but known under another type is an empty answer (NOERROR), an
|
||||
// unknown name is NXDOMAIN.
|
||||
func (z *fakeZone) answer(q []byte) []byte {
|
||||
if len(q) < 12 || binary.BigEndian.Uint16(q[4:6]) != 1 {
|
||||
return nil
|
||||
}
|
||||
i, labels := 12, []string{}
|
||||
for {
|
||||
if i >= len(q) {
|
||||
return nil
|
||||
}
|
||||
l := int(q[i])
|
||||
if l == 0 {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if i+1+l > len(q) {
|
||||
return nil
|
||||
}
|
||||
labels = append(labels, string(q[i+1:i+1+l]))
|
||||
i += 1 + l
|
||||
}
|
||||
if i+4 > len(q) {
|
||||
return nil
|
||||
}
|
||||
name, qtype := strings.ToLower(strings.Join(labels, ".")), binary.BigEndian.Uint16(q[i:i+2])
|
||||
question := q[12 : i+4]
|
||||
|
||||
z.mu.Lock()
|
||||
txt, ip := z.txt[name], z.addr[name]
|
||||
z.mu.Unlock()
|
||||
|
||||
var answers [][]byte
|
||||
switch {
|
||||
case qtype == 16:
|
||||
for _, v := range txt {
|
||||
var rdata []byte
|
||||
for len(v) > 255 {
|
||||
rdata = append(rdata, 255)
|
||||
rdata = append(rdata, v[:255]...)
|
||||
v = v[255:]
|
||||
}
|
||||
rdata = append(rdata, byte(len(v)))
|
||||
rdata = append(rdata, v...)
|
||||
answers = append(answers, record(16, rdata))
|
||||
}
|
||||
case qtype == 1 && ip != "":
|
||||
answers = append(answers, record(1, net.ParseIP(ip).To4()))
|
||||
}
|
||||
rcode := uint16(0)
|
||||
if len(txt) == 0 && ip == "" {
|
||||
rcode = 3
|
||||
}
|
||||
resp := make([]byte, 12, 512)
|
||||
copy(resp, q[:2])
|
||||
binary.BigEndian.PutUint16(resp[2:], 0x8180|rcode)
|
||||
binary.BigEndian.PutUint16(resp[4:], 1)
|
||||
binary.BigEndian.PutUint16(resp[6:], uint16(len(answers)))
|
||||
resp = append(resp, question...)
|
||||
for _, a := range answers {
|
||||
resp = append(resp, a...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// record is one answer record named by the question (a pointer to offset 12),
|
||||
// class IN, with a TTL of a minute.
|
||||
func record(rtype uint16, rdata []byte) []byte {
|
||||
r := []byte{0xC0, 0x0C, 0, 0, 0, 1, 0, 0, 0, 60, 0, 0}
|
||||
binary.BigEndian.PutUint16(r[2:], rtype)
|
||||
binary.BigEndian.PutUint16(r[10:], uint16(len(rdata)))
|
||||
return append(r, rdata...)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type accountForm struct {
|
||||
Err string
|
||||
Username string
|
||||
Email string
|
||||
Notify bool
|
||||
}
|
||||
|
||||
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
||||
@@ -44,7 +45,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us
|
||||
}
|
||||
|
||||
func formOf(u store.User) accountForm {
|
||||
return accountForm{Username: u.Username, Email: u.Email}
|
||||
return accountForm{Username: u.Username, Email: u.Email, Notify: u.Notify}
|
||||
}
|
||||
|
||||
// HandleAccount shows the Account page.
|
||||
@@ -58,7 +59,7 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
||||
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
||||
page.Username, page.Email = f.Username, f.Email
|
||||
page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify
|
||||
page.WithResult(accountFlash(r), f.Err)
|
||||
h.view.Render(w, status, "account", page)
|
||||
}
|
||||
@@ -90,6 +91,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
f.Username = strings.TrimSpace(r.PostFormValue("username"))
|
||||
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
f.Notify = r.PostFormValue("notify") != ""
|
||||
if f.Username == "" {
|
||||
f.Username = u.Username
|
||||
}
|
||||
@@ -112,7 +115,14 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
|
||||
if f.Notify != u.Notify {
|
||||
if err := h.store.SetUserNotify(u.ID, f.Notify); err != nil {
|
||||
logf("panel: account: set notifications of user %d: %v", u.ID, err)
|
||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify)
|
||||
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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.NewOpenDKIM(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`,
|
||||
`<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's own name, not for your domains`,
|
||||
`value="v=spf1 a -all"`,
|
||||
`<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") {
|
||||
t.Error("the warning about a missing SPF 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>`,
|
||||
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||
`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>`,
|
||||
`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 like the second row.
|
||||
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="health-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="health-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`) != 2 {
|
||||
t.Fatalf("before publishing, both 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`) != 2 {
|
||||
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// Server › Settings holds what is true of the whole instance, whoever is
|
||||
// signed in: log retention (the outbound log's, and the inbound log's while the
|
||||
// inbound relay is on) and the read-only view of the rate limits. A
|
||||
// inbound relay is on), the read-only view of the rate limits, the switch for
|
||||
// notification e-mails and the DNS records of the server's own name those are
|
||||
// sent from. A
|
||||
// user's own name, e-mail, password and DMARC default are on Account
|
||||
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
||||
|
||||
@@ -47,6 +52,12 @@ func (h *Handlers) currentRetentions() retentions {
|
||||
|
||||
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
||||
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
||||
on, err := h.store.NotificationsEnabled()
|
||||
if err != nil {
|
||||
logf("panel: settings: read the notifications switch: %v", err)
|
||||
}
|
||||
page.WithNotifications(on, notify.From(h.cfg.Hostname))
|
||||
page.WithMail(h.serverMail(false).records)
|
||||
// The inbound field exists while there is an inbound journal to keep.
|
||||
if h.cfg.InboundEnabled {
|
||||
page.WithInbound(strconv.Itoa(form.inbound))
|
||||
@@ -87,12 +98,20 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
notifications := r.PostFormValue("notify") != ""
|
||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save send log retention. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetNotificationsEnabled(notifications); err != nil {
|
||||
logf("panel: settings: set the notifications switch failed: %v", err)
|
||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save the notifications switch. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
if h.cfg.InboundEnabled {
|
||||
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
||||
logf("panel: settings: set inbound-log retention failed: %v", err)
|
||||
@@ -100,9 +119,9 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
||||
"Could not save inbound log retention. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days)", days, form.inbound)
|
||||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days, notifications: %t)", days, form.inbound, notifications)
|
||||
} else {
|
||||
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
||||
logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications)
|
||||
}
|
||||
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -119,3 +138,48 @@ func (h *Handlers) sendLogRetentionDays() int {
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// serverMailState is the server's own name as a sender of notifications: the
|
||||
// records to publish for it, as Settings draws them, and their state, as
|
||||
// Health lists it.
|
||||
type serverMailState struct {
|
||||
records []view.Record
|
||||
rows []view.MailRow
|
||||
// status is the verdict of Health's card: ok, warn or unknown. A record
|
||||
// that is not in place is a warning, never an error: without them the
|
||||
// notifications are still sent.
|
||||
status string
|
||||
}
|
||||
|
||||
// serverMail reads the records of the server's own name and checks them in DNS
|
||||
// (cached like a domain's; force skips the cache, for Re-check DNS). When the
|
||||
// DKIM key cannot be read the page is still shown: the DKIM record is left out,
|
||||
// the SPF record is shown unchecked, and the card does not claim it is fine.
|
||||
func (h *Handlers) serverMail(force bool) serverMailState {
|
||||
host := h.cfg.Hostname
|
||||
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
|
||||
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
|
||||
}
|
||||
mail, err := h.domains.ServerMail()
|
||||
if err != nil {
|
||||
logf("panel: server mail: %v", err)
|
||||
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
|
||||
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
|
||||
}
|
||||
srv := h.dns.Server(host, false)
|
||||
check := h.dns.ServerMail(dnscheck.Query{
|
||||
Name: host, Selector: mail.Selector, ExpectedDKIM: mail.Record.Value,
|
||||
Hostname: srv.Hostname, ServerIPs: srv.IPs,
|
||||
}, force)
|
||||
dkim := dnsCheck(check.DKIM)
|
||||
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
|
||||
status := check.Overall
|
||||
if status == health.StatusError {
|
||||
status = health.StatusWarn
|
||||
}
|
||||
return serverMailState{
|
||||
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
|
||||
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
|
||||
status: string(status),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -120,6 +121,10 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
|
||||
view: v,
|
||||
auth: a,
|
||||
dns: dnscheck.New(nil),
|
||||
cfg: Config{SendLogRetentionEnvDefault: 90},
|
||||
// A domain service that does not know the server's own name, so that
|
||||
// the records of Settings and Health come out as "cannot be read";
|
||||
// the tests of those records use serverMailStand.
|
||||
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"),
|
||||
cfg: Config{SendLogRetentionEnvDefault: 90},
|
||||
}, password
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
@@ -114,7 +115,7 @@ func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "health", healthPage(h.shellMeta(r), h.healthChecks(), statusFlash(r)))
|
||||
h.view.Render(w, http.StatusOK, "health", h.healthView(r, statusFlash(r)))
|
||||
}
|
||||
|
||||
// HandleHealthFragment is the polled part of Health: the same two rows of
|
||||
@@ -123,7 +124,7 @@ func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request)
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), "")
|
||||
page := h.healthView(r, "")
|
||||
page.Refresh = true
|
||||
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
|
||||
}
|
||||
@@ -134,9 +135,18 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.dns.Server(h.cfg.Hostname, true)
|
||||
h.serverMail(true)
|
||||
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// healthView is the Health page for the request: the checks behind the cards
|
||||
// of Overview and the Notification mail card, which only Health shows.
|
||||
func (h *Handlers) healthView(r *http.Request, flash string) *view.Health {
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), flash)
|
||||
mail := h.serverMail(false)
|
||||
return page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
}
|
||||
|
||||
// healthPage turns one reading of the checks (healthChecks) into the Health
|
||||
// page: a table row per resource, process and socket, and the verdict of each
|
||||
// box in its head.
|
||||
|
||||
Reference in New Issue
Block a user