package handlers import ( "net/http/httptest" "strings" "testing" "time" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/view" ) // healthReading is one plausible reading of every check Health shows, so a test // can render the page and vary the one part it is about. func healthReading() healthReadings { c := checksFixture() c.Processes = []health.Process{ {Name: "panel", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK}, {Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError}, } c.Cert = health.Certificate{ Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com", NotAfter: time.Date(2026, 11, 8, 9, 14, 0, 0, time.UTC), DaysLeft: 30, Status: health.StatusOK, Detail: "Valid for another 30 day(s).", } c.Machine = health.Machine{ CPU: health.CPU{ Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4, Status: health.StatusOK, Detail: "4 cores · 4 threads", }, Memory: health.Memory{ Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50, Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.", }, Network: health.Network{ Measured: true, RxRate: 2048, TxRate: 1024, Status: health.StatusOK, Interfaces: []health.Interface{{Name: "eth0", RxBytes: 1 << 20, TxBytes: 1 << 19, RxRate: 2048, TxRate: 1024, Measured: true}}, }, Window: 5 * time.Second, Status: health.StatusOK, } c.Sockets = []health.Socket{ {Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", Present: true, Status: health.StatusOK, Detail: "Listening"}, {Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."}, } c.SocketStatus = health.StatusWarn c.Hostname = "mail.example.com" c.PTR = dnscheck.Result{ Status: health.StatusError, Detail: "No address has a reverse record.", Records: []string{"203.0.113.10 → no PTR record"}, } return c } func renderHealth(t *testing.T, h *Handlers, c healthReadings, flash string) string { t.Helper() rec := httptest.NewRecorder() h.view.Render(rec, 200, "health", healthPage(view.Meta{User: "admin", IsGlobal: true}, c, flash)) if rec.Code != 200 { t.Fatalf("render health: %d: %s", rec.Code, rec.Body.String()) } return rec.Body.String() } // Health shows every check of the reading, with the verdict of each box in its // head, the anchors the Overview cards lead to, and the two server actions. func TestHealthPageRendersEveryCheck(t *testing.T) { h, _ := settingsServer(t) out := renderHealth(t, h, healthReading(), "") for _, want := range []string{ "panel", "fatal", "exited too quickly", "mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC", `action="/server/health/reload"`, `action="/server/health/recheck"`, `hx-get="/server/health/fragment"`, `error`, // postfix, and the reverse DNS box `id="processes"`, `id="machine"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, // The machine card: the bars carry their reading in an attribute (the // CSP rules out sizing them with a style), and the figures are printed // beside them for anything that does not render a bar. `unknown`} { if !strings.Contains(out, want) { t.Errorf("degraded machine box is missing %q", want) } } } func TestHealthPageStatesOfTheOtherChecks(t *testing.T) { h, _ := settingsServer(t) c := healthReading() c.ProcessError = true c.ProcessStatus = health.StatusUnknown c.Processes = []health.Process(nil) c.Cert = health.Certificate{Status: health.StatusUnknown, Detail: "No certificate path is configured (TLS_CERT_FILE)."} c.Hostname = "" c.PTR = dnscheck.Result{Status: health.StatusUnknown} out := renderHealth(t, h, c, "DNS re-checked.") for _, want := range []string{ "Could not ask supervisord for the process list.", "No certificate path is configured (TLS_CERT_FILE).", "(SELFPOST_HOSTNAME is not set)", `
`, "DNS re-checked.", } { if !strings.Contains(out, want) { t.Errorf("health page is missing %q", want) } } } // The certificate card names a renewal Postfix has not taken up yet: its // expiry and when it was mounted, beside the served one's (A-65). func TestHealthPageNamesARenewalWaiting(t *testing.T) { h, _ := settingsServer(t) c := healthReading() cert := c.Cert cert.Renewal = &health.Renewal{ NotAfter: time.Date(2027, 1, 31, 8, 2, 0, 0, time.UTC), Mounted: time.Date(2026, 10, 15, 3, 12, 0, 0, time.UTC), } cert.Detail = "Postfix still serves the certificate above. A renewed one is waiting in the mounted file." c.Cert = cert out := renderHealth(t, h, c, "") for _, want := range []string{ "
Expires
", "2026-11-08 09:14 UTC", "
Renewal expires
", "2027-01-31 08:02 UTC", "
Renewal mounted
", "2026-10-15 03:12 UTC", "Postfix still serves the certificate above.", } { if !strings.Contains(out, want) { t.Errorf("health page is missing %q", want) } } } // The fragment is what the page holds in its polled rows, read again. func TestHealthFragmentCarriesTheSameRows(t *testing.T) { h, _ := settingsServer(t) h.machine = &health.MachineSampler{} h.cfg.Hostname = "mail.example.com" rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/server/health/fragment", nil) h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal)) out := rec.Body.String() if rec.Code != 200 { t.Fatalf("GET /server/health/fragment = %d:\n%s", rec.Code, out) } for _, want := range []string{ `id="health-stack" data-poll`, `hx-get="/server/health/fragment"`, `id="sockets" hx-swap-oob="true"`, `id="certificate" hx-swap-oob="true"`, `id="notification-mail" hx-swap-oob="true"`, `id="machine"`, `id="processes"`, `id="sender-check"`, `id="hostname"`, } { if !strings.Contains(out, want) { t.Errorf("health fragment is missing %q", want) } } for _, gone := range []string{"`, `class="sp-postmark`, `

mail.example.org

`, `href="/server/health"`, `href="/server/health"`, `href="/outbound/queue"`, `

Outbound domains

`, `example.org`, "DKIM", "SPF", "DMARC", "1\u00a0msg", `30 days`, `href="/server/health"`, `id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML"`, } { if !strings.Contains(out, want) { t.Errorf("Overview is missing %q", want) } } // The inbound relay has no service in this test, so its box is not drawn. for _, gone := range []string{ `action="/server/health/reload"`, `action="/server/health/recheck"`, `id="processes"`, "`); got != 6 { t.Errorf("the fragment holds %d cards, want 6", got) } for _, want := range []string{ `
`, `class="sp-postmark`, "Updated ", `id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML"`, } { if !strings.Contains(out, want) { t.Errorf("the fragment is missing %q", want) } } for _, gone := range []string{"