368 lines
11 KiB
Go
368 lines
11 KiB
Go
// Package mailsmoke_test exercises the real SMTP adapter of Mail
|
|
// Service against a real SMTP receiver running in a testcontainer.
|
|
// The suite is the small dedicated smoke suite called out in
|
|
// `TESTING.md §4` ("Add only a small dedicated smoke suite for the
|
|
// real mail adapter").
|
|
//
|
|
// The boundary contract under test is: a delivery accepted on Mail's
|
|
// internal HTTP surface in `smtp` mode is actually transmitted over
|
|
// SMTP to the configured upstream and is observable on the
|
|
// receiver's inspection API. No other Galaxy service is booted; the
|
|
// test is intentionally narrow.
|
|
package mailsmoke_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/integration/internal/harness"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
const (
|
|
mailpitImage = "axllent/mailpit:latest"
|
|
mailpitSMTPPort = "1025/tcp"
|
|
mailpitAPIPort = "8025/tcp"
|
|
mailDeliveryPath = "/api/v1/internal/deliveries"
|
|
commandSource = "mailsmoke"
|
|
commandTemplate = "auth.login_code"
|
|
smokeRecipient = "smoke-recipient@example.com"
|
|
smokeFromEmail = "noreply@galaxy.example.com"
|
|
)
|
|
|
|
var smokeSeq atomic.Int64
|
|
|
|
// TestMailServiceDeliversToRealSMTPProvider drives Mail Service in
|
|
// `smtp` mode at a real Mailpit testcontainer. The service must
|
|
// transmit the configured payload over SMTP and the receiver must
|
|
// register it as a stored message visible on its HTTP inspection API.
|
|
func TestMailServiceDeliversToRealSMTPProvider(t *testing.T) {
|
|
mailpit := startMailpitContainer(t)
|
|
|
|
mailService := startMailServiceWithSMTP(t, mailpit.SMTPEndpoint())
|
|
|
|
suffix := strconv.FormatInt(smokeSeq.Add(1), 10)
|
|
idempotencyKey := "mailsmoke:" + suffix
|
|
uniqueRecipient := "smoke-" + suffix + "-" + smokeRecipient
|
|
|
|
// Mail Service has a synchronous trusted REST surface for the
|
|
// auth login-code path (`/api/v1/internal/login-code-deliveries`).
|
|
// It accepts the request, renders the template, and drives the
|
|
// configured SMTP provider — exactly what the smoke suite needs
|
|
// to verify against the real Mailpit container.
|
|
loginCodeBody := map[string]any{
|
|
"email": uniqueRecipient,
|
|
"code": "123456",
|
|
"locale": "en",
|
|
}
|
|
bodyBytes, err := json.Marshal(loginCodeBody)
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest(http.MethodPost,
|
|
mailService.BaseURL+"/api/v1/internal/login-code-deliveries",
|
|
bytes.NewReader(bodyBytes),
|
|
)
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Idempotency-Key", idempotencyKey)
|
|
resp := doRequest(t, req)
|
|
require.Equalf(t,
|
|
http.StatusOK,
|
|
resp.StatusCode,
|
|
"submit login-code delivery: %s", resp.Body,
|
|
)
|
|
|
|
// Mailpit exposes received messages at /api/v1/messages with a
|
|
// JSON envelope containing `messages_count` plus per-message
|
|
// items. Wait until our envelope shows up.
|
|
waitForMailpitMessage(t, mailpit.APIBaseURL(), uniqueRecipient, 30*time.Second)
|
|
}
|
|
|
|
// --- mailpit container ---
|
|
|
|
type mailpitContainer struct {
|
|
container testcontainers.Container
|
|
smtpHost string
|
|
smtpPort string
|
|
apiHost string
|
|
apiPort string
|
|
}
|
|
|
|
func (m *mailpitContainer) SMTPEndpoint() string {
|
|
return m.smtpHost + ":" + m.smtpPort
|
|
}
|
|
|
|
func (m *mailpitContainer) APIBaseURL() string {
|
|
return "http://" + m.apiHost + ":" + m.apiPort
|
|
}
|
|
|
|
func startMailpitContainer(t *testing.T) *mailpitContainer {
|
|
t.Helper()
|
|
|
|
// Mail Service hardcodes `gomail.TLSMandatory`; the smoke suite
|
|
// must give Mailpit a usable cert+key so STARTTLS succeeds even
|
|
// against a self-signed server. The cert is short-lived and is
|
|
// regenerated per test run.
|
|
certPEM, keyPEM := generateSelfSignedCert(t, "mailpit-smoke")
|
|
|
|
ctx := context.Background()
|
|
req := testcontainers.ContainerRequest{
|
|
Image: mailpitImage,
|
|
ExposedPorts: []string{
|
|
mailpitSMTPPort,
|
|
mailpitAPIPort,
|
|
},
|
|
Env: map[string]string{
|
|
"MP_SMTP_TLS_CERT": "/etc/mailpit/cert.pem",
|
|
"MP_SMTP_TLS_KEY": "/etc/mailpit/key.pem",
|
|
},
|
|
Files: []testcontainers.ContainerFile{
|
|
{
|
|
Reader: bytes.NewReader(certPEM),
|
|
ContainerFilePath: "/etc/mailpit/cert.pem",
|
|
FileMode: 0o644,
|
|
},
|
|
{
|
|
Reader: bytes.NewReader(keyPEM),
|
|
ContainerFilePath: "/etc/mailpit/key.pem",
|
|
FileMode: 0o600,
|
|
},
|
|
},
|
|
WaitingFor: wait.ForLog("accessible via").
|
|
WithStartupTimeout(30 * time.Second),
|
|
}
|
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
ContainerRequest: req,
|
|
Started: true,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
if err := testcontainers.TerminateContainer(container); err != nil {
|
|
t.Errorf("terminate mailpit container: %v", err)
|
|
}
|
|
})
|
|
|
|
smtpHost, err := container.Host(ctx)
|
|
require.NoError(t, err)
|
|
smtpPort, err := container.MappedPort(ctx, mailpitSMTPPort)
|
|
require.NoError(t, err)
|
|
|
|
apiPort, err := container.MappedPort(ctx, mailpitAPIPort)
|
|
require.NoError(t, err)
|
|
|
|
return &mailpitContainer{
|
|
container: container,
|
|
smtpHost: smtpHost,
|
|
smtpPort: smtpPort.Port(),
|
|
apiHost: smtpHost,
|
|
apiPort: apiPort.Port(),
|
|
}
|
|
}
|
|
|
|
func waitForMailpitMessage(t *testing.T, apiBaseURL, recipient string, timeout time.Duration) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
req, err := http.NewRequest(http.MethodGet, apiBaseURL+"/api/v1/messages", nil)
|
|
require.NoError(t, err)
|
|
resp := doRequest(t, req)
|
|
if resp.StatusCode == http.StatusOK {
|
|
var body struct {
|
|
Messages []struct {
|
|
To []struct {
|
|
Address string `json:"Address"`
|
|
} `json:"To"`
|
|
Subject string `json:"Subject"`
|
|
} `json:"messages"`
|
|
}
|
|
if json.Unmarshal([]byte(resp.Body), &body) == nil {
|
|
for _, m := range body.Messages {
|
|
for _, addr := range m.To {
|
|
if addr.Address == recipient {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
t.Fatalf("mailpit did not register a message for %s within %s", recipient, timeout)
|
|
}
|
|
|
|
// --- mail service in real-SMTP mode ---
|
|
|
|
type mailService struct {
|
|
BaseURL string
|
|
}
|
|
|
|
func startMailServiceWithSMTP(t *testing.T, smtpAddr string) mailService {
|
|
t.Helper()
|
|
|
|
redisRuntime := harness.StartRedisContainer(t)
|
|
mailInternalAddr := harness.FreeTCPAddress(t)
|
|
mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail")
|
|
|
|
mailEnv := harness.StartMailServicePersistence(t, redisRuntime.Addr).Env
|
|
mailEnv["MAIL_LOG_LEVEL"] = "info"
|
|
mailEnv["MAIL_INTERNAL_HTTP_ADDR"] = mailInternalAddr
|
|
mailEnv["MAIL_TEMPLATE_DIR"] = mailTemplateDir(t)
|
|
mailEnv["MAIL_SMTP_MODE"] = "smtp"
|
|
mailEnv["MAIL_SMTP_ADDR"] = smtpAddr
|
|
mailEnv["MAIL_SMTP_FROM_EMAIL"] = smokeFromEmail
|
|
mailEnv["MAIL_SMTP_FROM_NAME"] = "Galaxy Mail Smoke"
|
|
mailEnv["MAIL_SMTP_TIMEOUT"] = "10s"
|
|
mailEnv["MAIL_SMTP_INSECURE_SKIP_VERIFY"] = "true"
|
|
mailEnv["MAIL_STREAM_BLOCK_TIMEOUT"] = "100ms"
|
|
mailEnv["MAIL_OPERATOR_REQUEST_TIMEOUT"] = "5s"
|
|
mailEnv["MAIL_SHUTDOWN_TIMEOUT"] = "2s"
|
|
mailEnv["OTEL_TRACES_EXPORTER"] = "none"
|
|
mailEnv["OTEL_METRICS_EXPORTER"] = "none"
|
|
|
|
mailProcess := harness.StartProcess(t, "mail", mailBinary, mailEnv)
|
|
waitForMailReady(t, mailProcess, "http://"+mailInternalAddr)
|
|
|
|
return mailService{BaseURL: "http://" + mailInternalAddr}
|
|
}
|
|
|
|
// --- shared helpers ---
|
|
|
|
func waitForMailReady(t *testing.T, process *harness.Process, baseURL string) {
|
|
t.Helper()
|
|
client := &http.Client{Timeout: 250 * time.Millisecond}
|
|
t.Cleanup(client.CloseIdleConnections)
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
req, err := http.NewRequest(http.MethodGet, baseURL+mailDeliveryPath, nil)
|
|
require.NoError(t, err)
|
|
response, err := client.Do(req)
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, response.Body)
|
|
response.Body.Close()
|
|
if response.StatusCode == http.StatusOK {
|
|
return
|
|
}
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatalf("wait for mail readiness: timeout\n%s", process.Logs())
|
|
}
|
|
|
|
type httpResponse struct {
|
|
StatusCode int
|
|
Body string
|
|
Header http.Header
|
|
}
|
|
|
|
func postJSON(t *testing.T, url string, body any) httpResponse {
|
|
t.Helper()
|
|
payload, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return doRequest(t, req)
|
|
}
|
|
|
|
func doRequest(t *testing.T, request *http.Request) httpResponse {
|
|
t.Helper()
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
Transport: &http.Transport{DisableKeepAlives: true},
|
|
}
|
|
t.Cleanup(client.CloseIdleConnections)
|
|
|
|
response, err := client.Do(request)
|
|
require.NoError(t, err)
|
|
defer response.Body.Close()
|
|
|
|
payload, err := io.ReadAll(response.Body)
|
|
require.NoError(t, err)
|
|
return httpResponse{
|
|
StatusCode: response.StatusCode,
|
|
Body: string(payload),
|
|
Header: response.Header.Clone(),
|
|
}
|
|
}
|
|
|
|
// generateSelfSignedCert produces a short-lived RSA cert + key for the
|
|
// Mailpit container so STARTTLS succeeds against
|
|
// `MAIL_SMTP_INSECURE_SKIP_VERIFY=true` clients.
|
|
func generateSelfSignedCert(t *testing.T, commonName string) ([]byte, []byte) {
|
|
t.Helper()
|
|
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
|
require.NoError(t, err)
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: commonName},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
DNSNames: []string{"localhost", commonName},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
|
require.NoError(t, err)
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(priv),
|
|
})
|
|
return certPEM, keyPEM
|
|
}
|
|
|
|
func mailTemplateDir(t *testing.T) string {
|
|
t.Helper()
|
|
return filepath.Join(repositoryRoot(t), "mail", "templates")
|
|
}
|
|
|
|
func repositoryRoot(t *testing.T) string {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("resolve repository root: runtime caller is unavailable")
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
|
}
|
|
|
|
// silence unused-import noise for symbols touched only via reflection /
|
|
// conditional compilation.
|
|
var _ = fmt.Sprintf
|
|
var _ = errors.New
|
|
var _ = assert.Equal
|