Files
mesh-catalog/modules/mailu/cmd/automx/automx_test.go
T
jschoubben 70500c21a8 Run mailu's automx as a Go program mounted into the mirrored alpine image, answering as captured but naming the IMAP server (issues 1631, 1632, 1700, 1701, ADR 0480)
Python automx2, its database and its automx_rows tool go; the container keeps its name, network and port, gains an http health.
2026-10-21 19:29:05 +02:00

261 lines
8.1 KiB
Go

package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// The environment mailu gives automx on novox when the fixtures were captured (files/mailu.env): the mail domain,
// the route name for both servers, the ports.
var capturedEnvironment = map[string]string{
"AMX_MAIL_DOMAINS": "novox.be",
"AMX_IMAP_ADDRESS": "mail.novox.be",
"AMX_IMAP_PORT": "143",
"AMX_SMTP_ADDRESS": "mail.novox.be",
"AMX_SMTP_PORT": "587",
"AUTOMX_PORT": "4243",
}
func getenvOf(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
func testServer(t *testing.T) *httptest.Server {
t.Helper()
cfg, err := configFrom(getenvOf(capturedEnvironment))
if err != nil {
t.Fatal(err)
}
s := httptest.NewServer(newHandler(cfg))
t.Cleanup(s.Close)
return s
}
const captured = "testdata/captured"
// A captured request: its first line is the method and the URL the client used, then the headers sent, then after
// a blank line the body sent (CAPTURED, how the files were written).
type request struct {
method, url string
header [][2]string
body string
}
func readRequest(t *testing.T, stem string) request {
t.Helper()
raw, err := os.ReadFile(stem + ".request")
if err != nil {
t.Fatal(err)
}
head, body, hasBody := strings.Cut(string(raw), "\n\n")
if !hasBody {
head = strings.TrimSuffix(head, "\n")
}
lines := strings.Split(head, "\n")
method, target, ok := strings.Cut(lines[0], " ")
if !ok {
t.Fatalf("%s: first line %q is not METHOD URL", stem, lines[0])
}
r := request{method: method, url: target, body: body}
for _, l := range lines[1:] {
k, v, ok := strings.Cut(l, ": ")
if !ok {
t.Fatalf("%s: header line %q", stem, l)
}
r.header = append(r.header, [2]string{k, v})
}
return r
}
func readFile(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// A fixture asks for its body to differ only where DIFFERENCES says so: a stem with a .corrected.body is answered
// with that, and its Content-Length is the corrected body's.
func expectedBody(t *testing.T, stem string) (body string, corrected bool) {
if _, err := os.Stat(stem + ".corrected.body"); err == nil {
return readFile(t, stem+".corrected.body"), true
}
return readFile(t, stem+".body"), false
}
func TestEveryCapturedRequestIsAnsweredAsAutomxAnswered(t *testing.T) {
stems, _ := filepath.Glob(filepath.Join(captured, "*.request"))
if len(stems) < 17 {
t.Fatalf("%d captured requests found, want the 17 of issue 1630", len(stems))
}
srv := testServer(t)
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
for _, f := range stems {
stem := strings.TrimSuffix(f, ".request")
t.Run(filepath.Base(stem), func(t *testing.T) {
rq := readRequest(t, stem)
u, err := url.Parse(rq.url)
if err != nil {
t.Fatal(err)
}
// The mesh's route reaches the program with the client's name and scheme; the program is told them in
// the forwarding headers (the Location of fixture 14 holds them).
req, err := http.NewRequest(rq.method, srv.URL+u.RequestURI(), strings.NewReader(rq.body))
if err != nil {
t.Fatal(err)
}
for _, h := range rq.header {
req.Header.Set(h[0], h[1])
}
req.Host = u.Host
req.Header.Set("X-Forwarded-Proto", u.Scheme)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, _ := io.ReadAll(resp.Body)
if want := strings.TrimRight(readFile(t, stem+".status"), "\n"); resp.Status != want {
t.Errorf("status %q, captured %q", resp.Status, want)
}
wantBody, corrected := expectedBody(t, stem)
if !bytes.Equal(got, []byte(wantBody)) {
t.Errorf("body\n%q\nwant\n%q", got, wantBody)
}
want := map[string]string{}
for _, l := range strings.Split(strings.TrimRight(readFile(t, stem+".headers"), "\n"), "\n") {
if k, v, ok := strings.Cut(l, ": "); ok {
want[k] = v
}
}
if corrected {
want["Content-Length"] = strconv.Itoa(len(wantBody))
}
// Server names the Python program that answered (DIFFERENCES); Date changes on every request.
delete(want, "Server")
var extra []string
for k := range resp.Header {
if k == "Date" || k == "Server" {
continue
}
if _, ok := want[k]; !ok {
extra = append(extra, k)
}
}
sort.Strings(extra)
if len(extra) > 0 {
t.Errorf("headers the capture does not have: %v", extra)
}
for k, v := range want {
if g := resp.Header.Get(k); g != v {
t.Errorf("header %s: %q, captured %q", k, g, v)
}
}
})
}
}
// DIFFERENCES: the one change clause 9 of ADR 0480 allows to an answer is the IMAP server's name, written from
// AMX_IMAP_ADDRESS where automx wrote an empty one.
func TestTheOnlyCorrectedAnswerIsTheImapServersName(t *testing.T) {
corrected, _ := filepath.Glob(filepath.Join(captured, "*.corrected.body"))
if len(corrected) != 3 {
t.Fatalf("%d corrected answers, want the three Thunderbird answers (01, 02, 03): %v", len(corrected), corrected)
}
for _, c := range corrected {
orig := readFile(t, strings.TrimSuffix(c, ".corrected.body")+".body")
want := strings.Replace(orig, "<hostname />", "<hostname>mail.novox.be</hostname>", 1)
if strings.Contains(want, "<hostname />") || want == orig {
t.Fatalf("%s: the capture has no single empty hostname to correct", c)
}
if got := readFile(t, c); got != want {
t.Errorf("%s differs from the capture by more than the IMAP server's name", c)
}
}
}
func TestTheImapServerIsNamedFromTheImapAddress(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_IMAP_ADDRESS"] = "imap.example.test"
env["AMX_IMAP_PORT"] = "993"
cfg, err := configFrom(getenvOf(env))
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@novox.be", nil))
body := rec.Body.String()
if !strings.Contains(body, `<incomingServer type="imap"><hostname>imap.example.test</hostname><port>993</port>`) {
t.Errorf("incoming server not named from AMX_IMAP_ADDRESS and AMX_IMAP_PORT: %s", body)
}
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port>`) {
t.Errorf("outgoing server changed: %s", body)
}
}
func TestEveryDomainOfTheEnvironmentIsServed(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_MAIL_DOMAINS"] = "novox.be, example.test"
cfg, err := configFrom(getenvOf(env))
if err != nil {
t.Fatal(err)
}
h := newHandler(cfg)
for _, d := range []string{"novox.be", "example.test"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@"+d, nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<domain>"+d+"</domain>") {
t.Errorf("%s: %d %s", d, rec.Code, rec.Body.String())
}
}
}
// Failure is loud (issue 1631 AC2): a missing variable stops the program and its name is in the message.
func TestAMissingVariableStopsTheProgramNamingIt(t *testing.T) {
for name := range capturedEnvironment {
t.Run(name, func(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
delete(env, name)
if _, err := configFrom(getenvOf(env)); err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("configFrom without %s: %v", name, err)
}
var out bytes.Buffer
if code := run(getenvOf(env), &out, func(*http.Server) error { t.Fatal("served without its configuration"); return nil }); code == 0 || !strings.Contains(out.String(), name) {
t.Errorf("run without %s: exit %d, said %q", name, code, out.String())
}
})
}
}
func TestAPortThatIsNotANumberIsRefusedNamingIt(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_SMTP_PORT"] = "submission"
if _, err := configFrom(getenvOf(env)); err == nil || !strings.Contains(err.Error(), "AMX_SMTP_PORT") {
t.Fatalf("got %v", err)
}
}