Merge branch 'feat/admin-username-reserved-bypass'
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Admin web build (push) Waiting to run
CI / Grammy store bot (push) Waiting to run
CI / Docker main topology smoke (push) Waiting to run

This commit is contained in:
Astra 2026-09-15 12:04:32 +01:00
commit fde4db01fa
4 changed files with 50 additions and 4 deletions

View file

@ -206,7 +206,12 @@ type UsersService interface {
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
SetScamFake(ctx context.Context, userID int64, scam, fake 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) 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) 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) UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (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 { if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil 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 { if err != nil {
return CommandResult{}, err return CommandResult{}, err
} }

View file

@ -781,6 +781,10 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
return u, nil 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) { func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
u, ok := f.users[userID] u, ok := f.users[userID]
if !ok { if !ok {

View file

@ -318,8 +318,21 @@ func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, user
return !found || u.ID == selfID, nil return !found || u.ID == selfID, nil
} }
// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。 // UpdateUsername 修改当前用户的主 usernameself-service。空字符串表示删除 username。
func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { 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) self, err := s.loadSelf(ctx, userID)
if err != nil { if err != nil {
return domain.User{}, err 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) return s.projectOne(ctx, self.ID, self)
} }
if username != "" { if username != "" {
if !validUsername(username) || s.reserved.Contains(username) { if !validUsername(username) || (enforceReserved && s.reserved.Contains(username)) {
return domain.User{}, domain.ErrUsernameInvalid return domain.User{}, domain.ErrUsernameInvalid
} }
ok, err := s.checkUsernameAvailable(ctx, self.ID, username) ok, err := s.checkUsernameAvailable(ctx, self.ID, username)

View file

@ -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() // marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser()
// for a fixed username lookup, since memory.UserStore.Create always assigns an // 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 // id from its own auto-increment sequence and can never produce the fixed