package authsessionuser_test import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "testing" "time" "galaxy/integration/internal/harness" "github.com/stretchr/testify/require" ) const ( testClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" testTimeZone = "Europe/Kaliningrad" ) type authsessionUserHarness struct { mailStub *harness.MailStub authsessionPublicURL string userServiceURL string authsessionProcess *harness.Process userServiceProcess *harness.Process } func newAuthsessionUserHarness(t *testing.T) *authsessionUserHarness { t.Helper() redisServer := harness.StartMiniredis(t) mailStub := harness.NewMailStub(t) userServiceAddr := harness.FreeTCPAddress(t) authsessionPublicAddr := harness.FreeTCPAddress(t) authsessionInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession") userServiceEnv := map[string]string{ "USERSERVICE_LOG_LEVEL": "info", "USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr, "USERSERVICE_REDIS_ADDR": redisServer.Addr(), "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", } userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv) waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) authsessionEnv := map[string]string{ "AUTHSESSION_LOG_LEVEL": "info", "AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr, "AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr, "AUTHSESSION_REDIS_ADDR": redisServer.Addr(), "AUTHSESSION_USER_SERVICE_MODE": "rest", "AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "AUTHSESSION_MAIL_SERVICE_MODE": "rest", "AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(), "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", "AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(), } authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv) waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr) return &authsessionUserHarness{ mailStub: mailStub, authsessionPublicURL: "http://" + authsessionPublicAddr, userServiceURL: "http://" + userServiceAddr, authsessionProcess: authsessionProcess, userServiceProcess: userServiceProcess, } } func (h *authsessionUserHarness) sendChallenge(t *testing.T, email string) string { t.Helper() return h.sendChallengeWithAcceptLanguage(t, email, "") } func (h *authsessionUserHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string { t.Helper() response := postJSONValueWithHeaders( t, h.authsessionPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{"email": email}, map[string]string{"Accept-Language": acceptLanguage}, ) require.Equal(t, http.StatusOK, response.StatusCode) var body struct { ChallengeID string `json:"challenge_id"` } require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body)) require.NotEmpty(t, body.ChallengeID) return body.ChallengeID } func (h *authsessionUserHarness) confirmCode(t *testing.T, challengeID string, code string) httpResponse { t.Helper() return postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/confirm-email-code", map[string]string{ "challenge_id": challengeID, "code": code, "client_public_key": testClientPublicKey, "time_zone": testTimeZone, }) } type httpResponse struct { StatusCode int Body string Header http.Header } 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) } client := &http.Client{ Timeout: 250 * time.Millisecond, Transport: &http.Transport{ DisableKeepAlives: true, }, } t.Cleanup(client.CloseIdleConnections) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() responseBody, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(responseBody), 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 waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() client := &http.Client{Timeout: 250 * time.Millisecond} deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-missing/exists", 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 userservice 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} deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { response, err := postJSONValueMaybe(client, baseURL+"/api/v1/public/auth/send-email-code", 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 requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target)) } func requireJSONStatusRaw(t *testing.T, response httpResponse, wantStatus int, wantBody string) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.JSONEq(t, wantBody, response.Body) } func postEnsureUser(t *testing.T, baseURL string, email string, preferredLanguage string, timeZone string) ensureByEmailResponse { t.Helper() response := postJSONValue(t, baseURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ "email": email, "registration_context": map[string]string{ "preferred_language": preferredLanguage, "time_zone": timeZone, }, }) var body ensureByEmailResponse requireJSONStatus(t, response, http.StatusOK, &body) return body } func postBlockByEmail(t *testing.T, baseURL string, email string) { t.Helper() response := postJSONValue(t, baseURL+"/api/v1/internal/user-blocks/by-email", map[string]string{ "email": email, "reason_code": "policy_blocked", }) var body blockMutationResponse requireJSONStatus(t, response, http.StatusOK, &body) } func lookupUserByEmail(t *testing.T, baseURL string, email string) (httpResponse, userLookupResponse) { t.Helper() response := postJSONValue(t, baseURL+"/api/v1/internal/user-lookups/by-email", map[string]string{ "email": email, }) if response.StatusCode != http.StatusOK { return response, userLookupResponse{} } var body userLookupResponse require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body)) return response, body } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id,omitempty"` } type blockMutationResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id,omitempty"` } type userLookupResponse struct { User accountView `json:"user"` } type accountView struct { UserID string `json:"user_id"` Email string `json:"email"` UserName string `json:"user_name"` DisplayName string `json:"display_name,omitempty"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` DeclaredCountry string `json:"declared_country,omitempty"` Entitlement entitlementSnapshotView `json:"entitlement"` ActiveSanctions []activeSanctionView `json:"active_sanctions"` ActiveLimits []activeLimitView `json:"active_limits"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type entitlementSnapshotView struct { PlanCode string `json:"plan_code"` IsPaid bool `json:"is_paid"` Source string `json:"source"` Actor actorRefView `json:"actor"` ReasonCode string `json:"reason_code"` StartsAt time.Time `json:"starts_at"` EndsAt *time.Time `json:"ends_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type activeSanctionView struct { SanctionCode string `json:"sanction_code"` Scope string `json:"scope"` ReasonCode string `json:"reason_code"` Actor actorRefView `json:"actor"` AppliedAt time.Time `json:"applied_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type activeLimitView struct { LimitCode string `json:"limit_code"` Value int `json:"value"` ReasonCode string `json:"reason_code"` Actor actorRefView `json:"actor"` AppliedAt time.Time `json:"applied_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type actorRefView struct { Type string `json:"type"` ID string `json:"id,omitempty"` } func requireLookupNotFound(t *testing.T, response httpResponse) { t.Helper() requireJSONStatusRaw(t, response, http.StatusNotFound, `{"error":{"code":"subject_not_found","message":"subject not found"}}`) } func lastMailCodeFor(t *testing.T, stub *harness.MailStub, email string) string { t.Helper() deliveries := stub.RecordedDeliveries() for index := len(deliveries) - 1; index >= 0; index-- { if deliveries[index].Email == email { return deliveries[index].Code } } t.Fatalf("mail stub did not record delivery for %s", email) return "" } func sleepForDistinctCreatedAt() { time.Sleep(10 * time.Millisecond) } func formatStatusError(response httpResponse) string { return fmt.Sprintf("status=%d body=%s", response.StatusCode, response.Body) }