Files
galaxy-game/gateway/internal/restapi/public_auth_test.go
T
Ilia Denisov 9101aba816 phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:14:40 +02:00

452 lines
15 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"
)
const confirmEmailCodeTestTimeZone = "Europe/Kaliningrad"
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")
req.Header.Set("Accept-Language", "fr-FR, en;q=0.8")
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",
PreferredLanguage: "fr-FR",
}, authService.sendEmailCodeInput)
assert.True(t, authService.sendEmailCodeRouteClassOK)
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
}
func TestSendEmailCodeHandlerBodyLocaleOverridesHeader(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-456",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":"pilot@example.com","locale":"ru"}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "fr-FR")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, SendEmailCodeInput{
Email: "pilot@example.com",
Locale: "ru",
PreferredLanguage: "ru",
}, authService.sendEmailCodeInput)
}
func TestSendEmailCodeHandlerEmptyBodyLocaleFallsBackToHeader(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-789",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":"pilot@example.com","locale":" "}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "ru-RU")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, SendEmailCodeInput{
Email: "pilot@example.com",
Locale: " ",
PreferredLanguage: "ru-RU",
}, authService.sendEmailCodeInput)
}
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 ","time_zone":" `+confirmEmailCodeTestTimeZone+` "}`),
)
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",
TimeZone: confirmEmailCodeTestTimeZone,
}, 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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
{
name: "confirm email 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"}}`,
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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
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","time_zone":"`+confirmEmailCodeTestTimeZone+`"}`),
)
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
}