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>
332 lines
16 KiB
Go
332 lines
16 KiB
Go
package view
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---- System log
|
|
|
|
// The page shows the newest line first, and its one box is the element that
|
|
// polls: the fragment answers with that same box.
|
|
func TestSystemLogShowsNewestFirstAndPollsItsBox(t *testing.T) {
|
|
out := renderSignedIn(t, "system-log", systemLogFixture())
|
|
pageHas(t, "System log", out,
|
|
`<h1 class="title is-3">System log</h1>`, `<h2>Recent lines</h2>`, `Last 200 lines · refreshes on its own`,
|
|
`id="system-log" data-poll aria-live="polite" hx-get="/server/log/fragment" hx-trigger="load" hx-swap="outerHTML"`,
|
|
`<span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]`,
|
|
`<span class="sp-w">postfix/smtp[2123]`, `<span class="sp-e">postfix/smtpd[2119]`,
|
|
)
|
|
if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "opendkim[39]") {
|
|
t.Error("the log is not shown newest line first")
|
|
}
|
|
// What a line says is escaped, not interpreted.
|
|
if !strings.Contains(out, "to=<m.keller@gmx.de>") || strings.Contains(out, "<m.keller@gmx.de>") {
|
|
t.Error("an address in a log line is not escaped")
|
|
}
|
|
|
|
frag := renderFragment(t, "system_log_body", systemLogFixture())
|
|
if !strings.HasPrefix(frag, `<div class="box" id="system-log" data-poll`) || strings.Contains(frag, "sp-head") {
|
|
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
|
|
}
|
|
if strings.Count(out, `class="sp-log"`) != 1 || strings.Count(frag, `class="sp-log"`) != 1 {
|
|
t.Error("the page and its fragment should each carry the log once")
|
|
}
|
|
}
|
|
|
|
// With nothing to show, the box says why instead: nothing logged yet, or the
|
|
// log could not be read — and never both.
|
|
func TestSystemLogSaysWhyItHasNoLines(t *testing.T) {
|
|
out := renderSignedIn(t, "system-log", NewSystemLog(admin(), nil, ""))
|
|
pageHas(t, "System log", out, `No log lines yet.`, `class="sp-empty"`)
|
|
pageLacks(t, "System log", out, `class="sp-log"`, `Could not read`)
|
|
|
|
out = renderSignedIn(t, "system-log", NewSystemLog(admin(), nil, "Could not read the mail log."))
|
|
pageHas(t, "System log", out, `Could not read the mail log.`, `ti-alert-circle`)
|
|
pageLacks(t, "System log", out, `No log lines yet.`, `class="sp-log"`)
|
|
}
|
|
|
|
// ---- Backup
|
|
|
|
// Both forms keep what they were: the encryption fields and their script hooks,
|
|
// the multipart import and the anchor the domain page links to.
|
|
func TestBackupHasBothFormsAndTheEncryptionHooks(t *testing.T) {
|
|
out := renderSignedIn(t, "backup", backupFixture())
|
|
pageHas(t, "Backup", out,
|
|
`action="/server/backup"`, `action="/server/backup/import" enctype="multipart/form-data"`,
|
|
`name="encrypt" value="1"`, `data-encrypt-toggle`, `data-encrypt-fields`,
|
|
`name="password"`, `name="password_confirm"`, `minlength="12"`, `placeholder="at least 12 characters"`,
|
|
`name="file"`, `accept=".json,.spde,application/json"`, `required data-import-file`,
|
|
`data-import-password-fields`, `name="import_password"`, `<div class="box" id="import">`,
|
|
)
|
|
pageLacks(t, "Backup", out, `notification`)
|
|
}
|
|
|
|
// The box is ticked on arrival, as on the domain export: the secret file is
|
|
// sealed unless the operator unticks it, and the page comes back the same way
|
|
// after a refusal, with the password fields in view.
|
|
func TestBackupEncryptionIsTickedLikeTheDomainExport(t *testing.T) {
|
|
backup := renderSignedIn(t, "backup", backupFixture())
|
|
export := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture())
|
|
const box = `name="encrypt" value="1" checked data-encrypt-toggle>`
|
|
pageHas(t, "Backup", backup, box)
|
|
pageHas(t, "Domain settings", export, box)
|
|
for _, hook := range []string{`data-encrypt-fields`, `name="password" type="password" autocomplete="new-password" minlength="12" placeholder="at least 12 characters"`,
|
|
`name="password_confirm" type="password" autocomplete="new-password"`,
|
|
`Keep this password: SelfPost does not store it, and the file cannot be opened without it.`} {
|
|
pageHas(t, "Backup", backup, hook)
|
|
pageHas(t, "Domain settings", export, hook)
|
|
}
|
|
}
|
|
|
|
// A refusal is the flash above the boxes, with the encryption box still ticked.
|
|
func TestBackupRefusalIsTheFlashAndKeepsTheTick(t *testing.T) {
|
|
out := renderSignedIn(t, "backup", NewBackup(admin(), 12, "The two passwords do not match."))
|
|
pageHas(t, "Backup", out, `<div class="notification is-danger is-light">`, `The two passwords do not match.`, `name="encrypt" value="1" checked`)
|
|
if strings.Index(out, `class="notification`) > strings.Index(out, `class="box"`) {
|
|
t.Error("the flash is not above the first box")
|
|
}
|
|
}
|
|
|
|
// ---- Users
|
|
|
|
func TestUsersListsTheReachOfEachUser(t *testing.T) {
|
|
out := renderSignedIn(t, "users", usersFixture())
|
|
pageHas(t, "Users", out,
|
|
`<h1 class="title is-3">Users</h1>`, `<span class="sp-no">01</span><h2>Panel users</h2>`, `3 users`,
|
|
`href="/server/users/new"`, `Create user`, `<div class="notification is-success is-light">`, `User created.`,
|
|
`<strong>admin</strong> <span class="tag is-light">you</span>`,
|
|
`<span class="tag is-primary is-light">global</span>`, `<span class="tag is-light">domain</span>`,
|
|
`<th>Outbound domains</th><th>Inbound domains</th>`,
|
|
`<td class="sp-muted">shop.example.org</td><td class="sp-muted">—</td>`,
|
|
`<td class="sp-muted">All</td><td class="sp-muted">acme.io</td>`,
|
|
`mix@<wbr>example.org`, `team@<wbr>shop.example.org`, `href="/server/users/3"`,
|
|
)
|
|
if strings.Count(out, `>you<`) != 1 {
|
|
t.Error("only the signed-in user's own row carries the mark")
|
|
}
|
|
|
|
// Without the inbound feature the list has no inbound column, nor a lead
|
|
// that speaks of one.
|
|
p := NewUsers(admin(), false, "").WithRows([]UserRow{
|
|
NewUserRow(UserRowInput{ID: 1, Username: "admin", Global: true, You: true}),
|
|
})
|
|
out = renderSignedIn(t, "users", p)
|
|
pageHas(t, "Users", out, `1 user<`, `<th>Outbound domains</th><th></th>`)
|
|
pageLacks(t, "Users", out, `<th>Inbound domains</th>`, `outbound and inbound separately`, `notification`)
|
|
// A user who has not set an address is a dash, not a gap.
|
|
pageHas(t, "Users", out, `<td class="sp-muted">—</td>`)
|
|
}
|
|
|
|
func TestReachWords(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
all bool
|
|
names []string
|
|
want string
|
|
}{
|
|
{true, []string{"ignored.example"}, "All"},
|
|
{false, []string{"a.example", "b.example"}, "a.example, b.example"},
|
|
{false, nil, "—"},
|
|
} {
|
|
if got := reachWords(tt.all, tt.names); got != tt.want {
|
|
t.Errorf("reachWords(%v, %v) = %q, want %q", tt.all, tt.names, got, tt.want)
|
|
}
|
|
}
|
|
// A global user reaches everything whatever the lists say.
|
|
row := NewUserRow(UserRowInput{ID: 1, Username: "root", Global: true})
|
|
if row.Outbound != "All" || row.Inbound != "All" || row.Role.Status != "global" {
|
|
t.Errorf("a global user's row = %+v", row)
|
|
}
|
|
}
|
|
|
|
// ---- The user form
|
|
|
|
// The form for an existing user shows what is stored and sends everything the
|
|
// handler reads under the names it has always read it by. Saving it untouched
|
|
// sends the same values back: the role as selected, the All ticks as ticked, the
|
|
// single domains as ticked, and a blank password, which the handler reads as
|
|
// "keep".
|
|
func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
|
|
out := renderSignedIn(t, "user", userFixture())
|
|
pageHas(t, "User", out,
|
|
`<h1 class="title is-3">acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3/delete"`,
|
|
`<form class="sp-form" method="post" action="/server/users/3">`,
|
|
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail</label>`,
|
|
`<input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off">`,
|
|
`name="password" type="password" autocomplete="new-password">`,
|
|
`Leave empty to keep the current one.`,
|
|
`<select id="role" name="role">`, `<option value="domain" selected>`, `<option value="global">`,
|
|
`name="all_domains" value="1" checked`, `name="domain_ids" value="1">`, `name="domain_ids" value="3">`,
|
|
`name="all_inbound_domains" value="1">`, `name="inbound_domain_ids" value="1">`, `name="inbound_domain_ids" value="2" checked`,
|
|
`>Save user</button>`, `href="/server/users">Cancel</a>`,
|
|
)
|
|
pageLacks(t, "User", out, `name="password" type="password" autocomplete="new-password" required`, `disabled`,
|
|
`notification`, `type="hidden"`)
|
|
if strings.Count(out, ` checked`) != 2 {
|
|
t.Errorf("exactly the stored ticks are checked, got %d", strings.Count(out, ` checked`))
|
|
}
|
|
}
|
|
|
|
func TestUserFormForANewUser(t *testing.T) {
|
|
p := NewUserForm(admin(), UserFormInput{Role: "domain", PasswordMin: 12, ShowInbound: true,
|
|
OutDomains: []DomainChoice{{ID: 1, Name: "example.org"}}})
|
|
out := renderSignedIn(t, "user", p)
|
|
pageHas(t, "User", out,
|
|
`<h1 class="title is-3">Create user</h1>`, `action="/server/users/new"`, `At least 12 characters.`,
|
|
`autocomplete="new-password" required>`, `>Create user</button>`, `<option value="domain" selected>`)
|
|
pageLacks(t, "User", out, `/delete`, `Leave empty to keep`, ` checked`, `ops@acme.io`)
|
|
pageHas(t, "User", out, `name="email" type="email" value=""`)
|
|
}
|
|
|
|
// The only global user cannot be demoted: the select is read-only and the role
|
|
// travels in a hidden field, so a save sends it. The head does not offer Delete.
|
|
func TestUserFormOfTheOnlyGlobalUser(t *testing.T) {
|
|
p := NewUserForm(admin(), UserFormInput{ID: 1, Name: "admin", Username: "admin", Role: "global", RoleLocked: true,
|
|
PasswordMin: 12, ShowInbound: true})
|
|
out := renderSignedIn(t, "user", p)
|
|
pageHas(t, "User", out, `<input type="hidden" name="role" value="global">`, `<select id="role" name="role" disabled>`,
|
|
`<option value="global" selected>`)
|
|
pageLacks(t, "User", out, `/delete`, `Delete user`)
|
|
}
|
|
|
|
// Inbound domains are offered only where the feature is on; the handler keeps
|
|
// what the user already has there when the form has no such list.
|
|
func TestUserFormHasNoInboundListWhenTheFeatureIsOff(t *testing.T) {
|
|
p := NewUserForm(admin(), UserFormInput{ID: 2, Name: "shop-team", Username: "shop-team", Role: "domain", PasswordMin: 12})
|
|
out := renderSignedIn(t, "user", p)
|
|
pageHas(t, "User", out, `<h2>Outbound domains</h2>`)
|
|
pageLacks(t, "User", out, `Inbound domains`, `all_inbound_domains`, `inbound_domain_ids`)
|
|
}
|
|
|
|
// A form that was refused says so above the boxes, shows what was typed, and
|
|
// names the user in the head as stored, not as typed.
|
|
func TestUserFormRefusalKeepsWhatWasTyped(t *testing.T) {
|
|
p := NewUserForm(admin(), UserFormInput{ID: 3, Name: "acme-ops", Username: "<b>x</b>", Email: `a"b@x`, Role: "global",
|
|
PasswordMin: 12, Error: "That username is already in use."})
|
|
out := renderSignedIn(t, "user", p)
|
|
pageHas(t, "User", out, `<div class="notification is-danger is-light">`, `That username is already in use.`,
|
|
`<h1 class="title is-3">acme-ops</h1>`, `value="<b>x</b>"`, `value="a"b@x"`, `<option value="global" selected>`)
|
|
if strings.Index(out, `class="notification`) > strings.Index(out, `<form class="sp-form"`) {
|
|
t.Error("the flash is not above the form")
|
|
}
|
|
}
|
|
|
|
// ---- Delete a user
|
|
|
|
func TestUserDeleteNamesWhatGoesWithTheUser(t *testing.T) {
|
|
out := renderSignedIn(t, "user-delete", userDeleteFixture())
|
|
pageHas(t, "Delete user", out,
|
|
`<h1 class="title is-3">Delete acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3">acme-ops</a>`,
|
|
`<div class="box sp-danger-zone">`, `This cannot be undone`,
|
|
`end any signed-in session of this user at once;`,
|
|
`remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).`,
|
|
`<form method="post" action="/server/users/3/delete">`, `Delete acme-ops</button>`, `href="/server/users/3">Keep it</a>`,
|
|
)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
in UserDeleteInput
|
|
want string
|
|
}{
|
|
{"global", UserDeleteInput{ID: 1, Username: "root", Global: true},
|
|
`remove their global access to the server and to every domain.`},
|
|
{"named domains", UserDeleteInput{ID: 2, Username: "u", Outbound: []string{"a.example", "b.example"}, AllInbound: true},
|
|
`remove the assignments to <strong>a.example</strong>, <strong>b.example</strong> (outbound) and to <strong>all inbound domains</strong> (inbound).`},
|
|
{"inbound only", UserDeleteInput{ID: 2, Username: "u", Inbound: []string{"in.example"}},
|
|
`remove the assignments to <strong>in.example</strong> (inbound).`},
|
|
} {
|
|
got := renderSignedIn(t, "user-delete", NewUserDelete(admin(), tt.in))
|
|
if !strings.Contains(got, tt.want) {
|
|
t.Errorf("%s: missing %q in\n%s", tt.name, tt.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Help
|
|
|
|
var helpLink = regexp.MustCompile(`class="sp-help" href="/help#([a-z-]+)"`)
|
|
|
|
// A box's Help link is only worth having if the Help page has the topic it
|
|
// names. Every page of the panel is rendered with its fixture, and every anchor
|
|
// it links to must be a topic of the Help page.
|
|
func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
|
help := renderSignedIn(t, "help", NewHelp(admin(), true))
|
|
linked := map[string][]string{}
|
|
for name, fixture := range pageFixtures {
|
|
for _, m := range helpLink.FindAllStringSubmatch(renderSignedIn(t, name, fixture()), -1) {
|
|
linked[m[1]] = append(linked[m[1]], name)
|
|
}
|
|
}
|
|
if len(linked) == 0 {
|
|
t.Fatal("no page links to a Help topic, so nothing was checked")
|
|
}
|
|
for anchor, pages := range linked {
|
|
if !strings.Contains(help, `class="sp-help-topic" id="`+anchor+`"`) {
|
|
t.Errorf("%v link to /help#%s, which is not a topic of the Help page", pages, anchor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The menu of the Help page jumps to topics that exist, and every topic is in it.
|
|
func TestHelpMenuAndTopicsAgree(t *testing.T) {
|
|
for _, global := range []bool{true, false} {
|
|
out := renderSignedIn(t, "help", NewHelp(admin(), global))
|
|
topics := regexp.MustCompile(`class="sp-help-topic" id="([a-z-]+)"`).FindAllStringSubmatch(out, -1)
|
|
menu := regexp.MustCompile(`<a href="#([a-z-]+)">`).FindAllStringSubmatch(out, -1)
|
|
if len(topics) != len(menu) || len(topics) == 0 {
|
|
t.Fatalf("global=%v: %d topics, %d menu entries", global, len(topics), len(menu))
|
|
}
|
|
for i := range topics {
|
|
if topics[i][1] != menu[i][1] {
|
|
t.Errorf("global=%v: topic %d is %q, its menu entry points at %q", global, i, topics[i][1], menu[i][1])
|
|
}
|
|
}
|
|
unique(t, "Help", ids(out))
|
|
}
|
|
}
|
|
|
|
// The topics about Overview and Health are for the role that sees those pages;
|
|
// a domain administrator is not shown checks they cannot open.
|
|
func TestHelpLeavesOutTheOverviewForADomainAdministrator(t *testing.T) {
|
|
out := renderSignedIn(t, "help", NewHelp(Meta{User: "ops", HasOutbound: true}, false))
|
|
pageHas(t, "Help", out, `id="dns"`, `id="apps"`, `id="limits"`, `<h2>Outbound</h2>`)
|
|
pageLacks(t, "Help", out, `id="checks"`, `id="certificate"`, `id="rdns"`, `<h2>Overview</h2>`, `href="/server/health"`)
|
|
}
|
|
|
|
// The e-mail help says only what is so: it is the user's own address, they can
|
|
// change it under Account, and it can receive DMARC reports if they choose.
|
|
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
|
|
out := renderSignedIn(t, "user", userFixture())
|
|
pageHas(t, "User", out, `The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.`)
|
|
pageLacks(t, "User", out, `notification`, `reset`)
|
|
}
|
|
|
|
// With domains that follow the user's default report address, deleting them
|
|
// leaves those domains with none, and the page says so: up to three names, then
|
|
// the count of the rest. With none following, the page says nothing about it.
|
|
func TestUserDeleteSaysWhichDomainsLoseTheirReportAddress(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
following []string
|
|
want string
|
|
}{
|
|
{[]string{"example.org"}, `<strong>example.org</strong> will have no DMARC report address until someone sets one.`},
|
|
{[]string{"example.org", "shop.example.org"}, `<strong>example.org</strong> and <strong>shop.example.org</strong> will have no DMARC report address until someone sets one.`},
|
|
{[]string{"a.example", "b.example", "c.example"}, `<strong>a.example</strong>, <strong>b.example</strong> and <strong>c.example</strong> will have no DMARC report address`},
|
|
{[]string{"a.example", "b.example", "c.example", "d.example", "e.example"}, `<strong>a.example</strong>, <strong>b.example</strong>, <strong>c.example</strong> and 2 more will have no DMARC report address`},
|
|
} {
|
|
in := UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Following: tt.following}
|
|
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in))
|
|
pageHas(t, "Delete user", out, tt.want)
|
|
if strings.Contains(out, "d.example") && len(tt.following) > 3 {
|
|
t.Errorf("a fourth name is spelled out: %v", tt.following)
|
|
}
|
|
}
|
|
// A global user's domains follow them as well.
|
|
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), UserDeleteInput{ID: 2, Username: "root", Global: true, Following: []string{"example.org"}}))
|
|
pageHas(t, "Delete user", out, `will have no DMARC report address`)
|
|
|
|
out = renderSignedIn(t, "user-delete", userDeleteFixture())
|
|
pageLacks(t, "Delete user", out, `DMARC report address`, `The domains`)
|
|
}
|