package publichttp import ( "bytes" "context" "errors" "net/http" "net/http/httptest" "testing" "time" "galaxy/authsession/internal/service/confirmemailcode" "galaxy/authsession/internal/service/sendemailcode" "galaxy/authsession/internal/service/shared" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) const publicConfirmTimeZone = "Europe/Kaliningrad" func TestSendEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() handler := mustNewHandler(t, DefaultConfig(), Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{ChallengeID: "challenge-123"}, nil }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, errors.New("unexpected call") }), }) recorder := httptest.NewRecorder() request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/send-email-code", bytes.NewBufferString(`{"email":" pilot@example.com "}`), ) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.JSONEq(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String()) } func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { t.Parallel() handler := mustNewHandler(t, DefaultConfig(), Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(_ context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) { assert.Equal(t, confirmemailcode.Input{ ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key-material", TimeZone: publicConfirmTimeZone, }, input) return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil }), }) recorder := httptest.NewRecorder() request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+publicConfirmTimeZone+` "}`), ) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.JSONEq(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String()) } func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) { t.Parallel() tests := []struct { name string target string body string wantStatus int wantBody string }{ { name: "empty body", target: "/api/v1/public/auth/send-email-code", body: ``, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`, }, { name: "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"}}`, }, { name: "multiple objects", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}{"email":"next@example.com"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`, }, { name: "unknown field", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com","extra":true}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`, }, { name: "invalid json type", target: "/api/v1/public/auth/send-email-code", body: `{"email":123}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"request body contains an invalid value for \"email\""}}`, }, { name: "invalid email", 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"}}`, }, { name: "empty code", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`, }, { name: "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"}}`, }, } handler := mustNewHandler(t, DefaultConfig(), Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, errors.New("unexpected call") }), }) for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body)) if tt.body != "" { request.Header.Set("Content-Type", "application/json") } handler.ServeHTTP(recorder, request) assert.Equal(t, tt.wantStatus, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.JSONEq(t, tt.wantBody, recorder.Body.String()) }) } } func TestPublicAuthHandlersMapServiceErrors(t *testing.T) { t.Parallel() tests := []struct { name string target string body string deps Dependencies wantStatus int wantBody string }{ { name: "send route hides blocked by policy", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, shared.BlockedByPolicy() }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, errors.New("unexpected call") }), }, wantStatus: http.StatusServiceUnavailable, wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, }, { name: "confirm invalid client public key", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.InvalidClientPublicKey() }), }, wantStatus: http.StatusBadRequest, wantBody: `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, }, { name: "confirm challenge not found", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.ChallengeNotFound() }), }, wantStatus: http.StatusNotFound, wantBody: `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, }, { name: "confirm challenge expired", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.ChallengeExpired() }), }, wantStatus: http.StatusGone, wantBody: `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, }, { name: "confirm blocked by policy", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.BlockedByPolicy() }), }, wantStatus: http.StatusForbidden, wantBody: `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, }, { name: "confirm session limit exceeded", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.SessionLimitExceeded() }), }, wantStatus: http.StatusConflict, wantBody: `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, }, { name: "confirm hides internal error", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, shared.InternalError(errors.New("broken invariant")) }), }, wantStatus: http.StatusServiceUnavailable, wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() handler := mustNewHandler(t, DefaultConfig(), tt.deps) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) assert.Equal(t, tt.wantStatus, recorder.Code) assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type")) assert.JSONEq(t, tt.wantBody, recorder.Body.String()) }) } } func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) { t.Parallel() cfg := DefaultConfig() cfg.RequestTimeout = 5 * time.Millisecond handler := mustNewHandler(t, cfg, Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, context.DeadlineExceeded }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, errors.New("unexpected call") }), }) recorder := httptest.NewRecorder() request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/send-email-code", bytes.NewBufferString(`{"email":"pilot@example.com"}`), ) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) assert.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, recorder.Body.String()) } func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) { t.Parallel() tests := []struct { name string target string body string deps Dependencies wantBody string }{ { name: "send email blank challenge id", target: "/api/v1/public/auth/send-email-code", body: `{"email":"pilot@example.com"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{ChallengeID: " "}, nil }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{}, errors.New("unexpected call") }), }, wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, }, { name: "confirm blank device session id", target: "/api/v1/public/auth/confirm-email-code", body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`, deps: Dependencies{ SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{DeviceSessionID: " "}, nil }), }, wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() handler := mustNewHandler(t, DefaultConfig(), tt.deps) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) assert.JSONEq(t, tt.wantBody, recorder.Body.String()) }) } } func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) { t.Parallel() logger, buffer := newObservedLogger() handler := mustNewHandler(t, DefaultConfig(), Dependencies{ Logger: logger, SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) { return sendemailcode.Result{}, errors.New("unexpected call") }), ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) { return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil }), }) recorder := httptest.NewRecorder() request := httptest.NewRequest( http.MethodPost, "/api/v1/public/auth/confirm-email-code", bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+publicConfirmTimeZone+`"}`), ) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(recorder, request) 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") assert.NotContains(t, logOutput, "device-session-123") } func mustNewHandler(t *testing.T, cfg Config, deps Dependencies) http.Handler { t.Helper() handler, err := newHandlerWithConfig(cfg, deps) require.NoError(t, err) return handler } type sendEmailCodeFunc func(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error) func (f sendEmailCodeFunc) Execute(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error) { return f(ctx, input) } type confirmEmailCodeFunc func(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) func (f confirmEmailCodeFunc) Execute(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) { return f(ctx, input) } func newObservedLogger() (*zap.Logger, *bytes.Buffer) { buffer := &bytes.Buffer{} encoderConfig := zap.NewProductionEncoderConfig() encoderConfig.TimeKey = "" core := zapcore.NewCore( zapcore.NewJSONEncoder(encoderConfig), zapcore.AddSync(buffer), zap.DebugLevel, ) return zap.New(core), buffer }