package gatewayuser_test import ( "bytes" "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "path/filepath" "testing" "time" gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1" contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1" "galaxy/integration/internal/harness" usermodel "galaxy/model/user" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) const ( gatewayUserDefaultHTTPTimeout = time.Second gatewayUserTestTimeZone = "Europe/Kaliningrad" ) type gatewayUserHarness struct { redis *redis.Client userServiceURL string gatewayGRPCAddr string responseSignerPublicKey ed25519.PublicKey gatewayProcess *harness.Process userServiceProcess *harness.Process } func newGatewayUserHarness(t *testing.T) *gatewayUserHarness { t.Helper() redisServer := harness.StartMiniredis(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisServer.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, redisClient.Close()) }) responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name()) userServiceAddr := harness.FreeTCPAddress(t) gatewayPublicAddr := harness.FreeTCPAddress(t) gatewayGRPCAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway") userServiceEnv := harness.StartUserServicePersistence(t, redisServer.Addr()).Env userServiceEnv["USERSERVICE_LOG_LEVEL"] = "info" userServiceEnv["USERSERVICE_INTERNAL_HTTP_ADDR"] = userServiceAddr userServiceEnv["OTEL_TRACES_EXPORTER"] = "none" userServiceEnv["OTEL_METRICS_EXPORTER"] = "none" userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv) harness.WaitForHTTPStatus(t, userServiceProcess, "http://"+userServiceAddr+"/api/v1/internal/users/user-missing/exists", http.StatusOK) gatewayEnv := map[string]string{ "GATEWAY_LOG_LEVEL": "info", "GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr, "GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr, "GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "GATEWAY_REDIS_MASTER_ADDR": redisServer.Addr(), "GATEWAY_REDIS_PASSWORD": "integration", "GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:", "GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events", "GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events", "GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:", "GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath), "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", } gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, gatewayEnv) harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK) harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr) return &gatewayUserHarness{ redis: redisClient, userServiceURL: "http://" + userServiceAddr, gatewayGRPCAddr: gatewayGRPCAddr, responseSignerPublicKey: responseSignerPublicKey, gatewayProcess: gatewayProcess, userServiceProcess: userServiceProcess, } } func (h *gatewayUserHarness) dialGateway(t *testing.T) *grpc.ClientConn { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() conn, err := grpc.DialContext( ctx, h.gatewayGRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) return conn } func (h *gatewayUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse { t.Helper() response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ "email": email, "registration_context": map[string]string{ "preferred_language": preferredLanguage, "time_zone": timeZone, }, }) var body ensureByEmailResponse requireJSONStatus(t, response, http.StatusOK, &body) return body } func (h *gatewayUserHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse { t.Helper() response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{ "email": email, }) var body userLookupResponse requireJSONStatus(t, response, http.StatusOK, &body) return body } func (h *gatewayUserHarness) applyProfileUpdateBlock(t *testing.T, userID string) { t.Helper() response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{ "sanction_code": "profile_update_block", "scope": "lobby", "reason_code": "manual_block", "actor": map[string]string{ "type": "admin", "id": "admin-1", }, "applied_at": "2026-04-09T10:00:00Z", }) require.Equal(t, http.StatusOK, response.StatusCode, "response body: %s", response.Body) } func (h *gatewayUserHarness) seedGatewaySession(t *testing.T, deviceSessionID string, userID string, clientPrivateKey ed25519.PrivateKey) { t.Helper() record := gatewaySessionRecord{ DeviceSessionID: deviceSessionID, UserID: userID, ClientPublicKey: base64.StdEncoding.EncodeToString(clientPrivateKey.Public().(ed25519.PublicKey)), Status: "active", } payload, err := json.Marshal(record) require.NoError(t, err) require.NoError(t, h.redis.Set(context.Background(), "gateway:session:"+deviceSessionID, payload, 0).Err()) } func (h *gatewayUserHarness) executeCommand(t *testing.T, deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandResponse { t.Helper() conn := h.dialGateway(t) client := gatewayv1.NewEdgeGatewayClient(conn) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() response, err := client.ExecuteCommand(ctx, newExecuteCommandRequest(deviceSessionID, requestID, messageType, payload, clientPrivateKey)) require.NoError(t, err) assertSignedExecuteCommandResponse(t, response, h.responseSignerPublicKey) return response } type httpResponse struct { StatusCode int Body string Header http.Header } type gatewaySessionRecord struct { DeviceSessionID string `json:"device_session_id"` UserID string `json:"user_id"` ClientPublicKey string `json:"client_public_key"` Status string `json:"status"` RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"` } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id,omitempty"` } type userLookupResponse struct { User usermodel.Account `json:"user"` } func postJSONValue(t *testing.T, targetURL string, body any) httpResponse { t.Helper() payload, err := json.Marshal(body) require.NoError(t, err) request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload)) require.NoError(t, err) request.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: gatewayUserDefaultHTTPTimeout} response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() responseBody, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(responseBody), Header: response.Header.Clone(), } } func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target)) } 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 fmt.Errorf("unexpected trailing JSON input") } return err } return nil } func newClientPrivateKey(label string) ed25519.PrivateKey { seed := sha256.Sum256([]byte("galaxy-integration-gateway-user-client-" + label)) return ed25519.NewKeyFromSeed(seed[:]) } func newExecuteCommandRequest(deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest { payloadHash := contractsgatewayv1.ComputePayloadHash(payload) request := &gatewayv1.ExecuteCommandRequest{ ProtocolVersion: contractsgatewayv1.ProtocolVersionV1, DeviceSessionId: deviceSessionID, MessageType: messageType, TimestampMs: time.Now().UnixMilli(), RequestId: requestID, PayloadBytes: payload, PayloadHash: payloadHash, TraceId: "trace-" + requestID, } request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{ ProtocolVersion: request.GetProtocolVersion(), DeviceSessionID: request.GetDeviceSessionId(), MessageType: request.GetMessageType(), TimestampMS: request.GetTimestampMs(), RequestID: request.GetRequestId(), PayloadHash: request.GetPayloadHash(), }) return request } func assertSignedExecuteCommandResponse(t *testing.T, response *gatewayv1.ExecuteCommandResponse, publicKey ed25519.PublicKey) { t.Helper() require.NoError(t, contractsgatewayv1.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash())) require.NoError(t, contractsgatewayv1.VerifyResponseSignature(publicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{ ProtocolVersion: response.GetProtocolVersion(), RequestID: response.GetRequestId(), TimestampMS: response.GetTimestampMs(), ResultCode: response.GetResultCode(), PayloadHash: response.GetPayloadHash(), })) }