audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s

Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):

- The Outbound log counts first and clamps the page to the last; a number
  too large for an int (strconv's ErrRange with the largest int) is the
  last page on both logs. Tested at the edges: first, last, past the last
  however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
  (ev.submitter) when a field of the page differs from what it was drawn
  with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
  lists and the DMARC maps too - and a failed step is an error flash on
  Health that names it, not plain text; the success flash says what was
  written.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 11:20:18 +03:00
co-authored by Claude Opus 5.5
parent 643892ab91
commit ca876c1631
23 changed files with 355 additions and 48 deletions
+20
View File
@@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Changed
- **Reload configuration writes everything a restore's first start writes.**
The button on Server › Health left the DMARC report maps and the filter
lists out while it said the configuration was regenerated; it now writes
them too, and a step that fails is a message on the page that names it,
not a plain-text error (audit 2026-10-12, L13).
### Security ### Security
- **The sign-in told which usernames exist by how long it took.** A wrong - **The sign-in told which usernames exist by how long it took.** A wrong
@@ -31,6 +39,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
failure in a row, up to a minute. A box whose session had expired was failure in a row, up to a minute. A box whose session had expired was
filled with the sign-in page; the whole page goes to sign-in now (audit filled with the sign-in page; the whole page goes to sign-in now (audit
2026-10-12, M11). 2026-10-12, M11).
- **The Outbound log showed an empty table under "Page 9 of 2"** for a page
past its end - a link kept while old rows were pruned, or a number typed.
It shows the last page, as the Inbound log does (audit 2026-10-12, L10).
- **An auto limit's multiplier took "NaN"**, stored nothing, and was
recalculated with the default without a word. It is refused like any
number out of range (audit 2026-10-12, L11).
- **A message refused under a rate limit was a grey tag** beside its two
red siblings, *wrong From* and *wrong client*. It is red (audit
2026-10-12, L18).
- **Recalculate rate limit dropped what was typed on the page.** The button
leaves the page; when a field holds an edit not saved yet, it now asks
first (audit 2026-10-12, L19).
## [2.5.3] - 2026-10-15 ## [2.5.3] - 2026-10-15
+2 -2
View File
@@ -1038,8 +1038,8 @@ tables, Postfix's sender map, inbound relay maps (when
are re-derived from SQLite and both daemons are reloaded, so drift between the are re-derived from SQLite and both daemons are reloaded, so drift between the
extracted archive and the database is healed before mail flows. Manual extracted archive and the database is healed before mail flows. Manual
`POST /server/health/reload` (the *Reload configuration* button on Server › `POST /server/health/reload` (the *Reload configuration* button on Server ›
Health) resyncs OpenDKIM, the sender map, and Health) runs the same Resync, the filter lists with it, since `2.5.4`.
inbound maps only — not DMARC maps. Stopped-container Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide). `tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
+14 -2
View File
@@ -1217,14 +1217,26 @@ command whose leftover is the owner's alone.
`handleAjaxResponse()` follows before it looks at the status. No jsdom `handleAjaxResponse()` follows before it looks at the status. No jsdom
(no node tooling in the repo): `testdata/poll_test.js` runs panel.js in (no node tooling in the repo): `testdata/poll_test.js` runs panel.js in
node against a stand-in for the DOM and htmx with a moved clock, from node against a stand-in for the DOM and htmx with a moved clock, from
`TestPanelJSPolling` (skipped where node is absent - the test server's `TestPanelJS` (skipped where node is absent - the test server's
Go has none; it fails on the old panel.js with ten findings). Go has none; it fails on the old panel.js with ten findings).
- [ ] `parsePage` clamp; `NaN` refused; `rate-limited` tag red (add the status to - [x] `parsePage` clamp; `NaN` refused; `rate-limited` tag red (add the status to
`components.go`, flip the test); the recalc button posts only what it `components.go`, flip the test); the recalc button posts only what it
needs or warns; `HandleReload` either covers DMARC maps and filter lists or needs or warns; `HandleReload` either covers DMARC maps and filter lists or
the flash and the two comments say what it covers. The log's paging is the flash and the two comments say what it covers. The log's paging is
tested at its edges: first page, last, past the last, an empty log (a row tested at its edges: first page, last, past the last, an empty log (a row
of review-2026-08-followups, placed here 2026-10-14). — **Sonnet** of review-2026-08-followups, placed here 2026-10-14). — **Sonnet**
*Done 2026-10-15:* the Outbound log counts first and clamps to the last
page; a number too large for an int is the last page too (strconv's
ErrRange with the largest int), on both logs. `checkAutoMultiplier` is
written "not within", so NaN and the infinities are out. The mockup drew
rate-limited grey: `design:` commit first (out-log, and the kit's status
row, which had also lost `reject`), then `components.go`. The recalc
button warns: `data-unsaved` on both recalc buttons, and panel.js asks
on submit (`ev.submitter`) when a field of the page differs from what it
was drawn with (`testdata/unsaved_test.js` under `TestPanelJS`).
`HandleReload` covers what the restore's Resync covers, the filter lists
and the DMARC maps included, and a failed step is an error flash on
Health naming it.
- [ ] `domain.Service.Delete`: say the domain is deleted when a later step fails, - [ ] `domain.Service.Delete`: say the domain is deleted when a later step fails,
and run `apps.Resync()` anyway. — **Opus** and run `apps.Resync()` anyway. — **Opus**
- [ ] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the - [ ] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the
+11 -7
View File
@@ -473,11 +473,15 @@ readings, in the order of what stops mail first:
record the internet publishes for this server's IP (forward-confirmed record the internet publishes for this server's IP (forward-confirmed
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
*Re-check DNS* after changing DNS. *Re-check DNS* after changing DNS.
- **Configuration** — the **Reload configuration** button re-applies OpenDKIM - **Configuration** — the **Reload configuration** button writes again, from
tables and the Postfix sender map from the database (and inbound relay maps the database, every file the panel keeps for the mail path - OpenDKIM's
when `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the tables, the Postfix sender map, the inbound relay maps and the filter lists
panel shows after manual edits under `/data`. It does **not** rebuild DMARC (with `INBOUND_RELAY_ENABLE=true`), the DMARC report maps (with
Postfix maps; a full restore Resync does (see [Restore](#restore)). `DMARC_REPORTS_ENABLE=true`) - and has OpenDKIM and Postfix re-read them:
what the first start after a restore does (see [Restore](#restore)). Use it
if the daemons drifted from what the panel shows after manual edits under
`/data`. A step that fails is named on the page, and the panel's log says
why.
### Queue and System log ### Queue and System log
@@ -817,8 +821,8 @@ when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
and both daemons are reloaded, healing any drift between the extracted files and and both daemons are reloaded, healing any drift between the extracted files and
the database. The manifest is deleted only after that Resync is done: a first the database. The manifest is deleted only after that Resync is done: a first
start that fails on the way leaves it, and the next start is a restore again start that fails on the way leaves it, and the next start is a restore again
and runs the Resync. The *Reload configuration* button on Server › Health resyncs and runs the Resync. The *Reload configuration* button on Server › Health runs the same Resync.
OpenDKIM, the sender map, and inbound maps only — not DMARC maps. This is why the This is why the
compose file pins a fixed tag rather than `:latest`: without a known version, compose file pins a fixed tag rather than `:latest`: without a known version,
there'd be no way to tell which image restoring a given backup actually requires there'd be no way to tell which image restoring a given backup actually requires
(see [Fixed image tag](#fixed-image-tag)). (see [Fixed image tag](#fixed-image-tag)).
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 58/81 | [audit-2026-10-12.md](audit-2026-10-12.md) | | audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 59/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -10,6 +11,7 @@ import (
"github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
@@ -293,3 +295,37 @@ func TestDeletingAUserLeavesDomainAddressesAndNamesNobody(t *testing.T) {
confirm := send(s.h.HandleUserDeleteConfirm, &globalPrincipal, "GET", "/server/users/"+idStr(admin.ID)+"/delete", map[string]string{"uid": idStr(admin.ID)}, nil).Body.String() confirm := send(s.h.HandleUserDeleteConfirm, &globalPrincipal, "GET", "/server/users/"+idStr(admin.ID)+"/delete", map[string]string{"uid": idStr(admin.ID)}, nil).Body.String()
lacks(t, "delete confirmation", confirm, `report address`, `will have no`) lacks(t, "delete confirmation", confirm, `report address`, `will have no`)
} }
// failingDMARCMaps stands in for Postfix refusing the DMARC maps.
type failingDMARCMaps struct{}
func (failingDMARCMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error {
return errors.New("disk full")
}
// Reload configuration writes what a restore's first start writes, the DMARC
// maps among it, and a failure is a message on Health that names the step,
// not a plain-text page (audit 2026-10-12, L13).
func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
s, maps := hostedStand(t)
// An OpenDKIM that reloads nothing - the stand has no supervisor - and
// a sampler for the Health page a failure is shown on.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
s.h.machine = &health.MachineSampler{}
rec := postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/health?reloaded=1" {
t.Fatalf("reload = %d, Location %q", rec.Code, rec.Header().Get("Location"))
}
if len(maps.got) != 1 {
t.Errorf("the reload rebuilt the DMARC maps %d times, want once", len(maps.got))
}
s.h.dmarc = dmarc.NewService(s.h.store, failingDMARCMaps{}, "mail.example.org", true)
rec = postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("a failed reload = %d, want 500", rec.Code)
}
has(t, "failed reload", rec.Body.String(), "Reload failed at the DMARC report maps", "Reload configuration")
}
+28 -13
View File
@@ -162,26 +162,41 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther) http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
} }
// HandleReload re-applies both the OpenDKIM configuration and the Postfix // HandleReload writes again, from the database, every file the panel keeps
// sender map on demand (architecture.md § Panel HTTP surface). // for the mail path and has OpenDKIM and Postfix re-read them - what the
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's
// tables, the sender map, with the inbound relay on its maps and the filter
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP
// surface). Before 2.5.4 it left the last two out, and said "regenerated"
// over them (audit 2026-10-12, L13). A failure is a message on Health that
// names the step; the log has the error.
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok { if _, ok := h.requireGlobal(w, r); !ok {
return return
} }
if err := h.domains.Resync(); err != nil { type step struct {
logf("panel: manual reload (opendkim): %v", err) name string
http.Error(w, "reload failed", http.StatusInternalServerError) run func() error
return
} }
if err := h.apps.Resync(); err != nil { steps := []step{
logf("panel: manual reload (postfix): %v", err) {"OpenDKIM's tables", h.domains.Resync},
http.Error(w, "reload failed", http.StatusInternalServerError) {"the sender map", h.apps.Resync},
return
} }
if h.cfg.InboundEnabled && h.inbound != nil { if h.cfg.InboundEnabled && h.inbound != nil {
if err := h.inbound.Resync(); err != nil { steps = append(steps, step{"the inbound relay maps", h.inbound.Resync})
logf("panel: manual reload (inbound): %v", err) }
http.Error(w, "reload failed", http.StatusInternalServerError) if h.cfg.InboundEnabled && h.lists != nil {
steps = append(steps, step{"the filter lists", h.lists.Sync})
}
if h.dmarc != nil && h.dmarc.Enabled() {
steps = append(steps, step{"the DMARC report maps", h.dmarc.Resync})
}
for _, s := range steps {
if err := s.run(); err != nil {
logf("panel: manual reload (%s): %v", s.name, err)
page := h.healthView(r, "")
page.Flash = &view.Flash{Error: true, Text: view.Plain("Reload failed at " + s.name + "; what came before it was written and re-read. The panel's log says why.")}
h.view.Render(w, http.StatusInternalServerError, "health", page)
return return
} }
} }
@@ -229,6 +229,8 @@ func TestInboundLogPages(t *testing.T) {
// A page past the end, or nonsense, is the last page, or the first. // A page past the end, or nonsense, is the last page, or the first.
has(t, "page 9", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=9", globalPrincipal).Body.String(), `Page 2 of 2`, `Hello A`) has(t, "page 9", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=9", globalPrincipal).Body.String(), `Page 2 of 2`, `Hello A`)
has(t, "page x", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=x", globalPrincipal).Body.String(), `Page 1 of 2`) has(t, "page x", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=x", globalPrincipal).Body.String(), `Page 1 of 2`)
has(t, "page huge", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=99999999999999999999", globalPrincipal).Body.String(), `Page 2 of 2`, `Hello A`)
has(t, "page 0", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=0", globalPrincipal).Body.String(), `Page 1 of 2`)
} }
func TestInboundLogEmptyAndOff(t *testing.T) { func TestInboundLogEmptyAndOff(t *testing.T) {
@@ -237,6 +239,9 @@ func TestInboundLogEmptyAndOff(t *testing.T) {
body := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String() body := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String()
has(t, "empty log", body, `No messages logged yet.`) has(t, "empty log", body, `No messages logged yet.`)
lacks(t, "empty log", body, `<table`, `Page 1`) lacks(t, "empty log", body, `<table`, `Page 1`)
body = s.get(h.HandleInboundLog, "/inbound/log?p=5", globalPrincipal).Body.String()
has(t, "empty log, page 5", body, `No messages logged yet.`)
lacks(t, "empty log, page 5", body, `<table`, `Page `)
h.cfg.InboundEnabled = false h.cfg.InboundEnabled = false
if rec := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal); rec.Code != http.StatusNotFound { if rec := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal); rec.Code != http.StatusNotFound {
+16 -7
View File
@@ -380,21 +380,22 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
} }
} }
page := parsePage(q.Get("p"))
total, err := h.store.CountSendLog(filter) total, err := h.store.CountSendLog(filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
lastPage := 1 lastPage := 1
if total > 0 { if total > 0 {
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize) lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
} }
// A page past the end is the last one, as on the Inbound log: a link
// kept while the log was pruned, or a number typed, shows rows, not an
// empty table under "Page 9 of 2" (audit 2026-10-12, L10).
page := min(parsePage(q.Get("p")), lastPage)
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, filter.Domain, filter.AppLogin) out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, filter.Domain, filter.AppLogin)
table := make([]view.OutLogRow, len(rows)) table := make([]view.OutLogRow, len(rows))
for i, row := range rows { for i, row := range rows {
@@ -409,8 +410,16 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
// parsePage clamps the "p" query parameter to a valid page number, defaulting // parsePage clamps the "p" query parameter to a valid page number, defaulting
// to 1 for anything missing or malformed rather than rejecting the request. // to 1 for anything missing or malformed rather than rejecting the request.
//
// A number too large for an int is past every last page, and is taken as
// that: strconv returns it as the largest int with ErrRange (Go 1.26,
// strconv.ParseInt: "the maximum magnitude integer of the appropriate
// bitSize and sign"), and the caller clamps it to the last page.
func parsePage(v string) int { func parsePage(v string) int {
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
if errors.Is(err, strconv.ErrRange) && n > 0 {
return n
}
if err != nil || n < 1 { if err != nil || n < 1 {
return 1 return 1
} }
@@ -74,6 +74,19 @@ func TestOutLogPagesKeepTheFiltersAndThePage(t *testing.T) {
lacks(t, "page 2", second, `Older →`) lacks(t, "page 2", second, `Older →`)
// A row's page is opened from the page it is on and leads back to it. // A row's page is opened from the page it is on and leads back to it.
has(t, "page 2", second, `?domain=bs.example.ru&amp;p=2">Details</a>`) has(t, "page 2", second, `?domain=bs.example.ru&amp;p=2">Details</a>`)
// The edges (audit 2026-10-12, L10): a page past the end is the last,
// however far past; nonsense, zero or a negative number is the first.
for _, p := range []string{"3", "9", "99999999999999999999", "9223372036854775807"} {
body := getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p="+p)
has(t, "page "+p, body, `Page 2 of 2`, `<a href="/outbound/log?domain=bs.example.ru">← Newer</a>`)
if got := strings.Count(body, `">Details</a>`); got == 0 {
t.Errorf("page %s shows no rows", p)
}
}
for _, p := range []string{"0", "-1", "x", ""} {
has(t, "page "+p, getBody(t, h.HandleDeliveries, "/outbound/log?domain=bs.example.ru&p="+p), `Page 1 of 2`)
}
} }
// Nothing logged yet is a sentence in the polled region, which goes on polling, // Nothing logged yet is a sentence in the polled region, which goes on polling,
@@ -84,6 +97,10 @@ func TestOutLogEmpty(t *testing.T) {
body := getBodyAs(t, h.HandleDeliveries, "/outbound/log", p) body := getBodyAs(t, h.HandleDeliveries, "/outbound/log", p)
has(t, "empty log", body, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `New rows appear on their own`) has(t, "empty log", body, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `New rows appear on their own`)
lacks(t, "empty log", body, `<table`, `Page 1`) lacks(t, "empty log", body, `<table`, `Page 1`)
// A page asked of an empty log is the same empty log.
body = getBodyAs(t, h.HandleDeliveries, "/outbound/log?p=5", p)
has(t, "empty log, page 5", body, `No messages logged yet.`)
lacks(t, "empty log, page 5", body, `<table`, `Page `)
// Only the global role is pointed at the setting that decides how long rows live. // Only the global role is pointed at the setting that decides how long rows live.
lacks(t, "empty log", body, `href="/server/settings"`) lacks(t, "empty log", body, `href="/server/settings"`)
has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `href="/server/settings">Settings</a>`) has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `href="/server/settings">Settings</a>`)
+4 -1
View File
@@ -50,8 +50,11 @@ func parseAutoMultiplier(raw string) (float64, error) {
// checkAutoMultiplier is the rule for an auto limit's multiplier, shared by // checkAutoMultiplier is the rule for an auto limit's multiplier, shared by
// the form and a domain import. // the form and a domain import.
// Written as "not within" so NaN, which compares false with everything, is
// outside too: ParseFloat reads "NaN" (audit 2026-10-12, L11 - stored as NULL,
// read back as 0, recalculated as the default without a word).
func checkAutoMultiplier(v float64) error { func checkAutoMultiplier(v float64) error {
if v < store.MinAutoMultiplier || v > store.MaxAutoMultiplier { if !(v >= store.MinAutoMultiplier && v <= store.MaxAutoMultiplier) {
return fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier) return fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
} }
return nil return nil
+1 -1
View File
@@ -501,7 +501,7 @@ func queueSummary(out string) string {
func statusFlash(r *http.Request) string { func statusFlash(r *http.Request) string {
switch { switch {
case r.URL.Query().Get("reloaded") != "": case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it." return "Configuration regenerated from the database - OpenDKIM's tables, every map the panel writes for Postfix, the filter lists - and OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "": case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked." return "DNS re-checked."
default: default:
@@ -0,0 +1,23 @@
package handlers
import (
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// An auto limit's multiplier is a number within its bounds: NaN and the
// infinities, which strconv reads, are refused like any other number out of
// range (audit 2026-10-12, L11).
func TestAutoMultiplierIsANumberInRange(t *testing.T) {
for _, raw := range []string{"NaN", "nan", "+Inf", "-Inf", "inf", "1.4", "5.1", "x"} {
if v, err := parseAutoMultiplier(raw); err == nil {
t.Errorf("%q accepted as %v", raw, v)
}
}
for raw, want := range map[string]float64{"": store.DefaultAutoMultiplier, "1.5": 1.5, " 2.5 ": 2.5, "5": 5} {
if v, err := parseAutoMultiplier(raw); err != nil || v != want {
t.Errorf("%q = %v, %v; want %v", raw, v, err, want)
}
}
}
+3
View File
@@ -146,6 +146,9 @@ var statusClass = map[string]string{
"error": "tag is-danger is-light", "fail": "tag is-danger is-light", "error": "tag is-danger is-light", "fail": "tag is-danger is-light",
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light", "mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
"reject": "tag is-danger is-light", "reject": "tag is-danger is-light",
// A message refused under a level-2 limit, beside its two siblings that
// are red (audit 2026-10-12, L18).
"rate-limited": "tag is-danger is-light",
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light", "quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
"global": "tag is-primary is-light", "global": "tag is-primary is-light",
+1 -1
View File
@@ -85,7 +85,7 @@ func TestStatusTagMapsEveryStatusOfTheKitTable(t *testing.T) {
} }
func TestStatusTagTreatsAnUnknownStatusAsNeutral(t *testing.T) { func TestStatusTagTreatsAnUnknownStatusAsNeutral(t *testing.T) {
for _, status := range []string{"", "rate-limited", "3", "DKIM", "something new"} { for _, status := range []string{"", "limited", "3", "DKIM", "something new"} {
if got := StatusTag(status); got != "tag is-light" { if got := StatusTag(status); got != "tag is-light" {
t.Errorf("StatusTag(%q) = %q, want the neutral tag", status, got) t.Errorf("StatusTag(%q) = %q, want the neutral tag", status, got)
} }
+1 -1
View File
@@ -101,7 +101,7 @@ func KitPage() *Kit {
StatusRows: []KitStatusRow{ StatusRows: []KitStatusRow{
{"ok, published, running, sent, accept", Tag{Status: "ok"}}, {"ok, published, running, sent, accept", Tag{Status: "ok"}},
{"warn, deferred, quiet, tempfail", Tag{Status: "warn"}}, {"warn, deferred, quiet, tempfail", Tag{Status: "warn"}},
{"error, fail, mismatch, bounced, reject", Tag{Status: "fail"}}, {"error, fail, mismatch, bounced, reject, rate-limited", Tag{Status: "fail"}},
{"unknown, off, neutral counts", Tag{Status: "off"}}, {"unknown, off, neutral counts", Tag{Status: "off"}},
{"held: quarantine · role: global", Tag{Status: "quarantine"}}, {"held: quarantine · role: global", Tag{Status: "quarantine"}},
}, },
+1 -1
View File
@@ -22,7 +22,7 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
`<th>Time, UTC</th>`, `09-21 14:01:52`, `<th>Time, UTC</th>`, `09-21 14:01:52`,
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`, `shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`, `<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`, `<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-danger is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
`href="/outbound/log/184220">Details</a>`, `href="/outbound/log/184220">Details</a>`,
`Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`, `Page 1 of 412`, `<a href="/outbound/log?p=2">Older →</a>`, `New rows appear on their own`,
) )
+13 -7
View File
@@ -6,17 +6,23 @@ import (
"testing" "testing"
) )
// The refreshes of the monitoring boxes survive a failure and back off, // panel.js run in node against a stand-in for the parts of the DOM and htmx
// checked by running panel.js in node against a stand-in for the DOM and // it uses (testdata/*_test.js): the refreshes of the monitoring boxes
// htmx (testdata/poll_test.js; audit 2026-10-12, M11). Skipped where node is // survive a failure and back off (audit 2026-10-12, M11), and a button that
// not installed - the test server's Go has none; the workstation runs it. // leaves the page asks first when a field holds an unsaved edit (L19).
func TestPanelJSPolling(t *testing.T) { // Skipped where node is not installed - the test server's Go has none; the
// workstation runs it.
func TestPanelJS(t *testing.T) {
node, err := exec.LookPath("node") node, err := exec.LookPath("node")
if err != nil { if err != nil {
t.Skip("node is not installed") t.Skip("node is not installed")
} }
out, err := exec.Command(node, filepath.Join("testdata", "poll_test.js"), filepath.Join("static", "panel.js")).CombinedOutput() for _, script := range []string{"poll_test.js", "unsaved_test.js"} {
t.Run(script, func(t *testing.T) {
out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("panel.js polling: %v\n%s", err, out) t.Fatalf("%v\n%s", err, out)
}
})
} }
} }
+49
View File
@@ -99,9 +99,58 @@
var form = ev.target.closest("form[data-confirm]"); var form = ev.target.closest("form[data-confirm]");
if (form && !window.confirm(form.dataset.confirm)) { if (form && !window.confirm(form.dataset.confirm)) {
ev.preventDefault(); ev.preventDefault();
return;
}
// A button that does something else than save - recalculate a rate
// limit - leaves the page, and what was typed on it and not saved goes
// with it. Such a button carries data-unsaved, the question to ask when
// a field of the page differs from what the page was drawn with (audit
// 2026-10-12, L19). HTML Living Standard, SubmitEvent: submitter is the
// button that submitted the form.
var button = ev.submitter;
if (button && button.dataset && button.dataset.unsaved && hasUnsavedEdit() &&
!window.confirm(button.dataset.unsaved)) {
ev.preventDefault();
} }
}); });
// hasUnsavedEdit tells whether a field of the page holds a value the page
// was not drawn with: defaultValue, defaultChecked and defaultSelected are
// what the markup said (HTML Living Standard, the input, select and
// textarea elements). A select none of whose options says selected was
// drawn with its first option, or none when it is a list (size or multiple).
function hasUnsavedEdit() {
var fields = document.querySelectorAll("input, select, textarea");
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" || f.type === "reset") {
continue;
}
if (f.type === "checkbox" || f.type === "radio") {
if (f.checked !== f.defaultChecked) {
return true;
}
} else if (f.tagName === "SELECT") {
var drawn = -1;
for (var j = 0; j < f.options.length; j++) {
if (f.options[j].defaultSelected) {
drawn = j;
break;
}
}
if (drawn < 0 && !f.multiple && f.size <= 1 && f.options.length > 0) {
drawn = 0;
}
if (f.selectedIndex !== drawn) {
return true;
}
} else if (f.value !== f.defaultValue) {
return true;
}
}
return false;
}
// --- Conditional field visibility ------------------------------------ // --- Conditional field visibility ------------------------------------
// Several forms hide a block until a select, checkbox or file input says it // Several forms hide a block until a select, checkbox or file input says it
// applies. One rule table drives them all so the five near-identical helpers // applies. One rule table drives them all so the five near-identical helpers
+1 -1
View File
@@ -64,7 +64,7 @@
<div class="buttons"> <div class="buttons">
<button type="submit" class="button is-primary">{{.Submit}}</button> <button type="submit" class="button is-primary">{{.Submit}}</button>
{{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}">Recalculate rate limit</button>{{end}} {{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}" data-unsaved="Recalculating leaves this page, and what you changed on it and did not save is lost. Recalculate anyway?">Recalculate rate limit</button>{{end}}
<a class="button is-text" href="{{.Cancel}}">Cancel</a> <a class="button is-text" href="{{.Cancel}}">Cancel</a>
</div> </div>
</form> </form>
@@ -66,7 +66,7 @@
</form> </form>
<div class="buttons"> <div class="buttons">
<button type="submit" class="button is-primary" form="rate-limit">Save limit</button> <button type="submit" class="button is-primary" form="rate-limit">Save limit</button>
{{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button">Recalculate now</button></form>{{end}} {{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button" data-unsaved="Recalculating leaves this page, and what you changed on it and did not save is lost. Recalculate anyway?">Recalculate now</button></form>{{end}}
{{if .Rate.Active}}<form method="post" action="{{.LimitAction}}" data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."><input type="hidden" name="clear" value="1"><button type="submit" class="button is-danger is-light">Remove limit</button></form>{{end}} {{if .Rate.Active}}<form method="post" action="{{.LimitAction}}" data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."><input type="hidden" name="clear" value="1"><button type="submit" class="button is-danger is-light">Remove limit</button></form>{{end}}
</div> </div>
<p class="sp-muted mt-4">{{template "inline" .LimitNote}}</p> <p class="sp-muted mt-4">{{template "inline" .LimitNote}}</p>
+1 -1
View File
@@ -1,6 +1,6 @@
// The polling of panel.js, run in node against a stand-in for the few parts // The polling of panel.js, run in node against a stand-in for the few parts
// of the DOM and htmx it uses, with a clock the test moves. Run by // of the DOM and htmx it uses, with a clock the test moves. Run by
// TestPanelJSPolling (paneljs_test.go): node poll_test.js <panel.js>. // TestPanelJS (paneljs_test.go): node poll_test.js <panel.js>.
// Exits non-zero with what went wrong. // Exits non-zero with what went wrong.
"use strict"; "use strict";
const fs = require("fs"); const fs = require("fs");
+105
View File
@@ -0,0 +1,105 @@
// The question panel.js asks before a button that leaves the page - a rate
// limit's recalculation - when a field holds an edit not saved yet (audit
// 2026-10-12, L19). Run by TestPanelJS (paneljs_test.go): node
// unsaved_test.js <panel.js>. Exits non-zero with what went wrong.
"use strict";
const fs = require("fs");
const vm = require("vm");
const failures = [];
function check(cond, what) {
if (!cond) failures.push(what);
}
const listeners = {};
let fields = [];
const document = {
hidden: false,
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
body: { addEventListener() {} },
getElementById: () => null,
querySelectorAll: (sel) => (sel === "input, select, textarea" ? fields : []),
};
let asked = [];
let answer = false;
const window = {
addEventListener() {},
confirm(q) {
asked.push(q);
return answer;
},
};
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
document, window, htmx: { ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
});
function input(type, value, defaultValue) {
return { tagName: "INPUT", type, value, defaultValue, disabled: false };
}
function box(checked, defaultChecked) {
return { tagName: "INPUT", type: "checkbox", checked, defaultChecked, disabled: false };
}
function select(selectedIndex, defaults, opts) {
return Object.assign({
tagName: "SELECT", type: "select-one", selectedIndex, disabled: false, multiple: false, size: 0,
options: defaults.map((d) => ({ defaultSelected: d })),
}, opts || {});
}
// submit fires the document's submit listeners for a form with no
// data-confirm, sent by a button with the given data-unsaved, and says
// whether the submission went on.
function submit(unsaved) {
let prevented = false;
const ev = {
target: { closest: () => null },
submitter: { dataset: unsaved === undefined ? {} : { unsaved } },
preventDefault() { prevented = true; },
};
(listeners.submit || []).forEach((fn) => fn(ev));
return !prevented;
}
const Q = "Recalculating leaves this page. Recalculate anyway?";
const drawn = () => [
input("text", "100", "100"),
input("hidden", "x", "y"), // never an edit
box(true, true),
select(0, [false, false, false]), // no option says selected: the first
select(2, [false, false, true]),
];
function scenario(name, edit, wantAsked) {
fields = drawn();
edit(fields);
asked = [];
answer = false;
const went = submit(Q);
check(asked.length === (wantAsked ? 1 : 0), name + ": asked " + asked.length + " times, want " + (wantAsked ? 1 : 0));
check(went === !wantAsked, name + ": the submission " + (went ? "went on" : "was stopped"));
}
scenario("nothing changed", () => {}, false);
scenario("a number typed", (f) => (f[0].value = "150"), true);
scenario("a box unticked", (f) => (f[2].checked = false), true);
scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true);
scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true);
scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false);
// Answered yes: the submission goes on.
fields = drawn();
fields[0].value = "150";
asked = [];
answer = true;
check(submit(Q) === true && asked.length === 1, "answered yes, the recalculation did not go on");
// A button without the question - Save - never asks.
asked = [];
answer = false;
check(submit(undefined) === true && asked.length === 0, "a plain submit asked or was stopped");
if (failures.length) {
console.log(failures.join("\n"));
process.exit(1);
}
console.log("ok");