panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):
- Account: "Send me notifications" in the Profile form, saved with the
profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
mail with the DKIM and SPF records of the server's own name, drawn with the
record partial of a domain's DNS page and checked in DNS. Sending rate
limits are box 02 in the left column. A record that is not in place is
tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
and a link to Settings, beside Configuration (07); part of the polled
fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
account, word for word with help.html.
If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.
e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,7 +148,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
// the two actions: Re-check DNS in the head, Reload configuration under the tables
|
||||
`action="/server/health/recheck"`, `action="/server/health/reload"`,
|
||||
// the anchors the Overview cards lead to
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`,
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="notification-mail"`, `id="configuration"`,
|
||||
// the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style)
|
||||
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
|
||||
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
|
||||
@@ -158,8 +158,8 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
||||
if got := strings.Count(out, `class="columns"`); got != 2 {
|
||||
t.Errorf("Health has %d rows of boxes, want 2", got)
|
||||
if got := strings.Count(out, `class="columns"`); got != 3 {
|
||||
t.Errorf("Health has %d rows of boxes, want 3", got)
|
||||
}
|
||||
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
|
||||
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
|
||||
@@ -169,29 +169,54 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
}
|
||||
|
||||
// The fragment is the polled part of the page and nothing else: the same rows,
|
||||
// the second one marked to be swapped in by its id, no shell and no actions.
|
||||
// the second one marked to be swapped in by its id, and the Notification mail
|
||||
// card, a column of the last row, swapped in the same way. No shell and no
|
||||
// actions.
|
||||
func TestHealthFragmentIsThePolledRows(t *testing.T) {
|
||||
page := renderSignedIn(t, "health", healthFixture(false))
|
||||
frag := renderFragment(t, "health_body", healthFixture(true))
|
||||
|
||||
const card = `<div class="column is-6" id="health-mail"`
|
||||
// The part of s before the card, and the card itself up to what follows it
|
||||
// on the page (the Configuration box).
|
||||
rows := func(s string) string {
|
||||
a := strings.Index(s, `<div class="columns" id="health-machine"`)
|
||||
b := strings.Index(s, `id="configuration"`)
|
||||
if b < 0 {
|
||||
b = len(s)
|
||||
}
|
||||
return s[a:b]
|
||||
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
|
||||
}
|
||||
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
|
||||
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
|
||||
want := strings.TrimSpace(rows(page))
|
||||
if !strings.HasPrefix(want, got) {
|
||||
mail := func(s string) string {
|
||||
a := strings.Index(s, card)
|
||||
if b := strings.Index(s, `id="configuration"`); b > a {
|
||||
return s[a:b]
|
||||
}
|
||||
return s[a:]
|
||||
}
|
||||
// Same markup, apart from the out-of-band marks and what follows the card on the page.
|
||||
oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") }
|
||||
if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`)
|
||||
if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`,
|
||||
`id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`)
|
||||
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
|
||||
}
|
||||
|
||||
// The Notification mail card says which record is in place and which is not,
|
||||
// with the host each is published at; a missing record is a warning, since
|
||||
// notifications are sent without it. The way to the values is a link.
|
||||
func TestHealthNotificationMailCard(t *testing.T) {
|
||||
out := renderSignedIn(t, "health", healthFixture(false))
|
||||
pageHas(t, "Health", out,
|
||||
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
|
||||
`href="/help#notifications"`,
|
||||
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
|
||||
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
|
||||
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||
`href="/server/settings#notification-mail"`,
|
||||
)
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read keeps its rows: the figure becomes
|
||||
// a dash and the reason stays in the detail column.
|
||||
func TestHealthMachineWithoutReadings(t *testing.T) {
|
||||
@@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) {
|
||||
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
|
||||
}
|
||||
|
||||
// Event notifications are not built (roadmap: panel-notifications), and a page
|
||||
// carries no box for a feature that does not exist.
|
||||
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
|
||||
for name, out := range map[string]string{
|
||||
"Account": renderSignedIn(t, "account", accountFixture()),
|
||||
"Settings": renderSignedIn(t, "settings", settingsFixture()),
|
||||
} {
|
||||
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
|
||||
}
|
||||
// The checkbox under the e-mail is the user's own switch for notifications: it
|
||||
// shows what is stored, and an unchecked box is the absence of the attribute.
|
||||
func TestAccountShowsTheNotifyCheckbox(t *testing.T) {
|
||||
a := accountFixture()
|
||||
out := renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify" checked> Send me notifications`, `href="/help#notifications"`)
|
||||
|
||||
a.Notify = false
|
||||
out = renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify"> Send me notifications`)
|
||||
pageLacks(t, "Account", out, `name="notify" checked`)
|
||||
}
|
||||
|
||||
// ---- Settings
|
||||
@@ -310,6 +337,54 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
|
||||
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
|
||||
}
|
||||
|
||||
// Notifications are one switch for the instance, saved with the rest of the
|
||||
// page, and the records of the server's own name are drawn like a domain's.
|
||||
func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
|
||||
s := settingsFixture()
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out,
|
||||
`<input type="checkbox" name="notify" checked> Send notification e-mails`,
|
||||
`From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="/account">Account</a>.`,
|
||||
`id="notification-mail"`, `href="/help#notifications"`,
|
||||
// DKIM is published; SPF is not, and says what that costs.
|
||||
`<h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small>`,
|
||||
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small>`,
|
||||
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||
`Notifications are sent without it, and a receiver may file them as spam.`,
|
||||
)
|
||||
|
||||
// Switched off, and both records in place: the box is unchecked and the
|
||||
// warning under SPF is gone.
|
||||
s.Notify = false
|
||||
s.WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, "v=DKIM1; k=rsa; p=AAAA", DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "ok"}),
|
||||
})
|
||||
out = renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out, `<input type="checkbox" name="notify"> Send notification e-mails`,
|
||||
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`)
|
||||
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`)
|
||||
}
|
||||
|
||||
// A record of the server's own name that is wrong or missing is a warning, never
|
||||
// the red of a sending domain's: notifications are sent without it.
|
||||
func TestMailRecordsAreWarningsNotErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
check DNSCheck
|
||||
want Tag
|
||||
}{
|
||||
{DNSCheck{Status: "ok"}, Tag{Status: "published", Label: "published"}},
|
||||
{DNSCheck{Status: "error"}, Tag{Status: "warn", Label: "missing"}},
|
||||
{DNSCheck{Status: "error", Found: []string{"v=spf1 -all"}}, Tag{Status: "warn", Label: "mismatch"}},
|
||||
{DNSCheck{Status: "warn"}, Tag{Status: "warn", Label: "weak"}},
|
||||
{DNSCheck{Status: "unknown"}, Tag{Status: "unknown", Label: "not checked"}},
|
||||
} {
|
||||
if got := NewMailRow("SPF", "mail.example.org", c.check).State; got != c.want {
|
||||
t.Errorf("a record that checked as %+v is %+v, want %+v", c.check, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
|
||||
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
|
||||
Reference in New Issue
Block a user