package http import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "strconv" "strings" "testing" "time" jwt "github.com/golang-jwt/jwt/v5" roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/auth" ) // fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。 // 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。 type fakeRoomClient struct { lastCreate *roomv1.CreateRoomRequest lastJoin *roomv1.JoinRoomRequest lastHeartbeat *roomv1.RoomHeartbeatRequest lastLeave *roomv1.LeaveRoomRequest lastMicUp *roomv1.MicUpRequest lastMicDown *roomv1.MicDownRequest lastChangeMicSeat *roomv1.ChangeMicSeatRequest lastConfirmMic *roomv1.ConfirmMicPublishingRequest lastRTCEvent *roomv1.ApplyRTCEventRequest lastMicSeatLock *roomv1.SetMicSeatLockRequest lastChatEnabled *roomv1.SetChatEnabledRequest lastSetAdmin *roomv1.SetRoomAdminRequest lastTransferHost *roomv1.TransferRoomHostRequest lastMute *roomv1.MuteUserRequest lastKick *roomv1.KickUserRequest lastUnban *roomv1.UnbanUserRequest lastGift *roomv1.SendGiftRequest createErr error joinErr error } func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) { f.lastCreate = req if f.createErr != nil { return nil, f.createErr } return &roomv1.CreateRoomResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1}, }, nil } func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { f.lastJoin = req if f.joinErr != nil { return nil, f.joinErr } return &roomv1.JoinRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil } func (f *fakeRoomClient) RoomHeartbeat(_ context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) { f.lastHeartbeat = req return &roomv1.RoomHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil } func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) { f.lastLeave = req return &roomv1.LeaveRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3}}, nil } func (f *fakeRoomClient) MicUp(_ context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { f.lastMicUp = req return &roomv1.MicUpResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4}}, nil } func (f *fakeRoomClient) MicDown(_ context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) { f.lastMicDown = req return &roomv1.MicDownResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 5}}, nil } func (f *fakeRoomClient) ChangeMicSeat(_ context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) { f.lastChangeMicSeat = req return &roomv1.ChangeMicSeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 6}}, nil } func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) { f.lastConfirmMic = req return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil } func (f *fakeRoomClient) ApplyRTCEvent(_ context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) { f.lastRTCEvent = req return &roomv1.ApplyRTCEventResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) { f.lastMicSeatLock = req return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetChatEnabled(_ context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) { f.lastChatEnabled = req return &roomv1.SetChatEnabledResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) { f.lastSetAdmin = req return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil } func (f *fakeRoomClient) TransferRoomHost(_ context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) { f.lastTransferHost = req return &roomv1.TransferRoomHostResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 10}}, nil } func (f *fakeRoomClient) MuteUser(_ context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) { f.lastMute = req return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 11}}, nil } func (f *fakeRoomClient) KickUser(_ context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) { f.lastKick = req return &roomv1.KickUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 12}}, nil } func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) { f.lastUnban = req return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil } func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) { f.lastGift = req return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil } type fakeUserAuthClient struct { lastLoginThirdParty *userv1.LoginThirdPartyRequest lastLoginPassword *userv1.LoginPasswordRequest lastSetPassword *userv1.SetPasswordRequest lastRefresh *userv1.RefreshTokenRequest lastLogout *userv1.LogoutRequest loginPasswordCount int loginThirdCount int refreshCount int logoutCount int loginErr error } type fakeUserProfileClient struct { lastGet *userv1.GetUserRequest getRequests []*userv1.GetUserRequest lastComplete *userv1.CompleteOnboardingRequest lastUpdate *userv1.UpdateUserProfileRequest lastCountry *userv1.ChangeUserCountryRequest getErr error completeErr error countryErr error regionID int64 regionByUserID map[int64]int64 } type fakeUserIdentityClient struct { lastResolve *userv1.ResolveDisplayUserIDRequest resolveByDisplay map[string]int64 resolveErr error } type fakeRoomQueryClient struct { lastList *roomv1.ListRoomsRequest resp *roomv1.ListRoomsResponse err error } type fakeUserCountryQueryClient struct { last *userv1.ListRegistrationCountriesRequest resp *userv1.ListRegistrationCountriesResponse err error } type fakeUserHostClient struct { last *userv1.GetCoinSellerProfileRequest profile *userv1.CoinSellerProfile err error lastHost *userv1.GetHostProfileRequest hostProfile *userv1.HostProfile hostErr error lastBD *userv1.GetBDProfileRequest bdProfile *userv1.BDProfile bdErr error } type fakeWalletClient struct { last *walletv1.GetBalancesRequest resp *walletv1.GetBalancesResponse err error lastTransfer *walletv1.TransferCoinFromSellerRequest transferResp *walletv1.TransferCoinFromSellerResponse transferErr error } func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) { f.lastLoginPassword = req f.loginPasswordCount++ if f.loginErr != nil { return nil, f.loginErr } return &userv1.AuthResponse{Token: &userv1.AuthToken{UserId: 10001}}, nil } func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) { f.lastLoginThirdParty = req f.loginThirdCount++ return &userv1.AuthResponse{ Token: &userv1.AuthToken{ UserId: 10001, DisplayUserId: "100001", SessionId: "sess-1", AccessToken: "access-1", RefreshToken: "refresh-1", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: false, OnboardingStatus: "profile_required", }, IsNewUser: true, ProfileCompleted: false, OnboardingStatus: "profile_required", }, nil } func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { f.lastSetPassword = req return &userv1.SetPasswordResponse{PasswordSet: true}, nil } func (f *fakeUserAuthClient) RefreshToken(_ context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) { f.lastRefresh = req f.refreshCount++ return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil } func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) { f.lastLogout = req f.logoutCount++ return &userv1.LogoutResponse{Revoked: true}, nil } func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) { f.lastGet = req f.getRequests = append(f.getRequests, req) if f.getErr != nil { return nil, f.getErr } regionID := f.regionID if f.regionByUserID != nil { if mapped := f.regionByUserID[req.GetUserId()]; mapped != 0 { regionID = mapped } } if regionID == 0 { // 测试默认区域固定为 1001,避免新增入口在未配置 fake region 时误走 GLOBAL 桶。 regionID = 1001 } return &userv1.GetUserResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Username: "hy", Gender: "female", Avatar: "https://cdn.example/avatar.png", Birth: "2000-01-02", RegionId: regionID, ProfileCompleted: true, OnboardingStatus: "completed", }}, nil } func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) { f.lastComplete = req if f.completeErr != nil { return nil, f.completeErr } return &userv1.CompleteOnboardingResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Username: req.GetUsername(), Avatar: req.GetAvatar(), Country: req.GetCountry(), RegionId: 1001, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", UpdatedAtMs: 4000, }, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", Token: &userv1.AuthToken{ UserId: req.GetUserId(), SessionId: req.GetMeta().GetSessionId(), AccessToken: "access-completed", ExpiresInSec: 1800, TokenType: "Bearer", DisplayUserId: "100001", ProfileCompleted: true, OnboardingStatus: "completed", }}, nil } func (f *fakeUserProfileClient) UpdateUserProfile(_ context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) { f.lastUpdate = req return &userv1.UpdateUserProfileResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Username: valueOfString(req.Username), Avatar: valueOfString(req.Avatar), Gender: valueOfString(req.Gender), Birth: valueOfString(req.Birth), UpdatedAtMs: 2000, }}, nil } func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) { f.lastCountry = req if f.countryErr != nil { return nil, f.countryErr } return &userv1.ChangeUserCountryResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Country: req.GetCountry(), UpdatedAtMs: 3000, }, NextChangeAllowedAtMs: 3600000}, nil } func (f *fakeUserIdentityClient) GetUserIdentity(_ context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) { return &userv1.GetUserIdentityResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: "100001", Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) { f.lastResolve = req if f.resolveErr != nil { return nil, f.resolveErr } userID := int64(0) if f.resolveByDisplay != nil { userID = f.resolveByDisplay[req.GetDisplayUserId()] } return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: userID, DisplayUserId: req.GetDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ChangeDisplayUserID(_ context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) { return &userv1.ChangeDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: req.GetNewDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ApplyPrettyDisplayUserID(_ context.Context, req *userv1.ApplyPrettyDisplayUserIDRequest) (*userv1.ApplyPrettyDisplayUserIDResponse, error) { return &userv1.ApplyPrettyDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: req.GetPrettyDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) { f.lastList = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &roomv1.ListRoomsResponse{}, nil } func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &userv1.ListRegistrationCountriesResponse{}, nil } func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) { f.lastHost = req if f.hostErr != nil { return nil, f.hostErr } if f.hostProfile != nil { return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil } return &userv1.GetHostProfileResponse{}, nil } func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) { f.lastBD = req if f.bdErr != nil { return nil, f.bdErr } if f.bdProfile != nil { return &userv1.GetBDProfileResponse{BdProfile: f.bdProfile}, nil } return &userv1.GetBDProfileResponse{}, nil } func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.profile != nil { return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: f.profile}, nil } return &userv1.GetCoinSellerProfileResponse{}, nil } func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &walletv1.GetBalancesResponse{}, nil } func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) { f.lastTransfer = req if f.transferErr != nil { return nil, f.transferErr } if f.transferResp != nil { return f.transferResp, nil } return &walletv1.TransferCoinFromSellerResponse{}, nil } func (f *fakeWalletClient) ListResources(context.Context, *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) { return &walletv1.ListResourcesResponse{}, nil } func (f *fakeWalletClient) GetResourceGroup(context.Context, *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) { return &walletv1.GetResourceGroupResponse{}, nil } func (f *fakeWalletClient) ListGiftConfigs(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { return &walletv1.ListGiftConfigsResponse{}, nil } func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) { return &walletv1.ListUserResourcesResponse{}, nil } func (f *fakeWalletClient) EquipUserResource(context.Context, *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) { return &walletv1.EquipUserResourceResponse{}, nil } func valueOfString(value *string) string { if value == nil { return "" } return *value } // fakeRoomGuardClient 只覆盖腾讯云 IM 回调测试需要的守卫 RPC。 type fakeRoomGuardClient struct { speakResp *roomv1.CheckSpeakPermissionResponse presenceResp *roomv1.VerifyRoomPresenceResponse lastSpeak *roomv1.CheckSpeakPermissionRequest lastPresence *roomv1.VerifyRoomPresenceRequest err error } func (f *fakeRoomGuardClient) CheckSpeakPermission(_ context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) { f.lastSpeak = req if f.err != nil { return nil, f.err } if f.speakResp != nil { return f.speakResp, nil } return &roomv1.CheckSpeakPermissionResponse{Allowed: true, RoomVersion: 1}, nil } func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) { f.lastPresence = req if f.err != nil { return nil, f.err } if f.presenceResp != nil { return f.presenceResp, nil } return &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}, nil } type fakeObjectUploader struct { lastKey string lastContent []byte lastSizeBytes int64 lastContentType string err error } func (f *fakeObjectUploader) PutObject(_ context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) { f.lastKey = key f.lastSizeBytes = sizeBytes f.lastContentType = contentType body, err := io.ReadAll(reader) if err != nil { return "", err } f.lastContent = body if f.err != nil { return "", f.err } return "https://media.example.com/" + key, nil } func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) { client := &fakeRoomClient{} profileClient := &fakeUserProfileClient{regionID: 1001} router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"client-room-ignored","seat_count":8,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-test") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if recorder.Header().Get("X-Request-ID") != "req-test" { t.Fatalf("response request header mismatch: got %q", recorder.Header().Get("X-Request-ID")) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != codeOK || response.Message != "ok" || response.RequestID != "req-test" { t.Fatalf("unexpected envelope: %+v", response) } if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != "req-test" { t.Fatalf("request_id was not propagated to room-service meta: %+v", client.lastCreate) } if client.lastCreate.GetMeta().GetActorUserId() != 42 { t.Fatalf("actor user mismatch: got %d", client.lastCreate.GetMeta().GetActorUserId()) } if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "client-room-ignored" { t.Fatalf("CreateRoom must generate app-scoped room_id on gateway: %q", roomID) } if client.lastCreate.GetRoomShortId() != "100001" { t.Fatalf("CreateRoom must use owner display_user_id as room_short_id: %+v", client.lastCreate) } if client.lastCreate.GetMeta().GetCommandId() == "" || client.lastCreate.GetMeta().GetCommandId() == "req-test" { t.Fatalf("gateway-generated command_id must be non-empty and separate from request_id: %+v", client.lastCreate.GetMeta()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 { t.Fatalf("CreateRoom must derive visible_region_id from user-service: profile=%+v create=%+v", profileClient.lastGet, client.lastCreate) } if client.lastCreate.GetRoomName() != "Lobby" || client.lastCreate.GetRoomAvatar() != "https://cdn.example.com/room.png" || client.lastCreate.GetRoomDescription() != "welcome" { t.Fatalf("CreateRoom should trim and forward room profile fields: %+v", client.lastCreate) } } func TestRoomCommandsPropagateClientCommandID(t *testing.T) { tests := []struct { name string path string body string commandID string extractMeta func(*fakeRoomClient) *roomv1.RequestMeta }{ { name: "create", path: "/api/v1/rooms/create", body: `{"room_id":"room-1","command_id":"cmd-create-1","seat_count":8,"mode":"voice","room_name":"Lobby"}`, commandID: "cmd-create-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastCreate.GetMeta() }, }, { name: "join", path: "/api/v1/rooms/join", body: `{"room_id":"room-1","command_id":"cmd-join-1","role":"audience"}`, commandID: "cmd-join-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastJoin.GetMeta() }, }, { name: "leave", path: "/api/v1/rooms/leave", body: `{"room_id":"room-1","command_id":"cmd-leave-1"}`, commandID: "cmd-leave-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastLeave.GetMeta() }, }, { name: "heartbeat", path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1","command_id":"cmd-heartbeat-1"}`, commandID: "cmd-heartbeat-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastHeartbeat.GetMeta() }, }, { name: "mic_up", path: "/api/v1/rooms/mic/up", body: `{"room_id":"room-1","command_id":"cmd-mic-up-1","seat_no":1}`, commandID: "cmd-mic-up-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicUp.GetMeta() }, }, { name: "mic_down", path: "/api/v1/rooms/mic/down", body: `{"room_id":"room-1","command_id":"cmd-mic-down-1","target_user_id":43}`, commandID: "cmd-mic-down-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicDown.GetMeta() }, }, { name: "change_mic", path: "/api/v1/rooms/mic/change", body: `{"room_id":"room-1","command_id":"cmd-change-mic-1","target_user_id":43,"seat_no":2}`, commandID: "cmd-change-mic-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastChangeMicSeat.GetMeta() }, }, { name: "confirm_mic_publishing", path: "/api/v1/rooms/mic/publishing/confirm", body: `{"room_id":"room-1","command_id":"cmd-confirm-mic-1","mic_session_id":"mic-1","room_version":4,"event_time_ms":1700000001000,"source":"client"}`, commandID: "cmd-confirm-mic-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastConfirmMic.GetMeta() }, }, { name: "mic_lock", path: "/api/v1/rooms/mic/lock", body: `{"room_id":"room-1","command_id":"cmd-mic-lock-1","seat_no":2,"locked":true}`, commandID: "cmd-mic-lock-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicSeatLock.GetMeta() }, }, { name: "chat_enabled", path: "/api/v1/rooms/chat/enabled", body: `{"room_id":"room-1","command_id":"cmd-chat-enabled-1","enabled":false}`, commandID: "cmd-chat-enabled-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastChatEnabled.GetMeta() }, }, { name: "set_admin", path: "/api/v1/rooms/admin/set", body: `{"room_id":"room-1","command_id":"cmd-admin-1","target_user_id":43,"enabled":true}`, commandID: "cmd-admin-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastSetAdmin.GetMeta() }, }, { name: "transfer_host", path: "/api/v1/rooms/host/transfer", body: `{"room_id":"room-1","command_id":"cmd-host-1","target_user_id":43}`, commandID: "cmd-host-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastTransferHost.GetMeta() }, }, { name: "mute", path: "/api/v1/rooms/user/mute", body: `{"room_id":"room-1","command_id":"cmd-mute-1","target_user_id":43,"muted":true}`, commandID: "cmd-mute-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMute.GetMeta() }, }, { name: "kick", path: "/api/v1/rooms/user/kick", body: `{"room_id":"room-1","command_id":"cmd-kick-1","target_user_id":43}`, commandID: "cmd-kick-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastKick.GetMeta() }, }, { name: "unban", path: "/api/v1/rooms/user/unban", body: `{"room_id":"room-1","command_id":"cmd-unban-1","target_user_id":43}`, commandID: "cmd-unban-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastUnban.GetMeta() }, }, { name: "gift", path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","command_id":"cmd-gift-1","target_user_id":43,"gift_id":"rose","gift_count":1}`, commandID: "cmd-gift-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastGift.GetMeta() }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } meta := test.extractMeta(client) if meta == nil { t.Fatal("room-service meta was not sent") } if meta.GetCommandId() != test.commandID { t.Fatalf("command_id mismatch: got %q meta=%+v", meta.GetCommandId(), meta) } if meta.GetRequestId() != "req-"+test.name { t.Fatalf("request_id must remain trace-only and unchanged: %+v", meta) } if meta.GetActorUserId() != 42 { t.Fatalf("actor user mismatch: %+v", meta) } }) } } func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{ Rooms: []*roomv1.RoomListItem{ {RoomId: "room-1", VisibleRegionId: 1001, Heat: 90}, }, NextCursor: "cursor-2", }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=me&limit=2&cursor=cursor-1&q=room&visible_region_id=9999", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-list") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("ListRooms must fetch current user from user-service: %+v", profileClient.lastGet) } if queryClient.lastList == nil { t.Fatal("room query client was not called") } if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 { t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList) } if queryClient.lastList.GetTab() != "me" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" { t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList) } } func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) { queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?limit=bad", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-list-limit") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-room-list-limit") if queryClient.lastList != nil { t.Fatalf("invalid limit must not reach room-service: %+v", queryClient.lastList) } } func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T) { countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{ Countries: []*userv1.Country{ { CountryId: 86, CountryName: "China", CountryCode: "CN", IsoAlpha3: "CHN", IsoNumeric: "156", CountryDisplayName: "中国", PhoneCountryCode: "+86", Enabled: true, Flag: "CN", SortOrder: 10, }, }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserCountryQueryClient(countryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/countries", nil) request.Header.Set("X-Request-ID", "req-countries") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != "req-countries" { t.Fatalf("country query request metadata mismatch: %+v", countryClient.last) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } countries, ok := data["countries"].([]any) if !ok || len(countries) != 1 { t.Fatalf("countries response shape mismatch: %+v", data) } country, ok := countries[0].(map[string]any) if !ok || country["country_code"] != "CN" || country["iso_numeric"] != "156" || country["phone_country_code"] != "+86" || country["enabled"] != true { t.Fatalf("country metadata mismatch: %+v", country) } } func TestListH5LinksReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{Links: []appconfig.H5Link{ {Key: "host-center", Label: "Host Center", URL: "https://h5.example.com/host", UpdatedAtMs: 1700000000000}, {Key: "agency-center", Label: "Agency Center", URL: "https://h5.example.com/agency", UpdatedAtMs: 1700000001000}, }}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) request.Header.Set("X-Request-ID", "req-h5-links") request.Header.Set("X-App-Package", "com.org.laluparty") request.Header.Set("X-App-Platform", "android") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["total"].(float64) != 2 { t.Fatalf("h5 total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 2 { t.Fatalf("h5 items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["key"] != "host-center" || first["url"] != "https://h5.example.com/host" || first["updated_at_ms"].(float64) != 1700000000000 { t.Fatalf("h5 item mismatch: %+v", first) } } func TestListH5LinksRequiresConfigReader(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) request.Header.Set("X-Request-ID", "req-h5-links-missing") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-h5-links-missing") } func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{Banners: []appconfig.Banner{ { ID: 8, CoverURL: "https://cdn.example.com/banner.png", BannerType: "h5", Param: "https://h5.example.com/activity", Platform: "android", SortOrder: 10, RegionID: 1, CountryCode: "CN", Description: "homepage", UpdatedAtMs: 1700000002000, }, }}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?platform=android&country=CN®ion_id=1", nil) request.Header.Set("X-Request-ID", "req-app-banners") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["total"].(float64) != 1 { t.Fatalf("banner total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 1 { t.Fatalf("banner items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" { t.Fatalf("banner item mismatch: %+v", first) } if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["updated_at_ms"].(float64) != 1700000002000 { t.Fatalf("banner metadata mismatch: %+v", first) } } func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) { hostClient := &fakeUserHostClient{ hostProfile: &userv1.HostProfile{ UserId: 42, Status: "active", Source: "admin_create_agency", }, bdProfile: &userv1.BDProfile{ UserId: 42, Status: "active", Role: "bd_leader", }, profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-identity") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 || hostClient.lastHost.GetMeta().GetRequestId() != "req-host-identity" { t.Fatalf("host identity host request mismatch: %+v", hostClient.lastHost) } if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 { t.Fatalf("host identity bd request mismatch: %+v", hostClient.lastBD) } if hostClient.last == nil || hostClient.last.GetUserId() != 42 { t.Fatalf("host identity coin seller request mismatch: %+v", hostClient.last) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["is_host"] != true || data["is_agent"] != true || data["is_bd"] != true || data["is_bd_leader"] != true || data["is_coin_seller"] != true { t.Fatalf("host identity response mismatch: %+v", response) } } func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) { hostClient := &fakeUserHostClient{ hostErr: xerr.ToGRPCError(xerr.New(xerr.NotFound, "host profile not found")), bdErr: xerr.ToGRPCError(xerr.New(xerr.NotFound, "bd profile not found")), err: xerr.ToGRPCError(xerr.New(xerr.NotFound, "coin seller profile not found")), } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-identity-empty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["is_host"] != false || data["is_agent"] != false || data["is_bd"] != false || data["is_bd_leader"] != false || data["is_coin_seller"] != false { t.Fatalf("empty host identity response mismatch: %+v", response) } } func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-confirm","target_user_id":43,"mic_session_id":"mic-session-1","room_version":12,"event_time_ms":1700000001234,"source":"client"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/publishing/confirm", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-confirm-mic") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-confirm-mic") if client.lastConfirmMic == nil { t.Fatal("ConfirmMicPublishing request was not sent") } if client.lastConfirmMic.GetTargetUserId() != 43 || client.lastConfirmMic.GetMicSessionId() != "mic-session-1" || client.lastConfirmMic.GetRoomVersion() != 12 || client.lastConfirmMic.GetEventTimeMs() != 1700000001234 || client.lastConfirmMic.GetSource() != "client" { t.Fatalf("confirm fields mismatch: %+v", client.lastConfirmMic) } } func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","username":"hy","gender":"male","country":"CN","invite_code":"INV-1","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-third") request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") request.Header.Set("User-Agent", "hyapp-test/1.0") request.Header.Set("CF-IPCountry", "sg") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != codeOK || response.RequestID != "req-auth-third" { t.Fatalf("unexpected envelope: %+v", response) } req := userClient.lastLoginThirdParty if req == nil { t.Fatal("LoginThirdParty request was not sent") } if req.GetMeta().GetRequestId() != "req-auth-third" || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" { t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta()) } if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" { t.Fatalf("third-party registration fields were not propagated: %+v", req) } if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" { t.Fatalf("Firebase provider and ID token credential were not propagated: %+v", req) } if req.GetDevice() != "iPhone 15" || req.GetOsVersion() != "iOS 18.1" || req.GetAvatar() == "" || req.GetBirth() != "2000-01-02" || req.GetAppVersion() != "1.2.3" || req.GetBuildNumber() != "123" { t.Fatalf("third-party device/profile fields were not propagated: %+v", req) } if req.GetSource() != "campaign-a" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" || req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" { t.Fatalf("third-party profile fields were not propagated: %+v", req) } } func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.ProviderIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`) for i := 0; i < 2; i++ { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-third-rate") request.Header.Set("X-Forwarded-For", "203.0.113.10") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-third-rate") } } if userClient.loginThirdCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginThirdCount) } } func TestAccountLoginRateLimitByAccount(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.DisplayUserIDIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"100001","password":"bad","device_id":"dev-1"}`) for i := 0; i < 2; i++ { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-password-rate") request.Header.Set("X-Forwarded-For", "203.0.113.20") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-password-rate") } } if userClient.loginPasswordCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginPasswordCount) } } func TestAccountLoginAcceptsAccountField(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"123456","password":"1234567","device_id":"ios"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-account-login") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-account-login") if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" { t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword) } if userClient.lastLoginPassword.GetPassword() != "1234567" || userClient.lastLoginPassword.GetMeta().GetDeviceId() != "ios" { t.Fatalf("password login payload mismatch: %+v", userClient.lastLoginPassword) } } func TestRefreshRateLimitByDeviceID(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.DeviceIDLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1"}`) for i := 0; i < 2; i++ { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-refresh-rate") request.Header.Set("X-Forwarded-For", "203.0.113.30") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-refresh-rate") } } if userClient.refreshCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.refreshCount) } } func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.SessionIDIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"session_id":"sess-1","refresh_token":"refresh-1"}`) for i := 0; i < 2; i++ { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-logout-rate") request.Header.Set("X-Forwarded-For", "203.0.113.40") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, codeRateLimited, "req-logout-rate") } } if userClient.logoutCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.logoutCount) } } func TestSetPasswordRequiresTokenAndPropagatesActorUserID(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"password":"secret-pass"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/set", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-set-password") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if userClient.lastSetPassword == nil || userClient.lastSetPassword.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", userClient.lastSetPassword) } if userClient.lastSetPassword.GetMeta().GetRequestId() != "req-set-password" { t.Fatalf("request_id was not propagated: %+v", userClient.lastSetPassword.GetMeta()) } } func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"female","birth":"2000-01-02"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile/update", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastUpdate == nil || profileClient.lastUpdate.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastUpdate) } if profileClient.lastUpdate.GetMeta().GetRequestId() != "req-profile" || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" { t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate) } } func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile-get") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastGet) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["gender"] != "female" { t.Fatalf("profile response mismatch: %+v", response) } } func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{ Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/me/balances", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-balances") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetRequestId() != "req-balances" { t.Fatalf("wallet request mismatch: %+v", walletClient.last) } if len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "COIN" { t.Fatalf("wallet default asset types mismatch: %+v", walletClient.last) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } balances, ok := data["balances"].([]any) if !ok || len(balances) != 1 { t.Fatalf("balances response shape mismatch: %+v", data) } balance, ok := balances[0].(map[string]any) if !ok || balance["asset_type"] != "COIN" || balance["available_amount"] != float64(120) { t.Fatalf("balance response mismatch: %+v", balance) } } func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) { walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{ TransactionId: "wtx-coin-seller", SellerBalanceAfter: 80000, TargetBalanceAfter: 80000, Amount: 80000, RechargeUsdMinor: 100, RechargeCurrencyCode: "USD", RechargePolicyId: 7, RechargePolicyVersion: "recharge-v1", RechargePolicyCoinAmount: 80000, RechargePolicyUsdMinorAmount: 100, }} hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1001}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"commandId":"cmd-seller-transfer","targetUserId":163002,"amount":80000,"reason":"player recharge"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-seller") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.last == nil || hostClient.last.GetUserId() != 42 { t.Fatalf("coin seller identity request mismatch: %+v", hostClient.last) } if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163002" { t.Fatalf("target display user id was not resolved: %+v", identityClient.lastResolve) } if walletClient.lastTransfer == nil || walletClient.lastTransfer.GetSellerUserId() != 42 || walletClient.lastTransfer.GetTargetUserId() != 900002 || walletClient.lastTransfer.GetSellerRegionId() != 1001 || walletClient.lastTransfer.GetTargetRegionId() != 1001 || walletClient.lastTransfer.GetAmount() != 80000 || walletClient.lastTransfer.GetCommandId() != "cmd-seller-transfer" { t.Fatalf("wallet transfer request mismatch: %+v", walletClient.lastTransfer) } if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 { t.Fatalf("coin seller region lookup mismatch: %+v", profileClient.getRequests) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["transaction_id"] != "wtx-coin-seller" || data["seller_balance_after"] != float64(80000) || data["recharge_usd_minor"] != float64(100) { t.Fatalf("coin seller transfer response mismatch: %+v", response) } } func TestCoinSellerTransferRejectsCrossRegionBeforeWallet(t *testing.T) { walletClient := &fakeWalletClient{} hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1002}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"command_id":"cmd-seller-cross-region","target_user_id":163002,"amount":80000,"reason":"player recharge"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-seller-cross-region") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastTransfer != nil { t.Fatalf("cross-region transfer must not reach wallet-service: %+v", walletClient.lastTransfer) } } func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"country":"US"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-country") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastCountry == nil || profileClient.lastCountry.GetUserId() != 42 || profileClient.lastCountry.GetCountry() != "US" { t.Fatalf("country change request mismatch: %+v", profileClient.lastCountry) } profileClient = &fakeUserProfileClient{countryErr: xerr.ToGRPCError(xerr.New(xerr.CountryChangeCooldown, "country change is cooling down"))} router = NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request = httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-country-cooldown") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown") } func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-onboarding") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete) } if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" { t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete) } if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" { t.Fatalf("complete onboarding must propagate current session_id: %+v", profileClient.lastComplete.GetMeta()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" { t.Fatalf("unexpected onboarding response: %+v", response) } token, ok := data["token"].(map[string]any) if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true { t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"]) } } func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) { tests := []struct { name string path string body string }{ {name: "profile_update", path: "/api/v1/users/me/profile/update", body: `{"username":"hy"}`}, {name: "country_change", path: "/api/v1/users/me/country/change", body: `{"country":"US"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-profile-mutation") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-mutation") if profileClient.lastUpdate != nil || profileClient.lastCountry != nil { t.Fatalf("profile gate must stop mutation before user-service: update=%+v country=%+v", profileClient.lastUpdate, profileClient.lastCountry) } }) } } func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testing.T) { tests := []struct { name string method string path string body string }{ {name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"}, {name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`}, {name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`}, {name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`}, {name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"}, {name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`}, {name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }) handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-profile-required") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-required") if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || roomClient.lastGift != nil { t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient) } }) } } func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...) body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", payload) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-upload-avatar") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode upload response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected upload envelope: %+v", response) } if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".png") { t.Fatalf("unexpected avatar object key: %q", uploader.lastKey) } if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/png" { t.Fatalf("uploaded object mismatch: key=%s size=%d type=%s body=%x", uploader.lastKey, uploader.lastSizeBytes, uploader.lastContentType, uploader.lastContent) } if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" { t.Fatalf("unexpected upload data: %+v", data) } } func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", []byte("not an image")) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-upload-avatar-invalid") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-upload-avatar-invalid") if uploader.lastKey != "" { t.Fatalf("invalid avatar must not be uploaded: %+v", uploader) } } func TestUploadFileRequiresObjectUploader(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) body, contentType := multipartUploadBody(t, "file", "document.txt", "text/plain", []byte("hello")) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upload-disabled") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upload-disabled") } func TestAuthLoginMapsGRPCReason(t *testing.T) { userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"100001","password":"bad","device_id":"ios"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-failed") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.AuthFailed), "req-auth-failed") } func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) { // room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。 statusCode, code, message := mapReasonToHTTP(xerr.Conflict) if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" { t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message) } } func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`))) request.Header.Set("X-Request-ID", "req-auth") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusUnauthorized, codeUnauthorized, "req-auth") } func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidJSON, "req-json") } func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":8,"mode":"voice","room_name":"Lobby"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-generated-room-id") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if client.lastCreate == nil { t.Fatal("CreateRoom must be forwarded after gateway generates room_id") } if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "room:1" { t.Fatalf("client room_id must not be forwarded: %q", roomID) } } func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { tests := []struct { name string body string }{ {name: "seat_count", body: `{"room_id":"room-1","mode":"voice","room_name":"Lobby"}`}, {name: "mode", body: `{"room_id":"room-1","seat_count":8,"room_name":"Lobby"}`}, {name: "room_name", body: `{"room_id":"room-1","seat_count":8,"mode":"voice"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-create-required-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-create-required-"+test.name) if client.lastCreate != nil { t.Fatalf("missing required create field must not be forwarded: %+v", client.lastCreate) } }) } } func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","seat_count":8,"mode":"voice","room_name":"Lobby"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upstream") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-upstream") } func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) { roomClient := &fakeRoomClient{ createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")), } router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-owner-2","seat_count":8,"mode":"voice","room_name":"Lobby"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-owner-conflict") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-owner-conflict") } func TestJoinRoomClosedWritesRoomClosedEnvelope(t *testing.T) { roomClient := &fakeRoomClient{ joinErr: xerr.ToGRPCError(xerr.New(xerr.RoomClosed, "room closed")), } router := NewHandler(roomClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-closed","command_id":"cmd-join-closed"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-closed") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.RoomClosed), "room closed", "req-room-closed") } func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) { router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-usersig") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" { t.Fatalf("unexpected usersig response: %+v", response) } missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, UserSigTTL: time.Hour, }).Routes(auth.NewVerifier("secret")) missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) missingConfigRequest.Header.Set("X-Request-ID", "req-usersig-missing") missingConfigRecorder := httptest.NewRecorder() missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest) assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, codeInternalError, "req-usersig-missing") } func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) { previousNow := timeNow timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } defer func() { timeNow = previousNow }() guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: 2 * time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-token") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != codeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["user_id"] != "42" || data["rtc_user_id"] != "42" || data["room_id"] != "room_1001" || data["str_room_id"] != "room_1001" { t.Fatalf("unexpected rtc identity mapping: %+v", data) } if data["rtc_room_id_type"] != "string" || data["app_scene"] != "voice_chat_room" || data["role"] != "audience" { t.Fatalf("unexpected rtc policy fields: %+v", data) } if data["user_sig"] == "" || int64(data["expire_at_ms"].(float64)) != time.Unix(1_700_000_000, 0).Add(2*time.Hour).UnixMilli() { t.Fatalf("unexpected rtc usersig fields: %+v", data) } if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room_1001" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != "req-rtc-token" { t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence) } } func TestTencentRTCTokenRejectsInvalidRoomIDBeforeGuard(t *testing.T) { guard := &fakeRoomGuardClient{} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room:1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-invalid") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-rtc-invalid") if guard.lastPresence != nil { t.Fatalf("invalid room_id must not call presence guard: %+v", guard.lastPresence) } } func TestTencentRTCTokenMapsPresenceDenialReasons(t *testing.T) { tests := []struct { name string reason string statusCode int code string }{ {name: "not_in_room", reason: "not_in_room", statusCode: http.StatusForbidden, code: codePermissionDenied}, {name: "user_banned", reason: "user_banned", statusCode: http.StatusForbidden, code: codePermissionDenied}, {name: "room_not_found", reason: "room_not_found", statusCode: http.StatusNotFound, code: codeNotFound}, {name: "room_closed", reason: "room_closed", statusCode: http.StatusConflict, code: string(xerr.RoomClosed)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: test.reason, RoomVersion: 3}} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, test.statusCode, test.code, "req-rtc-"+test.name) }) } } func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) { missingConfigGuard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}} missingConfigHandler := NewHandlerWithConfig(&fakeRoomClient{}, missingConfigGuard, nil, nil, TencentIMConfig{}) missingConfigHandler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) missingConfigRouter := missingConfigHandler.Routes(auth.NewVerifier("secret")) missingConfigRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) missingConfigRequest.Header.Set("X-Request-ID", "req-rtc-config") missingConfigRecorder := httptest.NewRecorder() missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest) assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, codeInternalError, "req-rtc-config") if missingConfigGuard.lastPresence != nil { t.Fatalf("missing rtc config must fail before room-service guard: %+v", missingConfigGuard.lastPresence) } upstreamGuard := &fakeRoomGuardClient{err: errors.New("room-service unavailable")} upstreamHandler := NewHandlerWithConfig(&fakeRoomClient{}, upstreamGuard, nil, nil, TencentIMConfig{}) upstreamHandler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) upstreamRouter := upstreamHandler.Routes(auth.NewVerifier("secret")) upstreamRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) upstreamRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) upstreamRequest.Header.Set("X-Request-ID", "req-rtc-upstream") upstreamRecorder := httptest.NewRecorder() upstreamRouter.ServeHTTP(upstreamRecorder, upstreamRequest) assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, codeUpstreamError, "req-rtc-upstream") } func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ SDKAppID: 1400000000, CallbackSignKey: "callback-key", }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"EventGroupId":2,"EventType":203,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback?app_code=lalu", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-rtc-callback") request.Header.Set("SdkAppId", "1400000000") request.Header.Set("Sign", signTencentRTCCallback("callback-key", body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentRTCCallback(t, recorder, http.StatusOK, 0) if roomClient.lastRTCEvent == nil { t.Fatal("RTC callback must forward known events to room-service") } if roomClient.lastRTCEvent.GetEventType() != "audio_started" || roomClient.lastRTCEvent.GetTargetUserId() != 42 || roomClient.lastRTCEvent.GetEventTimeMs() != 1700000001000 { t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent) } meta := roomClient.lastRTCEvent.GetMeta() if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != "req-rtc-callback" || meta.GetAppCode() != "lalu" { t.Fatalf("RTC event meta mismatch: %+v", meta) } if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") { t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent) } } func TestTencentRTCCallbackRejectsInvalidSignatureAndIgnoresUnknownEvents(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ SDKAppID: 1400000000, CallbackSignKey: "callback-key", }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"EventGroupId":2,"EventType":204,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42","Reason":0}}`) badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(body)) badRequest.Header.Set("SdkAppId", "1400000000") badRequest.Header.Set("Sign", "bad-sign") badRecorder := httptest.NewRecorder() router.ServeHTTP(badRecorder, badRequest) assertTencentRTCCallback(t, badRecorder, http.StatusForbidden, 1) if roomClient.lastRTCEvent != nil { t.Fatalf("invalid signature must not reach room-service: %+v", roomClient.lastRTCEvent) } unknownBody := []byte(`{"EventGroupId":2,"EventType":201,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`) unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(unknownBody)) unknownRequest.Header.Set("SdkAppId", "1400000000") unknownRequest.Header.Set("Sign", signTencentRTCCallback("callback-key", unknownBody)) unknownRecorder := httptest.NewRecorder() router.ServeHTTP(unknownRecorder, unknownRequest) assertTencentRTCCallback(t, unknownRecorder, http.StatusOK, 0) if roomClient.lastRTCEvent != nil { t.Fatalf("unknown RTC event must be acknowledged without room-service call: %+v", roomClient.lastRTCEvent) } } func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) { guard := &fakeRoomGuardClient{} router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, CallbackAuthToken: "callback-token", }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"room-1","Requestor_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-im-join") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 0) if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != "req-im-join" { t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence) } guard.presenceResp = &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: "not_in_room"} deniedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body)) deniedRecorder := httptest.NewRecorder() router.ServeHTTP(deniedRecorder, deniedRequest) assertTencentCallback(t, deniedRecorder, http.StatusOK, 1) } func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) { guard := &fakeRoomGuardClient{ speakResp: &roomv1.CheckSpeakPermissionResponse{Allowed: false, Reason: "user_muted"}, } router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 1) if guard.lastSpeak == nil || guard.lastSpeak.GetRoomId() != "room-1" || guard.lastSpeak.GetUserId() != 42 { t.Fatalf("speak guard request mismatch: %+v", guard.lastSpeak) } } func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) { router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, CallbackAuthToken: "callback-token", }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&CallbackAuthToken=bad-token", bytes.NewReader(body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 1) unknownBody := []byte(`{"CallbackCommand":"Group.CallbackAfterSendMsg","GroupId":"room-1","From_Account":"42"}`) unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&CallbackAuthToken=callback-token", bytes.NewReader(unknownBody)) unknownRecorder := httptest.NewRecorder() router.ServeHTTP(unknownRecorder, unknownRequest) assertTencentCallback(t, unknownRecorder, http.StatusOK, 0) } func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, requestID string) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != code || response.RequestID != requestID { t.Fatalf("unexpected envelope: %+v", response) } } func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, message string, requestID string) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response responseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != code || response.Message != message || response.RequestID != requestID { t.Fatalf("unexpected envelope: %+v", response) } } func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, errorCode int) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response tencentIMCallbackResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode tencent callback response failed: %v", err) } if response.ActionStatus != "OK" || response.ErrorCode != errorCode { t.Fatalf("unexpected tencent callback response: %+v", response) } } func assertTencentRTCCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code int) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response tencentRTCCallbackResponse if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode tencent rtc callback response failed: %v", err) } if response.Code != code { t.Fatalf("unexpected tencent rtc callback response: %+v", response) } } func signTencentRTCCallback(key string, body []byte) string { mac := hmac.New(sha256.New, []byte(key)) _, _ = mac.Write(body) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig { config := AuthRateLimitConfig{ Enabled: true, Window: time.Minute, IPLimit: 1000, DeviceIDLimit: 1000, ProviderIPLimit: 1000, DisplayUserIDIPLimit: 1000, DisplayUserIDLimit: 1000, SessionIDIPLimit: 1000, } mutator(&config) return config } func signGatewayToken(t *testing.T, secret string, userID int64) string { t.Helper() return signGatewayTokenWithProfile(t, secret, userID, true) } func signGatewayTokenWithProfile(t *testing.T, secret string, userID int64, profileCompleted bool) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": strconv.FormatInt(userID, 10), "app_code": "lalu", "sid": "sess-test", "profile_completed": profileCompleted, "onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted], }) signed, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("sign token failed: %v", err) } return signed } func multipartUploadBody(t *testing.T, fieldName string, filename string, contentType string, payload []byte) (*bytes.Buffer, string) { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) header := make(textproto.MIMEHeader) header.Set("Content-Disposition", `form-data; name="`+fieldName+`"; filename="`+filename+`"`) header.Set("Content-Type", contentType) part, err := writer.CreatePart(header) if err != nil { t.Fatalf("create multipart part failed: %v", err) } if _, err := part.Write(payload); err != nil { t.Fatalf("write multipart payload failed: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer failed: %v", err) } return &body, writer.FormDataContentType() }