1140 lines
47 KiB
Go
1140 lines
47 KiB
Go
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 {
|
|
lastUpdate *userv1.UpdateUserProfileRequest
|
|
lastCountry *userv1.ChangeUserCountryRequest
|
|
countryErr 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",
|
|
},
|
|
IsNewUser: true,
|
|
}, 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"}}, 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) 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 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{}
|
|
router := NewHandler(client).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())
|
|
}
|
|
}
|
|
|
|
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 := NewHandler(client).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 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":"google","credential":"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.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":"google","credential":"id-token-1","device_id":"dev-1"}`)
|
|
|
|
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 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 := NewHandler(&fakeRoomClient{createErr: errors.New("room unavailable")}).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()
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"user_id": float64(userID),
|
|
})
|
|
signed, err := token.SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatalf("sign token failed: %v", err)
|
|
}
|
|
|
|
return signed
|
|
}
|