package view
import (
"bytes"
"io/fs"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/health"
)
// 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 section index each long page shows in the navigation column works by
// overriding an empty "sections" block defined in the layout, which only holds
// as long as the layout is parsed before the page's own files (see pageFiles).
// Reverse that order and every index would silently disappear — the empty
// definition would win and no page would fail to render — so the two ends are
// asserted here: the long pages produce a list, and a page that defines nothing
// produces nothing at all.
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
// Anchors the index links to, taken from the page's own cards.
wantAnchors := map[string]string{
"status": `href="#certificate"`,
"domain_detail": `href="#danger"`,
}
for name, page := range engine.Pages() {
var buf bytes.Buffer
// The domain page's index hides the freshly generated credential entry
// unless one is on the page, so the data map carries the key it reads.
if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err)
}
out := buf.String()
anchor, wanted := wantAnchors[name]
switch {
case wanted && !strings.Contains(out, anchor):
t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
case !wanted && strings.TrimSpace(out) != "":
t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
}
}
}
// A section link that points at no card is a link that does nothing, and
// nothing about rendering the page says so. Every anchor the index offers must
// name an element the same page defines an id for.
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
// The pages that carry an index; both are checked with a credential shown,
// which is the domain page's one conditional entry.
for _, name := range []string{"status", "domain_detail"} {
var index bytes.Buffer
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err)
}
// The cards are spread over the page's template files, so the ids are
// collected from the files rather than from a rendered page — rendering
// one would need the whole of a handler's data map.
ids := map[string]bool{}
for _, file := range pageFiles[name] {
body, err := fs.ReadFile(assetsFS, file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
// Cards only: a form field's id is not somewhere a section link may
// land, so matching those too would weaken the check.
for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
ids[m[1]] = true
}
}
for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
if !ids[m[1]] {
t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
}
}
}
}
// 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() {
var buf bytes.Buffer
err := engine.Page(name).ExecuteTemplate(&buf, "layout.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.
var buf bytes.Buffer
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
}); err != nil {
t.Fatalf("execute login: %v", err)
}
out := buf.String()
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", out)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("login page is missing legal notice %q", 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, "backup", 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("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
})
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, ``) || !strings.Contains(out, `Mail queue`) {
t.Errorf("active page is not marked:\n%s", out)
}
if strings.Contains(out, `href="/mail-queue"`) {
t.Errorf("active page still links to itself:\n%s", out)
}
if !strings.Contains(out, `href="/deliveries"`) {
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("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, ``) || !strings.Contains(out, `Status`) {
t.Errorf("the status page is not marked active:\n%s", out)
}
if !strings.Contains(out, `href="/domains"`) {
t.Errorf("Domains does not link to /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)
}
}
// Whether a page takes the whole column or the reading measure is declared by
// the page's own "wide" block (see layout.html), which the layout stamps into
// '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{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
for name, page := range engine.Pages() {
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)
}
}
}
// 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 /domains (or the intended page) instead`, name)
}
})
}
// The reload action is a server-health control and lives only on the status
// page.
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name)
}
})
}
// 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*=|