package rpc import ( "context" "errors" "strings" "testing" "time" "github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" appaccount "telesrv/internal/app/account" "telesrv/internal/domain" "telesrv/internal/postresponse" "telesrv/internal/store/memory" ) func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) { current := [8]byte{1} other := [8]byte{2} accountSvc := &rpcDeletionAccountService{ Service: appaccount.NewService(memory.NewPasswordStore()), outcome: domain.AccountDeleteOutcome{ Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{ {AuthKeyID: current, UserID: 42}, {AuthKeyID: other, UserID: 42}, }}, }, } sessions := &deletionCaptureSessions{} facts := &recordingUserProjectionFactInvalidator{} r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System) ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77)) ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"}) if err != nil || !ok { t.Fatalf("delete account ok=%v err=%v", ok, err) } if sessions.wasClosed(current) { t.Fatal("current auth key closed before rpc_result delivery") } if !sessions.wasClosed(other) { t.Fatal("other auth key was not revoked immediately") } if accountSvc.sweepCalls != 0 { t.Fatalf("account lifecycle sweeps before rpc_result delivery = %d, want 0", accountSvc.sweepCalls) } if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 { t.Fatalf("deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones) } postresponse.Run(ctx) if !sessions.wasClosed(current) { t.Fatal("current auth key not closed after rpc_result delivery") } if accountSvc.sweepCalls != 0 { t.Fatalf("account lifecycle sweeps after rpc_result delivery = %d, want 0", accountSvc.sweepCalls) } } func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) { accountSvc := &rpcDeletionAccountService{ Service: appaccount.NewService(memory.NewPasswordStore()), outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800}, } r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System) ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1}) ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"}) if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") { t.Fatalf("delayed delete ok=%v err=%v", ok, err) } } func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) { if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) { t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver") } if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) { t.Fatal("confirm-phone methods must remain fully authorized") } } func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) { revoked := [8]byte{3} svc := &rpcDeletionAccountService{ Service: appaccount.NewService(memory.NewPasswordStore()), sweepResults: []domain.AccountDeletionResult{{ Changed: true, User: domain.User{ID: 42, Deleted: true}, RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}}, }}, sweepErr: errors.New("later candidate failed"), } sessions := &deletionCaptureSessions{} r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System) r.runAccountLifecycleOnce(context.Background(), 10) if !sessions.wasClosed(revoked) { t.Fatal("committed deletion authorization was not closed after partial sweep failure") } } func TestModerationAccountDeletionClosesRevokedSessions(t *testing.T) { revoked := [8]byte{4} sessions := &deletionCaptureSessions{} facts := &recordingUserProjectionFactInvalidator{} r := New(Config{}, Deps{Sessions: sessions, UserProjectionFacts: facts}, zaptest.NewLogger(t), clock.System) r.NotifyModerationAccountDeletion(context.Background(), domain.AccountDeletionResult{ Changed: true, User: domain.User{ID: 42, Deleted: true}, RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}}, }) if !sessions.wasClosed(revoked) { t.Fatal("moderation-deleted authorization session was not closed") } if len(facts.freezes) != 1 || facts.freezes[0] != 42 || len(facts.phones) != 1 || facts.phones[0] != 42 { t.Fatalf("moderation-deleted user fact invalidations freezes=%v phones=%v, want [42]/[42]", facts.freezes, facts.phones) } } type rpcDeletionAccountService struct { *appaccount.Service outcome domain.AccountDeleteOutcome err error sweepResults []domain.AccountDeletionResult sweepErr error sweepCalls int } func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) { return s.outcome, s.err } func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) { return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil } func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) { return nil, nil } func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) { return "", domain.AuthCodeDelivery{}, false, nil } func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) { return false, nil } func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) { s.sweepCalls++ return s.sweepResults, s.sweepErr } type deletionCaptureSessions struct { captureSessions closed [][8]byte } func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int { s.closed = append(s.closed, id) return 1 } func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool { for _, closed := range s.closed { if closed == id { return true } } return false }