Files
selfpost/internal/health/health_test.go
T
mixandClaude Opus 5.5 98e09fef4a
test / test (push) Canceled after 0s
health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.

Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.

Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.

Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.

Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:21 +03:00

291 lines
10 KiB
Go

package health
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestWorstPicksMostSevere(t *testing.T) {
cases := []struct {
in []Status
want Status
}{
{nil, StatusUnknown},
{[]Status{StatusOK, StatusOK}, StatusOK},
{[]Status{StatusOK, StatusWarn}, StatusWarn},
{[]Status{StatusWarn, StatusError, StatusOK}, StatusError},
{[]Status{StatusUnknown, StatusOK}, StatusOK},
}
for _, c := range cases {
if got := Worst(c.in...); got != c.want {
t.Errorf("Worst(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseProcesses(t *testing.T) {
// Real supervisorctl output: column-aligned, one line per program. Where
// and when it was seen is not recorded; its columns are those of
// supervisor 4.2.5 supervisorctl.py _show_statuses() and its FATAL text
// supervisor/process.py's spawnerr.
out := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly (process log may have details)
postfix-reload STOPPED Not started
logrotate RUNNING pid 25, uptime 0:04:08
`
procs := parseProcesses(out)
if len(procs) != 5 {
t.Fatalf("parsed %d processes, want 5: %+v", len(procs), procs)
}
want := map[string]Status{
"opendkim": StatusOK,
"panel": StatusOK,
"postfix": StatusError,
"postfix-reload": StatusOK, // one-shot: idle is its healthy state
"logrotate": StatusOK,
}
for _, p := range procs {
if want[p.Name] != p.Status {
t.Errorf("%s (%s): status %q, want %q", p.Name, p.State, p.Status, want[p.Name])
}
}
if procs[0].Detail != "pid 21, uptime 0:04:10" {
t.Errorf("detail = %q", procs[0].Detail)
}
}
func TestParseProcessesSkipsNonStatusLines(t *testing.T) {
out := `error: <class 'socket.error'>, [Errno 2] No such file or directory
unix:///run/supervisor.sock refused connection
`
if procs := parseProcesses(out); len(procs) != 0 {
t.Errorf("error output parsed as processes: %+v", procs)
}
}
func TestCheckCertificate(t *testing.T) {
dir := t.TempDir()
valid := filepath.Join(dir, "valid.pem")
writeCert(t, valid, "mail.example.com", 90*24*time.Hour)
if got := CheckCertificate(valid); got.Status != StatusOK {
t.Errorf("valid certificate: status %q (%s)", got.Status, got.Detail)
} else if got.Subject != "mail.example.com" {
t.Errorf("subject = %q", got.Subject)
}
soon := filepath.Join(dir, "soon.pem")
writeCert(t, soon, "mail.example.com", 3*24*time.Hour)
if got := CheckCertificate(soon); got.Status != StatusWarn {
t.Errorf("nearly expired certificate: status %q (%s)", got.Status, got.Detail)
}
expired := filepath.Join(dir, "expired.pem")
writeCert(t, expired, "mail.example.com", -24*time.Hour)
if got := CheckCertificate(expired); got.Status != StatusError {
t.Errorf("expired certificate: status %q (%s)", got.Status, got.Detail)
}
if got := CheckCertificate(filepath.Join(dir, "absent.pem")); got.Status != StatusError {
t.Errorf("missing certificate: status %q", got.Status)
}
junk := filepath.Join(dir, "junk.pem")
if err := os.WriteFile(junk, []byte("not a certificate\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := CheckCertificate(junk); got.Status != StatusError {
t.Errorf("unparsable certificate: status %q", got.Status)
}
if got := CheckCertificate(""); got.Status != StatusUnknown {
t.Errorf("unconfigured certificate: status %q", got.Status)
}
}
func TestCheckSocket(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "opendkim.sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Skipf("unix sockets unavailable here: %v", err)
}
defer l.Close()
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
}
missing := filepath.Join(dir, "journal.sock")
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
t.Errorf("missing optional socket: status %q", got.Status)
}
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
t.Errorf("missing required socket: status %q", got.Status)
}
plain := filepath.Join(dir, "plain")
if err := os.WriteFile(plain, nil, 0o600); err != nil {
t.Fatal(err)
}
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
}
}
// writeCert writes a self-signed certificate expiring after validFor (negative
// for an already-expired one).
func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(validFor),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
}
func TestLivenessFromParsedProcesses(t *testing.T) {
// Composed, in the columns of TestParseProcesses.
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix RUNNING pid 23, uptime 0:04:08
postfix-reload STOPPED Not started
`
procs := parseProcesses(allRunning)
for _, p := range procs {
if mailPathPrograms[p.Name] && p.Status != StatusOK {
t.Fatalf("%s should be ok for liveness, got %q", p.Name, p.Status)
}
}
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly
`
procs = parseProcesses(postfixDead)
for _, p := range procs {
if p.Name == "postfix" && p.Status == StatusOK {
t.Fatal("postfix FATAL should not grade as OK")
}
}
}
// Composed, after supervisor 4.2.5 rpcinterface.py _interpretProcessInfo()
// (str() of a timedelta). "127:00:01" is not a form supervisor prints - it
// only shows that the parser takes hours past 24.
func TestUptime(t *testing.T) {
for detail, want := range map[string]time.Duration{
"pid 41, uptime 0:03:12": 3*time.Minute + 12*time.Second,
"pid 41, uptime 18 days, 3:02:11": 18*24*time.Hour + 3*time.Hour + 2*time.Minute + 11*time.Second,
"pid 41, uptime 1 day, 0:00:00": 24 * time.Hour,
"pid 7, uptime 127:00:01": 127*time.Hour + time.Second,
} {
if got, ok := Uptime(detail); !ok || got != want {
t.Errorf("Uptime(%q) = %s, %v; want %s", detail, got, ok, want)
}
}
for _, detail := range []string{"", "Not started", "Oct 12 04:00 PM", "pid 41, uptime soon", "pid 41, uptime x days, 0:00:01", "pid 41, uptime 0:00"} {
if got, ok := Uptime(detail); ok {
t.Errorf("Uptime(%q) = %s, want none", detail, got)
}
}
}
// The certificate shown is the one Postfix serves; a different mounted file is
// a renewal waiting, a warning once it waited past cert-reload's interval
// (assumptions A-65).
func TestCheckServedCertificate(t *testing.T) {
dir := t.TempDir()
served := filepath.Join(dir, "served.pem")
mounted := filepath.Join(dir, "mounted.pem")
day := 24 * time.Hour
// The same file: nothing waits.
writeCert(t, served, "mail.example.com", 60*day)
b, _ := os.ReadFile(served)
if err := os.WriteFile(mounted, b, 0o600); err != nil {
t.Fatal(err)
}
if got := CheckServedCertificate(served, mounted, day, time.Now()); got.Status != StatusOK || got.Renewal != nil || got.Path != served {
t.Errorf("same certificate: %+v", got)
}
// Postfix's copy nears its end, a renewal was mounted an hour ago: no
// warning, the renewal named.
writeCert(t, served, "mail.example.com", 5*day)
writeCert(t, mounted, "mail.example.com", 90*day)
now := time.Now()
if err := os.Chtimes(mounted, now.Add(-time.Hour), now.Add(-time.Hour)); err != nil {
t.Fatal(err)
}
got := CheckServedCertificate(served, mounted, day, now)
if got.Status != StatusOK || got.Renewal == nil || got.Renewal.Late || got.DaysLeft > 5 {
t.Fatalf("renewal waiting: %+v", got)
}
if !strings.Contains(got.Detail, "at most a day after it was mounted") {
t.Errorf("renewal waiting, detail: %q", got.Detail)
}
if d := got.Renewal.NotAfter.Sub(now); d < 89*day {
t.Errorf("the renewal's expiry is the mounted certificate's: %v", d)
}
// Waiting past the interval and an hour: a warning, cert-reload named.
if err := os.Chtimes(mounted, now.Add(-26*time.Hour), now.Add(-26*time.Hour)); err != nil {
t.Fatal(err)
}
got = CheckServedCertificate(served, mounted, day, now)
if got.Status != StatusWarn || got.Renewal == nil || !got.Renewal.Late || !strings.Contains(got.Detail, "cert-reload") {
t.Errorf("renewal late: %+v", got)
}
// The served one expired: still an error, the renewal said to be coming.
writeCert(t, served, "mail.example.com", -day)
if err := os.Chtimes(mounted, now, now); err != nil {
t.Fatal(err)
}
got = CheckServedCertificate(served, mounted, day, now)
if got.Status != StatusError || got.Renewal == nil || !strings.Contains(got.Detail, "A renewed one is waiting") {
t.Errorf("expired, renewal waiting: %+v", got)
}
// No served path known: the mounted file, as before.
if got := CheckServedCertificate("", mounted, day, now); got.Path != mounted || got.Renewal != nil {
t.Errorf("served path unknown: %+v", got)
}
}
func TestIntervalText(t *testing.T) {
for d, want := range map[time.Duration]string{
24 * time.Hour: "a day", 48 * time.Hour: "2 days", time.Hour: "an hour",
6 * time.Hour: "6 hours", 2 * time.Second: "2 seconds", 90 * time.Minute: "90 minutes",
} {
if got := intervalText(d); got != want {
t.Errorf("intervalText(%v) = %q, want %q", d, got, want)
}
}
}