378 lines
12 KiB
Go
378 lines
12 KiB
Go
package restapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/gateway/internal/config"
|
|
"galaxy/gateway/internal/testutil"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authService := &recordingAuthServiceClient{
|
|
sendEmailCodeResult: SendEmailCodeResult{
|
|
ChallengeID: "challenge-123",
|
|
},
|
|
}
|
|
handler := newPublicHandler(ServerDependencies{AuthService: authService})
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/public/auth/send-email-code",
|
|
strings.NewReader(`{"email":" pilot@example.com "}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String())
|
|
assert.Equal(t, 1, authService.sendEmailCodeCalls)
|
|
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
|
|
assert.Equal(t, SendEmailCodeInput{Email: "pilot@example.com"}, authService.sendEmailCodeInput)
|
|
assert.True(t, authService.sendEmailCodeRouteClassOK)
|
|
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
|
|
}
|
|
|
|
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authService := &recordingAuthServiceClient{
|
|
confirmEmailCodeResult: ConfirmEmailCodeResult{
|
|
DeviceSessionID: "device-session-123",
|
|
},
|
|
}
|
|
handler := newPublicHandler(ServerDependencies{AuthService: authService})
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/public/auth/confirm-email-code",
|
|
strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusOK, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String())
|
|
assert.Equal(t, 0, authService.sendEmailCodeCalls)
|
|
assert.Equal(t, 1, authService.confirmEmailCodeCalls)
|
|
assert.Equal(t, ConfirmEmailCodeInput{
|
|
ChallengeID: "challenge-123",
|
|
Code: "123456",
|
|
ClientPublicKey: "public-key-material",
|
|
}, authService.confirmEmailCodeInput)
|
|
assert.True(t, authService.confirmEmailCodeRouteClassOK)
|
|
assert.Equal(t, PublicRouteClassPublicAuth, authService.confirmEmailCodeRouteClass)
|
|
}
|
|
|
|
func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
body string
|
|
wantStatus int
|
|
wantBody string
|
|
wantSendCalls int
|
|
wantConfirmCalls int
|
|
}{
|
|
{
|
|
name: "send email 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"}}`,
|
|
wantSendCalls: 0,
|
|
wantConfirmCalls: 0,
|
|
},
|
|
{
|
|
name: "send email validation error",
|
|
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"}}`,
|
|
wantSendCalls: 0,
|
|
wantConfirmCalls: 0,
|
|
},
|
|
{
|
|
name: "confirm email empty code",
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
|
|
wantSendCalls: 0,
|
|
wantConfirmCalls: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authService := &recordingAuthServiceClient{}
|
|
handler := newPublicHandler(ServerDependencies{AuthService: authService})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, tt.wantStatus, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, tt.wantBody, recorder.Body.String())
|
|
assert.Equal(t, tt.wantSendCalls, authService.sendEmailCodeCalls)
|
|
assert.Equal(t, tt.wantConfirmCalls, authService.confirmEmailCodeCalls)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
body string
|
|
authClient *recordingAuthServiceClient
|
|
wantStatus int
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "auth service projected bad request",
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
|
|
authClient: &recordingAuthServiceClient{
|
|
confirmEmailCodeErr: &AuthServiceError{
|
|
StatusCode: http.StatusBadRequest,
|
|
Code: errorCodeInvalidRequest,
|
|
Message: "confirmation code is invalid",
|
|
},
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
wantBody: `{"error":{"code":"invalid_request","message":"confirmation code is invalid"}}`,
|
|
},
|
|
{
|
|
name: "auth service projected custom too many requests",
|
|
target: "/api/v1/public/auth/send-email-code",
|
|
body: `{"email":"pilot@example.com"}`,
|
|
authClient: &recordingAuthServiceClient{
|
|
sendEmailCodeErr: &AuthServiceError{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Code: "upstream_rate_limited",
|
|
Message: "too many attempts for this email",
|
|
},
|
|
},
|
|
wantStatus: http.StatusTooManyRequests,
|
|
wantBody: `{"error":{"code":"upstream_rate_limited","message":"too many attempts for this email"}}`,
|
|
},
|
|
{
|
|
name: "auth service projected gateway normalizes blank gateway error fields",
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
|
|
authClient: &recordingAuthServiceClient{
|
|
confirmEmailCodeErr: &AuthServiceError{
|
|
StatusCode: http.StatusBadGateway,
|
|
},
|
|
},
|
|
wantStatus: http.StatusBadGateway,
|
|
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
|
|
},
|
|
{
|
|
name: "unexpected auth service error",
|
|
target: "/api/v1/public/auth/send-email-code",
|
|
body: `{"email":"pilot@example.com"}`,
|
|
authClient: &recordingAuthServiceClient{
|
|
sendEmailCodeErr: errors.New("boom"),
|
|
},
|
|
wantStatus: http.StatusInternalServerError,
|
|
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newPublicHandler(ServerDependencies{AuthService: tt.authClient})
|
|
req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, tt.wantStatus, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, tt.wantBody, recorder.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultAuthServiceReturnsServiceUnavailable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := newPublicHandler(ServerDependencies{})
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
body string
|
|
wantStatus int
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "send email code",
|
|
method: http.MethodPost,
|
|
target: "/api/v1/public/auth/send-email-code",
|
|
body: `{"email":"pilot@example.com"}`,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`,
|
|
},
|
|
{
|
|
name: "confirm email code",
|
|
method: http.MethodPost,
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
|
|
wantStatus: http.StatusServiceUnavailable,
|
|
wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`,
|
|
},
|
|
{
|
|
name: "healthz remains available",
|
|
method: http.MethodGet,
|
|
target: "/healthz",
|
|
wantStatus: http.StatusOK,
|
|
wantBody: `{"status":"ok"}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
|
|
if tt.body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, tt.wantStatus, recorder.Code)
|
|
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
|
|
assert.Equal(t, tt.wantBody, recorder.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authService := &recordingAuthServiceClient{
|
|
sendEmailCodeErr: context.DeadlineExceeded,
|
|
}
|
|
cfg := config.DefaultPublicHTTPConfig()
|
|
cfg.AuthUpstreamTimeout = 5 * time.Millisecond
|
|
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/public/auth/send-email-code",
|
|
strings.NewReader(`{"email":"pilot@example.com"}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
|
assert.Equal(t, `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`, recorder.Body.String())
|
|
}
|
|
|
|
func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger, buffer := testutil.NewObservedLogger(t)
|
|
handler := newPublicHandler(ServerDependencies{
|
|
Logger: logger,
|
|
AuthService: &recordingAuthServiceClient{
|
|
confirmEmailCodeResult: ConfirmEmailCodeResult{DeviceSessionID: "device-session-123"},
|
|
},
|
|
})
|
|
|
|
req := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/public/auth/confirm-email-code",
|
|
strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
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")
|
|
}
|
|
|
|
// recordingAuthServiceClient captures handler inputs and route classification
|
|
// so tests can assert the exact adapter delegation contract.
|
|
type recordingAuthServiceClient struct {
|
|
sendEmailCodeResult SendEmailCodeResult
|
|
sendEmailCodeErr error
|
|
sendEmailCodeInput SendEmailCodeInput
|
|
sendEmailCodeRouteClass PublicRouteClass
|
|
sendEmailCodeRouteClassOK bool
|
|
sendEmailCodeCalls int
|
|
|
|
confirmEmailCodeResult ConfirmEmailCodeResult
|
|
confirmEmailCodeErr error
|
|
confirmEmailCodeInput ConfirmEmailCodeInput
|
|
confirmEmailCodeRouteClass PublicRouteClass
|
|
confirmEmailCodeRouteClassOK bool
|
|
confirmEmailCodeCalls int
|
|
}
|
|
|
|
func (c *recordingAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) {
|
|
c.sendEmailCodeCalls++
|
|
c.sendEmailCodeInput = input
|
|
|
|
c.sendEmailCodeRouteClass, c.sendEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx)
|
|
|
|
return c.sendEmailCodeResult, c.sendEmailCodeErr
|
|
}
|
|
|
|
func (c *recordingAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
|
|
c.confirmEmailCodeCalls++
|
|
c.confirmEmailCodeInput = input
|
|
|
|
c.confirmEmailCodeRouteClass, c.confirmEmailCodeRouteClassOK = PublicRouteClassFromContext(ctx)
|
|
|
|
return c.confirmEmailCodeResult, c.confirmEmailCodeErr
|
|
}
|