feat: runtime manager
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user