package gatewayauthsessionmail_test import ( "bytes" "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "net/http" "net/url" "path/filepath" "runtime" "testing" "time" gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1" contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1" "galaxy/integration/internal/harness" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) const ( gatewaySendEmailCodePath = "/api/v1/public/auth/send-email-code" gatewayConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code" gatewayMailDeliveriesPath = "/api/v1/internal/deliveries" testEmail = "pilot@example.com" testTimeZone = "Europe/Kaliningrad" ) type gatewayAuthsessionMailHarness struct { redis *redis.Client userStub *harness.UserStub authsessionPublicURL string authsessionInternalURL string gatewayPublicURL string gatewayGRPCAddr string mailInternalURL string responseSignerPublicKey ed25519.PublicKey gatewayProcess *harness.Process authsessionProcess *harness.Process mailProcess *harness.Process } type httpResponse struct { StatusCode int Body string Header http.Header } type sendEmailCodeResponse struct { ChallengeID string `json:"challenge_id"` } type confirmEmailCodeResponse struct { DeviceSessionID string `json:"device_session_id"` } type gatewaySessionRecord struct { DeviceSessionID string `json:"device_session_id"` UserID string `json:"user_id"` ClientPublicKey string `json:"client_public_key"` Status string `json:"status"` RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"` } 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"` } func newGatewayAuthsessionMailHarness(t *testing.T) *gatewayAuthsessionMailHarness { t.Helper() redisRuntime := harness.StartRedisContainer(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisRuntime.Addr, Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, redisClient.Close()) }) userStub := harness.NewUserStub(t) responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name()) mailInternalAddr := harness.FreeTCPAddress(t) authsessionPublicAddr := harness.FreeTCPAddress(t) authsessionInternalAddr := harness.FreeTCPAddress(t) gatewayPublicAddr := harness.FreeTCPAddress(t) gatewayGRPCAddr := harness.FreeTCPAddress(t) mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail") authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession") gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway") mailProcess := harness.StartProcess(t, "mail", mailBinary, map[string]string{ "MAIL_LOG_LEVEL": "info", "MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr, "MAIL_REDIS_ADDR": redisRuntime.Addr, "MAIL_TEMPLATE_DIR": moduleTemplateDir(t), "MAIL_SMTP_MODE": "stub", "MAIL_STREAM_BLOCK_TIMEOUT": "100ms", "MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(), "MAIL_SHUTDOWN_TIMEOUT": "2s", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForMailReady(t, mailProcess, "http://"+mailInternalAddr) authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{ "AUTHSESSION_LOG_LEVEL": "info", "AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr, "AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr, "AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_REDIS_ADDR": redisRuntime.Addr, "AUTHSESSION_USER_SERVICE_MODE": "rest", "AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(), "AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_MAIL_SERVICE_MODE": "rest", "AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr, "AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:", "AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr) gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, map[string]string{ "GATEWAY_LOG_LEVEL": "info", "GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr, "GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr, "GATEWAY_SESSION_CACHE_REDIS_ADDR": redisRuntime.Addr, "GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:", "GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events", "GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events", "GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:", "GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath), "GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr, "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(), "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK) harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr) return &gatewayAuthsessionMailHarness{ redis: redisClient, userStub: userStub, authsessionPublicURL: "http://" + authsessionPublicAddr, authsessionInternalURL: "http://" + authsessionInternalAddr, gatewayPublicURL: "http://" + gatewayPublicAddr, gatewayGRPCAddr: gatewayGRPCAddr, mailInternalURL: "http://" + mailInternalAddr, responseSignerPublicKey: responseSignerPublicKey, gatewayProcess: gatewayProcess, authsessionProcess: authsessionProcess, mailProcess: mailProcess, } } func (h *gatewayAuthsessionMailHarness) stopMail(t *testing.T) { t.Helper() h.mailProcess.Stop(t) } func (h *gatewayAuthsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string { t.Helper() response := postJSONValueWithHeaders( t, h.gatewayPublicURL+gatewaySendEmailCodePath, 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.ChallengeID } func (h *gatewayAuthsessionMailHarness) confirmCode(t *testing.T, challengeID string, code string, clientPrivateKey ed25519.PrivateKey) httpResponse { t.Helper() return postJSONValue(t, h.gatewayPublicURL+gatewayConfirmEmailCodePath, map[string]string{ "challenge_id": challengeID, "code": code, "client_public_key": encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)), "time_zone": testTimeZone, }) } func (h *gatewayAuthsessionMailHarness) 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 *gatewayAuthsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse { t.Helper() target := h.mailInternalURL + gatewayMailDeliveriesPath 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 *gatewayAuthsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse { t.Helper() request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+gatewayMailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil) require.NoError(t, err) return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK) } func (h *gatewayAuthsessionMailHarness) waitForGatewaySession(t *testing.T, deviceSessionID string) gatewaySessionRecord { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { payload, err := h.redis.Get(context.Background(), "gateway:session:"+deviceSessionID).Bytes() if err == nil { var record gatewaySessionRecord require.NoError(t, decodeStrictJSONPayload(payload, &record)) return record } time.Sleep(25 * time.Millisecond) } t.Fatalf("gateway session projection for %s was not published in time", deviceSessionID) return gatewaySessionRecord{} } func (h *gatewayAuthsessionMailHarness) dialGateway(t *testing.T) *grpc.ClientConn { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() conn, err := grpc.DialContext( ctx, h.gatewayGRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) return conn } func postJSONValue(t *testing.T, targetURL string, body any) httpResponse { t.Helper() return postJSONValueWithHeaders(t, targetURL, body, nil) } 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: 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(), } } 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 templateVariableString(t *testing.T, variables map[string]any, field string) string { t.Helper() value, ok := variables[field] require.True(t, ok, "template variable %q is missing", field) text, ok := value.(string) require.True(t, ok, "template variable %q must be a string", field) require.NotEmpty(t, text) return text } func newClientPrivateKey(label string) ed25519.PrivateKey { seed := sha256.Sum256([]byte("galaxy-integration-gateway-authsessionmail-client-" + label)) return ed25519.NewKeyFromSeed(seed[:]) } func encodePublicKey(publicKey ed25519.PublicKey) string { return base64.StdEncoding.EncodeToString(publicKey) } func newSubscribeEventsRequest(deviceSessionID string, requestID string, clientPrivateKey ed25519.PrivateKey) *gatewayv1.SubscribeEventsRequest { payloadHash := contractsgatewayv1.ComputePayloadHash(nil) request := &gatewayv1.SubscribeEventsRequest{ ProtocolVersion: contractsgatewayv1.ProtocolVersionV1, DeviceSessionId: deviceSessionID, MessageType: contractsgatewayv1.SubscribeMessageType, TimestampMs: time.Now().UnixMilli(), RequestId: requestID, PayloadHash: payloadHash, TraceId: "trace-" + requestID, } request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{ ProtocolVersion: request.GetProtocolVersion(), DeviceSessionID: request.GetDeviceSessionId(), MessageType: request.GetMessageType(), TimestampMS: request.GetTimestampMs(), RequestID: request.GetRequestId(), PayloadHash: request.GetPayloadHash(), }) return request } func assertBootstrapEvent(t *testing.T, event *gatewayv1.GatewayEvent, responseSignerPublicKey ed25519.PublicKey, wantRequestID string) { t.Helper() require.Equal(t, contractsgatewayv1.ServerTimeEventType, event.GetEventType()) require.Equal(t, wantRequestID, event.GetEventId()) require.Equal(t, wantRequestID, event.GetRequestId()) require.NoError(t, contractsgatewayv1.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash())) require.NoError(t, contractsgatewayv1.VerifyEventSignature(responseSignerPublicKey, event.GetSignature(), contractsgatewayv1.EventSigningFields{ EventType: event.GetEventType(), EventID: event.GetEventId(), TimestampMS: event.GetTimestampMs(), RequestID: event.GetRequestId(), TraceID: event.GetTraceId(), PayloadHash: event.GetPayloadHash(), })) } 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+gatewayMailDeliveriesPath, 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+gatewaySendEmailCodePath, 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), "..", "..")) }