2026-04-30 02:30:32 +08:00

1457 lines
60 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
roomv1 "hyapp/api/proto/room/v1"
userv1 "hyapp/api/proto/user/v1"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
// fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。
// 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。
type fakeRoomClient struct {
lastCreate *roomv1.CreateRoomRequest
lastJoin *roomv1.JoinRoomRequest
lastLeave *roomv1.LeaveRoomRequest
lastMicUp *roomv1.MicUpRequest
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
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
}
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
return &roomv1.JoinRoomResponse{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) 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
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastCountry *userv1.ChangeUserCountryRequest
getErr error
completeErr error
countryErr error
regionID int64
}
type fakeRoomQueryClient struct {
lastList *roomv1.ListRoomsRequest
resp *roomv1.ListRoomsResponse
err error
}
type fakeUserCountryQueryClient struct {
last *userv1.ListRegistrationCountriesRequest
resp *userv1.ListRegistrationCountriesResponse
err 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
if f.getErr != nil {
return nil, f.getErr
}
regionID := f.regionID
if regionID == 0 {
// 测试默认区域固定为 1001避免新增入口在未配置 fake region 时误走 GLOBAL 桶。
regionID = 1001
}
return &userv1.GetUserResponse{User: &userv1.User{
UserId: req.GetUserId(),
DisplayUserId: "100001",
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),
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 *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 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
}
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":"room-1","seat_count":8,"mode":"voice"}`)
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 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)
}
}
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"}`,
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: "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=new&limit=2&cursor=cursor-1&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() != "new" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" {
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 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 TestPasswordLoginRateLimitByDisplayUserID(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(`{"display_user_id":"100001","password":"bad","device_id":"dev-1"}`)
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/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 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","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.Birth) != "2000-01-02" {
t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate)
}
}
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","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.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: "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.lastGift != nil {
t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient)
}
})
}
}
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(`{"display_user_id":"100001","password":"bad","device_id":"ios"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/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 TestCreateRoomRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandler(client).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"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-invalid-room-id")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-invalid-room-id")
if client.lastCreate != nil {
t.Fatalf("invalid room_id must not be forwarded to room-service: %+v", client.lastCreate)
}
}
func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "room_id", body: `{"seat_count":8,"mode":"voice"}`},
{name: "seat_count", body: `{"room_id":"room-1","mode":"voice"}`},
{name: "mode", body: `{"room_id":"room-1","seat_count":8}`},
}
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"}`)
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 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},
}
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 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 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 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": float64(userID),
"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
}