- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
354 lines
13 KiB
Go
354 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"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)
|
|
}
|
|
}
|