diff --git a/internal/admin/service.go b/internal/admin/service.go index 93fa8838..48119837 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -206,7 +206,12 @@ type UsersService interface { SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) SetScamFake(ctx context.Context, userID int64, scam, fake bool) (domain.User, error) SetSupport(ctx context.Context, userID int64, support bool) (domain.User, error) - UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) + // UpdateUsernameAdmin sets a username on the operator's behalf, bypassing + // the config.ReservedUsernames block (see users.Service.UpdateUsernameAdmin) -- + // self-service UpdateUsername enforces that list, but an operator + // deliberately assigning a reserved word to an account needs to be able + // to do so. + UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) @@ -1438,7 +1443,7 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm if req.DryRun { return CommandResult{Message: "dry-run completed", Details: details}, nil } - updated, err := s.users.UpdateUsername(ctx, req.UserID, username) + updated, err := s.users.UpdateUsernameAdmin(ctx, req.UserID, username) if err != nil { return CommandResult{}, err } diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index c9e564f7..9822fab0 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -781,6 +781,10 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern return u, nil } +func (f *fakeUsersService) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) { + return f.UpdateUsername(ctx, userID, username) +} + func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) { u, ok := f.users[userID] if !ok { diff --git a/internal/app/users/service.go b/internal/app/users/service.go index f10913b1..bbb1c2f1 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -318,8 +318,21 @@ func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, user return !found || u.ID == selfID, nil } -// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。 +// UpdateUsername 修改当前用户的主 username(self-service)。空字符串表示删除 username。 func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { + return s.updateUsername(ctx, userID, username, true) +} + +// UpdateUsernameAdmin sets a user's username on the operator's behalf (via +// the admin console). Unlike UpdateUsername, it does not consult +// config.ReservedUsernames -- an operator who deliberately reserved a word +// still needs to be able to hand it to a specific account. Validity and +// availability (no collision with another account) are still enforced. +func (s *Service) UpdateUsernameAdmin(ctx context.Context, userID int64, username string) (domain.User, error) { + return s.updateUsername(ctx, userID, username, false) +} + +func (s *Service) updateUsername(ctx context.Context, userID int64, username string, enforceReserved bool) (domain.User, error) { self, err := s.loadSelf(ctx, userID) if err != nil { return domain.User{}, err @@ -334,7 +347,7 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str return s.projectOne(ctx, self.ID, self) } if username != "" { - if !validUsername(username) || s.reserved.Contains(username) { + if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) { return domain.User{}, domain.ErrUsernameInvalid } ok, err := s.checkUsernameAvailable(ctx, self.ID, username) diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index 1e824114..9321367e 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -127,6 +127,30 @@ func TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting(t *testing.T) { } } +// TestServiceUpdateUsernameAdminBypassesReserved locks in that the admin +// console can deliberately assign a config.ReservedUsernames word to an +// account, even though self-service UpdateUsername refuses the same claim. +func TestServiceUpdateUsernameAdminBypassesReserved(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + target, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000005", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + svc := NewService(store, WithReservedUsernames([]string{"admin"})) + + if _, err := svc.UpdateUsername(ctx, target.ID, "admin"); !errors.Is(err, domain.ErrUsernameInvalid) { + t.Fatalf("self-service claim of reserved username err = %v, want username invalid", err) + } + u, err := svc.UpdateUsernameAdmin(ctx, target.ID, "admin") + if err != nil { + t.Fatalf("admin claim of reserved username: %v", err) + } + if u.Username != "admin" { + t.Fatalf("admin claim of reserved username: got username %q, want %q", u.Username, "admin") + } +} + // marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser() // for a fixed username lookup, since memory.UserStore.Create always assigns an // id from its own auto-increment sequence and can never produce the fixed