Files
galaxy-game/integration/mailsmoke/mail_smoke_test.go
T
2026-04-28 20:39:18 +02:00

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