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, "", "mail.novox.be", 1) if strings.Contains(want, "") || 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, `imap.example.test993`) { t.Errorf("incoming server not named from AMX_IMAP_ADDRESS and AMX_IMAP_PORT: %s", body) } if !strings.Contains(body, `mail.novox.be587`) { 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(), ""+d+"") { 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) } }