Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.
The queue
- out-queue, named after its mockup: today's content - the listing of
postqueue -p in a log pane, polled as before (GET
/outbound/queue/fragment) - and the "How retries work" facts. The
mockup's table of messages is the roadmap's queue-as-a-table; the
outline guard exempts this page for exactly that reason. Routes and
the global-only check are unchanged; the page has no actions and
never had.
- Inbound has no Queue entry and no route serves one.
The old design is gone
- Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
mail_queue.html, mail_queue_body.html.
- view.go has one path: every page is layout.html + components.html +
its own files; kitPages, kitFragments, the map-data branch of Render,
the dict and back template functions and metaOf's map branch are
removed. handlers: pageBase removed.
- legacy_pages.txt holds its header and no entry.
- panel.js: the old copy handler (button.copy in .code-row) is
removed. A show-when rule now applies to every element it matches,
and hiding adds Bulma's is-hidden beside the hidden attribute - a
.box or a grid row has a display of its own and stayed visible.
- The user form hides both domain lists again when the role is Global
(Box.DomainPick, an attribute; data-global-role on the select).
- Tests of the old layout are deleted; what they asserted about the
shell (version only signed in, the menu follows the user's reach,
Inbound only with the feature, Help present, the current entry
marked) is asserted on the kit pages, two tests added for it.
For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.
Evidence
- docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
headless Edge, the real template served locally with its fixture -
NOT from the container (no Docker on this machine).
- panel/audit.js on out-queue, user and backup at 1440 and 1280:
366 texts checked, problems: [].
- In a browser against the local fixture server: the encryption
fields of Backup, the manual / auto limit fields and the custom
report address of Domain settings show and hide with their control;
all 28 fixture pages answer 200 with panel.css, /static/legacy.css
answers 404.
- Not exercised: polling against a real postqueue (none on Windows).
- 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>
200 lines
7.4 KiB
Go
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}
|
|
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")
|
|
}
|
|
}
|