Files
selfpost/test/e2e/notifications_test.go
T
mixandClaude Opus 5.5 b8e156c9f4 password-reset: a confirmed e-mail, then a link to reset the password
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.

Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).

internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).

Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.

Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:03:27 +03:00

143 lines
4.8 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 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"},
// A new e-mail asks for the password (password-reset).
"current_password": {adminPassword},
})
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 the panel's signer 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"
file, raw, err := findSinkFile(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 := checkPanelSignature(h, file, raw, serverName); err != nil {
t.Errorf("the panel's signer on its own mail: %v", err)
}
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")
}