panel-notifications: Account, Server > Settings, Server > Health, help; e2e
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:
mix
2026-10-12 16:21:48 +03:00
co-authored by Claude Opus 5.5
parent c9027381e5
commit b905f3e560
22 changed files with 1003 additions and 59 deletions
+137
View File
@@ -0,0 +1,137 @@
package e2e
import (
"fmt"
"net/url"
"strings"
"testing"
"time"
)
// The server's own name in the run, and where the administrator is written to:
// a mailbox behind the sink-MX, so a notification can be read back.
const (
serverName = "mail.e2e.test"
adminEmail = "admin@sink.e2e.test"
)
// serverMailRecord scrapes the DKIM record of the server's own name off
// Server › Settings, where the panel tells the administrator to publish it.
func (c *panelClient) serverMailRecord() (name, value string, err error) {
_, body, err := c.get("/server/settings")
if err != nil {
return "", "", err
}
name, ok := extractCodeRow(body, "Host / name")
if !ok {
return "", "", fmt.Errorf("could not find the server's DKIM record name on Settings")
}
value, ok = extractCodeRow(body, "Value to publish")
if !ok {
return "", "", fmt.Errorf("could not find the server's DKIM record value on Settings")
}
return name, value, nil
}
// setAccountEmail saves the signed-in user's profile with an e-mail and
// notifications on.
func (c *panelClient) setAccountEmail(username, email string) error {
resp, body, err := c.postForm("/account/profile", url.Values{
"username": {username},
"email": {email},
"notify": {"on"},
})
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("save profile: status %d: %s", resp.StatusCode, body)
}
_, page, err := c.get("/account")
if err != nil {
return err
}
if !strings.Contains(page, email) {
return fmt.Errorf("the Account page does not show %s after saving it", email)
}
return nil
}
// testNotificationsSetUp gives the administrator a mailbox and publishes the
// DKIM record of the server's own name, as an operator would after reading
// Server › Settings. It runs before the step that makes a rate limit refuse
// mail, so that the refusal finds someone to tell.
func testNotificationsSetUp(t *testing.T, sc *scenario) {
if err := sc.panel.setAccountEmail(adminUser, adminEmail); err != nil {
t.Fatalf("set the administrator's e-mail: %v", err)
}
name, value, err := sc.panel.serverMailRecord()
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(name, "._domainkey."+serverName) {
t.Fatalf("the record on Settings is %q, want a DKIM key of %s", name, serverName)
}
rel, ok := trimZoneSuffix(name)
if !ok {
t.Fatalf("record name %q is not under e2e.test", name)
}
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: rel, value: value})
if err != nil {
t.Fatalf("publish the server's dkim record to the fake zone: %v", err)
}
sc.zoneRecords = records
}
// testRateLimitRefusalIsNotified checks the whole path of a notification: the
// level-2 refusal of the previous step is noticed by the notifier (it looks
// every minute), written to the administrator, submitted by the unprivileged
// panel through sendmail, signed by OpenDKIM with the key of the server's own
// name and delivered. It also holds what a notification is not: a row of the
// Outbound log. That it is said once is for the unit tests to hold
// (internal/notify): here it would be a minute of waiting for nothing to happen.
func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
subject := "[SelfPost] " + senderDomain + ": a rate limit is refusing mail"
raw, err := findSinkMessage(h.stageDir, subject, 100*time.Second)
if err != nil {
t.Fatalf("no notification reached %s: %v\npanel log:\n%s", adminEmail, err, tailLines(h.logs("selfpost"), "notify", 20))
}
msg := string(raw)
for _, want := range []string{
"From: SelfPost <postmaster@" + serverName + ">",
"To: <" + adminEmail + ">",
"Auto-Submitted: auto-generated",
"refused",
} {
if !strings.Contains(msg, want) {
t.Errorf("the notification lacks %q:\n%s", want, msg)
}
}
if err := verifyDKIM(raw, serverName); err != nil {
t.Errorf("the notification is not signed as %s with the key Settings shows: %v", serverName, err)
}
// The panel's own mail is not an application's: no row in the log.
rows, err := sc.panel.sendLogRows(senderDomain, "")
if err != nil {
t.Fatalf("read the send log: %v", err)
}
if strings.Contains(rows, "rate limit is refusing") || strings.Contains(rows, "postmaster@"+serverName) {
t.Errorf("a notification is listed in the Outbound log:\n%s", rows)
}
}
// tailLines returns the last n lines of log that contain filter.
func tailLines(log, filter string, n int) string {
var keep []string
for _, line := range strings.Split(log, "\n") {
if strings.Contains(line, filter) {
keep = append(keep, line)
}
}
if len(keep) > n {
keep = keep[len(keep)-n:]
}
return strings.Join(keep, "\n")
}