285 lines
9.9 KiB
Go
285 lines
9.9 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/infrastructure/config"
|
|
"RenewCMS/internal/infrastructure/mailer"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"io"
|
|
"mime"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var smtpMailerClock = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
func smtpMailerFor(server *fakeSMTPServer, settings config.SmtpSettings) *mailer.MailRepository {
|
|
if settings.Host == "" {
|
|
settings.Host = "127.0.0.1"
|
|
}
|
|
settings.Port = server.port()
|
|
if settings.Sender == "" {
|
|
settings.Sender = "notify@example.com"
|
|
}
|
|
return mailer.NewMailRepository(settings, mailer.WithTLSConfig(server.clientTLS()), mailer.WithClock(func() time.Time { return smtpMailerClock }))
|
|
}
|
|
|
|
func verificationData() useCases.MailValidationData {
|
|
return useCases.MailValidationData{Host: "https://cms.example.com", VerificationCode: "abc123"}
|
|
}
|
|
|
|
func parseDeliveredMail(t *testing.T, raw string) (*mail.Message, string) {
|
|
t.Helper()
|
|
message, err := mail.ReadMessage(strings.NewReader(raw))
|
|
if err != nil {
|
|
t.Fatalf("delivered data is not a valid message: %v\n%s", err, raw)
|
|
}
|
|
decoded, err := io.ReadAll(quotedprintable.NewReader(message.Body))
|
|
if err != nil {
|
|
t.Fatalf("body is not valid quoted-printable: %v", err)
|
|
}
|
|
return message, string(decoded)
|
|
}
|
|
|
|
func TestSMTPMailerDelivery(t *testing.T) {
|
|
t.Run("Delivers a well-formed HTML message to a plain local server", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
|
|
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
sessions := server.received()
|
|
if len(sessions) != 1 || sessions[0].from != "notify@example.com" || sessions[0].to != "reader@example.org" {
|
|
t.Fatalf("unexpected envelope %+v", sessions)
|
|
}
|
|
message, body := parseDeliveredMail(t, sessions[0].data)
|
|
if got := message.Header.Get("From"); got != `"RenewCMS" <notify@example.com>` {
|
|
t.Errorf("unexpected From %q", got)
|
|
}
|
|
if got := message.Header.Get("To"); got != "<reader@example.org>" {
|
|
t.Errorf("unexpected To %q", got)
|
|
}
|
|
subject, _ := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject"))
|
|
if subject != "Verify your RenewCMS account" {
|
|
t.Errorf("unexpected Subject %q", subject)
|
|
}
|
|
if got := message.Header.Get("Content-Type"); got != "text/html; charset=UTF-8" {
|
|
t.Errorf("unexpected Content-Type %q", got)
|
|
}
|
|
if got := message.Header.Get("MIME-Version"); got != "1.0" {
|
|
t.Errorf("unexpected MIME-Version %q", got)
|
|
}
|
|
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
|
|
t.Errorf("verification link missing from body %q", body)
|
|
}
|
|
})
|
|
|
|
t.Run("Stamps a Date from the clock and a unique Message-ID on the sender's domain", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
repo := smtpMailerFor(server, config.SmtpSettings{})
|
|
|
|
_ = repo.Send("a@example.org", "verification", verificationData())
|
|
_ = repo.Send("a@example.org", "verification", verificationData())
|
|
|
|
sessions := server.received()
|
|
first, _ := parseDeliveredMail(t, sessions[0].data)
|
|
second, _ := parseDeliveredMail(t, sessions[1].data)
|
|
date, err := first.Header.Date()
|
|
if err != nil || !date.Equal(smtpMailerClock) {
|
|
t.Errorf("expected Date %v, got %v (%v)", smtpMailerClock, date, err)
|
|
}
|
|
id := first.Header.Get("Message-ID")
|
|
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
|
|
t.Errorf("unexpected Message-ID %q", id)
|
|
}
|
|
if id == second.Header.Get("Message-ID") {
|
|
t.Error("Message-IDs must be unique")
|
|
}
|
|
})
|
|
|
|
t.Run("Encodes a non-ASCII subject and body without corrupting them", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
repo := smtpMailerFor(server, config.SmtpSettings{})
|
|
|
|
err := repo.Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := server.received()[0].data
|
|
headerBlock, _, _ := strings.Cut(raw, "\r\n\r\n")
|
|
for _, r := range headerBlock {
|
|
if r > 127 {
|
|
t.Fatalf("header contains a raw non-ASCII rune %q", r)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Authenticates with PLAIN over STARTTLS", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "PLAIN LOGIN"})
|
|
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
|
|
|
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
session := server.received()[0]
|
|
if !session.usedTLS || session.username != "user@example.com" || session.password != "s3cret" {
|
|
t.Errorf("unexpected session %+v", session)
|
|
}
|
|
})
|
|
|
|
t.Run("Falls back to LOGIN when PLAIN is not offered", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "LOGIN"})
|
|
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
|
|
|
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
session := server.received()[0]
|
|
if session.username != "user@example.com" || session.password != "s3cret" {
|
|
t.Errorf("unexpected credentials %+v", session)
|
|
}
|
|
})
|
|
|
|
t.Run("Skips authentication when no login is configured", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN"})
|
|
|
|
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, command := range server.received()[0].commands {
|
|
if strings.HasPrefix(strings.ToUpper(command), "AUTH") {
|
|
t.Errorf("unexpected %q", command)
|
|
}
|
|
}
|
|
})
|
|
|
|
for _, mechanisms := range []string{"PLAIN", "LOGIN"} {
|
|
t.Run("Refuses to send "+mechanisms+" credentials in clear text to a non-loopback server", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: mechanisms, listenAddress: "127.0.0.2:0"})
|
|
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2"}
|
|
|
|
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if session := server.received()[0]; session.password != "" || session.data != "" {
|
|
t.Errorf("credentials or data leaked: %+v", session)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
|
|
|
|
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if len(server.received()[0].data) != 0 {
|
|
t.Error("no message may be delivered")
|
|
}
|
|
})
|
|
|
|
t.Run("Fails the STARTTLS handshake against an untrusted certificate", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true})
|
|
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: server.port(), Sender: "notify@example.com"})
|
|
|
|
err := repo.Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected a certificate error")
|
|
}
|
|
if len(server.received()[0].data) != 0 {
|
|
t.Error("no message may be delivered")
|
|
}
|
|
})
|
|
|
|
t.Run("Uses implicit TLS on port 465", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{implicitTLS: true, listenAddress: "127.0.0.1:465"})
|
|
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: 465, Sender: "notify@example.com"}, mailer.WithTLSConfig(server.clientTLS()))
|
|
|
|
err := repo.Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
if !server.received()[0].usedTLS {
|
|
t.Error("expected a TLS session")
|
|
}
|
|
})
|
|
|
|
t.Run("Surfaces a rejected recipient", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectRcpt: true})
|
|
|
|
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
|
|
t.Run("Surfaces a rejected DATA command", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectData: true})
|
|
|
|
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
|
|
t.Run("Surfaces an unreachable server", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
repo := smtpMailerFor(server, config.SmtpSettings{})
|
|
_ = server.listener.Close()
|
|
|
|
if err := repo.Send("reader@example.org", "verification", verificationData()); err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSMTPMailerRejectsUnsafeInput(t *testing.T) {
|
|
cases := map[string]struct {
|
|
sender string
|
|
receiver string
|
|
template string
|
|
}{
|
|
"receiver with an injected header": {"notify@example.com", "a@example.org\r\nBcc: evil@example.net", "verification"},
|
|
"receiver that is not an address": {"notify@example.com", "not-an-address", "verification"},
|
|
"empty receiver": {"notify@example.com", "", "verification"},
|
|
"sender that is not an address": {"nope", "a@example.org", "verification"},
|
|
"unknown template": {"notify@example.com", "a@example.org", "does-not-exist"},
|
|
}
|
|
|
|
for name, tc := range cases {
|
|
t.Run("Rejects a "+name+" before opening a connection", func(t *testing.T) {
|
|
server := newFakeSMTPServer(t, fakeSMTPOptions{})
|
|
repo := smtpMailerFor(server, config.SmtpSettings{Sender: tc.sender})
|
|
|
|
err := repo.Send(tc.receiver, tc.template, verificationData())
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if len(server.received()) != 0 {
|
|
t.Errorf("no connection may be made, got %d", len(server.received()))
|
|
}
|
|
})
|
|
}
|
|
}
|