Files
selfpost/internal/web/view/server_test.go
T
mixandClaude Opus 5.5 96db0b7a86 panel-redesign: three choices for a domain's report address; row actions
Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00

334 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=&lt;m.keller@gmx.de&gt;") || 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" data-global-role="global">`, `<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" data-global-role="global" disabled>`,
`<option value="global" selected>`)
pageLacks(t, "User", out, `/delete`, `Delete user`)
}
// The role select names the value that reaches every domain, and each list of
// domains is marked, so panel.js can hide the lists while the role is Global.
func TestUserFormMarksTheListsTheGlobalRoleHides(t *testing.T) {
out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`)
if n := strings.Count(out, `<div class="box" data-domain-pick>`); n != 2 {
t.Errorf("%d boxes are marked data-domain-pick, want the outbound and the inbound list", n)
}
out = renderSignedIn(t, "user", NewUserForm(admin(), UserFormInput{ID: 2, Name: "shop-team", Username: "shop-team", Role: "domain", PasswordMin: 12}))
if n := strings.Count(out, "data-domain-pick"); n != 1 {
t.Errorf("%d boxes are marked without the inbound feature, want the outbound list alone", n)
}
}
// 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="&lt;b&gt;x&lt;/b&gt;"`, `value="a&#34;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-]+)"`)
helpTopic = regexp.MustCompile(`class="sp-help-topic" id="([a-z-]+)"`)
)
// menuOnlyTopics are the topics of the Help page that no box of a fixture page
// links to, each with the reason it is reached from the menu alone. The rest of
// the Help tests are in help_test.go.
var menuOnlyTopics = map[string]string{
HelpAccount: "the Account page is its own topic: its boxes (profile, password) explain themselves, so no box leads to it",
}
// A box's Help link is only worth having if the Help page has the topic it
// names, and a topic is only worth having if something leads to it. Every page
// of the panel is rendered with its fixture; every anchor it links to must be a
// topic of the Help page, and every topic must be linked from a page, except
// those in menuOnlyTopics.
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")
}
topics := map[string]bool{}
for _, m := range helpTopic.FindAllStringSubmatch(help, -1) {
topics[m[1]] = true
}
for anchor, pages := range linked {
if !topics[anchor] {
t.Errorf("%v link to /help#%s, which is not a topic of the Help page", pages, anchor)
}
}
for topic := range topics {
reason, menuOnly := menuOnlyTopics[topic]
switch {
case len(linked[topic]) == 0 && !menuOnly:
t.Errorf("Help topic %q is linked from no page: link it from the box it explains, or list it in menuOnlyTopics with the reason", topic)
case len(linked[topic]) > 0 && menuOnly:
t.Errorf("Help topic %q is listed as menu-only (%s) but %v link to it", topic, reason, linked[topic])
}
}
for topic := range menuOnlyTopics {
if !topics[topic] {
t.Errorf("menuOnlyTopics names %q, which is not a topic of the Help page", topic)
}
}
}
// 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.`)
pageLacks(t, "User", out, `notification`, `reset`)
}
// A domain's report address belongs to the domain, not to a user: deleting a
// user, global or not, says nothing about any domain losing one.
func TestUserDeleteSaysNothingOfReportAddresses(t *testing.T) {
for _, in := range []UserDeleteInput{
{ID: 3, Username: "acme-ops", AllOutbound: true, Outbound: []string{"example.org"}},
{ID: 2, Username: "root", Global: true},
} {
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in))
pageLacks(t, "Delete user", out, `report address`, `will have no`, `The domains`)
}
}