chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
789
internal/admin/service.go
Normal file
789
internal/admin/service.go
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionSetSendFrozen = "account.set_send_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
ActionSetChannelVerified = "channel.set_verified"
|
||||
ActionRevokeSessions = "account.revoke_sessions"
|
||||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
maxReasonLength = 1000
|
||||
maxHistoryBatches = 100
|
||||
maxPremiumMonths = 120
|
||||
)
|
||||
|
||||
type CommandRepository interface {
|
||||
BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error)
|
||||
FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error)
|
||||
}
|
||||
|
||||
type RestrictionStore interface {
|
||||
GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error)
|
||||
SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error)
|
||||
IsSendFrozen(ctx context.Context, userID int64) (bool, error)
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error)
|
||||
ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
||||
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
||||
}
|
||||
|
||||
type AuthKeyRevoker interface {
|
||||
RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error
|
||||
}
|
||||
|
||||
type UsersService interface {
|
||||
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
|
||||
GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error)
|
||||
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
|
||||
}
|
||||
|
||||
type UserNotifier interface {
|
||||
NotifyUserChanged(ctx context.Context, u domain.User) error
|
||||
}
|
||||
|
||||
type ChannelsService interface {
|
||||
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
|
||||
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
|
||||
}
|
||||
|
||||
type ChannelNotifier interface {
|
||||
NotifyChannelChanged(ctx context.Context, ch domain.Channel) error
|
||||
}
|
||||
|
||||
type MessagesService interface {
|
||||
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||
GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
|
||||
DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
|
||||
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
Commands CommandRepository
|
||||
Restrictions RestrictionStore
|
||||
Auth AuthService
|
||||
Revoker AuthKeyRevoker
|
||||
Users UsersService
|
||||
UserNotifier UserNotifier
|
||||
Channels ChannelsService
|
||||
ChannelNotifier ChannelNotifier
|
||||
Messages MessagesService
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
commands CommandRepository
|
||||
restrictions RestrictionStore
|
||||
auth AuthService
|
||||
revoker AuthKeyRevoker
|
||||
users UsersService
|
||||
userNotifier UserNotifier
|
||||
channels ChannelsService
|
||||
channelNotifier ChannelNotifier
|
||||
messages MessagesService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(deps Dependencies) *Service {
|
||||
s := &Service{now: time.Now}
|
||||
return s.Configure(deps)
|
||||
}
|
||||
|
||||
func (s *Service) Configure(deps Dependencies) *Service {
|
||||
if deps.Commands != nil {
|
||||
s.commands = deps.Commands
|
||||
}
|
||||
if deps.Restrictions != nil {
|
||||
s.restrictions = deps.Restrictions
|
||||
}
|
||||
if deps.Auth != nil {
|
||||
s.auth = deps.Auth
|
||||
}
|
||||
if deps.Revoker != nil {
|
||||
s.revoker = deps.Revoker
|
||||
}
|
||||
if deps.Users != nil {
|
||||
s.users = deps.Users
|
||||
}
|
||||
if deps.UserNotifier != nil {
|
||||
s.userNotifier = deps.UserNotifier
|
||||
}
|
||||
if deps.Channels != nil {
|
||||
s.channels = deps.Channels
|
||||
}
|
||||
if deps.ChannelNotifier != nil {
|
||||
s.channelNotifier = deps.ChannelNotifier
|
||||
}
|
||||
if deps.Messages != nil {
|
||||
s.messages = deps.Messages
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type CommandMeta struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
AlreadyExecuted bool `json:"already_executed"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
TargetUserID int64 `json:"target_user_id,omitempty"`
|
||||
TargetPeer domain.Peer `json:"target_peer,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type SetSendFrozenRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
}
|
||||
|
||||
type GrantPremiumRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Months int `json:"months"`
|
||||
}
|
||||
|
||||
type SetVerifiedRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type SetChannelVerifiedRequest struct {
|
||||
CommandMeta
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type RevokeSessionsRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Hash int64 `json:"hash,omitempty"`
|
||||
KeepHash int64 `json:"keep_hash,omitempty"`
|
||||
RevokeAll bool `json:"revoke_all,omitempty"`
|
||||
}
|
||||
|
||||
type DeletePrivateMessagesRequest struct {
|
||||
CommandMeta
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Peer domain.Peer `json:"peer"`
|
||||
IDs []int `json:"ids"`
|
||||
Revoke bool `json:"revoke"`
|
||||
}
|
||||
|
||||
type DeletePrivateHistoryRequest struct {
|
||||
CommandMeta
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Peer domain.Peer `json:"peer"`
|
||||
MaxID int `json:"max_id,omitempty"`
|
||||
MinDate int `json:"min_date,omitempty"`
|
||||
MaxDate int `json:"max_date,omitempty"`
|
||||
JustClear bool `json:"just_clear,omitempty"`
|
||||
Revoke bool `json:"revoke"`
|
||||
MaxBatches int `json:"max_batches,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
|
||||
if s == nil || s.restrictions == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
frozen, err := s.restrictions.IsSendFrozen(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frozen {
|
||||
return domain.ErrUserSendRestricted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.restrictions == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetSendFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
prev, found, err := s.restrictions.GetSendRestriction(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_frozen": found && prev.Frozen,
|
||||
"new_frozen": req.Frozen,
|
||||
"would_change": !found || prev.Frozen != req.Frozen,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.restrictions.SetSendRestriction(ctx, domain.AccountSendRestriction{
|
||||
UserID: req.UserID,
|
||||
Frozen: req.Frozen,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandID: req.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
return CommandResult{Message: "send restriction updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if req.Months < 0 || req.Months > maxPremiumMonths {
|
||||
return CommandResult{}, fmt.Errorf("months must be between 0 and %d", maxPremiumMonths)
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionGrantPremium, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Bot {
|
||||
return CommandResult{}, domain.ErrPremiumBotUnsupported
|
||||
}
|
||||
details := premiumCommandDetails(u, req.Months, s.now())
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.GrantPremium(ctx, req.UserID, req.Months)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_premium_until"] = updated.PremiumUntil
|
||||
details["updated_premium_active"] = updated.PremiumActiveAt(s.now().Unix())
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
msg := "premium updated"
|
||||
if req.Months == 0 {
|
||||
msg = "premium cleared"
|
||||
}
|
||||
return CommandResult{Message: msg, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetVerified, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_verified": u.Verified,
|
||||
"new_verified": req.Verified,
|
||||
"would_change": u.Verified != req.Verified,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.SetVerified(ctx, req.UserID, req.Verified)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_verified"] = updated.Verified
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "verified updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
|
||||
if req.ChannelID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("channel_id is required")
|
||||
}
|
||||
if s == nil || s.channels == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
|
||||
}
|
||||
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelVerified, 0, target, req, func() (CommandResult, error) {
|
||||
ch, err := s.channels.GetChannelByID(ctx, req.ChannelID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if ch.Monoforum || (!ch.Broadcast && !ch.Megagroup) {
|
||||
return CommandResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
details := map[string]any{
|
||||
"title": ch.Title,
|
||||
"username": ch.Username,
|
||||
"broadcast": ch.Broadcast,
|
||||
"megagroup": ch.Megagroup,
|
||||
"previous_verified": ch.Verified,
|
||||
"new_verified": req.Verified,
|
||||
"would_change": ch.Verified != req.Verified,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.channels.SetVerified(ctx, req.ChannelID, req.Verified)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_verified"] = updated.Verified
|
||||
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "channel verified updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.auth == nil || s.revoker == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
|
||||
}
|
||||
if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) {
|
||||
return CommandResult{}, fmt.Errorf("choose one revoke mode")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
items, err := s.auth.ListAuthorizations(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
targets, keep, err := revokeTargets(items, req)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"target_hashes": authorizationHashes(targets),
|
||||
"target_count": len(targets),
|
||||
"keep_hash": keep.Hash,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
var revoked []domain.Authorization
|
||||
if req.Hash != 0 {
|
||||
deleted, found, err := s.auth.ResetAuthorization(ctx, req.UserID, req.Hash)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if found {
|
||||
revoked = append(revoked, deleted)
|
||||
}
|
||||
} else {
|
||||
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
revoked = append(revoked, deleted...)
|
||||
}
|
||||
for _, a := range revoked {
|
||||
if err := s.revoker.RevokeAuthorizationAuthKey(ctx, a.AuthKeyID, req.UserID); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
}
|
||||
details["revoked_hashes"] = authorizationHashes(revoked)
|
||||
details["revoked_count"] = len(revoked)
|
||||
return CommandResult{Message: "sessions revoked", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeletePrivateMessages(ctx context.Context, req DeletePrivateMessagesRequest) (CommandResult, error) {
|
||||
ids, err := normalizeIDs(req.IDs)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
req.IDs = ids
|
||||
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
|
||||
}
|
||||
if s == nil || s.messages == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateMessages, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
|
||||
list, err := s.messages.GetMessages(ctx, req.OwnerUserID, req.IDs)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
found, missing, err := validatePrivateMessageSelection(req.OwnerUserID, req.Peer, req.IDs, list.Messages)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"requested_ids": req.IDs,
|
||||
"found_ids": found,
|
||||
"missing_ids": missing,
|
||||
"revoke": req.Revoke,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return CommandResult{}, fmt.Errorf("messages not found for owner/peer: %v", missing)
|
||||
}
|
||||
res, err := s.messages.DeleteMessages(ctx, req.OwnerUserID, domain.DeleteMessagesRequest{
|
||||
OwnerUserID: req.OwnerUserID,
|
||||
IDs: req.IDs,
|
||||
Revoke: req.Revoke,
|
||||
Date: int(s.now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["deleted"] = summarizeDeleteResult(res)
|
||||
details["changed"] = res.Changed()
|
||||
return CommandResult{Message: "messages deleted", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHistoryRequest) (CommandResult, error) {
|
||||
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
|
||||
}
|
||||
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || req.MinDate < 0 || req.MaxDate < 0 {
|
||||
return CommandResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if req.MaxBatches <= 0 {
|
||||
req.MaxBatches = 10
|
||||
}
|
||||
if req.MaxBatches > maxHistoryBatches {
|
||||
return CommandResult{}, fmt.Errorf("max_batches exceeds %d", maxHistoryBatches)
|
||||
}
|
||||
if s == nil || s.messages == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateHistory, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
|
||||
preview, err := s.messages.GetHistory(ctx, req.OwnerUserID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
Peer: req.Peer,
|
||||
MaxID: req.MaxID,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details := map[string]any{
|
||||
"preview_ids": messageIDs(preview.Messages),
|
||||
"preview_count": len(preview.Messages),
|
||||
"batch_limit": domain.MaxDeleteHistoryBatch,
|
||||
"max_batches": req.MaxBatches,
|
||||
"revoke": req.Revoke,
|
||||
"just_clear": req.JustClear,
|
||||
"date_range_filter": req.MinDate != 0 || req.MaxDate != 0,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
totalDeleted := 0
|
||||
ownerBatches := make([]any, 0, req.MaxBatches)
|
||||
offset := 0
|
||||
for batch := 0; batch < req.MaxBatches; batch++ {
|
||||
res, err := s.messages.DeleteHistory(ctx, req.OwnerUserID, domain.DeleteHistoryRequest{
|
||||
OwnerUserID: req.OwnerUserID,
|
||||
Peer: req.Peer,
|
||||
MaxID: req.MaxID,
|
||||
MinDate: req.MinDate,
|
||||
MaxDate: req.MaxDate,
|
||||
JustClear: req.JustClear,
|
||||
Revoke: req.Revoke,
|
||||
Date: int(s.now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
self := res.Self()
|
||||
totalDeleted += len(self.MessageIDs)
|
||||
ownerBatches = append(ownerBatches, summarizeDeleteResult(res)...)
|
||||
offset = res.Offset
|
||||
if res.Offset == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
details["deleted_count"] = totalDeleted
|
||||
details["deleted"] = ownerBatches
|
||||
details["has_more"] = offset != 0
|
||||
msg := "history deleted"
|
||||
if offset != 0 {
|
||||
msg = "history partially deleted; run another command to continue"
|
||||
}
|
||||
return CommandResult{Message: msg, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) {
|
||||
if s == nil || s.commands == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin command store is not configured")
|
||||
}
|
||||
meta.CommandID = strings.TrimSpace(meta.CommandID)
|
||||
meta.Actor = strings.TrimSpace(meta.Actor)
|
||||
meta.Reason = strings.TrimSpace(meta.Reason)
|
||||
if meta.CommandID == "" || len(meta.CommandID) > maxCommandIDLength {
|
||||
return CommandResult{}, fmt.Errorf("command_id is required and must be <= %d bytes", maxCommandIDLength)
|
||||
}
|
||||
if meta.Actor == "" || len(meta.Actor) > maxActorLength {
|
||||
return CommandResult{}, fmt.Errorf("actor is required and must be <= %d bytes", maxActorLength)
|
||||
}
|
||||
if meta.Reason == "" || len(meta.Reason) > maxReasonLength {
|
||||
return CommandResult{}, fmt.Errorf("reason is required and must be <= %d bytes", maxReasonLength)
|
||||
}
|
||||
requestJSON, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return CommandResult{}, fmt.Errorf("marshal admin request: %w", err)
|
||||
}
|
||||
cmd, created, err := s.commands.BeginCommand(ctx, domain.AdminCommand{
|
||||
CommandID: meta.CommandID,
|
||||
Actor: meta.Actor,
|
||||
Action: action,
|
||||
TargetUserID: targetUserID,
|
||||
TargetPeer: targetPeer,
|
||||
DryRun: meta.DryRun,
|
||||
Reason: meta.Reason,
|
||||
RequestJSON: requestJSON,
|
||||
Status: domain.AdminCommandRunning,
|
||||
CreatedAt: s.now(),
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !created {
|
||||
return resultFromCommand(cmd), nil
|
||||
}
|
||||
result, opErr := fn()
|
||||
result.CommandID = meta.CommandID
|
||||
result.Action = action
|
||||
result.DryRun = meta.DryRun
|
||||
result.TargetUserID = targetUserID
|
||||
result.TargetPeer = targetPeer
|
||||
status := domain.AdminCommandCompleted
|
||||
if opErr != nil {
|
||||
status = domain.AdminCommandFailed
|
||||
result.Status = string(status)
|
||||
result.Error = opErr.Error()
|
||||
if result.Message == "" {
|
||||
result.Message = "command failed"
|
||||
}
|
||||
} else {
|
||||
result.Status = string(status)
|
||||
}
|
||||
resultJSON, marshalErr := json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
return result, fmt.Errorf("marshal admin result: %w", marshalErr)
|
||||
}
|
||||
errorText := ""
|
||||
if opErr != nil {
|
||||
errorText = opErr.Error()
|
||||
}
|
||||
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, opErr
|
||||
}
|
||||
|
||||
func resultFromCommand(cmd domain.AdminCommand) CommandResult {
|
||||
var result CommandResult
|
||||
if len(cmd.ResultJSON) > 0 {
|
||||
if err := json.Unmarshal(cmd.ResultJSON, &result); err == nil {
|
||||
result.AlreadyExecuted = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
result = CommandResult{
|
||||
CommandID: cmd.CommandID,
|
||||
Action: cmd.Action,
|
||||
Status: string(cmd.Status),
|
||||
AlreadyExecuted: true,
|
||||
DryRun: cmd.DryRun,
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
TargetPeer: cmd.TargetPeer,
|
||||
Message: "command already exists",
|
||||
Error: cmd.Error,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error {
|
||||
if s == nil || s.userNotifier == nil {
|
||||
return nil
|
||||
}
|
||||
return s.userNotifier.NotifyUserChanged(ctx, u)
|
||||
}
|
||||
|
||||
func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error {
|
||||
if s == nil || s.channelNotifier == nil {
|
||||
return nil
|
||||
}
|
||||
return s.channelNotifier.NotifyChannelChanged(ctx, ch)
|
||||
}
|
||||
|
||||
func premiumCommandDetails(u domain.User, months int, now time.Time) map[string]any {
|
||||
active := u.PremiumActiveAt(now.Unix())
|
||||
base := now
|
||||
if active {
|
||||
base = time.Unix(int64(u.PremiumUntil), 0)
|
||||
}
|
||||
projected := 0
|
||||
if months > 0 {
|
||||
projected = int(base.AddDate(0, months, 0).Unix())
|
||||
}
|
||||
return map[string]any{
|
||||
"previous_premium_until": u.PremiumUntil,
|
||||
"previous_premium_active": active,
|
||||
"months": months,
|
||||
"new_premium_until": projected,
|
||||
"would_change": months > 0 || u.PremiumUntil != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]domain.Authorization, domain.Authorization, error) {
|
||||
if req.Hash != 0 {
|
||||
for _, a := range items {
|
||||
if a.Hash == req.Hash {
|
||||
return []domain.Authorization{a}, domain.Authorization{}, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.Authorization{}, nil
|
||||
}
|
||||
var keep domain.Authorization
|
||||
if req.KeepHash != 0 {
|
||||
found := false
|
||||
for _, a := range items {
|
||||
if a.Hash == req.KeepHash {
|
||||
keep = a
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, domain.Authorization{}, fmt.Errorf("keep_hash authorization not found")
|
||||
}
|
||||
}
|
||||
targets := make([]domain.Authorization, 0, len(items))
|
||||
for _, a := range items {
|
||||
if req.KeepHash != 0 && a.Hash == req.KeepHash {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, a)
|
||||
}
|
||||
return targets, keep, nil
|
||||
}
|
||||
|
||||
func authorizationHashes(items []domain.Authorization) []int64 {
|
||||
out := make([]int64, 0, len(items))
|
||||
for _, a := range items {
|
||||
out = append(out, a.Hash)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeIDs(ids []int) ([]int, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return nil, fmt.Errorf("too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs)
|
||||
}
|
||||
seen := make(map[int]struct{}, len(ids))
|
||||
out := make([]int, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePrivateMessageSelection(ownerUserID int64, peer domain.Peer, ids []int, messages []domain.Message) ([]int, []int, error) {
|
||||
foundSet := make(map[int]domain.Message, len(messages))
|
||||
for _, msg := range messages {
|
||||
foundSet[msg.ID] = msg
|
||||
if msg.OwnerUserID != ownerUserID || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peer.ID {
|
||||
return nil, nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
}
|
||||
found := make([]int, 0, len(messages))
|
||||
missing := make([]int, 0)
|
||||
for _, id := range ids {
|
||||
if _, ok := foundSet[id]; ok {
|
||||
found = append(found, id)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, id)
|
||||
}
|
||||
return found, missing, nil
|
||||
}
|
||||
|
||||
func summarizeDeleteResult(res domain.DeleteMessagesResult) []any {
|
||||
out := make([]any, 0, len(res.Deleted))
|
||||
for _, item := range res.Deleted {
|
||||
ids := append([]int(nil), item.MessageIDs...)
|
||||
sort.Ints(ids)
|
||||
out = append(out, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"message_ids": ids,
|
||||
"pts": item.Event.Pts,
|
||||
"pts_count": item.Event.PtsCount,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageIDs(messages []domain.Message) []int {
|
||||
out := make([]int, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
out = append(out, msg.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
510
internal/admin/service_test.go
Normal file
510
internal/admin/service_test.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: repo,
|
||||
Restrictions: restrictions,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run freeze: %v", err)
|
||||
}
|
||||
if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 0 {
|
||||
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
|
||||
}
|
||||
|
||||
execReq := SetSendFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
}
|
||||
exec, err := svc.SetSendFrozen(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("execute freeze: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 {
|
||||
t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls)
|
||||
}
|
||||
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) {
|
||||
t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err)
|
||||
}
|
||||
|
||||
again, err := svc.SetSendFrozen(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate freeze: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || restrictions.setCalls != 1 {
|
||||
t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, FirstName: "Alice"},
|
||||
}}
|
||||
notifier := &fakeUserNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
UserNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.GrantPremium(ctx, GrantPremiumRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-premium", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Months: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run premium: %v", err)
|
||||
}
|
||||
if !dry.DryRun || users.grantCalls != 0 || len(notifier.users) != 0 {
|
||||
t.Fatalf("dry=%+v grantCalls=%d notified=%v, want no mutation", dry, users.grantCalls, notifier.users)
|
||||
}
|
||||
|
||||
req := GrantPremiumRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-premium", Actor: "ops", Reason: "grant"},
|
||||
UserID: 1001,
|
||||
Months: 2,
|
||||
}
|
||||
exec, err := svc.GrantPremium(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute premium: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || users.grantCalls != 1 || users.lastMonths != 2 || len(notifier.users) != 1 {
|
||||
t.Fatalf("exec=%+v grantCalls=%d months=%d notified=%v", exec, users.grantCalls, users.lastMonths, notifier.users)
|
||||
}
|
||||
again, err := svc.GrantPremium(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate premium: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || users.grantCalls != 1 || len(notifier.users) != 1 {
|
||||
t.Fatalf("again=%+v grantCalls=%d notified=%v, want idempotent replay", again, users.grantCalls, notifier.users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
1001: {ID: 1001, FirstName: "Alice"},
|
||||
}}
|
||||
notifier := &fakeUserNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Users: users,
|
||||
UserNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetVerified(ctx, SetVerifiedRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-verified", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Verified: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run verified: %v", err)
|
||||
}
|
||||
if !dry.DryRun || users.verifiedCalls != 0 || len(notifier.users) != 0 {
|
||||
t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, users.verifiedCalls, notifier.users)
|
||||
}
|
||||
|
||||
req := SetVerifiedRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-verified", Actor: "ops", Reason: "official"},
|
||||
UserID: 1001,
|
||||
Verified: true,
|
||||
}
|
||||
exec, err := svc.SetVerified(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute verified: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || users.verifiedCalls != 1 || !users.users[1001].Verified || len(notifier.users) != 1 {
|
||||
t.Fatalf("exec=%+v verifiedCalls=%d user=%+v notified=%v", exec, users.verifiedCalls, users.users[1001], notifier.users)
|
||||
}
|
||||
again, err := svc.SetVerified(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate verified: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || users.verifiedCalls != 1 || len(notifier.users) != 1 {
|
||||
t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, users.verifiedCalls, notifier.users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetChannelVerifiedDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channels := &fakeChannelsService{channels: map[int64]domain.Channel{
|
||||
2001: {ID: 2001, CreatorUserID: 1001, Title: "Ops Channel", Username: "ops", Broadcast: true},
|
||||
}}
|
||||
notifier := &fakeChannelNotifier{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Channels: channels,
|
||||
ChannelNotifier: notifier,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetChannelVerified(ctx, SetChannelVerifiedRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-channel-verified", Actor: "ops", Reason: "test", DryRun: true},
|
||||
ChannelID: 2001,
|
||||
Verified: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run channel verified: %v", err)
|
||||
}
|
||||
if !dry.DryRun || channels.verifiedCalls != 0 || len(notifier.channels) != 0 {
|
||||
t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, channels.verifiedCalls, notifier.channels)
|
||||
}
|
||||
|
||||
req := SetChannelVerifiedRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-channel-verified", Actor: "ops", Reason: "official"},
|
||||
ChannelID: 2001,
|
||||
Verified: true,
|
||||
}
|
||||
exec, err := svc.SetChannelVerified(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute channel verified: %v", err)
|
||||
}
|
||||
if exec.Status != string(domain.AdminCommandCompleted) || channels.verifiedCalls != 1 || !channels.channels[2001].Verified || len(notifier.channels) != 1 {
|
||||
t.Fatalf("exec=%+v verifiedCalls=%d channel=%+v notified=%v", exec, channels.verifiedCalls, channels.channels[2001], notifier.channels)
|
||||
}
|
||||
if exec.TargetPeer.Type != domain.PeerTypeChannel || exec.TargetPeer.ID != 2001 || exec.TargetUserID != 0 {
|
||||
t.Fatalf("target user=%d peer=%+v, want channel target", exec.TargetUserID, exec.TargetPeer)
|
||||
}
|
||||
again, err := svc.SetChannelVerified(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate channel verified: %v", err)
|
||||
}
|
||||
if !again.AlreadyExecuted || channels.verifiedCalls != 1 || len(notifier.channels) != 1 {
|
||||
t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, channels.verifiedCalls, notifier.channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePrivateMessagesUsesMessageServiceAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
messages := &fakeMessagesService{
|
||||
byID: []domain.Message{
|
||||
{OwnerUserID: 1001, ID: 11, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}},
|
||||
{OwnerUserID: 1001, ID: 12, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}},
|
||||
},
|
||||
}
|
||||
svc := NewService(Dependencies{Commands: repo, Messages: messages, Now: fixedNow})
|
||||
req := DeletePrivateMessagesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "delete-1", Actor: "ops", Reason: "abuse"},
|
||||
OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
IDs: []int{12, 11},
|
||||
Revoke: true,
|
||||
}
|
||||
|
||||
if _, err := svc.DeletePrivateMessages(ctx, req); err != nil {
|
||||
t.Fatalf("delete messages: %v", err)
|
||||
}
|
||||
if messages.deleteCalls != 1 || !reflect.DeepEqual(messages.lastDelete.IDs, []int{11, 12}) || !messages.lastDelete.Revoke {
|
||||
t.Fatalf("delete calls=%d req=%+v", messages.deleteCalls, messages.lastDelete)
|
||||
}
|
||||
if _, err := svc.DeletePrivateMessages(ctx, req); err != nil {
|
||||
t.Fatalf("duplicate delete messages: %v", err)
|
||||
}
|
||||
if messages.deleteCalls != 1 {
|
||||
t.Fatalf("duplicate delete calls=%d, want 1", messages.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePrivateMessagesRejectsMissingOnExecute(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: &fakeMessagesService{}, Now: fixedNow})
|
||||
_, err := svc.DeletePrivateMessages(ctx, DeletePrivateMessagesRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "delete-missing", Actor: "ops", Reason: "test"},
|
||||
OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
IDs: []int{99},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("delete missing message err=nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeSessionsSpecifiedClosesRevokedAuthKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
key := [8]byte{1, 2, 3}
|
||||
auth := &fakeAuthService{items: []domain.Authorization{
|
||||
{AuthKeyID: key, UserID: 1001, Hash: 555},
|
||||
}}
|
||||
revoker := &fakeRevoker{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Auth: auth, Revoker: revoker, Now: fixedNow})
|
||||
if _, err := svc.RevokeSessions(ctx, RevokeSessionsRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "revoke-1", Actor: "ops", Reason: "lost device"},
|
||||
UserID: 1001,
|
||||
Hash: 555,
|
||||
}); err != nil {
|
||||
t.Fatalf("revoke sessions: %v", err)
|
||||
}
|
||||
if auth.resetHash != 555 || len(revoker.keys) != 1 || revoker.keys[0] != key {
|
||||
t.Fatalf("resetHash=%d revoked=%v", auth.resetHash, revoker.keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePrivateHistoryLoopsUntilOffsetClears(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := &fakeMessagesService{historyOffsets: []int{1, 0}}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: messages, Now: fixedNow})
|
||||
res, err := svc.DeletePrivateHistory(ctx, DeletePrivateHistoryRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "history-1", Actor: "ops", Reason: "clear"},
|
||||
OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002},
|
||||
MaxBatches: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete history: %v", err)
|
||||
}
|
||||
if messages.historyCalls != 2 || res.Details["has_more"] != false {
|
||||
t.Fatalf("historyCalls=%d result=%+v", messages.historyCalls, res)
|
||||
}
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Unix(1_700_000_000, 0).UTC()
|
||||
}
|
||||
|
||||
type memoryCommandRepo struct {
|
||||
items map[string]domain.AdminCommand
|
||||
}
|
||||
|
||||
func newMemoryCommandRepo() *memoryCommandRepo {
|
||||
return &memoryCommandRepo{items: map[string]domain.AdminCommand{}}
|
||||
}
|
||||
|
||||
func (m *memoryCommandRepo) BeginCommand(_ context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) {
|
||||
if existing, ok := m.items[cmd.CommandID]; ok {
|
||||
return existing, false, nil
|
||||
}
|
||||
m.items[cmd.CommandID] = cmd
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) {
|
||||
cmd := m.items[commandID]
|
||||
cmd.Status = status
|
||||
cmd.ResultJSON = resultJSON
|
||||
cmd.Error = errorText
|
||||
m.items[commandID] = cmd
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountSendRestriction
|
||||
setCalls int
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
|
||||
if f.items == nil {
|
||||
return domain.AccountSendRestriction{}, false, nil
|
||||
}
|
||||
r, ok := f.items[userID]
|
||||
return r, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
|
||||
if f.items == nil {
|
||||
f.items = map[int64]domain.AccountSendRestriction{}
|
||||
}
|
||||
f.setCalls++
|
||||
r.UpdatedAt = fixedNow()
|
||||
f.items[r.UserID] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) {
|
||||
if f.items == nil {
|
||||
return false, nil
|
||||
}
|
||||
return f.items[userID].Frozen, nil
|
||||
}
|
||||
|
||||
type fakeMessagesService struct {
|
||||
byID []domain.Message
|
||||
deleteCalls int
|
||||
lastDelete domain.DeleteMessagesRequest
|
||||
historyCalls int
|
||||
historyOffsets []int
|
||||
}
|
||||
|
||||
func (f *fakeMessagesService) GetMessages(_ context.Context, _ int64, _ []int) (domain.MessageList, error) {
|
||||
return domain.MessageList{Messages: f.byID}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMessagesService) GetHistory(_ context.Context, _ int64, _ domain.MessageFilter) (domain.MessageList, error) {
|
||||
return domain.MessageList{Messages: []domain.Message{{ID: 1}}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMessagesService) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) {
|
||||
f.deleteCalls++
|
||||
f.lastDelete = req
|
||||
return domain.DeleteMessagesResult{
|
||||
OwnerUserID: userID,
|
||||
Deleted: []domain.DeletedMessagesForUser{{
|
||||
UserID: userID,
|
||||
MessageIDs: req.IDs,
|
||||
Event: domain.UpdateEvent{Pts: 10, PtsCount: len(req.IDs)},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMessagesService) DeleteHistory(_ context.Context, userID int64, _ domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) {
|
||||
offset := 0
|
||||
if f.historyCalls < len(f.historyOffsets) {
|
||||
offset = f.historyOffsets[f.historyCalls]
|
||||
}
|
||||
f.historyCalls++
|
||||
return domain.DeleteMessagesResult{
|
||||
OwnerUserID: userID,
|
||||
Deleted: []domain.DeletedMessagesForUser{{
|
||||
UserID: userID,
|
||||
MessageIDs: []int{f.historyCalls},
|
||||
Event: domain.UpdateEvent{Pts: f.historyCalls, PtsCount: 1},
|
||||
}},
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeAuthService struct {
|
||||
items []domain.Authorization
|
||||
resetHash int64
|
||||
}
|
||||
|
||||
func (f *fakeAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) {
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
func (f *fakeAuthService) ResetAuthorization(_ context.Context, _ int64, hash int64) (domain.Authorization, bool, error) {
|
||||
f.resetHash = hash
|
||||
for _, a := range f.items {
|
||||
if a.Hash == hash {
|
||||
return a, true, nil
|
||||
}
|
||||
}
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
|
||||
func (f *fakeAuthService) ResetAuthorizations(_ context.Context, _ int64, keep [8]byte) ([]domain.Authorization, error) {
|
||||
out := make([]domain.Authorization, 0)
|
||||
for _, a := range f.items {
|
||||
if a.AuthKeyID != keep {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeRevoker struct {
|
||||
keys [][8]byte
|
||||
}
|
||||
|
||||
func (f *fakeRevoker) RevokeAuthorizationAuthKey(_ context.Context, key [8]byte, _ int64) error {
|
||||
f.keys = append(f.keys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeUsersService struct {
|
||||
users map[int64]domain.User
|
||||
grantCalls int
|
||||
lastMonths int
|
||||
verifiedCalls int
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) {
|
||||
u, ok := f.users[userID]
|
||||
return u, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) GrantPremium(_ context.Context, userID int64, months int) (domain.User, error) {
|
||||
f.grantCalls++
|
||||
f.lastMonths = months
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if u.Bot {
|
||||
return domain.User{}, domain.ErrPremiumBotUnsupported
|
||||
}
|
||||
if months <= 0 {
|
||||
u.PremiumUntil = 0
|
||||
} else {
|
||||
u.PremiumUntil = int(fixedNow().AddDate(0, months, 0).Unix())
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) {
|
||||
f.verifiedCalls++
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
u.Verified = verified
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakeUserNotifier struct {
|
||||
users []int64
|
||||
}
|
||||
|
||||
func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) error {
|
||||
f.users = append(f.users, u.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeChannelsService struct {
|
||||
channels map[int64]domain.Channel
|
||||
verifiedCalls int
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) {
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, verified bool) (domain.Channel, error) {
|
||||
f.verifiedCalls++
|
||||
ch, ok := f.channels[channelID]
|
||||
if !ok {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
ch.Verified = verified
|
||||
f.channels[channelID] = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type fakeChannelNotifier struct {
|
||||
channels []int64
|
||||
}
|
||||
|
||||
func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error {
|
||||
f.channels = append(f.channels, ch.ID)
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue