feat: sync account freeze lifecycle
This commit is contained in:
parent
76bfc5100f
commit
47fcf0ea41
40 changed files with 1363 additions and 196 deletions
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -12,7 +14,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
ActionSetSendFrozen = "account.set_send_frozen"
|
||||
ActionSetAccountFrozen = "account.set_frozen"
|
||||
ActionGrantPremium = "account.grant_premium"
|
||||
ActionGrantStars = "account.grant_stars"
|
||||
ActionSetVerified = "account.set_verified"
|
||||
|
|
@ -21,12 +23,13 @@ const (
|
|||
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
||||
ActionDeletePrivateHistory = "messages.delete_private_history"
|
||||
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
maxReasonLength = 1000
|
||||
maxHistoryBatches = 100
|
||||
maxPremiumMonths = 120
|
||||
maxStarsGrant = 1_000_000_000
|
||||
maxCommandIDLength = 128
|
||||
maxActorLength = 128
|
||||
maxReasonLength = 1000
|
||||
maxHistoryBatches = 100
|
||||
maxPremiumMonths = 120
|
||||
maxStarsGrant = 1_000_000_000
|
||||
maxFreezeAppealURLLength = 2048
|
||||
)
|
||||
|
||||
type CommandRepository interface {
|
||||
|
|
@ -35,9 +38,8 @@ type CommandRepository interface {
|
|||
}
|
||||
|
||||
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)
|
||||
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
||||
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
|
|
@ -182,10 +184,12 @@ type CommandResult struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type SetSendFrozenRequest struct {
|
||||
type SetAccountFrozenRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Frozen bool `json:"frozen"`
|
||||
Until time.Time `json:"freeze_until,omitempty"`
|
||||
AppealURL string `json:"freeze_appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type GrantPremiumRequest struct {
|
||||
|
|
@ -240,52 +244,127 @@ type DeletePrivateHistoryRequest struct {
|
|||
MaxBatches int `json:"max_batches,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
|
||||
// AccountFreeze returns the durable account-level freeze state. A missing row
|
||||
// is the only non-frozen default; invalid active rows are rejected by the
|
||||
// store/schema instead of normalized on read.
|
||||
func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
if s == nil || s.restrictions == nil || userID == 0 {
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
freeze, found, err := s.restrictions.GetAccountFreeze(ctx, userID)
|
||||
if err != nil || !found {
|
||||
return freeze, found, err
|
||||
}
|
||||
if err := validateAccountFreeze(freeze); err != nil {
|
||||
return domain.AccountFreeze{}, false, fmt.Errorf("invalid durable account freeze for user %d: %w", userID, err)
|
||||
}
|
||||
return freeze, true, nil
|
||||
}
|
||||
|
||||
func validateAccountFreeze(freeze domain.AccountFreeze) error {
|
||||
if !freeze.Frozen {
|
||||
if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
|
||||
return fmt.Errorf("inactive freeze retains client-visible state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
frozen, err := s.restrictions.IsSendFrozen(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
if freeze.Since.IsZero() || freeze.Until.IsZero() || !freeze.Until.After(freeze.Since) ||
|
||||
freeze.Since.Unix() <= 0 || freeze.Until.Unix() > math.MaxInt32 {
|
||||
return fmt.Errorf("active freeze has invalid since/until")
|
||||
}
|
||||
if frozen {
|
||||
return domain.ErrUserSendRestricted
|
||||
if len(freeze.AppealURL) > maxFreezeAppealURLLength {
|
||||
return fmt.Errorf("active freeze appeal URL is too long")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(freeze.AppealURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return fmt.Errorf("active freeze has invalid appeal URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) {
|
||||
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
|
||||
freeze, found, err := s.AccountFreeze(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && freeze.Frozen {
|
||||
return domain.ErrUserFrozen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequest) (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)
|
||||
now := s.now().UTC()
|
||||
appealURL := strings.TrimSpace(req.AppealURL)
|
||||
if req.Frozen {
|
||||
if req.Until.IsZero() || req.Until.Unix() > math.MaxInt32 {
|
||||
return CommandResult{}, fmt.Errorf("freeze_until must be a non-zero int32 Unix timestamp")
|
||||
}
|
||||
if len(appealURL) > maxFreezeAppealURLLength {
|
||||
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be <= %d bytes", maxFreezeAppealURLLength)
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(appealURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be an absolute HTTP(S) URL")
|
||||
}
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
// Keep this time-relative check inside runCommand: a completed command ID
|
||||
// must remain replayable after its deadline, while a new stale request is
|
||||
// recorded as failed and cannot mutate the restriction row.
|
||||
if req.Frozen && !req.Until.After(now) {
|
||||
return CommandResult{}, fmt.Errorf("freeze_until must be in the future")
|
||||
}
|
||||
prev, found, err := s.restrictions.GetAccountFreeze(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{
|
||||
next := domain.AccountFreeze{
|
||||
UserID: req.UserID,
|
||||
Frozen: req.Frozen,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandID: req.CommandID,
|
||||
})
|
||||
}
|
||||
if req.Frozen {
|
||||
next.Since = now
|
||||
if found && prev.Frozen {
|
||||
next.Since = prev.Since
|
||||
}
|
||||
next.Until = req.Until.UTC()
|
||||
next.AppealURL = appealURL
|
||||
if !next.Until.After(next.Since) {
|
||||
return CommandResult{}, fmt.Errorf("freeze_until must be after freeze_since")
|
||||
}
|
||||
}
|
||||
wouldChange := !found || prev.Frozen != next.Frozen ||
|
||||
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) ||
|
||||
prev.AppealURL != next.AppealURL
|
||||
details := map[string]any{
|
||||
"previous_frozen": found && prev.Frozen,
|
||||
"new_frozen": req.Frozen,
|
||||
"would_change": wouldChange,
|
||||
}
|
||||
if req.Frozen {
|
||||
details["freeze_since"] = next.Since.Format(time.RFC3339)
|
||||
details["freeze_until"] = next.Until.Format(time.RFC3339)
|
||||
details["freeze_appeal_url"] = next.AppealURL
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.restrictions.SetAccountFreeze(ctx, next)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
return CommandResult{Message: "send restriction updated", Details: details}, nil
|
||||
return CommandResult{Message: "account freeze updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMemoryCommandRepo()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
|
|
@ -20,10 +21,12 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{
|
||||
dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Until: fixedNow().Add(7 * 24 * time.Hour),
|
||||
AppealURL: "https://appeals.example.test/account/1001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run freeze: %v", err)
|
||||
|
|
@ -32,23 +35,29 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls)
|
||||
}
|
||||
|
||||
execReq := SetSendFrozenRequest{
|
||||
execReq := SetAccountFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Until: fixedNow().Add(7 * 24 * time.Hour),
|
||||
AppealURL: "https://appeals.example.test/account/1001",
|
||||
}
|
||||
exec, err := svc.SetSendFrozen(ctx, execReq)
|
||||
exec, err := svc.SetAccountFrozen(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)
|
||||
if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) {
|
||||
t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err)
|
||||
}
|
||||
freeze, found, err := svc.AccountFreeze(ctx, 1001)
|
||||
if err != nil || !found || !freeze.Frozen || !freeze.Since.Equal(fixedNow()) || freeze.AppealURL != execReq.AppealURL {
|
||||
t.Fatalf("AccountFreeze = %+v found=%v err=%v", freeze, found, err)
|
||||
}
|
||||
|
||||
again, err := svc.SetSendFrozen(ctx, execReq)
|
||||
again, err := svc.SetAccountFrozen(ctx, execReq)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate freeze: %v", err)
|
||||
}
|
||||
|
|
@ -57,6 +66,101 @@ func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Restrictions: restrictions, Now: fixedNow})
|
||||
for _, req := range []SetAccountFrozenRequest{
|
||||
{CommandMeta: CommandMeta{CommandID: "bad-until", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow(), AppealURL: "https://appeals.example.test"},
|
||||
{CommandMeta: CommandMeta{CommandID: "too-far", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: time.Unix(1<<31, 0), AppealURL: "https://appeals.example.test"},
|
||||
{CommandMeta: CommandMeta{CommandID: "bad-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "javascript:bad"},
|
||||
{CommandMeta: CommandMeta{CommandID: "long-url", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(time.Hour), AppealURL: "https://appeals.example.test/" + strings.Repeat("x", maxFreezeAppealURLLength)},
|
||||
} {
|
||||
if _, err := svc.SetAccountFrozen(ctx, req); err == nil {
|
||||
t.Fatalf("SetAccountFrozen(%+v) succeeded", req)
|
||||
}
|
||||
}
|
||||
freezeReq := SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "freeze", Actor: "ops", Reason: "test"}, UserID: 1001, Frozen: true, Until: fixedNow().Add(24 * time.Hour), AppealURL: "https://appeals.example.test"}
|
||||
if _, err := svc.SetAccountFrozen(ctx, freezeReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{CommandMeta: CommandMeta{CommandID: "unfreeze", Actor: "ops", Reason: "accepted"}, UserID: 1001}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
freeze, found, err := svc.AccountFreeze(ctx, 1001)
|
||||
if err != nil || !found || freeze.Frozen || !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
|
||||
t.Fatalf("unfrozen state = %+v found=%v err=%v", freeze, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenUpdatePreservesOriginalSince(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := fixedNow()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Restrictions: restrictions,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "freeze-initial", Actor: "ops", Reason: "review"},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Until: now.Add(24 * time.Hour),
|
||||
AppealURL: "https://appeals.example.test/initial",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalSince := now
|
||||
now = now.Add(2 * time.Hour)
|
||||
updatedUntil := now.Add(72 * time.Hour)
|
||||
if _, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "freeze-update", Actor: "ops", Reason: "extend review"},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Until: updatedUntil,
|
||||
AppealURL: "https://appeals.example.test/updated",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
freeze, found, err := svc.AccountFreeze(ctx, 1001)
|
||||
if err != nil || !found || !freeze.Since.Equal(originalSince) || !freeze.Until.Equal(updatedUntil) ||
|
||||
freeze.AppealURL != "https://appeals.example.test/updated" {
|
||||
t.Fatalf("updated freeze = %+v found=%v err=%v", freeze, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountFrozenReplayRemainsIdempotentAfterDeadline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := fixedNow()
|
||||
restrictions := &fakeRestrictionStore{}
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
Restrictions: restrictions,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
req := SetAccountFrozenRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "freeze-expiring", Actor: "ops", Reason: "review"},
|
||||
UserID: 1001,
|
||||
Frozen: true,
|
||||
Until: now.Add(time.Hour),
|
||||
AppealURL: "https://appeals.example.test/expiring",
|
||||
}
|
||||
if _, err := svc.SetAccountFrozen(ctx, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(2 * time.Hour)
|
||||
replayed, err := svc.SetAccountFrozen(ctx, req)
|
||||
if err != nil || !replayed.AlreadyExecuted || restrictions.setCalls != 1 {
|
||||
t.Fatalf("expired replay = %+v err=%v setCalls=%d", replayed, err, restrictions.setCalls)
|
||||
}
|
||||
stale := req
|
||||
stale.CommandID = "new-stale-freeze"
|
||||
if _, err := svc.SetAccountFrozen(ctx, stale); err == nil || restrictions.setCalls != 1 {
|
||||
t.Fatalf("new stale request err=%v setCalls=%d, want rejection without state mutation", err, restrictions.setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := &fakeUsersService{users: map[int64]domain.User{
|
||||
|
|
@ -365,21 +469,21 @@ func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, s
|
|||
}
|
||||
|
||||
type fakeRestrictionStore struct {
|
||||
items map[int64]domain.AccountSendRestriction
|
||||
items map[int64]domain.AccountFreeze
|
||||
setCalls int
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) {
|
||||
func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
if f.items == nil {
|
||||
return domain.AccountSendRestriction{}, false, nil
|
||||
return domain.AccountFreeze{}, false, nil
|
||||
}
|
||||
r, ok := f.items[userID]
|
||||
return r, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) {
|
||||
func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
if f.items == nil {
|
||||
f.items = map[int64]domain.AccountSendRestriction{}
|
||||
f.items = map[int64]domain.AccountFreeze{}
|
||||
}
|
||||
f.setCalls++
|
||||
r.UpdatedAt = fixedNow()
|
||||
|
|
@ -387,13 +491,6 @@ func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.Ac
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue