2026-04-27 02:29:42 +08:00

419 lines
16 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
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, *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) LeaveRoom(context.Context, *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) MicUp(context.Context, *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) MicDown(context.Context, *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) ChangeMicSeat(context.Context, *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) MuteUser(context.Context, *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) KickUser(context.Context, *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) {
return nil, nil
}
func (f *fakeRoomClient) SendGift(context.Context, *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
return nil, nil
}
type fakeUserAuthClient struct {
lastSetPassword *userv1.SetPasswordRequest
loginErr error
}
func (f *fakeUserAuthClient) LoginPassword(context.Context, *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
if f.loginErr != nil {
return nil, f.loginErr
}
return &userv1.AuthResponse{Token: &userv1.AuthToken{UserId: 10001}}, nil
}
func (f *fakeUserAuthClient) LoginThirdParty(context.Context, *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
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, *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) {
return nil, nil
}
func (f *fakeUserAuthClient) Logout(context.Context, *userv1.LogoutRequest) (*userv1.LogoutResponse, error) {
return nil, nil
}
// 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","owner_user_id":42,"host_user_id":42,"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())
}
}
func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"wechat","credential":"openid-1","device_id":"ios"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-auth-third")
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)
}
}
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 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 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 TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
router := NewHandler(&fakeRoomClient{createErr: errors.New("room unavailable")}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"room_id":"room-1","owner_user_id":42,"host_user_id":42,"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 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))
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 {
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 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
}