fix: show region mismatch for role invitations
This commit is contained in:
parent
7d8ef54ca7
commit
2a7fff405d
@ -62,6 +62,7 @@ var catalog = map[Code]Spec{
|
||||
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
|
||||
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
|
||||
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
|
||||
RegionMismatch: spec(codes.PermissionDenied, httpStatusForbidden, RegionMismatch, "不是同一个地区"),
|
||||
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
|
||||
DeviceAlreadyRegistered: spec(
|
||||
codes.FailedPrecondition,
|
||||
|
||||
@ -69,6 +69,8 @@ const (
|
||||
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
|
||||
// RegionDisabled 表示区域已经停用,不能继续配置国家。
|
||||
RegionDisabled Code = "REGION_DISABLED"
|
||||
// RegionMismatch 表示当前动作要求双方同一区域,但用户、组织或上级身份的实时区域不一致。
|
||||
RegionMismatch Code = "REGION_MISMATCH"
|
||||
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
||||
|
||||
@ -94,6 +94,14 @@ func TestCatalogMappings(t *testing.T) {
|
||||
publicCode: string(DeviceAlreadyRegistered),
|
||||
publicMessage: "device already registered",
|
||||
},
|
||||
{
|
||||
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
|
||||
code: RegionMismatch,
|
||||
grpcCode: codes.PermissionDenied,
|
||||
httpStatus: httpStatusForbidden,
|
||||
publicCode: string(RegionMismatch),
|
||||
publicMessage: "不是同一个地区",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@ -5474,6 +5474,84 @@ func TestAgencyCenterInviteHostCreatesPendingInvitation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationRegionMismatchEnvelopeUsesConcreteMessage(t *testing.T) {
|
||||
regionErr := xerr.ToGRPCError(xerr.New(xerr.RegionMismatch, "target region does not match inviter region"))
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
hostSetup func() *fakeUserHostClient
|
||||
reached func(*fakeUserHostClient) bool
|
||||
}{
|
||||
{
|
||||
name: "agency_owner_invite_host",
|
||||
path: "/api/v1/agency-center/invitations/host",
|
||||
body: `{"command_id":"cmd-host-region","target_user_id":"100"}`,
|
||||
hostSetup: func() *fakeUserHostClient {
|
||||
return &fakeUserHostClient{
|
||||
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
|
||||
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
|
||||
inviteHostErr: regionErr,
|
||||
}
|
||||
},
|
||||
reached: func(client *fakeUserHostClient) bool { return client.lastInviteHost != nil },
|
||||
},
|
||||
{
|
||||
name: "bd_invite_agency",
|
||||
path: "/api/v1/bd/invitations/agency",
|
||||
body: `{"command_id":"cmd-agency-region","target_user_id":"100","agency_name":"Target Agency"}`,
|
||||
hostSetup: func() *fakeUserHostClient {
|
||||
return &fakeUserHostClient{
|
||||
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
|
||||
inviteAgencyErr: regionErr,
|
||||
}
|
||||
},
|
||||
reached: func(client *fakeUserHostClient) bool { return client.lastInviteAgency != nil },
|
||||
},
|
||||
{
|
||||
name: "bd_leader_invite_bd",
|
||||
path: "/api/v1/bd-leader/invitations/bd",
|
||||
body: `{"command_id":"cmd-bd-region","target_user_id":"100"}`,
|
||||
hostSetup: func() *fakeUserHostClient {
|
||||
return &fakeUserHostClient{
|
||||
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001},
|
||||
inviteBDErr: regionErr,
|
||||
}
|
||||
},
|
||||
reached: func(client *fakeUserHostClient) bool { return client.lastInviteBD != nil },
|
||||
},
|
||||
{
|
||||
name: "target_accept_invitation_after_region_changed",
|
||||
path: "/api/v1/role-invitations/9011/process",
|
||||
body: `{"command_id":"cmd-accept-region","action":"accept"}`,
|
||||
hostSetup: func() *fakeUserHostClient {
|
||||
return &fakeUserHostClient{processInviteErr: regionErr}
|
||||
},
|
||||
reached: func(client *fakeUserHostClient) bool { return client.lastProcessInvite != nil },
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hostClient := test.hostSetup()
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.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)
|
||||
|
||||
assertEnvelopeMessage(t, recorder, http.StatusForbidden, string(xerr.RegionMismatch), "不是同一个地区", "req-"+test.name)
|
||||
if !test.reached(hostClient) {
|
||||
t.Fatalf("route did not reach role-invitation RPC for %s", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{
|
||||
|
||||
@ -596,6 +596,147 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationCreateRejectsCrossRegionWithRegionMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedActiveUser(t, repository, 711, 10)
|
||||
seedActiveUser(t, repository, 712, 20)
|
||||
seedActiveUser(t, repository, 721, 10)
|
||||
seedActiveUser(t, repository, 722, 20)
|
||||
seedActiveUser(t, repository, 731, 10)
|
||||
seedActiveUser(t, repository, 732, 20)
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 711,
|
||||
Role: hostdomain.BDRoleBD,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.BDStatusActive,
|
||||
})
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 721,
|
||||
Role: hostdomain.BDRoleLeader,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.BDStatusActive,
|
||||
})
|
||||
repository.PutAgency(hostdomain.Agency{
|
||||
AgencyID: 733,
|
||||
OwnerUserID: 731,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.AgencyStatusActive,
|
||||
JoinEnabled: true,
|
||||
})
|
||||
svc := newHostService(repository, 5000, 6000)
|
||||
|
||||
// 三类 H5 邀请共用 RoleInvitation 事实;跨区属于明确业务边界,不应该退化成泛化权限拒绝。
|
||||
if _, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||
CommandID: "invite-agency-cross-region",
|
||||
InviterUserID: 711,
|
||||
TargetUserID: 712,
|
||||
AgencyName: "Cross Region Agency",
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("cross-region agency invite should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||
CommandID: "invite-bd-cross-region",
|
||||
InviterUserID: 721,
|
||||
TargetUserID: 722,
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("cross-region bd invite should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
|
||||
CommandID: "invite-host-cross-region",
|
||||
InviterUserID: 731,
|
||||
TargetUserID: 732,
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("cross-region host invite should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationAcceptRejectsRegionDriftWithRegionMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedActiveUser(t, repository, 741, 10)
|
||||
seedActiveUser(t, repository, 742, 10)
|
||||
seedActiveUser(t, repository, 751, 10)
|
||||
seedActiveUser(t, repository, 752, 10)
|
||||
seedActiveUser(t, repository, 761, 10)
|
||||
seedActiveUser(t, repository, 762, 10)
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 741,
|
||||
Role: hostdomain.BDRoleBD,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.BDStatusActive,
|
||||
})
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 751,
|
||||
Role: hostdomain.BDRoleLeader,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.BDStatusActive,
|
||||
})
|
||||
repository.PutAgency(hostdomain.Agency{
|
||||
AgencyID: 763,
|
||||
OwnerUserID: 761,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.AgencyStatusActive,
|
||||
JoinEnabled: true,
|
||||
})
|
||||
svc := newHostService(repository, 5100, 6100)
|
||||
|
||||
agencyInvite, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||
CommandID: "invite-agency-before-drift",
|
||||
InviterUserID: 741,
|
||||
TargetUserID: 742,
|
||||
AgencyName: "Drift Agency",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InviteAgency before region drift failed: %v", err)
|
||||
}
|
||||
bdInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||
CommandID: "invite-bd-before-drift",
|
||||
InviterUserID: 751,
|
||||
TargetUserID: 752,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InviteBD before region drift failed: %v", err)
|
||||
}
|
||||
hostInvite, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
|
||||
CommandID: "invite-host-before-drift",
|
||||
InviterUserID: 761,
|
||||
TargetUserID: 762,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InviteHost before region drift failed: %v", err)
|
||||
}
|
||||
|
||||
// 邀请发出后用户可能被后台迁区;接受时必须重新读 users 当前区域,并给 H5 可解释的跨区错误。
|
||||
seedActiveUser(t, repository, 742, 20)
|
||||
seedActiveUser(t, repository, 752, 20)
|
||||
seedActiveUser(t, repository, 762, 20)
|
||||
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||
CommandID: "accept-agency-after-drift",
|
||||
ActorUserID: 742,
|
||||
InvitationID: agencyInvite.InvitationID,
|
||||
Action: hostdomain.InvitationActionAccept,
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("agency accept after region drift should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||
CommandID: "accept-bd-after-drift",
|
||||
ActorUserID: 752,
|
||||
InvitationID: bdInvite.InvitationID,
|
||||
Action: hostdomain.InvitationActionAccept,
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("bd accept after region drift should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||
CommandID: "accept-host-after-drift",
|
||||
ActorUserID: 762,
|
||||
InvitationID: hostInvite.InvitationID,
|
||||
Action: hostdomain.InvitationActionAccept,
|
||||
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||
t.Fatalf("host accept after region drift should return REGION_MISMATCH: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -45,7 +45,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if targetRegionID != inviter.RegionID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match inviter region")
|
||||
}
|
||||
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner,
|
||||
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency,这里仍先行拦截。
|
||||
@ -131,7 +131,7 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if targetRegionID != inviter.RegionID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match leader region")
|
||||
}
|
||||
// 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请,
|
||||
// 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。
|
||||
@ -209,7 +209,7 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if targetRegionID != agency.RegionID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match agency region")
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match agency region")
|
||||
}
|
||||
// Host 入会邀请不能绕过“一人一个有效 Agency 归属”的硬约束,接受时还会再次校验当前状态。
|
||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
@ -495,7 +495,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
||||
return false, false, err
|
||||
}
|
||||
if targetRegionID != inviterRegionID {
|
||||
return false, false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
||||
return false, false, xerr.New(xerr.RegionMismatch, "target region no longer matches invitation")
|
||||
}
|
||||
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。
|
||||
// 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。
|
||||
@ -580,7 +580,7 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma
|
||||
return false, err
|
||||
}
|
||||
if targetRegionID != agency.RegionID {
|
||||
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches agency")
|
||||
return false, xerr.New(xerr.RegionMismatch, "target region no longer matches agency")
|
||||
}
|
||||
// 接受时再次检查有效归属和 Agency owner 身份,覆盖邀请发出后用户已经加入或创建团队的情况。
|
||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||
@ -629,7 +629,7 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
|
||||
return err
|
||||
}
|
||||
if targetRegionID != leaderRegionID {
|
||||
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
||||
return xerr.New(xerr.RegionMismatch, "target region no longer matches invitation")
|
||||
}
|
||||
// BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖,
|
||||
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user