feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -0,0 +1,110 @@
package authsessionmail_test
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAuthsessionMailBlackBoxSendEmailCodeCreatesSuppressedDelivery(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"status": []string{"suppressed"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "suppressed", list.Items[0].Status)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
detail := h.getDelivery(t, list.Items[0].DeliveryID)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "suppressed", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.False(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
attempts := h.getDeliveryAttempts(t, detail.DeliveryID)
require.Empty(t, attempts.Items)
}
func TestAuthsessionMailBlackBoxSendEmailCodeReturnsServiceUnavailableWhenMailServiceStops(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
h.stopMail(t)
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+authSendEmailCodePath,
map[string]string{"email": "pilot@example.com"},
nil,
)
require.Equal(t, 503, response.StatusCode)
require.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body)
}
func TestAuthsessionMailBlackBoxSMTPDeliveryReachesSentStateAndSMTPPayload(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{mailSMTPMode: "smtp"})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
var detail mailDeliveryDetailResponse
require.Eventually(t, func() bool {
detail = h.getDelivery(t, list.Items[0].DeliveryID)
return detail.Status == "sent"
}, 10*time.Second, 50*time.Millisecond)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "sent", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.True(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
code, ok := detail.TemplateVariables["code"].(string)
require.True(t, ok)
require.Len(t, code, 6)
var attempts mailDeliveryAttemptsResponse
require.Eventually(t, func() bool {
attempts = h.getDeliveryAttempts(t, detail.DeliveryID)
return len(attempts.Items) == 1 && attempts.Items[0].Status == "provider_accepted"
}, 10*time.Second, 50*time.Millisecond)
require.Len(t, attempts.Items, 1)
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
require.NotNil(t, h.smtp)
var payload string
require.Eventually(t, func() bool {
payload = h.smtp.LatestPayload()
return payload != ""
}, 10*time.Second, 50*time.Millisecond)
require.Contains(t, payload, "Subject:")
require.Contains(t, payload, "Your login code is "+code+".")
}
+394
View File
@@ -0,0 +1,394 @@
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 := map[string]string{
"MAIL_LOG_LEVEL": "info",
"MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr,
"MAIL_REDIS_ADDR": redisRuntime.Addr,
"MAIL_TEMPLATE_DIR": moduleTemplateDir(t),
"MAIL_STREAM_BLOCK_TIMEOUT": "100ms",
"MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(),
"MAIL_SHUTDOWN_TIMEOUT": "2s",
"OTEL_TRACES_EXPORTER": "none",
"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_ADDR": redisRuntime.Addr,
"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), "..", ".."))
}