Test: Add readyz tests

This commit is contained in:
Ralph Slooten
2026-06-11 16:31:55 +12:00
parent deeab9b04c
commit 1e549eab06
6 changed files with 172 additions and 129 deletions

View File

@@ -25,7 +25,7 @@ jobs:
with:
go-version: 'stable'
cache-dependency-path: "**/*.sum"
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v
- run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/healthcheck -v
env:
# set Mailpit to use the rqlite service container
MP_DATABASE: "http://localhost:4001"

View File

@@ -33,7 +33,7 @@ jobs:
# https://olegk.dev/github-actions-and-go
run: gofmt -s -w . && git diff --exit-code
- name: Run Go tests
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v
run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid ./internal/healthcheck -v
- name: Run Go benchmarking
run: go test -p 1 ./internal/storage ./internal/html2text -bench=.

View File

@@ -1,23 +1,19 @@
package cmd
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/healthcheck"
"github.com/spf13/cobra"
)
var (
useHTTPS bool
readyzWait bool
readyzTimeout time.Duration
readyzPollEvery = time.Second
useHTTPS bool
readyzWait bool
readyzTimeout time.Duration
)
// readyzCmd represents the healthcheck command
@@ -25,76 +21,30 @@ var readyzCmd = &cobra.Command{
Use: "readyz",
Short: "Run a healthcheck to test if Mailpit is running",
Long: `This command connects to the /readyz endpoint of a running Mailpit server
and exits with a status of 0 if the connection is successful, else with a
and exits with a status of 0 if the connection is successful, else with a
status 1 if unhealthy.
If running within Docker, it should automatically detect environment
settings to determine the HTTP bind interface & port.
`,
Run: func(_ *cobra.Command, _ []string) {
webroot := strings.TrimRight(path.Join("/", config.Webroot, "/"), "/") + "/"
proto := "http"
if useHTTPS {
proto = "https"
}
uri := fmt.Sprintf("%s://%s%sreadyz", proto, config.HTTPListen, webroot)
conf := &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
// do not verify TLS if this instance is using HTTPS as we connect using IP
// so won't be the same as the cert
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}
client := &http.Client{Transport: conf}
uri := healthcheck.URI(config.HTTPListen, config.Webroot, useHTTPS)
client := healthcheck.NewClient()
var err error
if readyzWait {
if err := waitForReady(client, uri, readyzTimeout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
err = healthcheck.Wait(client, uri, readyzTimeout)
} else {
err = healthcheck.Check(client, uri)
}
if err := checkReady(client, uri); err != nil {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}
func checkReady(client *http.Client, uri string) error {
res, err := client.Get(uri)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", res.Status)
}
return nil
}
func waitForReady(client *http.Client, uri string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if err := checkReady(client, uri); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for Mailpit to become ready", timeout)
}
time.Sleep(readyzPollEvery)
}
}
func init() {
rootCmd.AddCommand(readyzCmd)

View File

@@ -1,65 +0,0 @@
package cmd
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCheckReady(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := checkReady(srv.Client(), srv.URL); err != nil {
t.Fatalf("checkReady() error = %v", err)
}
}
func TestWaitForReadyRetriesUntilSuccess(t *testing.T) {
oldPoll := readyzPollEvery
readyzPollEvery = time.Millisecond
t.Cleanup(func() { readyzPollEvery = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := waitForReady(srv.Client(), srv.URL, 100*time.Millisecond); err != nil {
t.Fatalf("waitForReady() error = %v", err)
}
if calls < 2 {
t.Fatalf("waitForReady() calls = %d, want at least 2", calls)
}
}
func TestWaitForReadyTimesOut(t *testing.T) {
oldPoll := readyzPollEvery
readyzPollEvery = time.Millisecond
t.Cleanup(func() { readyzPollEvery = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
if err := waitForReady(srv.Client(), srv.URL, 5*time.Millisecond); err == nil {
t.Fatal("waitForReady() error = nil, want timeout")
}
if calls == 0 {
t.Fatal("waitForReady() did not call the endpoint")
}
}

View File

@@ -0,0 +1,70 @@
// Package healthcheck probes a running Mailpit instance's /readyz endpoint.
package healthcheck
import (
"crypto/tls"
"fmt"
"net/http"
"path"
"strings"
"time"
)
// PollInterval is the delay between polls in Wait. Exported as a variable so
// tests can shorten it.
var PollInterval = time.Second
// URI builds the readyz URL from a listen address, webroot, and TLS flag.
func URI(listen, webroot string, https bool) string {
proto := "http"
if https {
proto = "https"
}
root := strings.TrimRight(path.Join("/", webroot, "/"), "/") + "/"
return fmt.Sprintf("%s://%s%sreadyz", proto, listen, root)
}
// NewClient returns an HTTP client suitable for probing a Mailpit readyz
// endpoint. TLS verification is disabled because probes typically connect via
// IP, which won't match the server certificate.
func NewClient() *http.Client {
return &http.Client{Transport: &http.Transport{
IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
}}
}
// Check makes a single readiness probe. Returns nil if the server responds
// with 200 OK.
func Check(client *http.Client, uri string) error {
res, err := client.Get(uri)
if err != nil {
return err
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", res.Status)
}
return nil
}
// Wait polls uri until Check succeeds or timeout elapses.
func Wait(client *http.Client, uri string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if err := Check(client, uri); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for Mailpit to become ready", timeout)
}
time.Sleep(PollInterval)
}
}

View File

@@ -0,0 +1,88 @@
package healthcheck
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCheck(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := Check(srv.Client(), srv.URL); err != nil {
t.Fatalf("Check() error = %v", err)
}
}
func TestWaitRetriesUntilSuccess(t *testing.T) {
oldPoll := PollInterval
PollInterval = time.Millisecond
t.Cleanup(func() { PollInterval = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := Wait(srv.Client(), srv.URL, 100*time.Millisecond); err != nil {
t.Fatalf("Wait() error = %v", err)
}
if calls < 2 {
t.Fatalf("Wait() calls = %d, want at least 2", calls)
}
}
func TestWaitTimesOut(t *testing.T) {
oldPoll := PollInterval
PollInterval = time.Millisecond
t.Cleanup(func() { PollInterval = oldPoll })
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
if err := Wait(srv.Client(), srv.URL, 5*time.Millisecond); err == nil {
t.Fatal("Wait() error = nil, want timeout")
}
if calls == 0 {
t.Fatal("Wait() did not call the endpoint")
}
}
func TestURI(t *testing.T) {
tests := []struct {
name string
listen string
webroot string
https bool
want string
}{
{"plain", "127.0.0.1:8025", "", false, "http://127.0.0.1:8025/readyz"},
{"https", "127.0.0.1:8025", "", true, "https://127.0.0.1:8025/readyz"},
{"webroot", "127.0.0.1:8025", "/mailpit", false, "http://127.0.0.1:8025/mailpit/readyz"},
{"webroot trailing slash", "127.0.0.1:8025", "/mailpit/", false, "http://127.0.0.1:8025/mailpit/readyz"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := URI(tc.listen, tc.webroot, tc.https); got != tc.want {
t.Errorf("URI() = %q, want %q", got, tc.want)
}
})
}
}