From f15b06f6ffc35b64b75e0cfa66eca7ea7ff80484 Mon Sep 17 00:00:00 2001 From: Nathaniel Caza Date: Fri, 4 Oct 2024 17:04:08 -0500 Subject: [PATCH] update harness and add tests to validate mail server functionality --- test/smoke/harness/email.go | 188 +++++++++-------------------- test/smoke/harness/email_test.go | 29 +++++ test/smoke/harness/mailpit.go | 182 ++++++++++++++++++++++++++++ test/smoke/harness/mailpit_test.go | 41 +++++++ 4 files changed, 308 insertions(+), 132 deletions(-) create mode 100644 test/smoke/harness/email_test.go create mode 100644 test/smoke/harness/mailpit.go create mode 100644 test/smoke/harness/mailpit_test.go diff --git a/test/smoke/harness/email.go b/test/smoke/harness/email.go index 4203136f1d..c0e25d4214 100644 --- a/test/smoke/harness/email.go +++ b/test/smoke/harness/email.go @@ -5,9 +5,8 @@ import ( "strings" "time" - "github.com/mailhog/MailHog-Server/smtp" - "github.com/mailhog/data" - "github.com/mailhog/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type EmailServer interface { @@ -19,64 +18,74 @@ type EmailServer interface { type emailServer struct { h *Harness - store *storage.InMemory - l net.Listener - - expected []emailExpect -} - -type emailExpect struct { - address string - keywords []string + mp *mailpit } -func newEmailServer(h *Harness) *emailServer { - store := storage.CreateInMemory() - msgChan := make(chan *data.Message) - go func() { - // drain channel, TODO: check for leak - for range msgChan { +func findOpenPorts(num int) ([]string, error) { + var listeners []net.Listener + for range num { + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + for _, l := range listeners { + l.Close() + } + return nil, err } - }() + listeners = append(listeners, ln) + } + + var addrs []string + for _, l := range listeners { + addrs = append(addrs, l.Addr().String()) + l.Close() + } + + return addrs, nil +} - ln, err := net.Listen("tcp", "localhost:0") +func isListening(addr string) bool { + c, err := net.Dial("tcp", addr) if err != nil { - panic(err) + return false } + c.Close() + return true +} - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } +func newEmailServer(h *Harness) *emailServer { + mp, err := newMailpit(5) + require.NoError(h.t, err) - go smtp.Accept( - conn.(*net.TCPConn).RemoteAddr().String(), - conn, - store, - msgChan, - "goalet-test.local", - nil, - ) - } - }() + h.t.Logf("mailpit: smtp: %s", mp.smtpAddr) + h.t.Logf("mailpit: api: %s", mp.apiAddr) return &emailServer{ - h: h, - store: store, - l: ln, + h: h, + mp: mp, } } -func (e *emailServer) Close() error { return e.l.Close() } -func (e *emailServer) Addr() string { return e.l.Addr().String() } +func (e *emailServer) Close() error { return e.mp.Close() } +func (e *emailServer) Addr() string { return e.mp.smtpAddr } func (h *Harness) Email(id string) string { return h.emailG.Get(id) } func (h *Harness) SMTP() EmailServer { return h.email } func (e *emailServer) ExpectMessage(address string, keywords ...string) { - e.expected = append(e.expected, emailExpect{address: address, keywords: keywords}) + e.h.t.Helper() + + gotMessage := assert.Eventuallyf(e.h.t, func() bool { + found, err := e.mp.ReadMessage(address, keywords...) + require.NoError(e.h.t, err) + return found + }, 15*time.Second, 10*time.Millisecond, "expected to find email: address=%s; keywords=%v", address, keywords) + if gotMessage { + return + } + + msgs, err := e.mp.UnreadMessages() + assert.NoError(e.h.t, err) + e.h.t.Fatalf("timeout waiting for email; Got:\n%v", msgs) } type emailMessage struct { @@ -84,98 +93,13 @@ type emailMessage struct { body string } -func containsStr(s []string, search string) bool { - for _, str := range s { - if strings.Contains(str, search) { - return true - } - } - return false -} -func (e *emailServer) messages() []emailMessage { - _msgs, err := e.store.List(0, 1000) - if err != nil { - panic(err) - } - msgs := []data.Message(*_msgs) - - var result []emailMessage - for _, msg := range msgs { - var addrs []string - for _, p := range msg.To { - addrs = append(addrs, p.Mailbox+"@"+p.Domain) - } - - for _, part := range msg.MIME.Parts { - if !containsStr(part.Headers["Content-Type"], "text/plain") { - continue - } - result = append(result, emailMessage{ - body: part.Body, - address: addrs, - }) - } - } - - return result -} - -func (e *emailServer) waitAndAssert(timeout <-chan time.Time) bool { - msgs := e.messages() - - check := func(address string, keywords []string) bool { - - msgLoop: - for i, msg := range msgs { - var destMatch bool - for _, addr := range msg.address { - if addr == address { - destMatch = true - break - } - } - if !destMatch { - break - } - for _, w := range keywords { - if !strings.Contains(msg.body, w) { - continue msgLoop - } - } - msgs = append(msgs[:i], msgs[i+1:]...) - return true - } - return false - } +func (e *emailServer) WaitAndAssert() { + e.h.t.Helper() - for i, exp := range e.expected { - select { - case <-timeout: - e.h.t.Fatalf("timeout waiting for email: address=%s; message=%d keywords=%v\nGot: %s", exp.address, i, exp.keywords, msgs) - default: - } - if !check(exp.address, exp.keywords) { - return false - } - } + msgs, err := e.mp.UnreadMessages() + require.NoError(e.h.t, err) for _, msg := range msgs { e.h.t.Errorf("unexpected message: to=%s; body=%s", strings.Join(msg.address, ","), msg.body) } - - return true -} - -func (e *emailServer) WaitAndAssert() { - timeout := time.NewTimer(15 * time.Second) - defer timeout.Stop() - - t := time.NewTicker(time.Millisecond) - defer t.Stop() - - for !e.waitAndAssert(timeout.C) { - <-t.C - } - - e.expected = nil } diff --git a/test/smoke/harness/email_test.go b/test/smoke/harness/email_test.go new file mode 100644 index 0000000000..c6de08fc91 --- /dev/null +++ b/test/smoke/harness/email_test.go @@ -0,0 +1,29 @@ +package harness + +import ( + "net" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindOpenPorts(t *testing.T) { + // This test is more of a sanity check than anything else. + // + // Finding an open port is dependent on the system's network state, so it's + // difficult to write a deterministic test for it. This test is just to + // ensure that the function doesn't panic and returns a valid port. + ports, err := findOpenPorts(1) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ports[0], ":") { + t.Fatalf("expected port to contain colon, got %s", ports[0]) + } + + // Ensure the port is actually open + l, err := net.Listen("tcp", ports[0]) + require.NoError(t, err) + defer l.Close() +} diff --git a/test/smoke/harness/mailpit.go b/test/smoke/harness/mailpit.go new file mode 100644 index 0000000000..80a3ce6788 --- /dev/null +++ b/test/smoke/harness/mailpit.go @@ -0,0 +1,182 @@ +package harness + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/mail" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +type mailpit struct { + smtpAddr string + apiAddr string + cleanup func() error +} + +func newMailpit(retry int) (*mailpit, error) { + addrs, err := findOpenPorts(2) + if err != nil { + return nil, err + } + + var output bytes.Buffer + // detect if ../../../bin/tools/mailpit exists or ../../bin/tools/mailpit exists + cmdpath := "../../bin/tools/mailpit" + if _, err := os.Stat(cmdpath); err != nil { + if _, err := os.Stat("../" + cmdpath); err == nil { + cmdpath = "../" + cmdpath + } else { + return nil, fmt.Errorf("mailpit: %w", err) + } + } + + cmd := exec.Command(cmdpath, "-s", addrs[0], "-l", addrs[1]) + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + return nil, err + } + + t := time.NewTicker(100 * time.Millisecond) + defer t.Stop() + to := time.NewTimer(5 * time.Second) + defer to.Stop() + for { + // check if the process is still running + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + if retry > 0 && strings.Contains(output.String(), "address already in use") { + // small random delay, in case of conflict + time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) + return newMailpit(retry - 1) + } + return nil, errors.New(output.String()) + } + + select { + case <-to.C: + return nil, fmt.Errorf("mailpit: timeout: %s", output.String()) + case <-t.C: + if isListening(addrs[0]) && isListening(addrs[1]) { + return &mailpit{ + smtpAddr: addrs[0], + apiAddr: addrs[1], + cleanup: cmd.Process.Kill, + }, nil + } + } + } +} + +func (m *mailpit) Close() error { return m.cleanup() } + +func (m *mailpit) UnreadMessages() ([]emailMessage, error) { + resp, err := http.Get("http://" + m.apiAddr + "/api/v1/search?query=is:unread") + if err != nil { + return nil, fmt.Errorf("mailpit: search messages: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("mailpit: read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mailpit: search messages (bad response: %s): %s", resp.Status, string(data)) + } + + var body struct { + Messages []struct { + To []mail.Address + Snippet string + } + } + if err = json.Unmarshal(data, &body); err != nil { + return nil, fmt.Errorf("mailpit: unmarshal response: %w\n%s", err, string(data)) + } + + var result []emailMessage + for _, msg := range body.Messages { + var addrs []string + for _, p := range msg.To { + addrs = append(addrs, p.Address) + } + result = append(result, emailMessage{address: addrs, body: msg.Snippet}) + } + + return result, nil +} + +// ReadMessage will return true if an unread message was found and matched the keywords, marking it as read in the process. +func (m *mailpit) ReadMessage(to string, keywords ...string) (bool, error) { + quotedKeywords := make([]string, len(keywords)) + for i, k := range keywords { + quotedKeywords[i] = strconv.Quote(k) + } + query := fmt.Sprintf("is:unread to:%s %s", strconv.Quote(to), strings.Join(quotedKeywords, " ")) + + resp, err := http.Get("http://" + m.apiAddr + "/api/v1/search?query=" + url.QueryEscape(query)) + if err != nil { + return false, fmt.Errorf("mailpit: search messages: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("mailpit: read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("mailpit: search messages (bad response: %s): %s", resp.Status, string(data)) + } + + var body struct { + Messages []struct{ ID string } + } + if err = json.Unmarshal(data, &body); err != nil { + return false, fmt.Errorf("mailpit: unmarshal response: %w\n%s", err, string(data)) + } + + if len(body.Messages) == 0 { + return false, nil + } + + var reqBody struct { + IDs []string + Read bool + } + reqBody.IDs = append(reqBody.IDs, body.Messages[0].ID) // only read the first message + reqBody.Read = true + + reqData, err := json.Marshal(reqBody) + if err != nil { + return false, fmt.Errorf("mailpit: marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPut, "http://"+m.apiAddr+"/api/v1/messages", bytes.NewReader(reqData)) + if err != nil { + return false, fmt.Errorf("mailpit: create request: %w", err) + } + + resp, err = http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("mailpit: mark message as read: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("mailpit: mark message as read (bad response: %s)", resp.Status) + } + + return true, nil +} diff --git a/test/smoke/harness/mailpit_test.go b/test/smoke/harness/mailpit_test.go new file mode 100644 index 0000000000..1bd6309d86 --- /dev/null +++ b/test/smoke/harness/mailpit_test.go @@ -0,0 +1,41 @@ +package harness + +import ( + "net/smtp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMailpit(t *testing.T) { + mp, err := newMailpit(5) + require.NoError(t, err) + t.Cleanup(func() { _ = mp.Close() }) + + err = smtp.SendMail(mp.smtpAddr, nil, "example@example.com", []string{"foo@bar.com"}, []byte("Subject: Hello\nTo: foo@bar.com\n\nWorld!")) + require.NoError(t, err, "expected to be able to send email") + + err = smtp.SendMail(mp.smtpAddr, nil, "example@example.com", []string{"bin@baz.com"}, []byte("Subject: There\nTo: bin@baz.com\n\nThen!")) + require.NoError(t, err, "expected to be able to send email") + + found := assert.Eventually(t, func() bool { + found, err := mp.ReadMessage("foo@bar.com", "World") + require.NoError(t, err, "expected to be able to read email") + return found + }, 5*time.Second, 100*time.Millisecond, "expected to find email to foo@bar.com containing 'World'") + if !found { + msgs, err := mp.UnreadMessages() + require.NoError(t, err) + t.Fatalf("timeout waiting for email; Got:\n%v", msgs) + } + + found, err = mp.ReadMessage("foo@bar.com", "World") + require.NoError(t, err, "expected to be able to read email") + assert.False(t, found, "expected message to have been marked as read") + + remaining, err := mp.UnreadMessages() + require.NoError(t, err) + assert.Len(t, remaining, 1, "expected one unread message") +}