feat: sync account deletion lifecycle

Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:35:58 +08:00
parent 96a419b565
commit edb7057757
32 changed files with 3236 additions and 88 deletions

View file

@ -15,6 +15,15 @@ import (
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
return r.onAccountSendConfirmPhoneCode(ctx, req)
})
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
return r.onAccountConfirmPhone(ctx, req)
})
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
return true, nil
})
@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
if err != nil {
return false, internalErr()
}
if ttl.Days <= 0 {
if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
return false, tgerr400("TTL_DAYS_INVALID")
}
if svc, ok := r.accountSettingsSvc(); ok {

View file

@ -0,0 +1,152 @@
package rpc
import (
"context"
"errors"
"fmt"
"time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
)
type accountDeletionService interface {
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
}
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
svc, ok := r.deps.Account.(accountDeletionService)
return svc, ok
}
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 || (!authorized && !passwordPending) {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok || authKeyID == ([8]byte{}) {
return false, authKeyUnregisteredErr()
}
var password *domain.PasswordCheck
if check, present := req.GetPassword(); present {
converted := domainPasswordCheck(check)
password = &converted
}
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
if outcome.Kind == domain.AccountDeleteDelayed {
wait := outcome.WaitSeconds
if wait < 1 {
wait = 1
}
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
}
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
r.invalidateRPCProjectionForUser(userID)
dispatchNotifications := func() {
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.runAccountLifecycleOnce(dispatchCtx, 500)
}
if !postresponse.Register(ctx, dispatchNotifications) {
go dispatchNotifications()
}
return true, nil
}
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return nil, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return nil, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !authorized || userID == 0 {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
return true, nil
}
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
current, _ := AuthKeyIDFrom(ctx)
for _, authorization := range revoked {
a := authorization
finish := func() {
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
r.revokeAuthKeySessions(a.AuthKeyID)
}
if a.AuthKeyID == current {
if postresponse.Register(ctx, finish) {
continue
}
}
finish()
}
}
func accountDeletionErr(err error) error {
switch {
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
return passwordErr(err)
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
return tgerr.New(400, "HASH_INVALID")
case errors.Is(err, domain.ErrPhoneCodeEmpty):
return phoneCodeEmptyErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid):
return phoneCodeInvalidErr()
case errors.Is(err, domain.ErrPhoneCodeExpired):
return phoneCodeExpiredErr()
case errors.Is(err, domain.ErrAccountDeletionForbidden):
return botMethodInvalidErr()
case errors.Is(err, domain.ErrAccountDeleted):
return authKeyUnregisteredErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,177 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"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{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, 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")
}
postresponse.Run(ctx)
if !sessions.wasClosed(current) {
t.Fatal("current auth key not closed after rpc_result delivery")
}
}
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 TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
sessions := &offlineDeletionSessions{}
svc := &deletionWorkerService{}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
})
if len(svc.completed) != 1 || svc.completed[0] != 9 {
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
}
}
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")
}
}
type rpcDeletionAccountService struct {
*appaccount.Service
outcome domain.AccountDeleteOutcome
err error
sweepResults []domain.AccountDeletionResult
sweepErr error
}
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) {
return s.sweepResults, s.sweepErr
}
type deletionCaptureSessions struct {
captureSessions
closed [][8]byte
}
type offlineDeletionSessions struct{ captureSessions }
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
return 0, nil
}
type deletionWorkerService struct{ completed []int64 }
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return nil, nil
}
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
s.completed = append(s.completed, id)
return nil
}
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
}

View file

@ -0,0 +1,99 @@
package rpc
import (
"context"
"time"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
type accountLifecycleWorkerService interface {
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}
// RunAccountLifecycle executes all due account deletion sources through one
// tombstone path and drains the durable non-pts updateUser queue. The queue is
// a crash-safe, bounded online nudge: offline users are completed after the
// first attempt because getDialogs/getHistory hydration independently returns
// the authoritative tombstone. This avoids an immortal retry queue for a
// non-pts update that cannot participate in getDifference.
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
if interval <= 0 {
interval = time.Minute
}
if batch <= 0 {
batch = 500
}
r.runAccountLifecycleOnce(ctx, batch)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.runAccountLifecycleOnce(ctx, batch)
}
}
}
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
if !ok {
return
}
now := r.clock.Now().UTC()
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
cancel()
for _, result := range results {
if !result.Changed {
continue
}
r.invalidateRPCProjectionForUser(result.User.ID)
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
}
if err != nil {
// SweepDueAccountDeletions may return already-committed results before a
// later candidate fails. Always finish those sessions/caches and drain
// their durable notifications; the failed and remaining candidates are
// retried from their authoritative due rows on the next tick.
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
}
for {
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
claimCancel()
if err != nil {
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
return
}
for _, notification := range notifications {
r.dispatchAccountDeletionNotification(ctx, svc, notification)
}
if len(notifications) < batch {
return
}
}
}
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
now := r.clock.Now().UTC()
updates := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
Users: []tg.UserClass{tgUser(domain.User{
ID: notification.DeletedUserID,
Deleted: true,
})},
Date: int(now.Unix()),
}
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
}
}

View file

@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err
}
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
}
}
var hash string
var err error
if scoped, ok := r.deps.Auth.(interface {
@ -507,6 +520,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
}
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return false, accountDeletionErr(err)
}
return true, nil
}
}
}
var err error
if scoped, ok := r.deps.Auth.(interface {
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error

View file

@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
tg.AuthReportMissingCodeRequestTypeID,
tg.AuthResetLoginEmailRequestTypeID,
tg.AccountGetPasswordRequestTypeID,
// deleteAccount may complete the narrow password_pending login path when
// the user forgot 2FA. The handler resolves only that bound identity.
tg.AccountDeleteAccountRequestTypeID,
// 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权,
// 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。
tg.AccountSendVerifyEmailCodeRequestTypeID,

View file

@ -9,6 +9,9 @@ import (
// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。
func tgSelfUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,
@ -34,6 +37,9 @@ func tgSelfUser(u domain.User) *tg.User {
}
func tgUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{
ID: u.ID,
AccessHash: u.AccessHash,

View file

@ -0,0 +1,56 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
u := domain.User{
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
}
got := tgUser(u)
if got.ID != u.ID || !got.Deleted {
t.Fatalf("deleted user = %+v", got)
}
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
t.Fatalf("deleted user leaked profile state: %+v", got)
}
self := tgSelfUser(u)
if !self.Deleted || self.Self || self.ID != u.ID {
t.Fatalf("deleted self projection = %+v", self)
}
}
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
viewer := domain.User{ID: 7, FirstName: "Viewer"}
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewer.ID: viewer, deleted.ID: deleted,
}}}, zaptest.NewLogger(t), clock.System)
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
Messages: []domain.Message{{
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Body: "retained history",
}},
// Simulate an old denormalized message query row. The authoritative
// Users.ByIDs hydration must replace it, not keep an empty active user.
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
})
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
}
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
t.Fatalf("history TL user = %+v", got)
}
}