Files
selfpost/internal/web/view/templates_test.go
T
mixandClaude Opus 5.5 fa696efa8b panel-redesign: Server pages and Help on the kit; the help drawer is gone
Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:14:25 +03:00

534 lines
18 KiB
Go

package view
import (
"bytes"
"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 comes from render(), not from each handler's data map, so the
// footer is only correct as long as every page composes with the layout and
// render keeps supplying the key. Both are asserted here rather than trusted.
// Appropriate Legal Notices (copyright, licence, source, no warranty) must
// appear on every page, including the signed-out ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("New: %v", err)
}
legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko",
`href="/license"`,
"License (AGPL-3.0)",
`href="https://github.com/mixeme/selfpost"`,
"Source",
"No warranty",
}
rendered := 0
for name := range engine.Pages() {
if kitPages[name] {
continue // the kit layout has its own tests (components_test.go)
}
var buf bytes.Buffer
err := engine.Page(name).ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
})
if err != nil {
// Pages whose content block needs more data than this cannot be
// rendered here; the footer is in the shared layout, so one page
// that does render proves it for all of them.
continue
}
rendered++
out := buf.String()
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("page %q is missing legal notice %q", name, want)
}
}
}
if rendered == 0 {
t.Fatal("no page rendered, so the footer was never actually checked")
}
// Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present.
// Both signed-out pages go through Engine.Render, which supplies the
// engine's version and the legal lines itself.
signedOut := map[string]any{
"login": NewLogin("mail.example.org", ""),
"setup": NewSetup("token", ""),
}
for name, data := range signedOut {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, data)
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
}
out := rec.Body.String()
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the %s page shows the version to unauthenticated visitors:\n%s", name, out)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("%s page is missing legal notice %q", name, want)
}
}
}
}
func TestRenderSuppliesTheVersion(t *testing.T) {
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
data := map[string]any{"Title": "t", "User": "admin"}
engine.Render(rec, http.StatusOK, "mail_queue", data)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := data["Version"]; got != "9.9.9-test" {
t.Errorf("render did not supply Version (got %v)", got)
}
if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" {
t.Errorf("render did not supply Copyright (got %v)", got)
}
if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" {
t.Errorf("render did not supply SourceURL (got %v)", got)
}
body := rec.Body.String()
if !strings.Contains(body, "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", body)
}
if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") {
t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body)
}
}
func TestNavMarksActivePage(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
err = engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
"IsGlobal": true,
})
if err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
// The label is checked apart from the opening tag because each entry now
// carries an icon between the two.
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
t.Errorf("active page is not marked:\n%s", out)
}
if strings.Contains(out, `href="/outbound/queue"`) {
t.Errorf("active page still links to itself:\n%s", out)
}
if !strings.Contains(out, `href="/outbound/log"`) {
t.Errorf("inactive pages are not linked:\n%s", out)
}
}
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
t.Errorf("the status page is not marked active:\n%s", out)
}
if !strings.Contains(out, `href="/outbound/domains"`) {
t.Errorf("Domains does not link to /outbound/domains:\n%s", out)
}
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
t.Errorf("Status is not the first navigation entry:\n%s", out)
}
if strings.Contains(out, `href="/inbound/domains"`) || strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
}
}
func TestNavShowsInboundWhenEnabled(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
"InboundEnabled": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/inbound/domains"`) || !strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
}
dom := strings.Index(out, `href="/outbound/domains"`)
inb := strings.Index(out, `href="/inbound/domains"`)
if dom < 0 || inb < 0 || inb < dom {
t.Errorf("Inbound should follow Domains:\n%s", out)
}
}
// Whether a page takes the whole column or the reading measure is declared by
// the page's own "wide" block (see layout_legacy.html), which the layout stamps into
// <main>'s class list. A page that loses the block does not fail to render — it
// silently comes back at the measure, with its table squeezed into two thirds
// of the column — so the set is asserted here, in both directions.
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{
"mail_queue": true,
"inbound": true, "inbound_domain": true,
}
for name, page := range engine.Pages() {
if kitPages[name] {
continue // "wide" belongs to the legacy layout; the kit's width comes from its columns
}
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err)
}
got := strings.TrimSpace(buf.String())
switch {
case wide[name] && got != "wide":
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
case !wide[name] && got != "":
t.Errorf("page %q declares itself %q; only the pages that are tables of data, raw log lines or side-by-side cards take the whole column", name, got)
}
}
}
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)
}
}
}
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"inbound_domain.html": true,
"inbound_delete.html": true,
}
forEachTemplate(t, func(name, body string) {
if !drillDown[name] {
return
}
if !strings.Contains(body, `template "back_link"`) {
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
}
backIdx := strings.Index(body, `template "back_link"`)
cardIdx := strings.Index(body, `class="card`)
if cardIdx >= 0 && backIdx > cardIdx {
t.Errorf("%s places the back link after the first card", name)
}
})
}
// 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_legacy.html", "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))
}
}
func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
"FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes",
"QueueLifetime": "2 days",
}); err != nil {
t.Fatalf("execute mail_queue: %v", err)
}
out := buf.String()
for _, want := range []string{
"How delivery retries work",
"10 minutes",
"about 1 hour 7 minutes",
"2 days",
`id="retry-policy"`,
`hx-get="/outbound/queue/fragment"`,
} {
if !strings.Contains(out, want) {
t.Errorf("mail_queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("RetryFromDefaults was unset; the fallback note should stay off")
}
}
// The help drawer is gone from the old layout as well: a topic is a section of
// the Help page, linked from the head of the box it explains, and the Help
// entry of the navigation leads there.
func TestAuthenticatedLayoutHasNoHelpDrawer(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
"Title": "t", "User": "admin", "Active": "mail_queue", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute legacy layout: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/help"`) {
t.Error("the authenticated layout has no link to the Help page")
}
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
if strings.Contains(out, gone) {
t.Errorf("the authenticated layout still carries the help drawer (%q)", gone)
}
}
}
func TestLoginPageOmitsHelpDrawer(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
for name, data := range map[string]any{
"login": NewLogin("", ""),
"setup": NewSetup("token", ""),
} {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, data)
if rec.Code != http.StatusOK {
t.Fatalf("render %s: status %d: %s", name, rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "help-drawer") {
t.Errorf("%s page should not include the help drawer", name)
}
}
}
// 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")
}
}
func TestNavIncludesHelp(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "Active": "domains", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
if !strings.Contains(buf.String(), `href="/help"`) {
t.Error("nav is missing Help link")
}
}
// A group with nothing assigned is absent from that user's menu: a domain
// administrator who was given inbound domains only sees Inbound and none of the
// sending pages, and the mark takes them to what they have.
func TestLegacyNavFollowsTheUsersReach(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
nav := func(data map[string]any) string {
t.Helper()
data["User"], data["Active"] = "ops", ""
data["InboundEnabled"], data["DMARCEnabled"] = true, true
var buf bytes.Buffer
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", data); err != nil {
t.Fatalf("execute nav: %v", err)
}
return buf.String()
}
has := func(out, href string) bool { return strings.Contains(out, `href="`+href+`"`) }
out := nav(map[string]any{"HasInbound": true})
if !has(out, "/inbound/domains") {
t.Errorf("an inbound-only administrator has no Inbound entry:\n%s", out)
}
for _, gone := range []string{"/outbound/domains", "/outbound/log", "/outbound/dmarc", "/overview", "/server/users", "/outbound/queue"} {
if has(out, gone) {
t.Errorf("an inbound-only administrator is offered %s", gone)
}
}
out = nav(map[string]any{"HasOutbound": true})
if has(out, "/inbound/domains") || !has(out, "/outbound/domains") || !has(out, "/outbound/log") || !has(out, "/outbound/dmarc") {
t.Errorf("an outbound-only administrator's menu is wrong:\n%s", out)
}
out = nav(map[string]any{"IsGlobal": true})
for _, want := range []string{"/overview", "/outbound/domains", "/inbound/domains", "/outbound/dmarc", "/outbound/log", "/server/users"} {
if !has(out, want) {
t.Errorf("the global role lost %s", want)
}
}
}