package restapi import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "galaxy/gateway/internal/config" "galaxy/gateway/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const confirmEmailCodeTestTimeZone = "Europe/Kaliningrad" func TestSendEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() authService := &recordingAuthServiceClient{ sendEmailCodeResult: SendEmailCodeResult{ ChallengeID: "challenge-123", }, } handler := newPublicHandler(ServerDependencies{AuthService: authService}) req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":" pilot@example.com "}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.Equal(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String()) assert.Equal(t, 1, authService.sendEmailCodeCalls) assert.Equal(t, 0, authService.confirmEmailCodeCalls) assert.Equal(t, SendEmailCodeInput{Email: "pilot@example.com"}, authService.sendEmailCodeInput) assert.True(t, authService.sendEmailCodeRouteClassOK) assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass) } func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() authService := &recordingAuthServiceClient{ confirmEmailCodeResult: ConfirmEmailCodeResult{ DeviceSessionID: "device-session-123", }, } handler := newPublicHandler(ServerDependencies{AuthService: authService}) req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+confirmEmailCodeTestTimeZone+` "}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.Equal(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String()) assert.Equal(t, 0, authService.sendEmailCodeCalls) assert.Equal(t, 1, authService.confirmEmailCodeCalls) assert.Equal(t, ConfirmEmailCodeInput{ ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key-material", TimeZone: confirmEmailCodeTestTimeZone, }, authService.confirmEmailCodeInput) assert.True(t, authService.confirmEmailCodeRouteClassOK) assert.Equal(t, PublicRouteClassPublicAuth, authService.confirmEmailCodeRouteClass) } func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) { t.Parallel() tests := []struct { name string target string body string wantStatus int wantBody string wantSendCalls int wantConfirmCalls int }{ { name: "send email malformed json", target: "/api/v1/public/auth/send-email-code", body: `{"email":`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`, wantSendCalls: 0, wantConfirmCalls: 0, }, { name: "send email validation error", target: "/api/v1/public/auth/send-email-code", body: `{"email":"not-an-email"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"email must be a single valid email address"}}`, wantSendCalls: 0, wantConfirmCalls: 0, }, { name: "confirm email empty code", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`, wantSendCalls: 0, wantConfirmCalls: 0, }, { name: "confirm email empty time zone", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`, wantSendCalls: 0, wantConfirmCalls: 0, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() authService := &recordingAuthServiceClient{} handler := newPublicHandler(ServerDependencies{AuthService: authService}) req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body)) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, tt.wantStatus, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.Equal(t, tt.wantBody, recorder.Body.String()) assert.Equal(t, tt.wantSendCalls, authService.sendEmailCodeCalls) assert.Equal(t, tt.wantConfirmCalls, authService.confirmEmailCodeCalls) }) } } func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) { t.Parallel() tests := []struct { name string target string body string authClient *recordingAuthServiceClient wantStatus int wantBody string }{ { name: "auth service projected bad request", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, authClient: &recordingAuthServiceClient{ confirmEmailCodeErr: &AuthServiceError{ StatusCode: http.StatusBadRequest, Code: errorCodeInvalidRequest, Message: "confirmation code is invalid", }, }, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"confirmation code is invalid"}}`, }, { name: "auth service projected custom too many requests", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}`, authClient: &recordingAuthServiceClient{ sendEmailCodeErr: &AuthServiceError{ StatusCode: http.StatusTooManyRequests, Code: "upstream_rate_limited", Message: "too many attempts for this email", }, }, wantStatus: http.StatusTooManyRequests, wantBody: `{"error":{"code":"upstream_rate_limited","message":"too many attempts for this email"}}`, }, { name: "auth service projected gateway normalizes blank gateway error fields", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, authClient: &recordingAuthServiceClient{ confirmEmailCodeErr: &AuthServiceError{ StatusCode: http.StatusBadGateway, }, }, wantStatus: http.StatusBadGateway, wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`, }, { name: "unexpected auth service error", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}`, authClient: &recordingAuthServiceClient{ sendEmailCodeErr: errors.New("boom"), }, wantStatus: http.StatusInternalServerError, wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() handler := newPublicHandler(ServerDependencies{AuthService: tt.authClient}) req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body)) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, tt.wantStatus, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.Equal(t, tt.wantBody, recorder.Body.String()) }) } } func TestDefaultAuthServiceReturnsServiceUnavailable(t *testing.T) { t.Parallel() handler := newPublicHandler(ServerDependencies{}) tests := []struct { name string method string target string body string wantStatus int wantBody string }{ { name: "send email code", method: http.MethodPost, target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}`, wantStatus: http.StatusServiceUnavailable, wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, }, { name: "confirm email code", method: http.MethodPost, target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`, wantStatus: http.StatusServiceUnavailable, wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, }, { name: "healthz remains available", method: http.MethodGet, target: "/healthz", wantStatus: http.StatusOK, wantBody: `{"status":"ok"}`, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body)) if tt.body != "" { req.Header.Set("Content-Type", "application/json") } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, tt.wantStatus, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.Equal(t, tt.wantBody, recorder.Body.String()) }) } } func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) { t.Parallel() authService := &recordingAuthServiceClient{ sendEmailCodeErr: context.DeadlineExceeded, } cfg := config.DefaultPublicHTTPConfig() cfg.AuthUpstreamTimeout = 5 * time.Millisecond handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService}) req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":"pilot@example.com"}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) assert.Equal(t, `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, recorder.Body.String()) } func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) { t.Parallel() logger, buffer := testutil.NewObservedLogger(t) handler := newPublicHandler(ServerDependencies{ Logger: logger, AuthService: &recordingAuthServiceClient{ confirmEmailCodeResult: ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"}, }, }) req := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+confirmEmailCodeTestTimeZone+`"}`), ) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) require.Equal(t, http.StatusOK, recorder.Code) logOutput := buffer.String() assert.NotContains(t, logOutput, "challenge-123") assert.NotContains(t, logOutput, "123456") assert.NotContains(t, logOutput, "public-key-material") assert.NotContains(t, logOutput, "pilot@example.com") } // recordingAuthServiceClient captures handler inputs and route classification // so tests can assert the exact adapter delegation contract. type recordingAuthServiceClient struct { sendEmailCodeResult SendEmailCodeResult sendEmailCodeErr error sendEmailCodeInput SendEmailCodeInput sendEmailCodeRouteClass PublicRouteClass sendEmailCodeRouteClassOK bool sendEmailCodeCalls int confirmEmailCodeResult ConfirmEmailCodeResult confirmEmailCodeErr error confirmEmailCodeInput ConfirmEmailCodeInput confirmEmailCodeRouteClass PublicRouteClass confirmEmailCodeRouteClassOK bool confirmEmailCodeCalls int } func (c *recordingAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) { c.sendEmailCodeCalls++ c.sendEmailCodeInput = input c.sendEmailCodeRouteClass, c.sendEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx) return c.sendEmailCodeResult, c.sendEmailCodeErr } func (c *recordingAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) { c.confirmEmailCodeCalls++ c.confirmEmailCodeInput = input c.confirmEmailCodeRouteClass, c.confirmEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx) return c.confirmEmailCodeResult, c.confirmEmailCodeErr }