package app import ( "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "io" "log/slog" "math/big" "net" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "time" smtpadapter "galaxy/mail/internal/adapters/smtp" "galaxy/mail/internal/config" "galaxy/mail/internal/ports" "github.com/stretchr/testify/require" testcontainers "github.com/testcontainers/testcontainers-go" rediscontainer "github.com/testcontainers/testcontainers-go/modules/redis" "github.com/testcontainers/testcontainers-go/wait" ) const ( realRuntimeSmokeEnv = "MAIL_REAL_RUNTIME_SMOKE" realRuntimeRedisImage = "redis:7" realRuntimeMailpitImage = "axllent/mailpit:v1.28.2" realRuntimeMailpitCert = "/tmp/mailpit/server.crt" realRuntimeMailpitKey = "/tmp/mailpit/server.key" ) func TestRealRuntimeCompatibility(t *testing.T) { if os.Getenv(realRuntimeSmokeEnv) != "1" { t.Skipf("set %s=1 to run the real runtime smoke suite", realRuntimeSmokeEnv) } ctx := context.Background() redisContainer, err := rediscontainer.Run(ctx, realRuntimeRedisImage) require.NoError(t, err) testcontainers.CleanupContainer(t, redisContainer) redisAddr, err := redisContainer.Endpoint(ctx, "") require.NoError(t, err) certFiles := writeMailpitTLSFiles(t) mailpitContainer, err := testcontainers.Run( ctx, realRuntimeMailpitImage, testcontainers.WithExposedPorts("1025/tcp", "8025/tcp"), testcontainers.WithFiles( testcontainers.ContainerFile{ HostFilePath: certFiles.certPath, ContainerFilePath: realRuntimeMailpitCert, FileMode: 0o644, }, testcontainers.ContainerFile{ HostFilePath: certFiles.keyPath, ContainerFilePath: realRuntimeMailpitKey, FileMode: 0o600, }, ), testcontainers.WithEnv(map[string]string{ "MP_SMTP_TLS_CERT": realRuntimeMailpitCert, "MP_SMTP_TLS_KEY": realRuntimeMailpitKey, "MP_SMTP_REQUIRE_STARTTLS": "true", }), testcontainers.WithWaitStrategy( wait.ForAll( wait.ForListeningPort("1025/tcp"), wait.ForListeningPort("8025/tcp"), ).WithDeadline(30*time.Second), ), ) require.NoError(t, err) testcontainers.CleanupContainer(t, mailpitContainer) smtpAddr, err := mailpitContainer.PortEndpoint(ctx, "1025/tcp", "") require.NoError(t, err) mailpitHTTPBaseURL, err := mailpitContainer.PortEndpoint(ctx, "8025/tcp", "http") require.NoError(t, err) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisAddr) cfg.Templates.Dir = writeRuntimeTemplates(t) cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = time.Second cfg.StreamBlockTimeout = 100 * time.Millisecond cfg.AttemptWorkerConcurrency = 1 cfg.OperatorRequestTimeout = time.Second cfg.SMTP.Mode = config.SMTPModeSMTP cfg.SMTP.Addr = smtpAddr cfg.SMTP.FromEmail = "noreply@example.com" cfg.SMTP.Timeout = 2 * time.Second instance := startSmokeRuntime(t, cfg, runtimeDependencies{ providerFactory: func(cfg config.SMTPConfig, _ *slog.Logger) (ports.Provider, error) { return smtpadapter.New(smtpadapter.Config{ Addr: cfg.Addr, FromEmail: cfg.FromEmail, FromName: cfg.FromName, Timeout: cfg.Timeout, TLSConfig: certFiles.clientTLSConfig, }) }, schedulerPoll: 25 * time.Millisecond, }) response := postLoginCodeDelivery(t, instance.baseURL, loginCodeDeliveryRequest{ idempotencyKey: "real-runtime-smoke", email: "pilot@example.com", code: "246810", locale: "fr-FR", }) require.Equal(t, "sent", string(response.Outcome)) list := eventuallyListDeliveries(t, instance.baseURL, url.Values{ "source": []string{"authsession"}, "idempotency_key": []string{"real-runtime-smoke"}, }) require.Len(t, list.Items, 1) detail := eventuallyDeliveryStatus(t, instance.baseURL, list.Items[0].DeliveryID, "sent") require.Equal(t, "authsession", detail.Source) require.Equal(t, "auth.login_code", detail.TemplateID) require.Equal(t, "fr-FR", detail.Locale) require.True(t, detail.LocaleFallbackUsed) require.Equal(t, []string{"pilot@example.com"}, detail.To) attempts := getDeliveryAttempts(t, instance.baseURL, detail.DeliveryID) require.Len(t, attempts.Items, 1) require.Equal(t, "provider_accepted", attempts.Items[0].Status) messageText := waitForMailpitLatestText(t, mailpitHTTPBaseURL) require.Contains(t, messageText, "246810") } type smokeTLSFiles struct { certPath string keyPath string clientTLSConfig *tls.Config } func writeMailpitTLSFiles(t *testing.T) smokeTLSFiles { t.Helper() privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ CommonName: "localhost", }, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) keyPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey), }) root := t.TempDir() certPath := filepath.Join(root, "server.crt") keyPath := filepath.Join(root, "server.key") require.NoError(t, os.WriteFile(certPath, certPEM, 0o644)) require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600)) rootCAs := x509.NewCertPool() require.True(t, rootCAs.AppendCertsFromPEM(certPEM)) return smokeTLSFiles{ certPath: certPath, keyPath: keyPath, clientTLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, RootCAs: rootCAs, ServerName: "localhost", }, } } func startSmokeRuntime(t *testing.T, cfg config.Config, deps runtimeDependencies) *runtimeInstance { t.Helper() runtime, err := newRuntime(context.Background(), cfg, testLogger(), deps) require.NoError(t, err) instance := &runtimeInstance{ baseURL: "http://" + cfg.InternalHTTP.Addr, runtime: runtime, done: make(chan error, 1), } runCtx, cancel := context.WithCancel(context.Background()) instance.cancel = cancel go func() { instance.done <- runtime.Run(runCtx) }() waitForRuntimeReady(t, instance.baseURL) t.Cleanup(func() { instance.stop(t) }) return instance } func waitForMailpitLatestText(t *testing.T, baseURL string) string { t.Helper() client := &http.Client{ Timeout: 500 * time.Millisecond, Transport: &http.Transport{ DisableKeepAlives: true, }, } t.Cleanup(client.CloseIdleConnections) var payload string require.Eventually(t, func() bool { request, err := http.NewRequest(http.MethodGet, baseURL+"/view/latest.txt", nil) require.NoError(t, err) response, err := client.Do(request) if err != nil { return false } defer response.Body.Close() body, err := io.ReadAll(response.Body) require.NoError(t, err) if response.StatusCode != http.StatusOK { return false } payload = string(body) return strings.TrimSpace(payload) != "" }, 20*time.Second, 100*time.Millisecond) return payload }