fix(admin): close PR review blockers

Keep bot credentials out of durable command results, fail bot deletion closed when session revocation fails, reject invalid scam/fake states at every write boundary, and make direct collectible grants a single replayable PostgreSQL aggregate.

Also lock admin gift sender/message limits and add regression coverage for rollback, replay, moderation constraints, and credential redaction.
This commit is contained in:
iamxvbaba 2026-07-23 13:29:04 +08:00
parent 90792cdfab
commit 234061ef83
30 changed files with 859 additions and 93 deletions

View file

@ -2,6 +2,7 @@ package bots
import (
"context"
"errors"
"strings"
"testing"
@ -372,6 +373,38 @@ func TestRevokeBotTokenRevokesSessions(t *testing.T) {
}
}
func TestDeleteBotFailsClosedWhenSessionRevocationFails(t *testing.T) {
users := memory.NewUserStore()
botStore := &countingBotStore{BotStore: memory.NewBotStore(users)}
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
revocationErr := errors.New("authorization store unavailable")
rev := &captureRevoker{err: revocationErr}
svc := NewService(users, botStore, messages)
svc.SetRouterHooks(rev)
owner := newOwner(t, users, "+2099")
bot := makeBot(t, svc, owner, "Delete Guard Bot", "delete_guard_bot")
if _, err := svc.DeleteBot(context.Background(), bot.ID); !errors.Is(err, domain.ErrBotSessionsNotRevoked) {
t.Fatalf("DeleteBot error=%v, want ErrBotSessionsNotRevoked", err)
}
if botStore.deleteCalls != 0 {
t.Fatalf("DeleteBotAccount calls=%d after failed session revocation", botStore.deleteCalls)
}
if _, found, err := botStore.GetBot(context.Background(), bot.ID); err != nil || !found {
t.Fatalf("bot disappeared after failed revocation: found=%v err=%v", found, err)
}
rev.err = nil
deleted, err := svc.DeleteBot(context.Background(), bot.ID)
if err != nil {
t.Fatalf("DeleteBot after revocation recovery: %v", err)
}
if botStore.deleteCalls != 1 || deleted.ID != bot.ID || !deleted.Deleted {
t.Fatalf("deleted=%+v deleteCalls=%d", deleted, botStore.deleteCalls)
}
}
func TestBotWriteAccessGrant(t *testing.T) {
svc, users, _, _ := newTestService(t)
owner := newOwner(t, users, "+2012")
@ -400,11 +433,12 @@ type captureRevoker struct {
botUserID int64
pushedCommandsTo int64
pushedCommands []domain.BotCommand
err error
}
func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error {
c.botUserID = botUserID
return nil
return c.err
}
func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) {

View file

@ -469,12 +469,15 @@ func (s *Service) DeleteBot(ctx context.Context, botUserID int64) (domain.User,
if !ok {
return domain.User{}, fmt.Errorf("bot deletion is not supported by the configured store")
}
// Drop live sessions up front so the token stops working even if a caller
// races the tombstone; DeleteBotAccount also revokes the authorization rows.
if s.hooks != nil {
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
}
// Session revocation is part of the deletion invariant: a deleted bot must
// never retain an authenticated connection. Fail closed before tombstoning
// when the hook is unavailable or revocation fails.
if s.hooks == nil {
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil {
s.log.Warn("revoke bot sessions before delete", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return domain.User{}, domain.ErrBotSessionsNotRevoked
}
u, err := deleter.DeleteBotAccount(ctx, botUserID)
if err != nil {

View file

@ -181,6 +181,7 @@ type countingBotStore struct {
*memory.BotStore
getBotCalls int
getBotsCalls int
deleteCalls int
}
func (s *countingBotStore) reset() {
@ -198,6 +199,11 @@ func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map
return s.BotStore.GetBots(ctx, botUserIDs)
}
func (s *countingBotStore) DeleteBotAccount(_ context.Context, botUserID int64) (domain.User, error) {
s.deleteCalls++
return domain.User{ID: botUserID, Bot: true, Deleted: true}, nil
}
func TestBotFatherCancelAndUnknown(t *testing.T) {
svc, users, _, messages := newTestService(t)
owner := newOwner(t, users, "+1001")

View file

@ -505,6 +505,9 @@ func (s *Service) SetScamFake(ctx context.Context, channelID int64, scam, fake b
if s == nil || s.channels == nil || channelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
if scam && fake {
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
}
return s.channels.SetChannelScamFake(ctx, channelID, scam, fake)
}

View file

@ -517,6 +517,18 @@ func (s *Service) UpgradeReceipt(ctx context.Context, userID int64, commandKey s
return s.upgrades.StarGiftUpgradeReceipt(ctx, userID, commandKey)
}
// GrantUnique atomically assigns a freshly minted collectible to a user.
func (s *Service) GrantUnique(ctx context.Context, req domain.AdminStarGiftGrant) (domain.AdminStarGiftGrantResult, error) {
if s == nil || s.upgrades == nil {
return domain.AdminStarGiftGrantResult{}, fmt.Errorf("star gift upgrade store is not configured")
}
result, err := s.upgrades.GrantUniqueStarGift(ctx, req)
if err == nil {
s.InvalidateStarGiftCatalog()
}
return result, err
}
func (s *Service) Purchase(ctx context.Context, req domain.StarGiftPurchaseRequest) (domain.StarGiftPurchaseResult, error) {
if s == nil || s.lifecycle == nil {
return domain.StarGiftPurchaseResult{}, domain.ErrStarGiftUnavailable

View file

@ -371,6 +371,9 @@ func (s *Service) SetScamFake(ctx context.Context, userID int64, scam, fake bool
if userID == 0 {
return domain.User{}, ErrNotAuthorized
}
if scam && fake {
return domain.User{}, domain.ErrPeerModerationFlagsInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return domain.User{}, err