Files
galaxy-game/authsession/internal/api/publichttp/handler_test.go
T
2026-04-08 16:23:07 +02:00

464 lines
17 KiB
Go

package publichttp
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/service/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
bytes.NewBufferString(`{"email":" pilot@example.com "}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String())
}
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(_ context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) {
assert.Equal(t, confirmemailcode.Input{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
}, input)
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String())
}
func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
wantStatus int
wantBody string
}{
{
name: "empty body",
target: "/api/v1/public/auth/send-email-code",
body: ``,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`,
},
{
name: "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"}}`,
},
{
name: "multiple objects",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}{"email":"next@example.com"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`,
},
{
name: "unknown field",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "invalid json type",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":123}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains an invalid value for \"email\""}}`,
},
{
name: "invalid email",
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"}}`,
},
{
name: "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"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
deps Dependencies
wantStatus int
wantBody string
}{
{
name: "send route hides blocked by policy",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, shared.BlockedByPolicy()
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
},
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
{
name: "confirm invalid client public key",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.InvalidClientPublicKey()
}),
},
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`,
},
{
name: "confirm challenge not found",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.ChallengeNotFound()
}),
},
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`,
},
{
name: "confirm challenge expired",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.ChallengeExpired()
}),
},
wantStatus: http.StatusGone,
wantBody: `{"error":{"code":"challenge_expired","message":"challenge expired"}}`,
},
{
name: "confirm blocked by policy",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.BlockedByPolicy()
}),
},
wantStatus: http.StatusForbidden,
wantBody: `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`,
},
{
name: "confirm session limit exceeded",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.SessionLimitExceeded()
}),
},
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`,
},
{
name: "confirm hides internal error",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.InternalError(errors.New("broken invariant"))
}),
},
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.RequestTimeout = 5 * time.Millisecond
handler := mustNewHandler(t, cfg, Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, context.DeadlineExceeded
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
bytes.NewBufferString(`{"email":"pilot@example.com"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
assert.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, recorder.Body.String())
}
func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
deps Dependencies
wantBody string
}{
{
name: "send email blank challenge id",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: " "}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
},
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
{
name: "confirm blank device session id",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{DeviceSessionID: " "}, nil
}),
},
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
Logger: logger,
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
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")
assert.NotContains(t, logOutput, "device-session-123")
}
func mustNewHandler(t *testing.T, cfg Config, deps Dependencies) http.Handler {
t.Helper()
handler, err := newHandlerWithConfig(cfg, deps)
require.NoError(t, err)
return handler
}
type sendEmailCodeFunc func(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error)
func (f sendEmailCodeFunc) Execute(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error) {
return f(ctx, input)
}
type confirmEmailCodeFunc func(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error)
func (f confirmEmailCodeFunc) Execute(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) {
return f(ctx, input)
}
func newObservedLogger() (*zap.Logger, *bytes.Buffer) {
buffer := &bytes.Buffer{}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = ""
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(buffer),
zap.DebugLevel,
)
return zap.New(core), buffer
}