feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
+42
View File
@@ -45,6 +45,11 @@ const (
// public-auth delegation.
authServiceBaseURLEnvVar = "GATEWAY_AUTH_SERVICE_BASE_URL"
// userServiceBaseURLEnvVar names the environment variable that configures
// the optional User Service internal HTTP base URL used by authenticated
// gateway self-service delegation.
userServiceBaseURLEnvVar = "GATEWAY_USER_SERVICE_BASE_URL"
// adminHTTPAddrEnvVar names the environment variable that configures the
// private admin HTTP listener address. When it is empty, the admin listener
// remains disabled.
@@ -479,6 +484,15 @@ type AuthServiceConfig struct {
BaseURL string
}
// UserServiceConfig describes the optional authenticated self-service upstream
// used by the gateway runtime.
type UserServiceConfig struct {
// BaseURL is the absolute base URL of the User Service internal HTTP API.
// When BaseURL is empty, the gateway keeps using its built-in unavailable
// downstream adapter for the reserved `user.*` routes.
BaseURL string
}
// AdminHTTPConfig describes the private operational HTTP listener used for
// metrics exposure. The listener remains disabled when Addr is empty.
type AdminHTTPConfig struct {
@@ -610,6 +624,10 @@ type Config struct {
// Session Service.
AuthService AuthServiceConfig
// UserService configures the optional authenticated self-service
// delegation to User Service.
UserService UserServiceConfig
// AdminHTTP configures the optional private admin listener used for metrics
// exposure.
AdminHTTP AdminHTTPConfig
@@ -791,6 +809,13 @@ func DefaultAuthServiceConfig() AuthServiceConfig {
return AuthServiceConfig{}
}
// DefaultUserServiceConfig returns the default authenticated self-service
// upstream settings. The zero value keeps the built-in unavailable adapter
// active for reserved `user.*` routes.
func DefaultUserServiceConfig() UserServiceConfig {
return UserServiceConfig{}
}
// LoadFromEnv loads Config from the process environment, applies defaults for
// omitted settings, and validates the resulting values.
func LoadFromEnv() (Config, error) {
@@ -799,6 +824,7 @@ func LoadFromEnv() (Config, error) {
Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(),
AuthService: DefaultAuthServiceConfig(),
UserService: DefaultUserServiceConfig(),
AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
SessionCacheRedis: DefaultSessionCacheRedisConfig(),
@@ -856,6 +882,11 @@ func LoadFromEnv() (Config, error) {
cfg.AuthService.BaseURL = rawAuthServiceBaseURL
}
rawUserServiceBaseURL, ok := os.LookupEnv(userServiceBaseURLEnvVar)
if ok {
cfg.UserService.BaseURL = rawUserServiceBaseURL
}
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
if ok {
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
@@ -1124,6 +1155,17 @@ func LoadFromEnv() (Config, error) {
}
cfg.AuthService.BaseURL = strings.TrimRight(parsedAuthServiceBaseURL.String(), "/")
}
cfg.UserService.BaseURL = strings.TrimSpace(cfg.UserService.BaseURL)
if cfg.UserService.BaseURL != "" {
parsedUserServiceBaseURL, err := url.Parse(cfg.UserService.BaseURL)
if err != nil {
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", userServiceBaseURLEnvVar, err)
}
if parsedUserServiceBaseURL.Scheme == "" || parsedUserServiceBaseURL.Host == "" {
return Config{}, fmt.Errorf("load gateway config: %s must be an absolute URL", userServiceBaseURLEnvVar)
}
cfg.UserService.BaseURL = strings.TrimRight(parsedUserServiceBaseURL.String(), "/")
}
if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" {
cfg.AdminHTTP.Addr = addr
}
+115 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/pem"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -14,6 +15,8 @@ import (
"github.com/stretchr/testify/require"
)
var configEnvMu sync.Mutex
func TestLoadFromEnv(t *testing.T) {
customResponseSignerPrivateKeyPEMPath := new(string)
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
@@ -27,6 +30,9 @@ func TestLoadFromEnv(t *testing.T) {
customAuthServiceBaseURL := new(string)
*customAuthServiceBaseURL = " http://127.0.0.1:8082/ "
customUserServiceBaseURL := new(string)
*customUserServiceBaseURL = " http://127.0.0.1:8083/ "
customAuthenticatedGRPCAddr := new(string)
*customAuthenticatedGRPCAddr = "127.0.0.1:9191"
@@ -80,6 +86,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeout *string
publicHTTPAddr *string
authServiceBaseURL *string
userServiceBaseURL *string
authenticatedGRPCAddr *string
authenticatedGRPCFreshnessWindow *string
sessionCacheRedisAddr *string
@@ -217,6 +224,40 @@ func TestLoadFromEnv(t *testing.T) {
},
},
},
{
name: "custom user service base url",
userServiceBaseURL: customUserServiceBaseURL,
sessionCacheRedisAddr: customSessionCacheRedisAddr,
responseSignerPrivateKeyPEMPath: customResponseSignerPrivateKeyPEMPath,
want: Config{
ShutdownTimeout: 5 * time.Second,
Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(),
UserService: UserServiceConfig{
BaseURL: "http://127.0.0.1:8083",
},
AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
SessionCacheRedis: SessionCacheRedisConfig{
Addr: "127.0.0.1:6379",
DB: defaultSessionCacheRedisDB,
KeyPrefix: defaultSessionCacheRedisKeyPrefix,
LookupTimeout: defaultSessionCacheRedisLookupTimeout,
},
ReplayRedis: DefaultReplayRedisConfig(),
SessionEventsRedis: SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: defaultSessionEventsRedisReadBlockTimeout,
},
ClientEventsRedis: ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: defaultClientEventsRedisReadBlockTimeout,
},
ResponseSigner: ResponseSignerConfig{
PrivateKeyPEMPath: *customResponseSignerPrivateKeyPEMPath,
},
},
},
{
name: "custom authenticated grpc address",
authenticatedGRPCAddr: customAuthenticatedGRPCAddr,
@@ -368,6 +409,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeoutEnvVar,
publicHTTPAddrEnvVar,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
authenticatedGRPCAddrEnvVar,
authenticatedGRPCFreshnessWindowEnvVar,
sessionCacheRedisAddrEnvVar,
@@ -379,6 +421,7 @@ func TestLoadFromEnv(t *testing.T) {
setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout)
setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr)
setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL)
setEnvValue(t, userServiceBaseURLEnvVar, tt.userServiceBaseURL)
setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr)
setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow)
setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr)
@@ -492,7 +535,7 @@ func TestLoadFromEnvOperationalSettings(t *testing.T) {
restoreEnvs(t, append(
append(
append(
append(operationalEnvVars(), sessionCacheRedisEnvVars()...),
append(append(operationalEnvVars(), authServiceBaseURLEnvVar, userServiceBaseURLEnvVar), sessionCacheRedisEnvVars()...),
sessionEventsRedisEnvVars()...,
),
clientEventsRedisEnvVars()...,
@@ -563,6 +606,8 @@ func TestLoadFromEnvAuthService(t *testing.T) {
restoreEnvs(t,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
logLevelEnvVar,
sessionCacheRedisAddrEnvVar,
sessionEventsRedisStreamEnvVar,
clientEventsRedisStreamEnvVar,
@@ -581,6 +626,72 @@ func TestLoadFromEnvAuthService(t *testing.T) {
}
}
func TestLoadFromEnvUserService(t *testing.T) {
t.Parallel()
customSessionCacheRedisAddr := new(string)
*customSessionCacheRedisAddr = "127.0.0.1:6379"
customSessionEventsRedisStream := new(string)
*customSessionEventsRedisStream = "gateway:session_events"
customClientEventsRedisStream := new(string)
*customClientEventsRedisStream = "gateway:client_events"
customResponseSignerPrivateKeyPEMPath := new(string)
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
invalidRelativeURL := new(string)
*invalidRelativeURL = "/user"
invalidURL := new(string)
*invalidURL = "://bad"
tests := []struct {
name string
value *string
wantErr string
}{
{
name: "relative url rejected",
value: invalidRelativeURL,
wantErr: userServiceBaseURLEnvVar + " must be an absolute URL",
},
{
name: "malformed url rejected",
value: invalidURL,
wantErr: "parse " + userServiceBaseURLEnvVar,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
restoreEnvs(t,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
logLevelEnvVar,
sessionCacheRedisAddrEnvVar,
sessionEventsRedisStreamEnvVar,
clientEventsRedisStreamEnvVar,
responseSignerPrivateKeyPEMPathEnvVar,
)
setEnvValue(t, userServiceBaseURLEnvVar, tt.value)
setEnvValue(t, sessionCacheRedisAddrEnvVar, customSessionCacheRedisAddr)
setEnvValue(t, sessionEventsRedisStreamEnvVar, customSessionEventsRedisStream)
setEnvValue(t, clientEventsRedisStreamEnvVar, customClientEventsRedisStream)
setEnvValue(t, responseSignerPrivateKeyPEMPathEnvVar, customResponseSignerPrivateKeyPEMPath)
_, err := LoadFromEnv()
require.Error(t, err)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestLoadFromEnvAuthenticatedGRPCAntiAbuse(t *testing.T) {
customSessionCacheRedisAddr := new(string)
*customSessionCacheRedisAddr = "127.0.0.1:6379"
@@ -1276,6 +1387,9 @@ func setEnvValue(t *testing.T, envVar string, value *string) {
func restoreEnvs(t *testing.T, envVars ...string) {
t.Helper()
configEnvMu.Lock()
t.Cleanup(configEnvMu.Unlock)
for _, envVar := range envVars {
restoreEnv(t, envVar)
}
@@ -0,0 +1,311 @@
// Package userservice implements the authenticated Gateway -> User Service
// self-service downstream adapter.
package userservice
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
"galaxy/transcoder"
)
const (
getMyAccountResultCodeOK = "ok"
userServiceAccountPathSuffix = "/account"
userServiceProfilePathSuffix = "/profile"
userServiceSettingsPathSuffix = "/settings"
)
var stableErrorMessages = map[string]string{
"invalid_request": "request is invalid",
"subject_not_found": "subject not found",
"conflict": "request conflicts with current state",
"internal_error": "internal server error",
}
// HTTPClient implements downstream.Client against the trusted internal User
// Service REST API while preserving FlatBuffers at the external authenticated
// gateway boundary.
type HTTPClient struct {
baseURL string
httpClient *http.Client
}
// NewHTTPClient constructs one User Service downstream client backed by the
// trusted internal REST API at baseURL.
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new user service HTTP client: default transport is not *http.Transport")
}
return newHTTPClient(baseURL, &http.Client{
Transport: transport.Clone(),
})
}
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
if httpClient == nil {
return nil, errors.New("new user service HTTP client: http client must not be nil")
}
trimmedBaseURL := strings.TrimSpace(baseURL)
if trimmedBaseURL == "" {
return nil, errors.New("new user service HTTP client: base URL must not be empty")
}
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
if err != nil {
return nil, fmt.Errorf("new user service HTTP client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new user service HTTP client: base URL must be absolute")
}
return &HTTPClient{
baseURL: parsedBaseURL.String(),
httpClient: httpClient,
}, nil
}
// Close releases idle HTTP connections owned by the client transport.
func (c *HTTPClient) Close() error {
if c == nil || c.httpClient == nil {
return nil
}
type idleCloser interface {
CloseIdleConnections()
}
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
transport.CloseIdleConnections()
}
return nil
}
// ExecuteCommand routes one authenticated gateway command to the matching
// trusted internal User Service self-service route.
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
if c == nil || c.httpClient == nil {
return downstream.UnaryResult{}, errors.New("execute user service command: nil client")
}
if ctx == nil {
return downstream.UnaryResult{}, errors.New("execute user service command: nil context")
}
if err := ctx.Err(); err != nil {
return downstream.UnaryResult{}, err
}
if strings.TrimSpace(command.UserID) == "" {
return downstream.UnaryResult{}, errors.New("execute user service command: user_id must not be empty")
}
switch command.MessageType {
case usermodel.MessageTypeGetMyAccount:
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeGetMyAccount(ctx, command.UserID)
case usermodel.MessageTypeUpdateMyProfile:
request, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeUpdateMyProfile(ctx, command.UserID, request)
case usermodel.MessageTypeUpdateMySettings:
request, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeUpdateMySettings(ctx, command.UserID, request)
default:
return downstream.UnaryResult{}, fmt.Errorf("execute user service command: unsupported message type %q", command.MessageType)
}
}
func (c *HTTPClient) executeGetMyAccount(ctx context.Context, userID string) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.userPath(userID, userServiceAccountPathSuffix), nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute get my account: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) executeUpdateMyProfile(ctx context.Context, userID string, request *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceProfilePathSuffix), request)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute update my profile: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) executeUpdateMySettings(ctx context.Context, userID string, request *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceSettingsPathSuffix), request)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute update my settings: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) doRequest(ctx context.Context, method string, targetURL string, requestBody any) ([]byte, int, error) {
if c == nil || c.httpClient == nil {
return nil, 0, errors.New("nil client")
}
var bodyReader io.Reader
if requestBody != nil {
payload, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(payload)
}
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.httpClient.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return payload, response.StatusCode, nil
}
func (c *HTTPClient) userPath(userID string, suffix string) string {
return c.baseURL + "/api/v1/internal/users/" + url.PathEscape(userID) + suffix
}
func projectResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
switch {
case statusCode == http.StatusOK:
var response usermodel.AccountResponse
if err := decodeStrictJSONPayload(payload, &response); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
}
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: getMyAccountResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
case statusCode == http.StatusServiceUnavailable:
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
case statusCode >= 400 && statusCode <= 599:
errorResponse, err := decodeUserServiceError(statusCode, payload)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
}
payloadBytes, err := transcoder.ErrorResponseToPayload(errorResponse)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: errorResponse.Error.Code,
PayloadBytes: payloadBytes,
}, nil
default:
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
}
}
func decodeUserServiceError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
var response usermodel.ErrorResponse
if err := decodeStrictJSONPayload(payload, &response); err != nil {
return nil, err
}
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
response.Error.Message = normalizeErrorMessage(response.Error.Code, response.Error.Message)
if strings.TrimSpace(response.Error.Code) == "" {
return nil, errors.New("missing error code")
}
if strings.TrimSpace(response.Error.Message) == "" {
return nil, errors.New("missing error message")
}
return &response, nil
}
func normalizeErrorCode(statusCode int, code string) string {
trimmed := strings.TrimSpace(code)
if trimmed != "" {
return trimmed
}
switch statusCode {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusNotFound:
return "subject_not_found"
case http.StatusConflict:
return "conflict"
default:
return "internal_error"
}
}
func normalizeErrorMessage(code string, message string) string {
trimmed := strings.TrimSpace(message)
if trimmed != "" {
return trimmed
}
if stable, ok := stableErrorMessages[code]; ok {
return stable
}
return stableErrorMessages["internal_error"]
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
var _ downstream.Client = (*HTTPClient)(nil)
@@ -0,0 +1,399 @@
package userservice
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
"galaxy/transcoder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
baseURL string
wantURL string
wantErr string
}{
{
name: "absolute URL is normalized",
baseURL: " http://127.0.0.1:8081/ ",
wantURL: "http://127.0.0.1:8081",
},
{
name: "empty base URL is rejected",
baseURL: " ",
wantErr: "base URL must not be empty",
},
{
name: "relative base URL is rejected",
baseURL: "/relative",
wantErr: "base URL must be absolute",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, err := NewHTTPClient(tt.baseURL)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantURL, client.baseURL)
})
}
}
func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) {
t.Parallel()
wantResponse := sampleAccountResponse()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodGet, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path)
require.NoError(t, json.NewEncoder(writer).Encode(wantResponse))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode)
decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, wantResponse, decoded)
}
func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodPost, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path)
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body))
writer.WriteHeader(http.StatusConflict)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeUpdateMyProfile,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "conflict", result.ResultCode)
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, &usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}, decoded)
}
func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodPost, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path)
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body))
writer.WriteHeader(http.StatusBadRequest)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "invalid_request",
Message: "request is invalid",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
PreferredLanguage: "bad",
TimeZone: "Mars/Base",
})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeUpdateMySettings,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "invalid_request", result.ResultCode)
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "invalid_request", decoded.Error.Code)
}
func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "subject_not_found",
Message: "subject not found",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-missing",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "subject_not_found", result.ResultCode)
}
func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "service_unavailable",
Message: "service is unavailable",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
}
func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
<-request.Context().Done()
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
_, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "decode success response")
}
func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
client := newTestHTTPClient(t, server)
_, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: "user.unsupported",
PayloadBytes: []byte("payload"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported message type")
}
func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) {
t.Parallel()
routes, closeFn, err := NewRoutes("")
require.NoError(t, err)
require.NoError(t, closeFn())
router := downstream.NewStaticRouter(routes)
for _, messageType := range []string{
usermodel.MessageTypeGetMyAccount,
usermodel.MessageTypeUpdateMyProfile,
usermodel.MessageTypeUpdateMySettings,
} {
client, routeErr := router.Route(messageType)
require.NoError(t, routeErr)
_, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: messageType,
})
require.Error(t, execErr)
assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable)
}
}
func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) {
t.Parallel()
_, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{})
require.Error(t, err)
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
}
func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient {
t.Helper()
client, err := newHTTPClient(server.URL, server.Client())
require.NoError(t, err)
return client
}
func sampleAccountResponse() *usermodel.AccountResponse {
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
expiresAt := now.Add(30 * 24 * time.Hour)
return &usermodel.AccountResponse{
Account: usermodel.Account{
UserID: "user-123",
Email: "pilot@example.com",
RaceName: "Pilot Nova",
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
DeclaredCountry: "DE",
Entitlement: usermodel.EntitlementSnapshot{
PlanCode: "free",
IsPaid: false,
Source: "auth_registration",
Actor: usermodel.ActorRef{Type: "service", ID: "user-service"},
ReasonCode: "initial_free_entitlement",
StartsAt: now,
UpdatedAt: now,
},
ActiveSanctions: []usermodel.ActiveSanction{
{
SanctionCode: "profile_update_block",
Scope: "lobby",
ReasonCode: "manual_block",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
ExpiresAt: &expiresAt,
},
},
ActiveLimits: []usermodel.ActiveLimit{
{
LimitCode: "max_owned_private_games",
Value: 3,
ReasonCode: "manual_override",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
}
func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) {
t.Parallel()
response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`))
require.NoError(t, err)
assert.Equal(t, "invalid_request", response.Error.Code)
assert.Equal(t, "request is invalid", response.Error.Message)
}
func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
client := newTestHTTPClient(t, server)
_, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil context")
}
@@ -0,0 +1,46 @@
package userservice
import (
"context"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
)
var noOpClose = func() error { return nil }
// NewRoutes returns the reserved authenticated gateway routes owned by the
// Gateway -> User self-service boundary.
//
// When baseURL is empty, the returned routes still reserve the stable
// `user.*` message types but resolve them to a dependency-unavailable client
// so callers receive the transport-level unavailable outcome instead of a
// route-miss error.
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
client := downstream.Client(unavailableClient{})
closeFn := noOpClose
if baseURL != "" {
httpClient, err := NewHTTPClient(baseURL)
if err != nil {
return nil, nil, err
}
client = httpClient
closeFn = httpClient.Close
}
return map[string]downstream.Client{
usermodel.MessageTypeGetMyAccount: client,
usermodel.MessageTypeUpdateMyProfile: client,
usermodel.MessageTypeUpdateMySettings: client,
}, closeFn, nil
}
type unavailableClient struct{}
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
}
var _ downstream.Client = unavailableClient{}