Files
selfpost/internal/web/view/templates_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

200 lines
7.4 KiB
Go

package view
import (
"io/fs"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strings"
"testing"
)
// The navigation is rendered from the layout, not copied into each page, so
// every page template must resolve it. This is what makes "the nav is on every
// authenticated page" a structural property instead of a checklist item.
func TestEveryPageResolvesNav(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
for name, page := range engine.Pages() {
if page.Lookup("nav") == nil {
t.Errorf("page %q does not resolve the shared nav template", name)
}
}
}
// The version and the legal lines come from Render, not from each handler's
// data, so the footer is only correct as long as every page composes with the
// layout. It is asserted on every page, not trusted. Appropriate Legal Notices
// (copyright, licence, source, no warranty) appear on every page, signed out
// ones included; the running version only on the signed-in ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
engine := kitEngine(t) // version 9.9.9-test
legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko",
`href="/license"`,
"License (AGPL-3.0)",
`href="https://github.com/mixeme/selfpost"`,
"Source",
"No warranty",
}
signedOut := map[string]bool{"login": true, "setup": true, "login-reset": true, "login-reset-set": true, "confirm-email": true}
for name, fixture := range pageFixtures {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, fixture())
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
}
out := rec.Body.String()
if signedOut[name] {
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
}
} else if !strings.Contains(out, "SelfPost 9.9.9-test") {
t.Errorf("page %q does not show the version in the layout footer", name)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("%s page is missing legal notice %q", name, want)
}
}
// The help lives on the Help page: no page carries a drawer for it.
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
if strings.Contains(out, gone) {
t.Errorf("page %q still carries the help drawer (%q)", name, gone)
}
}
}
}
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
out := renderSignedIn(t, "settings", settingsFixture())
for _, want := range []string{
`id="rate-limits"`,
"Sending rate limits",
"Level 1 · per client IP", "600 / h", "<code>.env</code>",
"Level 2 · domain, application", `href="/outbound/domains"`,
`name="send_log_retention_days"`, `value="30"`, "Keep outbound log rows, days",
`action="/server/settings"`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q", want)
}
}
// What belongs to a user is on Account.
for _, gone := range []string{`name="current_password"`, `name="username"`, `name="dmarc_default"`, `name="email"`} {
if strings.Contains(out, gone) {
t.Errorf("the instance settings page carries the user's %s", gone)
}
}
}
// Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one.
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `href="/"`) {
t.Errorf(`%s links to "/", which is now the status redirect; link to /outbound/domains (or the intended page) instead`, name)
}
})
}
// The reload action is a server-health control and lives only on the Health
// page (as a partial call: its path is in the page's data, so no template but
// the view code names it).
func TestReloadFormLivesOnlyOnTheHealthPage(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, "/server/health/reload") {
t.Errorf("%s names /server/health/reload; the reload control belongs to NewHealth", name)
}
})
if got := renderSignedIn(t, "health", healthFixture(false)); !strings.Contains(got, `action="/server/health/reload"`) {
t.Error("Health has no Reload configuration form")
}
for _, page := range []string{"overview", "account", "settings"} {
if strings.Contains(renderSignedIn(t, page, pageFixtures[page]()), "/server/health/reload") {
t.Errorf("%s offers the reload control", page)
}
}
}
// The panel's Content-Security-Policy is a plain default-src 'self' with no
// inline exemption, which makes inline script and inline style a
// failure mode rather than a style question: an onclick= handler or a
// style="..." attribute added to a template does not error, it silently stops
// working in the browser. Behaviour belongs in static/panel.js (triggered from
// a data- attribute), appearance in static/panel.css.
func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
inlineHandler := regexp.MustCompile(`\son[a-z]+\s*=`)
inlineStyle := regexp.MustCompile(`\sstyle\s*=|<style[\s>]`)
scriptTag := regexp.MustCompile(`<script[^>]*>`)
forEachTemplate(t, func(name, body string) {
if m := inlineHandler.FindString(body); m != "" {
t.Errorf("%s has an inline event handler (%q); the CSP blocks it — move the behaviour into static/panel.js",
name, strings.TrimSpace(m))
}
if m := inlineStyle.FindString(body); m != "" {
t.Errorf("%s has an inline style (%q); the CSP blocks it — move the rule into static/panel.css",
name, strings.TrimSpace(m))
}
for _, tag := range scriptTag.FindAllString(body, -1) {
if !strings.Contains(tag, "src=") {
t.Errorf("%s has an inline script (%q); the CSP blocks it — put the code in static/panel.js", name, tag)
}
}
})
}
// default-src 'self' also means every asset a page pulls in must be one this
// server actually serves, so a typo in a /static path is a blocked request,
// not a 404 in the page's own colours.
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
for _, file := range []string{"templates/layout.html"} {
body, err := fs.ReadFile(assetsFS, file)
if err != nil {
t.Fatalf("read layout: %v", err)
}
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
if len(refs) == 0 {
t.Fatalf("%s references no static assets at all", file)
}
for _, m := range refs {
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
t.Errorf("%s references /static/%s, which is not embedded: %v", file, m[1], err)
}
}
}
}
// forEachTemplate runs fn over every embedded template's source.
func forEachTemplate(t *testing.T, fn func(name, body string)) {
t.Helper()
entries, err := fs.ReadDir(assetsFS, "templates")
if err != nil {
t.Fatalf("read templates: %v", err)
}
for _, e := range entries {
body, err := fs.ReadFile(assetsFS, path.Join("templates", e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
fn(e.Name(), string(body))
}
}
// The help drawer is gone from the pages of the kit: Overview points at the
// section of the Help page that explains its checks, from the head of its box.
func TestOverviewLinksToItsHelpTopic(t *testing.T) {
out := renderSignedIn(t, "overview", overviewFixture())
if !strings.Contains(out, `class="sp-help" href="/help#checks"`) {
t.Error("the Server health box has no link to its Help topic")
}
if strings.Contains(out, "help-drawer") {
t.Error("a page of the kit carries the old help drawer")
}
}