package authsessionmail_test import ( "bytes" "encoding/json" "errors" "io" "net/http" "net/url" "path/filepath" "runtime" "testing" "time" "galaxy/integration/internal/harness" "github.com/stretchr/testify/require" ) const ( authSendEmailCodePath = "/api/v1/public/auth/send-email-code" mailDeliveriesPath = "/api/v1/internal/deliveries" ) type authsessionMailHarness struct { userStub *harness.UserStub smtp *harness.SMTPCapture authsessionPublicURL string mailInternalURL string authsessionProcess *harness.Process mailProcess *harness.Process } type authsessionMailHarnessOptions struct { mailSMTPMode string } type httpResponse struct { StatusCode int Body string Header http.Header } type sendEmailCodeResponse struct { ChallengeID string `json:"challenge_id"` } type mailDeliveryListResponse struct { Items []mailDeliverySummary `json:"items"` } type mailDeliverySummary struct { DeliveryID string `json:"delivery_id"` Source string `json:"source"` TemplateID string `json:"template_id"` Locale string `json:"locale"` To []string `json:"to"` Status string `json:"status"` } type mailDeliveryDetailResponse struct { DeliveryID string `json:"delivery_id"` Source string `json:"source"` TemplateID string `json:"template_id"` Locale string `json:"locale"` LocaleFallbackUsed bool `json:"locale_fallback_used"` To []string `json:"to"` IdempotencyKey string `json:"idempotency_key"` Status string `json:"status"` TemplateVariables map[string]any `json:"template_variables,omitempty"` } type mailDeliveryAttemptsResponse struct { Items []mailAttemptResponse `json:"items"` } type mailAttemptResponse struct { Status string `json:"status"` } func newAuthsessionMailHarness(t *testing.T, opts authsessionMailHarnessOptions) *authsessionMailHarness { t.Helper() redisRuntime := harness.StartRedisContainer(t) userStub := harness.NewUserStub(t) mailInternalAddr := harness.FreeTCPAddress(t) authsessionPublicAddr := harness.FreeTCPAddress(t) authsessionInternalAddr := harness.FreeTCPAddress(t) mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail") authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession") if opts.mailSMTPMode == "" { opts.mailSMTPMode = "stub" } mailEnv := harness.StartMailServicePersistence(t, redisRuntime.Addr).Env mailEnv["MAIL_LOG_LEVEL"] = "info" mailEnv["MAIL_INTERNAL_HTTP_ADDR"] = mailInternalAddr mailEnv["MAIL_TEMPLATE_DIR"] = moduleTemplateDir(t) mailEnv["MAIL_STREAM_BLOCK_TIMEOUT"] = "100ms" mailEnv["MAIL_OPERATOR_REQUEST_TIMEOUT"] = time.Second.String() mailEnv["MAIL_SHUTDOWN_TIMEOUT"] = "2s" mailEnv["OTEL_TRACES_EXPORTER"] = "none" mailEnv["OTEL_METRICS_EXPORTER"] = "none" var smtpCapture *harness.SMTPCapture switch opts.mailSMTPMode { case "stub": mailEnv["MAIL_SMTP_MODE"] = "stub" case "smtp": smtpCapture = harness.StartSMTPCapture(t, harness.SMTPCaptureConfig{ SupportsSTARTTLS: true, }) mailEnv["MAIL_SMTP_MODE"] = "smtp" mailEnv["MAIL_SMTP_ADDR"] = smtpCapture.Addr() mailEnv["MAIL_SMTP_FROM_EMAIL"] = "noreply@example.com" mailEnv["MAIL_SMTP_FROM_NAME"] = "Galaxy Mail" mailEnv["MAIL_SMTP_TIMEOUT"] = "2s" mailEnv["MAIL_SMTP_INSECURE_SKIP_VERIFY"] = "true" mailEnv["SSL_CERT_FILE"] = smtpCapture.RootCAPath() default: t.Fatalf("unsupported mail SMTP mode %q", opts.mailSMTPMode) } mailProcess := harness.StartProcess(t, "mail", mailBinary, mailEnv) waitForMailReady(t, mailProcess, "http://"+mailInternalAddr) authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{ "AUTHSESSION_LOG_LEVEL": "info", "AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr, "AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr, "AUTHSESSION_REDIS_MASTER_ADDR": redisRuntime.Addr, "AUTHSESSION_REDIS_PASSWORD": "integration", "AUTHSESSION_USER_SERVICE_MODE": "rest", "AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(), "AUTHSESSION_MAIL_SERVICE_MODE": "rest", "AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr, "AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(), "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr) return &authsessionMailHarness{ userStub: userStub, smtp: smtpCapture, authsessionPublicURL: "http://" + authsessionPublicAddr, mailInternalURL: "http://" + mailInternalAddr, authsessionProcess: authsessionProcess, mailProcess: mailProcess, } } func (h *authsessionMailHarness) stopMail(t *testing.T) { t.Helper() h.mailProcess.Stop(t) } func (h *authsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) sendEmailCodeResponse { t.Helper() response := postJSONValueWithHeaders( t, h.authsessionPublicURL+authSendEmailCodePath, map[string]string{"email": email}, map[string]string{"Accept-Language": acceptLanguage}, ) require.Equal(t, http.StatusOK, response.StatusCode, response.Body) var body sendEmailCodeResponse require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body)) require.NotEmpty(t, body.ChallengeID) return body } func (h *authsessionMailHarness) eventuallyListDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse { t.Helper() var response mailDeliveryListResponse require.Eventually(t, func() bool { response = h.listDeliveries(t, query) return len(response.Items) > 0 }, 10*time.Second, 50*time.Millisecond) return response } func (h *authsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse { t.Helper() target := h.mailInternalURL + mailDeliveriesPath if encoded := query.Encode(); encoded != "" { target += "?" + encoded } request, err := http.NewRequest(http.MethodGet, target, nil) require.NoError(t, err) return doJSONRequest[mailDeliveryListResponse](t, request, http.StatusOK) } func (h *authsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse { t.Helper() request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil) require.NoError(t, err) return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK) } func (h *authsessionMailHarness) getDeliveryAttempts(t *testing.T, deliveryID string) mailDeliveryAttemptsResponse { t.Helper() request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID)+"/attempts", nil) require.NoError(t, err) return doJSONRequest[mailDeliveryAttemptsResponse](t, request, http.StatusOK) } func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse { t.Helper() payload, err := json.Marshal(body) require.NoError(t, err) request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload)) require.NoError(t, err) request.Header.Set("Content-Type", "application/json") for key, value := range headers { if value == "" { continue } request.Header.Set(key, value) } return doRequest(t, request) } func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T { t.Helper() response := doRequest(t, request) require.Equal(t, wantStatus, response.StatusCode, response.Body) var decoded T require.NoError(t, json.Unmarshal([]byte(response.Body), &decoded), response.Body) return decoded } func doRequest(t *testing.T, request *http.Request) httpResponse { t.Helper() client := &http.Client{ Timeout: 500 * time.Millisecond, 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(), } } func decodeStrictJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } 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) { request, err := http.NewRequest(http.MethodGet, baseURL+mailDeliveriesPath, nil) require.NoError(t, err) response, err := client.Do(request) 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()) } func waitForAuthsessionPublicReady(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) { response, err := postJSONValueMaybe(client, baseURL+authSendEmailCodePath, map[string]string{ "email": "", }) if err == nil && response.StatusCode == http.StatusBadRequest { return } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs()) } func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) { payload, err := json.Marshal(body) if err != nil { return httpResponse{}, err } request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload)) if err != nil { return httpResponse{}, err } request.Header.Set("Content-Type", "application/json") response, err := client.Do(request) if err != nil { return httpResponse{}, err } defer response.Body.Close() responseBody, err := io.ReadAll(response.Body) if err != nil { return httpResponse{}, err } return httpResponse{ StatusCode: response.StatusCode, Body: string(responseBody), Header: response.Header.Clone(), }, nil } func moduleTemplateDir(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), "..", "..")) }