feat: authsession service
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user