package authsession import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "galaxy/authsession/internal/adapters/mail" "galaxy/authsession/internal/adapters/userservice" "galaxy/authsession/internal/api/internalhttp" "galaxy/authsession/internal/api/publichttp" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/blockuser" "galaxy/authsession/internal/service/confirmemailcode" "galaxy/authsession/internal/service/getsession" "galaxy/authsession/internal/service/listusersessions" "galaxy/authsession/internal/service/revokeallusersessions" "galaxy/authsession/internal/service/revokedevicesession" "galaxy/authsession/internal/service/sendemailcode" "galaxy/authsession/internal/testkit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" ) const userServiceRESTCompatibilityCode = "123456" func TestUserServiceRESTCompatibilityPublicSendUsesResolveByEmailOutcomes(t *testing.T) { t.Parallel() harness := newUserServiceRESTCompatibilityHarness(t) require.NoError(t, harness.directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing"))) require.NoError(t, harness.directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_blocked"))) existing := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"existing@example.com"}`) creatable := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"creatable@example.com"}`) blocked := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"blocked@example.com"}`) assert.Equal(t, http.StatusOK, existing.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, existing.Body) assert.Equal(t, http.StatusOK, creatable.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-2"}`, creatable.Body) assert.Equal(t, http.StatusOK, blocked.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-3"}`, blocked.Body) attempts := harness.mailSender.RecordedAttempts() require.Len(t, attempts, 2) assert.Equal(t, common.Email("existing@example.com"), attempts[0].Input.Email) assert.Equal(t, common.Email("creatable@example.com"), attempts[1].Input.Email) } func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing.T) { t.Parallel() harness := newUserServiceRESTCompatibilityHarness(t) require.NoError(t, harness.directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing"))) require.NoError(t, harness.directory.QueueCreatedUserIDs(common.UserID("user-created"))) require.NoError(t, harness.directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_blocked"))) existingChallengeID := harness.sendChallengeID(t, "existing@example.com") createdChallengeID := harness.sendChallengeID(t, "created@example.com") blockedChallengeID := harness.sendChallengeID(t, "blocked@example.com") existing := gatewayCompatibilityPostJSONValue( t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", gatewayCompatibilityConfirmRequest(existingChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), ) created := gatewayCompatibilityPostJSONValue( t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", gatewayCompatibilityConfirmRequest(createdChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), ) blocked := gatewayCompatibilityPostJSONValue( t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", gatewayCompatibilityConfirmRequest(blockedChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey), ) assert.Equal(t, http.StatusOK, existing.StatusCode) assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, existing.Body) assert.Equal(t, http.StatusOK, created.StatusCode) assert.JSONEq(t, `{"device_session_id":"device-session-2"}`, created.Body) assert.Equal(t, http.StatusForbidden, blocked.StatusCode) assert.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, blocked.Body) existingSession, err := harness.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1")) require.NoError(t, err) assert.Equal(t, common.UserID("user-existing"), existingSession.UserID) createdSession, err := harness.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-2")) require.NoError(t, err) assert.Equal(t, common.UserID("user-created"), createdSession.UserID) } func TestUserServiceRESTCompatibilityInternalRevokeAllUsesExistsByUserID(t *testing.T) { t.Parallel() harness := newUserServiceRESTCompatibilityHarness(t) require.NoError(t, harness.directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1"))) existing := gatewayCompatibilityPostJSON( t, harness.internalBaseURL+"/api/v1/internal/users/user-1/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`, ) missing := gatewayCompatibilityPostJSON( t, harness.internalBaseURL+"/api/v1/internal/users/missing-user/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`, ) assert.Equal(t, http.StatusOK, existing.StatusCode) assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, existing.Body) assert.Equal(t, http.StatusNotFound, missing.StatusCode) assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, missing.Body) } func TestUserServiceRESTCompatibilityInternalBlockUserUsesRESTClient(t *testing.T) { t.Parallel() t.Run("block by user id", func(t *testing.T) { t.Parallel() harness := newUserServiceRESTCompatibilityHarness(t) require.NoError(t, harness.directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1"))) response := gatewayCompatibilityPostJSON( t, harness.internalBaseURL+"/api/v1/internal/user-blocks", `{"user_id":"user-1","reason_code":"policy_blocked","actor":{"type":"admin"}}`, ) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"user_id","subject_value":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body) }) t.Run("block by email", func(t *testing.T) { t.Parallel() harness := newUserServiceRESTCompatibilityHarness(t) response := gatewayCompatibilityPostJSON( t, harness.internalBaseURL+"/api/v1/internal/user-blocks", `{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`, ) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body) }) } type userServiceRESTCompatibilityHarness struct { publicBaseURL string internalBaseURL string mailSender *mail.StubSender sessionStore *testkit.InMemorySessionStore directory *userservice.StubDirectory } func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompatibilityHarness { t.Helper() challengeStore := &testkit.InMemoryChallengeStore{} sessionStore := &testkit.InMemorySessionStore{} directory := &userservice.StubDirectory{} userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory)) t.Cleanup(userServiceServer.Close) userDirectory, err := userservice.NewRESTClient(userservice.Config{ BaseURL: userServiceServer.URL, RequestTimeout: 250 * time.Millisecond, }) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, userDirectory.Close()) }) configProvider := testkit.StaticConfigProvider{} publisher := &testkit.RecordingProjectionPublisher{} mailSender := &mail.StubSender{} idGenerator := &testkit.SequenceIDGenerator{} codeGenerator := testkit.FixedCodeGenerator{Code: userServiceRESTCompatibilityCode} codeHasher := testkit.DeterministicCodeHasher{} clock := testkit.FixedClock{Time: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)} sendEmailCodeService, err := sendemailcode.NewWithObservability( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, nil, clock, zap.NewNop(), nil, ) require.NoError(t, err) confirmEmailCodeService, err := confirmemailcode.NewWithObservability( challengeStore, sessionStore, userDirectory, configProvider, publisher, idGenerator, codeHasher, clock, zap.NewNop(), nil, ) require.NoError(t, err) getSessionService, err := getsession.New(sessionStore) require.NoError(t, err) listUserSessionsService, err := listusersessions.New(sessionStore) require.NoError(t, err) revokeDeviceSessionService, err := revokedevicesession.NewWithObservability(sessionStore, publisher, clock, zap.NewNop(), nil) require.NoError(t, err) revokeAllUserSessionsService, err := revokeallusersessions.NewWithObservability(sessionStore, userDirectory, publisher, clock, zap.NewNop(), nil) require.NoError(t, err) blockUserService, err := blockuser.NewWithObservability(userDirectory, sessionStore, publisher, clock, zap.NewNop(), nil) require.NoError(t, err) publicCfg := publichttp.DefaultConfig() publicCfg.Addr = gatewayCompatibilityFreeAddr(t) publicServer, err := publichttp.NewServer(publicCfg, publichttp.Dependencies{ SendEmailCode: sendEmailCodeService, ConfirmEmailCode: confirmEmailCodeService, Logger: zap.NewNop(), }) require.NoError(t, err) internalCfg := internalhttp.DefaultConfig() internalCfg.Addr = gatewayCompatibilityFreeAddr(t) internalServer, err := internalhttp.NewServer(internalCfg, internalhttp.Dependencies{ GetSession: getSessionService, ListUserSessions: listUserSessionsService, RevokeDeviceSession: revokeDeviceSessionService, RevokeAllUserSessions: revokeAllUserSessionsService, BlockUser: blockUserService, Logger: zap.NewNop(), }) require.NoError(t, err) gatewayCompatibilityRunServer(t, publicServer.Run, publicServer.Shutdown, publicCfg.Addr) gatewayCompatibilityRunServer(t, internalServer.Run, internalServer.Shutdown, internalCfg.Addr) return userServiceRESTCompatibilityHarness{ publicBaseURL: "http://" + publicCfg.Addr, internalBaseURL: "http://" + internalCfg.Addr, mailSender: mailSender, sessionStore: sessionStore, directory: directory, } } func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email string) string { t.Helper() response := gatewayCompatibilityPostJSON(t, h.publicBaseURL+"/api/v1/public/auth/send-email-code", fmt.Sprintf(`{"email":"%s"}`, email)) assert.Equal(t, http.StatusOK, response.StatusCode) var body struct { ChallengeID string `json:"challenge_id"` } require.NoError(t, json.Unmarshal([]byte(response.Body), &body)) require.NotEmpty(t, body.ChallengeID) return body.ChallengeID } func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-resolutions/by-email": var input struct { Email string `json:"email"` } if !decodeUserServiceStubRequest(writer, request, &input) { return } result, err := directory.ResolveByEmail(request.Context(), common.Email(input.Email)) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } response := map[string]any{"kind": result.Kind} if !result.UserID.IsZero() { response["user_id"] = result.UserID.String() } if !result.BlockReasonCode.IsZero() { response["block_reason_code"] = result.BlockReasonCode.String() } writeUserServiceStubJSON(writer, http.StatusOK, response) case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/exists"): userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/exists") userIDValue, err := url.PathUnescape(userIDValue) if err != nil { writeUserServiceStubError(writer, http.StatusBadRequest, err) return } exists, err := directory.ExistsByUserID(request.Context(), common.UserID(userIDValue)) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } writeUserServiceStubJSON(writer, http.StatusOK, map[string]bool{"exists": exists}) case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/users/ensure-by-email": var input struct { Email string `json:"email"` RegistrationContext *struct { PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` } `json:"registration_context"` } if !decodeUserServiceStubRequest(writer, request, &input) { return } ensureInput := ports.EnsureUserInput{ Email: common.Email(input.Email), } if input.RegistrationContext != nil { ensureInput.RegistrationContext = &ports.RegistrationContext{ PreferredLanguage: input.RegistrationContext.PreferredLanguage, TimeZone: input.RegistrationContext.TimeZone, } } if ensureInput.RegistrationContext == nil { writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present")) return } if ensureInput.RegistrationContext.PreferredLanguage != "en" { writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout")) return } if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone { writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.time_zone must match public confirm time_zone")) return } result, err := directory.EnsureUserByEmail(request.Context(), ensureInput) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } response := map[string]any{"outcome": result.Outcome} if !result.UserID.IsZero() { response["user_id"] = result.UserID.String() } if !result.BlockReasonCode.IsZero() { response["block_reason_code"] = result.BlockReasonCode.String() } writeUserServiceStubJSON(writer, http.StatusOK, response) case request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/block"): userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/block") userIDValue, err := url.PathUnescape(userIDValue) if err != nil { writeUserServiceStubError(writer, http.StatusBadRequest, err) return } var input struct { ReasonCode string `json:"reason_code"` } if !decodeUserServiceStubRequest(writer, request, &input) { return } result, err := directory.BlockByUserID(request.Context(), ports.BlockUserByIDInput{ UserID: common.UserID(userIDValue), ReasonCode: userresolution.BlockReasonCode(input.ReasonCode), }) if err != nil { if errors.Is(err, ports.ErrNotFound) { writeUserServiceStubJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"}) return } writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } response := map[string]any{"outcome": result.Outcome} if !result.UserID.IsZero() { response["user_id"] = result.UserID.String() } writeUserServiceStubJSON(writer, http.StatusOK, response) case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-blocks/by-email": var input struct { Email string `json:"email"` ReasonCode string `json:"reason_code"` } if !decodeUserServiceStubRequest(writer, request, &input) { return } result, err := directory.BlockByEmail(request.Context(), ports.BlockUserByEmailInput{ Email: common.Email(input.Email), ReasonCode: userresolution.BlockReasonCode(input.ReasonCode), }) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } response := map[string]any{"outcome": result.Outcome} if !result.UserID.IsZero() { response["user_id"] = result.UserID.String() } writeUserServiceStubJSON(writer, http.StatusOK, response) default: http.NotFound(writer, request) } }) } func decodeUserServiceStubRequest(writer http.ResponseWriter, request *http.Request, target any) bool { decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { writeUserServiceStubError(writer, http.StatusBadRequest, err) return false } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("unexpected trailing JSON input")) return false } writeUserServiceStubError(writer, http.StatusBadRequest, err) return false } return true } func writeUserServiceStubJSON(writer http.ResponseWriter, statusCode int, value any) { payload, err := json.Marshal(value) if err != nil { writeUserServiceStubError(writer, http.StatusInternalServerError, err) return } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _, _ = writer.Write(payload) } func writeUserServiceStubError(writer http.ResponseWriter, statusCode int, err error) { http.Error(writer, err.Error(), statusCode) }