// Package gatewaylobby_test exercises the authenticated Gateway -> Game // Lobby boundary against real Gateway + real Auth/Session Service + real // User Service + real Game Lobby running on testcontainers PostgreSQL // and Redis. // // The boundary contract under test is: a client signs a FlatBuffers // `ExecuteCommandRequest` for one of the reserved `lobby.*` message // types; Gateway verifies the signature, looks up the device session, // resolves the calling `user_id`, routes the command to the Lobby // downstream client, and signs the FlatBuffers response. The suite // asserts on the gRPC response shape, the signed result envelope, and // the decoded FlatBuffers payload. // // Coverage maps onto `TESTING.md §6` `Gateway <-> Game Lobby`: // authenticated platform-level command routing. package gatewaylobby_test import ( "bytes" "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "net/http" "path/filepath" "testing" "time" gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1" contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1" "galaxy/integration/internal/harness" lobbymodel "galaxy/model/lobby" "galaxy/transcoder" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) const ( gatewaySendEmailCodePath = "/api/v1/public/auth/send-email-code" gatewayConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code" testEmail = "owner@example.com" testTimeZone = "Europe/Kaliningrad" ) // TestGatewayRoutesLobbyMyGamesListAndSignsResponse drives a single // authenticated user through the full public-auth flow, then issues // `lobby.my.games.list` via the authenticated gRPC ExecuteCommand // surface and asserts the routed-and-signed end-to-end pipeline. func TestGatewayRoutesLobbyMyGamesListAndSignsResponse(t *testing.T) { h := newGatewayLobbyHarness(t) clientPrivateKey := newClientPrivateKey("g1-owner") deviceSessionID, ownerUserID := h.authenticate(t, testEmail, clientPrivateKey) // Pre-seed: directly create a private game owned by this user via // Lobby's public REST surface. This mirrors what an admin/UI tool // would do; the seed proves Gateway routing reads back caller-owned // state, not just empty results. gameID := h.createPrivateGame(t, ownerUserID, "Gateway Routing Galaxy", time.Now().Add(48*time.Hour).Unix()) // Send authenticated `lobby.my.games.list` via the Gateway gRPC // surface. conn := h.dialGateway(t) client := gatewayv1.NewEdgeGatewayClient(conn) requestBytes, err := transcoder.MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{}) require.NoError(t, err) executeRequest := newExecuteCommandRequest( deviceSessionID, "req-list-1", lobbymodel.MessageTypeMyGamesList, requestBytes, clientPrivateKey, ) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() response, err := client.ExecuteCommand(ctx, executeRequest) require.NoError(t, err, "ExecuteCommand for lobby.my.games.list must succeed") require.Equal(t, "ok", response.GetResultCode()) require.NotEmpty(t, response.GetSignature(), "gateway must sign every successful response") // Verify the signed envelope. require.NoError(t, contractsgatewayv1.VerifyResponseSignature( h.responseSignerPublicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{ ProtocolVersion: response.GetProtocolVersion(), RequestID: response.GetRequestId(), TimestampMS: response.GetTimestampMs(), ResultCode: response.GetResultCode(), PayloadHash: response.GetPayloadHash(), }), ) require.NoError(t, contractsgatewayv1.VerifyPayloadHash( response.GetPayloadBytes(), response.GetPayloadHash())) // Decode the FlatBuffers payload. Lobby's `/my/games` may or may // not include the newly-seeded game depending on its membership / // status filter; the boundary contract under test here is the // Gateway routing + signing, not Lobby's own list semantics. We // assert the response decodes to a valid (possibly empty) list // and, if the game IS present, that the projected owner+type // fields survive the FlatBuffers roundtrip. decoded, err := transcoder.PayloadToMyGamesListResponse(response.GetPayloadBytes()) require.NoError(t, err) require.NotNil(t, decoded.Items, "Items must always be non-nil even when empty") for _, item := range decoded.Items { if item.GameID == gameID { assert.Equal(t, ownerUserID, item.OwnerUserID) assert.Equal(t, "private", item.GameType) return } } // Game absent from /my/games is acceptable for this test. Issue a // direct lobby read to confirm the game does exist on the lobby // side, so we know the routing path is the only thing we depend // on (not lobby's own `/my/games` filter). t.Logf("seeded game %s not in /my/games (likely lobby filter on draft); routing pipeline succeeded with empty items", gameID) require.True(t, h.gameExists(t, gameID), "seeded game must still be observable via lobby admin REST") } // TestGatewayRoutesLobbyOpenEnrollmentEnforcesOwnerOnly drives two // authenticated users: the owner who can transition the game to // `enrollment_open`, and a non-owner whose attempt is rejected with // the canonical lobby error envelope. The test exercises the // "owner-only commands before start" requirement of `TESTING.md §6`. func TestGatewayRoutesLobbyOpenEnrollmentEnforcesOwnerOnly(t *testing.T) { h := newGatewayLobbyHarness(t) ownerKey := newClientPrivateKey("g1-owner-2") ownerSessionID, ownerUserID := h.authenticate(t, "owner2@example.com", ownerKey) guestKey := newClientPrivateKey("g1-guest") guestSessionID, _ := h.authenticate(t, "guest@example.com", guestKey) gameID := h.createPrivateGame(t, ownerUserID, "Owner-Only Galaxy", time.Now().Add(48*time.Hour).Unix()) conn := h.dialGateway(t) client := gatewayv1.NewEdgeGatewayClient(conn) // Owner sends `lobby.game.open-enrollment` → success. ownerRequest, err := transcoder.OpenEnrollmentRequestToPayload(&lobbymodel.OpenEnrollmentRequest{ GameID: gameID, }) require.NoError(t, err) ownerResponse, err := client.ExecuteCommand( context.Background(), newExecuteCommandRequest(ownerSessionID, "req-owner-open", lobbymodel.MessageTypeOpenEnrollment, ownerRequest, ownerKey), ) require.NoError(t, err) assert.Equal(t, "ok", ownerResponse.GetResultCode()) decoded, err := transcoder.PayloadToOpenEnrollmentResponse(ownerResponse.GetPayloadBytes()) require.NoError(t, err) assert.Equal(t, gameID, decoded.GameID) assert.Equal(t, "enrollment_open", decoded.Status) // Guest sends the same command → must be rejected by lobby's // owner-only guard. The error envelope passes through Gateway and // arrives as ResultCode=forbidden (or 4xx code) with payload bytes // carrying the canonical ErrorResponse. guestRequest, err := transcoder.OpenEnrollmentRequestToPayload(&lobbymodel.OpenEnrollmentRequest{ GameID: gameID, }) require.NoError(t, err) guestResponse, err := client.ExecuteCommand( context.Background(), newExecuteCommandRequest(guestSessionID, "req-guest-open", lobbymodel.MessageTypeOpenEnrollment, guestRequest, guestKey), ) require.NoError(t, err, "non-2xx lobby responses must surface as a normal gRPC response with a non-ok ResultCode") require.NotEqual(t, "ok", guestResponse.GetResultCode(), "non-owner must not receive ok; got %s", guestResponse.GetResultCode()) decodedError, err := transcoder.PayloadToLobbyErrorResponse(guestResponse.GetPayloadBytes()) require.NoError(t, err) assert.NotEmpty(t, decodedError.Error.Code) assert.NotEmpty(t, decodedError.Error.Message) } // gatewayLobbyHarness owns the per-test infrastructure: shared // PostgreSQL+Redis containers, four real binaries, the Gateway // response-signer key, and the public/internal addresses for each // service. type gatewayLobbyHarness struct { redis *redis.Client mailStub *harness.MailStub authsessionPublicURL string gatewayPublicURL string gatewayGRPCAddr string userServiceURL string lobbyAdminURL string lobbyPublicURL string responseSignerPublicKey ed25519.PublicKey authsessionProcess *harness.Process gatewayProcess *harness.Process userServiceProcess *harness.Process lobbyProcess *harness.Process } func newGatewayLobbyHarness(t *testing.T) *gatewayLobbyHarness { t.Helper() redisRuntime := harness.StartRedisContainer(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisRuntime.Addr, Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, redisClient.Close()) }) mailStub := harness.NewMailStub(t) responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name()) userServiceAddr := harness.FreeTCPAddress(t) authsessionPublicAddr := harness.FreeTCPAddress(t) authsessionInternalAddr := harness.FreeTCPAddress(t) gatewayPublicAddr := harness.FreeTCPAddress(t) gatewayGRPCAddr := harness.FreeTCPAddress(t) lobbyPublicAddr := harness.FreeTCPAddress(t) lobbyInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession") gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway") lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby") userServiceEnv := harness.StartUserServicePersistence(t, redisRuntime.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) waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) authsessionEnv := map[string]string{ "AUTHSESSION_LOG_LEVEL": "info", "AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr, "AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr, "AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_REDIS_MASTER_ADDR": redisRuntime.Addr, "AUTHSESSION_REDIS_PASSWORD": "integration", "AUTHSESSION_USER_SERVICE_MODE": "rest", "AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_MAIL_SERVICE_MODE": "rest", "AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(), "AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(), "AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:", "AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", } authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv) waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr) lobbyEnv := harness.StartLobbyServicePersistence(t, redisRuntime.Addr).Env lobbyEnv["LOBBY_LOG_LEVEL"] = "info" lobbyEnv["LOBBY_PUBLIC_HTTP_ADDR"] = lobbyPublicAddr lobbyEnv["LOBBY_INTERNAL_HTTP_ADDR"] = lobbyInternalAddr lobbyEnv["LOBBY_USER_SERVICE_BASE_URL"] = "http://" + userServiceAddr lobbyEnv["LOBBY_GM_BASE_URL"] = mailStub.BaseURL() // unused; lobby just needs a syntactically valid URL. lobbyEnv["LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT"] = "200ms" lobbyEnv["OTEL_TRACES_EXPORTER"] = "none" lobbyEnv["OTEL_METRICS_EXPORTER"] = "none" lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, lobbyEnv) harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK) gatewayEnv := map[string]string{ "GATEWAY_LOG_LEVEL": "info", "GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr, "GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr, "GATEWAY_REDIS_MASTER_ADDR": redisRuntime.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), "GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr, "GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "GATEWAY_LOBBY_SERVICE_BASE_URL": "http://" + lobbyPublicAddr, "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(), "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100", "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 &gatewayLobbyHarness{ redis: redisClient, mailStub: mailStub, authsessionPublicURL: "http://" + authsessionPublicAddr, gatewayPublicURL: "http://" + gatewayPublicAddr, gatewayGRPCAddr: gatewayGRPCAddr, userServiceURL: "http://" + userServiceAddr, lobbyAdminURL: "http://" + lobbyInternalAddr, lobbyPublicURL: "http://" + lobbyPublicAddr, responseSignerPublicKey: responseSignerPublicKey, authsessionProcess: authsessionProcess, gatewayProcess: gatewayProcess, userServiceProcess: userServiceProcess, lobbyProcess: lobbyProcess, } } // authenticate runs the public-auth challenge/confirm flow through the // Gateway and returns the resulting `device_session_id` plus the // resolved `user_id`. func (h *gatewayLobbyHarness) authenticate(t *testing.T, email string, clientKey ed25519.PrivateKey) (string, string) { t.Helper() challengeID := h.sendChallenge(t, email) code := h.waitForChallengeCode(t, email) confirm := h.confirmCode(t, challengeID, code, clientKey) require.Equalf(t, http.StatusOK, confirm.StatusCode, "confirm status: %s", confirm.Body) var confirmBody struct { DeviceSessionID string `json:"device_session_id"` } require.NoError(t, decodeStrictJSONPayload([]byte(confirm.Body), &confirmBody)) require.NotEmpty(t, confirmBody.DeviceSessionID) user := h.lookupUserByEmail(t, email) // Wait for the gateway session projection to land in Redis. deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if _, err := h.redis.Get(context.Background(), "gateway:session:"+confirmBody.DeviceSessionID).Bytes(); err == nil { return confirmBody.DeviceSessionID, user.UserID } time.Sleep(25 * time.Millisecond) } t.Fatalf("gateway session projection for %s never arrived", confirmBody.DeviceSessionID) return "", "" } // waitForChallengeCode polls the mail stub until the requested email // has received an auth-code delivery and returns the cleartext code. func (h *gatewayLobbyHarness) waitForChallengeCode(t *testing.T, email string) string { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { for _, delivery := range h.mailStub.RecordedDeliveries() { if delivery.Email == email && delivery.Code != "" { return delivery.Code } } time.Sleep(25 * time.Millisecond) } t.Fatalf("auth code for %s never arrived at the mail stub", email) return "" } func (h *gatewayLobbyHarness) sendChallenge(t *testing.T, email string) string { t.Helper() response := postJSONValue(t, h.gatewayPublicURL+gatewaySendEmailCodePath, map[string]string{ "email": email, }) require.Equalf(t, http.StatusOK, response.StatusCode, "send-email-code: %s", response.Body) var body struct { ChallengeID string `json:"challenge_id"` } require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body)) require.NotEmpty(t, body.ChallengeID) return body.ChallengeID } func (h *gatewayLobbyHarness) confirmCode(t *testing.T, challengeID, code string, clientPrivateKey ed25519.PrivateKey) httpResponse { t.Helper() return postJSONValue(t, h.gatewayPublicURL+gatewayConfirmEmailCodePath, map[string]string{ "challenge_id": challengeID, "code": code, "client_public_key": encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)), "time_zone": testTimeZone, }) } func (h *gatewayLobbyHarness) lookupUserByEmail(t *testing.T, email string) struct { UserID string `json:"user_id"` } { t.Helper() resp := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{ "email": email, }) require.Equalf(t, http.StatusOK, resp.StatusCode, "user lookup: %s", resp.Body) // User Service returns the full user record; only user_id is needed. var body struct { User struct { UserID string `json:"user_id"` } `json:"user"` } require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) require.NotEmpty(t, body.User.UserID) return struct { UserID string `json:"user_id"` }{UserID: body.User.UserID} } func (h *gatewayLobbyHarness) createPrivateGame(t *testing.T, ownerUserID, gameName string, enrollmentEndsAt int64) string { t.Helper() resp := postJSONValueWithHeaders(t, h.lobbyPublicURL+"/api/v1/lobby/games", map[string]any{ "game_name": gameName, "game_type": "private", "min_players": 1, "max_players": 4, "start_gap_hours": 6, "start_gap_players": 1, "enrollment_ends_at": enrollmentEndsAt, "turn_schedule": "0 18 * * *", "target_engine_version": "1.0.0", }, map[string]string{"X-User-Id": ownerUserID}) require.Equalf(t, http.StatusCreated, resp.StatusCode, "create private game: %s", resp.Body) var record struct { GameID string `json:"game_id"` } require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) require.NotEmpty(t, record.GameID) return record.GameID } // gameExists checks whether the lobby admin surface still observes a // game that was created through the public surface. func (h *gatewayLobbyHarness) gameExists(t *testing.T, gameID string) bool { t.Helper() req, err := http.NewRequest(http.MethodGet, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID, nil) require.NoError(t, err) resp := doRequest(t, req) return resp.StatusCode == http.StatusOK } func (h *gatewayLobbyHarness) 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 } // --- request/response helpers --- func newExecuteCommandRequest(deviceSessionID, requestID, messageType string, payloadBytes []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest { payloadHash := contractsgatewayv1.ComputePayloadHash(payloadBytes) request := &gatewayv1.ExecuteCommandRequest{ ProtocolVersion: contractsgatewayv1.ProtocolVersionV1, DeviceSessionId: deviceSessionID, MessageType: messageType, TimestampMs: time.Now().UnixMilli(), RequestId: requestID, PayloadBytes: payloadBytes, 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 } type httpResponse struct { StatusCode int Body string Header http.Header } func postJSONValue(t *testing.T, targetURL string, body any) httpResponse { t.Helper() return postJSONValueWithHeaders(t, targetURL, body, nil) } func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) 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") for key, value := range headers { if value == "" { continue } request.Header.Set(key, value) } return doRequest(t, request) } func doRequest(t *testing.T, request *http.Request) httpResponse { t.Helper() client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{DisableKeepAlives: true}, } t.Cleanup(client.CloseIdleConnections) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() payload, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(payload), Header: response.Header.Clone(), } } 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 } func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() client := &http.Client{Timeout: 250 * time.Millisecond} t.Cleanup(client.CloseIdleConnections) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-readiness-probe/exists", nil) require.NoError(t, err) response, err := client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if response.StatusCode == http.StatusOK { return } } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs()) } func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() // AuthSession's public listener does not expose a `/healthz` path; // posting an empty-email send-email-code request is the cheapest // readiness signal and returns 400 once routing is up. client := &http.Client{Timeout: 250 * time.Millisecond} t.Cleanup(client.CloseIdleConnections) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { body := bytes.NewReader([]byte(`{"email":""}`)) req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/public/auth/send-email-code", body) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") response, err := client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if response.StatusCode == http.StatusBadRequest { return } } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for authsession readiness: timeout\n%s", process.Logs()) } func newClientPrivateKey(label string) ed25519.PrivateKey { seed := sha256.Sum256([]byte("galaxy-integration-gateway-lobby-client-" + label)) return ed25519.NewKeyFromSeed(seed[:]) } func encodePublicKey(publicKey ed25519.PublicKey) string { return base64.StdEncoding.EncodeToString(publicKey) }