Files
affiliate_dash/backend/internal/service/merchant_member_security_test.go

537 lines
20 KiB
Go

package service
import (
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func TestSetMemberPhoneIsMerchantScopedAndProtectsOwner(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "scope-a", "scope-owner-a", 1)
merchantB, _ := seedMerchantWithOwner(t, db, "scope-b", "scope-owner-b", 1)
staff := model.User{Username: "scope-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
staffMember := model.MerchantMember{MerchantID: merchantA, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&staffMember).Error; err != nil {
t.Fatalf("create staff member: %v", err)
}
// 本商户内可以正常绑定。
if _, err := svc.SetMemberPhone(merchantA, staffMember.ID, "13800001111", ownerAUserID); err != nil {
t.Fatalf("bind phone in own merchant: %v", err)
}
var loaded model.User
if err := db.First(&loaded, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if loaded.Phone != "13800001111" {
t.Fatalf("phone not persisted: %q", loaded.Phone)
}
// 跨商户不能操作别人的成员。
if _, err := svc.SetMemberPhone(merchantB, staffMember.ID, "13800002222", ownerAUserID); err == nil {
t.Fatal("binding a member through another merchant must be rejected")
}
// 负责人账号不能被普通成员改绑手机号。
var ownerMember model.MerchantMember
if err := db.Where("merchant_id = ? AND user_id = ?", merchantA, ownerAUserID).First(&ownerMember).Error; err != nil {
t.Fatalf("load owner member: %v", err)
}
if _, err := svc.SetMemberPhone(merchantA, ownerMember.ID, "13800003333", staff.ID); err == nil {
t.Fatal("a staff member must not rebind the owner's phone")
}
// 成员列表要带上手机号掩码。
members, err := svc.ListMembers(merchantA)
if err != nil {
t.Fatalf("list members: %v", err)
}
found := false
for _, member := range members {
if member.UserID != staff.ID {
continue
}
found = true
if member.User == nil || member.User.PhoneMasked != "138****1111" {
t.Fatalf("phone mask missing in member list: %+v", member.User)
}
}
if !found {
t.Fatal("staff member missing from list")
}
}
func TestResetMemberDeviceRevokesActiveDevice(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "device-scope", "device-owner", 1)
staff := model.User{Username: "device-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: staff.ID,
DeviceID: "D1-AAAA-BBBB-CCCC-DDDD",
PublicKey: "pk",
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
// 其他商户不能重置。
otherMerchantID, _ := seedMerchantWithOwner(t, db, "device-other", "device-other-owner", 1)
if _, err := svc.ResetMemberDevice(otherMerchantID, member.ID, ownerUserID); err == nil {
t.Fatal("resetting a member through another merchant must be rejected")
}
reset, err := svc.ResetMemberDevice(merchantID, member.ID, ownerUserID)
if err != nil {
t.Fatalf("reset device: %v", err)
}
if !reset {
t.Fatal("expected an active device to be revoked")
}
var reloaded model.UserDevice
if err := db.First(&reloaded, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if reloaded.Status != model.UserDeviceStatusRevoked {
t.Fatalf("device status = %q, want revoked", reloaded.Status)
}
if reloaded.RevokedBy == nil || *reloaded.RevokedBy != ownerUserID {
t.Fatalf("revoked_by = %v, want %d", reloaded.RevokedBy, ownerUserID)
}
}
func TestSetMemberPhoneRejectsInvalidPhone(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "phone-check", "phone-check-owner", 1)
staff := model.User{Username: "phone-check-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
if _, err := svc.SetMemberPhone(merchantID, member.ID, "12345", ownerUserID); err == nil {
t.Fatal("invalid phone must be rejected")
}
if _, err := svc.SetMemberPhone(merchantID, member.ID, " ", ownerUserID); err == nil {
t.Fatal("empty phone must be rejected")
}
}
func seedOperatorRole(t *testing.T, db *gorm.DB, merchantID uint) {
t.Helper()
if err := db.Create(&model.MerchantRole{
MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营",
Permissions: model.PermissionOrdersManage, Status: 1,
}).Error; err != nil {
t.Fatalf("create operator role: %v", err)
}
}
func TestUpdateMemberCanRenameAccountAndReleaseOldName(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "rename-merchant", "rename-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "old-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "new-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID)
if err != nil {
t.Fatalf("rename member: %v", err)
}
if updated.User == nil || updated.User.Username != "new-staff" {
t.Fatalf("unexpected member after rename: %+v", updated.User)
}
// 改名后旧用户名应立即释放。
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "old-staff", Password: "secret1", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("old username should be reusable: %v", err)
}
if created.User == nil || created.User.Username != "old-staff" {
t.Fatalf("unexpected recreated member: %+v", created.User)
}
}
func TestRemoveMemberReleasesUsernamePhoneAndDevice(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "release-merchant", "release-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "release-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1, Phone: "13800009999"}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: staff.ID, DeviceID: "D1-RELE-ASE0-0000-0001", PublicKey: "pk",
ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive,
BoundAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
if err := svc.RemoveMember(merchantID, member.ID, ownerUserID); err != nil {
t.Fatalf("remove member: %v", err)
}
var oldUser model.User
if err := db.Unscoped().First(&oldUser, staff.ID).Error; err != nil {
t.Fatalf("reload removed user: %v", err)
}
if !oldUser.DeletedAt.Valid {
t.Fatal("account without memberships should be soft-deleted")
}
if oldUser.Phone != "" {
t.Fatalf("phone should be released, got %q", oldUser.Phone)
}
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "release-staff", Password: "secret1", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("username should be reusable after removal: %v", err)
}
if _, err := svc.SetMemberPhone(merchantID, created.ID, "13800009999", ownerUserID); err != nil {
t.Fatalf("phone should be reusable after removal: %v", err)
}
var reloaded model.UserDevice
if err := db.First(&reloaded, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if reloaded.Status != model.UserDeviceStatusRevoked {
t.Fatalf("device should be revoked on removal, got %q", reloaded.Status)
}
}
func TestUpdateMemberCanResetPassword(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "password-merchant", "password-owner", 1)
seedOperatorRole(t, db, merchantID)
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
staff := model.User{Username: "password-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Password: "newpass123", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("reset password: %v", err)
}
var user model.User
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("newpass123")); err != nil {
t.Fatalf("new password should work: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("oldpass123")); err == nil {
t.Fatal("old password should no longer work")
}
// 留空表示不修改。
before := user.PasswordHash
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("update without password: %v", err)
}
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if user.PasswordHash != before {
t.Fatal("empty password must keep the current hash")
}
// 过短密码拒绝。
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "password-staff", Password: "123", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err == nil {
t.Fatal("short password must be rejected")
}
}
func TestUpdateMemberCanChangeNickname(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "nickname-merchant", "nickname-owner", 1)
seedOperatorRole(t, db, merchantID)
staff := model.User{Username: "nickname-staff", PasswordHash: "h", Nickname: "旧昵称", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
nickname := " 新昵称 "
updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "nickname-staff", Nickname: &nickname, Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID)
if err != nil {
t.Fatalf("change nickname: %v", err)
}
if updated.User == nil || updated.User.Nickname != "新昵称" {
t.Fatalf("unexpected nickname after update: %+v", updated.User)
}
// 不传昵称时保持原值。
if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{
Username: "nickname-staff", Role: model.MemberRoleOperator, Status: 1,
}, ownerUserID); err != nil {
t.Fatalf("update without nickname: %v", err)
}
var user model.User
if err := db.First(&user, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if user.Nickname != "新昵称" {
t.Fatalf("nickname should be kept, got %q", user.Nickname)
}
}
func TestAddMemberReactivatesDisabledMemberWithSameUsername(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "reuse-merchant", "reuse-owner", 1)
seedOperatorRole(t, db, merchantID)
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
staff := model.User{
Username: "kf_10", PasswordHash: string(oldHash), Nickname: "旧姓名",
Role: model.RoleMerchant, Status: 1, Phone: "13800001234",
}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
member := model.MerchantMember{
MerchantID: merchantID, UserID: staff.ID, Role: "support", Status: 1, IsDefault: true,
}
if err := db.Create(&member).Error; err != nil {
t.Fatalf("create member: %v", err)
}
if err := db.Model(&member).Update("status", 0).Error; err != nil {
t.Fatalf("disable member: %v", err)
}
member.Status = 0
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("re-add disabled member: %v", err)
}
if created.UserID != staff.ID {
t.Fatalf("reused user id = %d, want %d", created.UserID, staff.ID)
}
if created.Status != 1 || created.Role != model.MemberRoleOperator || !created.IsDefault {
t.Fatalf("unexpected reactivated member: %+v", created)
}
if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" {
t.Fatalf("unexpected reactivated user: %+v", created.User)
}
var reloaded model.User
if err := db.First(&reloaded, staff.ID).Error; err != nil {
t.Fatalf("reload staff: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("yaochao11")); err != nil {
t.Fatalf("new password should work: %v", err)
}
if reloaded.Phone != "13800001234" {
t.Fatalf("existing phone binding should be preserved, got %q", reloaded.Phone)
}
var memberCount int64
if err := db.Model(&model.MerchantMember{}).
Where("merchant_id = ? AND user_id = ?", merchantID, staff.ID).
Count(&memberCount).Error; err != nil {
t.Fatalf("count memberships: %v", err)
}
if memberCount != 1 {
t.Fatalf("membership count = %d, want 1", memberCount)
}
}
func TestAddMemberDoesNotReclaimActiveOrForeignAccount(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "reclaim-a", "reclaim-owner-a", 1)
merchantB, ownerBUserID := seedMerchantWithOwner(t, db, "reclaim-b", "reclaim-owner-b", 1)
seedOperatorRole(t, db, merchantA)
seedOperatorRole(t, db, merchantB)
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
active := model.User{Username: "active-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
if err := db.Create(&active).Error; err != nil {
t.Fatalf("create active staff: %v", err)
}
if err := db.Create(&model.MerchantMember{
MerchantID: merchantA, UserID: active.ID, Role: model.MemberRoleOperator, Status: 1,
}).Error; err != nil {
t.Fatalf("create active member: %v", err)
}
if _, err := svc.AddMember(merchantA, AddMemberInput{
Username: "active-staff", Password: "newpass123", Role: model.MemberRoleOperator,
}, ownerAUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
t.Fatalf("active account must not be re-added, got %v", err)
}
foreign := model.User{Username: "foreign-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1}
if err := db.Create(&foreign).Error; err != nil {
t.Fatalf("create foreign staff: %v", err)
}
foreignMember := model.MerchantMember{
MerchantID: merchantA, UserID: foreign.ID, Role: model.MemberRoleOperator, Status: 1,
}
if err := db.Create(&foreignMember).Error; err != nil {
t.Fatalf("create foreign member: %v", err)
}
if err := db.Model(&foreignMember).Update("status", 0).Error; err != nil {
t.Fatalf("disable foreign member: %v", err)
}
if _, err := svc.AddMember(merchantB, AddMemberInput{
Username: "foreign-staff", Password: "newpass123", Role: model.MemberRoleOperator,
}, ownerBUserID); err == nil || !strings.Contains(err.Error(), "员工用户名已存在") {
t.Fatalf("another merchant must not reclaim the account, got %v", err)
}
var reloaded model.User
if err := db.First(&reloaded, foreign.ID).Error; err != nil {
t.Fatalf("reload foreign staff: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(reloaded.PasswordHash), []byte("oldpass123")); err != nil {
t.Fatalf("foreign account password must remain unchanged: %v", err)
}
}
func TestAddMemberReplacesOrphanedAccountWithSameUsername(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerUserID := seedMerchantWithOwner(t, db, "orphan-merchant", "orphan-owner", 1)
seedOperatorRole(t, db, merchantID)
oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash old password: %v", err)
}
orphan := model.User{
Username: "kf_10", PasswordHash: string(oldHash), Nickname: "历史账号",
Role: model.RoleMerchant, Status: 1, Phone: "13800005678",
}
if err := db.Create(&orphan).Error; err != nil {
t.Fatalf("create orphan user: %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: orphan.ID, DeviceID: "D1-ORPH-AN00-0000-0001", PublicKey: "pk",
ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive,
BoundAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create orphan device: %v", err)
}
created, err := svc.AddMember(merchantID, AddMemberInput{
Username: "kf_10", Password: "yaochao11", Nickname: "测试2", Role: model.MemberRoleOperator,
}, ownerUserID)
if err != nil {
t.Fatalf("recreate orphaned account: %v", err)
}
if created.UserID == orphan.ID {
t.Fatal("orphaned account should be replaced by a fresh user row")
}
if created.User == nil || created.User.Username != "kf_10" || created.User.Nickname != "测试2" {
t.Fatalf("unexpected new user: %+v", created.User)
}
if err := bcrypt.CompareHashAndPassword([]byte(created.User.PasswordHash), []byte("yaochao11")); err != nil {
t.Fatalf("new password should work: %v", err)
}
var retired model.User
if err := db.Unscoped().First(&retired, orphan.ID).Error; err != nil {
t.Fatalf("reload retired user: %v", err)
}
if !retired.DeletedAt.Valid {
t.Fatal("orphaned account should be soft-deleted")
}
if retired.Phone != "" {
t.Fatalf("orphaned account phone should be released, got %q", retired.Phone)
}
var reloadedDevice model.UserDevice
if err := db.First(&reloadedDevice, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if reloadedDevice.Status != model.UserDeviceStatusRevoked {
t.Fatalf("orphaned account device should be revoked, got %q", reloadedDevice.Status)
}
}