package harness import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" ) const ( resolveByEmailPath = "/api/v1/internal/user-resolutions/by-email" ensureByEmailPath = "/api/v1/internal/users/ensure-by-email" blockByEmailPath = "/api/v1/internal/user-blocks/by-email" ) // EnsureUserCall stores one ensure-by-email request received by the external // user-service stub. type EnsureUserCall struct { // Email identifies the requested login or registration e-mail. Email string // PreferredLanguage stores the forwarded registration-context language. PreferredLanguage string // TimeZone stores the forwarded registration-context time zone. TimeZone string } // UserStub provides one stateful external HTTP user-service stub. type UserStub struct { server *httptest.Server mu sync.Mutex emailToUserID map[string]string userIDToEmail map[string]string blockedEmails map[string]string blockedUsers map[string]string ensureCalls []EnsureUserCall nextUserID int } // NewUserStub starts one stateful external HTTP user-service stub. func NewUserStub(t testing.TB) *UserStub { t.Helper() stub := &UserStub{ emailToUserID: make(map[string]string), userIDToEmail: make(map[string]string), blockedEmails: make(map[string]string), blockedUsers: make(map[string]string), nextUserID: 1, } stub.server = httptest.NewServer(http.HandlerFunc(stub.handle)) t.Cleanup(stub.server.Close) return stub } // BaseURL returns the stub base URL suitable for authsession runtime wiring. func (s *UserStub) BaseURL() string { if s == nil || s.server == nil { return "" } return s.server.URL } // SeedExisting adds one existing unblocked user record into the stub state. func (s *UserStub) SeedExisting(email string, userID string) { s.mu.Lock() defer s.mu.Unlock() s.emailToUserID[email] = userID s.userIDToEmail[userID] = email } // SeedBlockedEmail adds one blocked e-mail into the stub state. func (s *UserStub) SeedBlockedEmail(email string, reasonCode string) { s.mu.Lock() defer s.mu.Unlock() s.blockedEmails[email] = reasonCode if userID, ok := s.emailToUserID[email]; ok { s.blockedUsers[userID] = reasonCode } } // EnsureCalls returns a snapshot of ensure-by-email requests observed by the // stub so far. func (s *UserStub) EnsureCalls() []EnsureUserCall { s.mu.Lock() defer s.mu.Unlock() cloned := make([]EnsureUserCall, len(s.ensureCalls)) copy(cloned, s.ensureCalls) return cloned } // Reset clears all stub state and recorded calls. func (s *UserStub) Reset() { s.mu.Lock() defer s.mu.Unlock() s.emailToUserID = make(map[string]string) s.userIDToEmail = make(map[string]string) s.blockedEmails = make(map[string]string) s.blockedUsers = make(map[string]string) s.ensureCalls = nil s.nextUserID = 1 } func (s *UserStub) handle(writer http.ResponseWriter, request *http.Request) { switch { case request.Method == http.MethodPost && request.URL.Path == resolveByEmailPath: s.handleResolveByEmail(writer, request) case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/exists"): s.handleExistsByUserID(writer, request) case request.Method == http.MethodPost && request.URL.Path == ensureByEmailPath: s.handleEnsureByEmail(writer, request) case request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/block"): s.handleBlockByUserID(writer, request) case request.Method == http.MethodPost && request.URL.Path == blockByEmailPath: s.handleBlockByEmail(writer, request) default: http.NotFound(writer, request) } } func (s *UserStub) handleResolveByEmail(writer http.ResponseWriter, request *http.Request) { var payload struct { Email string `json:"email"` } if err := decodeStrictJSONRequest(request, &payload); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } s.mu.Lock() defer s.mu.Unlock() if reason, ok := s.blockedEmails[payload.Email]; ok { writeJSON(writer, http.StatusOK, map[string]any{ "kind": "blocked", "block_reason_code": reason, }) return } if userID, ok := s.emailToUserID[payload.Email]; ok { if reason, blocked := s.blockedUsers[userID]; blocked { writeJSON(writer, http.StatusOK, map[string]any{ "kind": "blocked", "block_reason_code": reason, }) return } writeJSON(writer, http.StatusOK, map[string]any{ "kind": "existing", "user_id": userID, }) return } writeJSON(writer, http.StatusOK, map[string]any{"kind": "creatable"}) } func (s *UserStub) handleExistsByUserID(writer http.ResponseWriter, request *http.Request) { userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/exists") userIDValue, err := url.PathUnescape(userIDValue) if err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } s.mu.Lock() defer s.mu.Unlock() _, exists := s.userIDToEmail[userIDValue] writeJSON(writer, http.StatusOK, map[string]bool{"exists": exists}) } func (s *UserStub) handleEnsureByEmail(writer http.ResponseWriter, request *http.Request) { var payload struct { Email string `json:"email"` RegistrationContext *struct { PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` } `json:"registration_context"` } if err := decodeStrictJSONRequest(request, &payload); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } if payload.RegistrationContext == nil { http.Error(writer, "registration_context must be present", http.StatusBadRequest) return } s.mu.Lock() defer s.mu.Unlock() s.ensureCalls = append(s.ensureCalls, EnsureUserCall{ Email: payload.Email, PreferredLanguage: payload.RegistrationContext.PreferredLanguage, TimeZone: payload.RegistrationContext.TimeZone, }) if reason, ok := s.blockedEmails[payload.Email]; ok { writeJSON(writer, http.StatusOK, map[string]any{ "outcome": "blocked", "block_reason_code": reason, }) return } if userID, ok := s.emailToUserID[payload.Email]; ok { if reason, blocked := s.blockedUsers[userID]; blocked { writeJSON(writer, http.StatusOK, map[string]any{ "outcome": "blocked", "block_reason_code": reason, }) return } writeJSON(writer, http.StatusOK, map[string]any{ "outcome": "existing", "user_id": userID, }) return } userID := fmt.Sprintf("user-%d", s.nextUserID) s.nextUserID++ s.emailToUserID[payload.Email] = userID s.userIDToEmail[userID] = payload.Email writeJSON(writer, http.StatusOK, map[string]any{ "outcome": "created", "user_id": userID, }) } func (s *UserStub) handleBlockByUserID(writer http.ResponseWriter, request *http.Request) { userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/block") userIDValue, err := url.PathUnescape(userIDValue) if err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } var payload struct { ReasonCode string `json:"reason_code"` } if err := decodeStrictJSONRequest(request, &payload); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } s.mu.Lock() defer s.mu.Unlock() email, exists := s.userIDToEmail[userIDValue] if !exists { writeJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"}) return } outcome := "blocked" if _, already := s.blockedUsers[userIDValue]; already { outcome = "already_blocked" } s.blockedUsers[userIDValue] = payload.ReasonCode s.blockedEmails[email] = payload.ReasonCode writeJSON(writer, http.StatusOK, map[string]any{ "outcome": outcome, "user_id": userIDValue, }) } func (s *UserStub) handleBlockByEmail(writer http.ResponseWriter, request *http.Request) { var payload struct { Email string `json:"email"` ReasonCode string `json:"reason_code"` } if err := decodeStrictJSONRequest(request, &payload); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } s.mu.Lock() defer s.mu.Unlock() outcome := "blocked" if _, already := s.blockedEmails[payload.Email]; already { outcome = "already_blocked" } s.blockedEmails[payload.Email] = payload.ReasonCode response := map[string]any{"outcome": outcome} if userID, ok := s.emailToUserID[payload.Email]; ok { s.blockedUsers[userID] = payload.ReasonCode response["user_id"] = userID } writeJSON(writer, http.StatusOK, response) } func writeJSON(writer http.ResponseWriter, statusCode int, value any) { payload, err := json.Marshal(value) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _, _ = writer.Write(payload) }