474 lines
18 KiB
Go
474 lines
18 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"
|
|
)
|
|
|
|
const publicConfirmTimeZone = "Europe/Kaliningrad"
|
|
|
|
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",
|
|
TimeZone: publicConfirmTimeZone,
|
|
}, 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 ","time_zone":" `+publicConfirmTimeZone+` "}`),
|
|
)
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
|
|
},
|
|
{
|
|
name: "empty time zone",
|
|
target: "/api/v1/public/auth/confirm-email-code",
|
|
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantBody: `{"error":{"code":"invalid_request","message":"time_zone 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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"` + publicConfirmTimeZone + `"}`,
|
|
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","time_zone":"`+publicConfirmTimeZone+`"}`),
|
|
)
|
|
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
|
|
}
|