package restapi import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewHTTPAuthServiceClient(t *testing.T) { t.Parallel() tests := []struct { name string baseURL string wantErr string }{ { name: "success", baseURL: " http://127.0.0.1:8080/ ", }, { name: "empty base url", wantErr: "base URL must not be empty", }, { name: "relative base url", baseURL: "/authsession", wantErr: "base URL must be absolute", }, { name: "malformed base url", baseURL: "://bad", wantErr: "parse base URL", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() client, err := NewHTTPAuthServiceClient(tt.baseURL) if tt.wantErr != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) assert.Equal(t, "http://127.0.0.1:8080", client.baseURL) assert.NoError(t, client.Close()) }) } } func TestHTTPAuthServiceClientSendEmailCodeSuccess(t *testing.T) { t.Parallel() var requestContentType string var requestBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, authServiceSendEmailCodePath, r.URL.Path) requestContentType = r.Header.Get("Content-Type") payload, err := io.ReadAll(r.Body) require.NoError(t, err) requestBody = string(payload) w.Header().Set("Content-Type", "application/json") _, err = io.WriteString(w, `{"challenge_id":"challenge-123"}`) require.NoError(t, err) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) result, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{ Email: "pilot@example.com", }) require.NoError(t, err) assert.Equal(t, SendEmailCodeResult{ChallengeID: "challenge-123"}, result) assert.Equal(t, "application/json", requestContentType) assert.JSONEq(t, `{"email":"pilot@example.com"}`, requestBody) } func TestHTTPAuthServiceClientConfirmEmailCodeSuccess(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, authServiceConfirmEmailCodePath, r.URL.Path) payload, err := io.ReadAll(r.Body) require.NoError(t, err) assert.JSONEq(t, `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key","time_zone":"Europe/Kaliningrad"}`, string(payload)) w.Header().Set("Content-Type", "application/json") _, err = io.WriteString(w, `{"device_session_id":"device-session-123"}`) require.NoError(t, err) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) result, err := client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{ ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key", TimeZone: "Europe/Kaliningrad", }) require.NoError(t, err) assert.Equal(t, ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"}, result) } func TestHTTPAuthServiceClientProjectsAuthServiceErrors(t *testing.T) { t.Parallel() tests := []struct { name string statusCode int responseBody string call func(*HTTPAuthServiceClient) error wantStatusCode int wantCode string wantMessage string }{ { name: "send email code error", statusCode: http.StatusServiceUnavailable, responseBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, call: func(client *HTTPAuthServiceClient) error { _, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{Email: "pilot@example.com"}) return err }, wantStatusCode: http.StatusServiceUnavailable, wantCode: "service_unavailable", wantMessage: "service is unavailable", }, { name: "confirm email code error", statusCode: http.StatusConflict, responseBody: `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, call: func(client *HTTPAuthServiceClient) error { _, err := client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{ ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key", TimeZone: "Europe/Kaliningrad", }) return err }, wantStatusCode: http.StatusConflict, wantCode: "session_limit_exceeded", wantMessage: "active session limit would be exceeded", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(tt.statusCode) _, err := io.WriteString(w, tt.responseBody) require.NoError(t, err) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) err := tt.call(client) require.Error(t, err) var authErr *AuthServiceError require.ErrorAs(t, err, &authErr) assert.Equal(t, tt.wantStatusCode, authErr.StatusCode) assert.Equal(t, tt.wantCode, authErr.Code) assert.Equal(t, tt.wantMessage, authErr.Message) }) } } func TestHTTPAuthServiceClientRejectsMalformedPayloads(t *testing.T) { t.Parallel() tests := []struct { name string path string statusCode int responseBody string wantErr string }{ { name: "send email code rejects unknown success field", path: authServiceSendEmailCodePath, statusCode: http.StatusOK, responseBody: `{"challenge_id":"challenge-123","extra":true}`, wantErr: "decode success response", }, { name: "confirm email code rejects empty success field", path: authServiceConfirmEmailCodePath, statusCode: http.StatusOK, responseBody: `{"device_session_id":" "}`, wantErr: "empty device_session_id", }, { name: "rejects missing error object", path: authServiceSendEmailCodePath, statusCode: http.StatusBadRequest, responseBody: `{}`, wantErr: "missing error object", }, { name: "rejects malformed error envelope", path: authServiceConfirmEmailCodePath, statusCode: http.StatusBadRequest, responseBody: `{"error":{"code":"invalid_code","message":"confirmation code is invalid","extra":true}}`, wantErr: "decode error response", }, { name: "rejects unexpected status", path: authServiceSendEmailCodePath, statusCode: http.StatusCreated, responseBody: `{"challenge_id":"challenge-123"}`, wantErr: "unexpected HTTP status 201", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, tt.path, r.URL.Path) w.Header().Set("Content-Type", "application/json") w.WriteHeader(tt.statusCode) _, err := io.WriteString(w, tt.responseBody) require.NoError(t, err) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) var err error switch tt.path { case authServiceSendEmailCodePath: _, err = client.SendEmailCode(context.Background(), SendEmailCodeInput{Email: "pilot@example.com"}) default: _, err = client.ConfirmEmailCode(context.Background(), ConfirmEmailCodeInput{ ChallengeID: "challenge-123", Code: "123456", ClientPublicKey: "public-key", TimeZone: "Europe/Kaliningrad", }) } require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) assert.NotErrorAs(t, err, new(*AuthServiceError)) }) } } func TestHTTPAuthServiceClientUsesCallerContext(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"challenge_id":"challenge-123"}`) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() _, err := client.SendEmailCode(ctx, SendEmailCodeInput{Email: "pilot@example.com"}) require.Error(t, err) assert.ErrorContains(t, err, "send email code via auth service") assert.True(t, errors.Is(err, context.DeadlineExceeded)) } func TestHTTPAuthServiceClientRejectsNilContext(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.FailNow(t, "unexpected request", r.URL.Path) })) defer server.Close() client := newTestHTTPAuthServiceClient(t, server) _, err := client.SendEmailCode(nil, SendEmailCodeInput{Email: "pilot@example.com"}) require.Error(t, err) assert.ErrorContains(t, err, "nil context") } func newTestHTTPAuthServiceClient(t *testing.T, server *httptest.Server) *HTTPAuthServiceClient { t.Helper() client, err := newHTTPAuthServiceClient(server.URL, server.Client()) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, client.Close()) }) return client } func TestDecodeStrictJSONPayloadRejectsTrailingJSON(t *testing.T) { t.Parallel() var target struct { Value string `json:"value"` } err := decodeStrictJSONPayload([]byte(`{"value":"ok"}{}`), &target) require.Error(t, err) assert.Equal(t, "unexpected trailing JSON input", err.Error()) } func TestDecodeAuthServiceErrorPreservesBlankFieldsForLaterNormalization(t *testing.T) { t.Parallel() authErr, err := decodeAuthServiceError(http.StatusBadGateway, []byte(`{"error":{"code":" ","message":" "}}`)) require.NoError(t, err) assert.Equal(t, http.StatusBadGateway, authErr.StatusCode) assert.True(t, strings.TrimSpace(authErr.Code) == "") assert.True(t, strings.TrimSpace(authErr.Message) == "") }