package postgres import ( "context" "crypto/rand" "crypto/sha256" "fmt" "strings" "testing" "time" "unicode/utf8" "github.com/jackc/pgx/v5/pgxpool" "telesrv/internal/domain" "telesrv/internal/store" ) func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() nonce := time.Now().UnixNano() users := NewUserStore(pool) deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me") peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer") var channelID int64 var collectiblePhoneID int64 t.Cleanup(func() { if channelID != 0 { _, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID) } if collectiblePhoneID != 0 { _, _ = pool.Exec(ctx, `DELETE FROM collectible_phones WHERE id = $1`, collectiblePhoneID) } _, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID}) _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID}) }) deletedUsername := fmt.Sprintf("deleteme%d", nonce) deleted, err := users.UpdateUsername(ctx, deleted.ID, deletedUsername) if err != nil { t.Fatalf("set deleted user username: %v", err) } authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1) authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2) if _, err := pool.Exec(ctx, `INSERT INTO contacts (user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name) VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil { t.Fatalf("insert reverse contact: %v", err) } if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{ SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history", }); err != nil { t.Fatalf("send shared message: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil { t.Fatalf("insert account settings: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil { t.Fatalf("insert stars balance: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil { t.Fatalf("insert TON balance: %v", err) } collectiblePhone := fmt.Sprintf("888%010d", nonce%10_000_000_000) if err := pool.QueryRow(ctx, `INSERT INTO collectible_phones (phone, tier, status, owner_user_id, purchase_date, currency, amount, created_at, updated_at) VALUES ($1, 'standard', 'owned', $2, $3, 'XTR', 100, $3, $3) RETURNING id`, collectiblePhone, deleted.ID, time.Now().UTC()).Scan(&collectiblePhoneID); err != nil { t.Fatalf("insert collectible phone: %v", err) } createdChannel, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ CreatorUserID: deleted.ID, Title: "Retained deletion membership", Megagroup: true, Date: int(time.Now().Unix()), }) if err != nil { t.Fatalf("create retained channel membership: %v", err) } channelID = createdChannel.Channel.ID var contactVersionBefore, channelParticipantsVersionBefore int64 if err := pool.QueryRow(ctx, ` SELECT COALESCE((SELECT version FROM read_model_versions WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionBefore); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, ` SELECT COALESCE((SELECT version FROM read_model_versions WHERE model = 'channel_participants' AND owner_user_id = 0 AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionBefore); err != nil { t.Fatal(err) } lifecycle := NewAccountLifecycleStore(pool) now := time.Now().UTC().Truncate(time.Second) digestOne := sha256.Sum256([]byte("confirm-one")) pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password", ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one", RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour), }) if err != nil || !created || pending.UserID != deleted.ID { t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err) } if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID { t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err) } revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute)) if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne { t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err) } if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found { t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err) } if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found { t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err) } digestTwo := sha256.Sum256([]byte("confirm-two")) pendingBeforeDelete, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ UserID: deleted.ID, RequesterAuthKeyID: authTwo, Reason: "Delete account", ConfirmHashDigest: digestTwo, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-two", RequestedAt: now.Add(time.Minute), ExecuteAt: now.Add(7 * 24 * time.Hour), }) if err != nil || !created { t.Fatalf("schedule deletion before tombstone = %+v created=%v err=%v", pendingBeforeDelete, created, err) } result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute)) if err != nil { t.Fatalf("execute account deletion: %v", err) } if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 { t.Fatalf("deletion result = %+v", result) } if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found { t.Fatalf("released phone found=%v err=%v", found, err) } if _, found, err := users.ByUsername(ctx, deletedUsername); err != nil || found { t.Fatalf("released username found=%v err=%v", found, err) } if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" { t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err) } if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, authTwo); err != nil || found { t.Fatalf("authorization after tombstone found=%v err=%v, want revoked", found, err) } if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found { t.Fatalf("permanent protocol auth key after tombstone found=%v err=%v, want retained", found, err) } var requestState string if err := pool.QueryRow(ctx, `SELECT state FROM account_deletion_requests WHERE id = $1`, pendingBeforeDelete.ID).Scan(&requestState); err != nil { t.Fatal(err) } if requestState != "executed" { t.Fatalf("pending request state after tombstone = %q, want executed", requestState) } var deletedVersion, contactVersionAfter, channelParticipantsVersionAfter int64 if err := pool.QueryRow(ctx, ` SELECT version FROM read_model_versions WHERE model = 'user_deleted' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, deleted.ID).Scan(&deletedVersion); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, ` SELECT COALESCE((SELECT version FROM read_model_versions WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1), 0)`, peer.ID).Scan(&contactVersionAfter); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, ` SELECT COALESCE((SELECT version FROM read_model_versions WHERE model = 'channel_participants' AND owner_user_id = 0 AND peer_type = 'channel' AND peer_id = $1), 0)`, channelID).Scan(&channelParticipantsVersionAfter); err != nil { t.Fatal(err) } if deletedVersion < 1 || contactVersionAfter != contactVersionBefore || channelParticipantsVersionAfter != channelParticipantsVersionBefore { t.Fatalf("logical-delete read-model fanout deleted=%d contact=%d->%d channel=%d->%d", deletedVersion, contactVersionBefore, contactVersionAfter, channelParticipantsVersionBefore, channelParticipantsVersionAfter) } if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil { t.Fatal("deleted account profile mutation unexpectedly succeeded") } history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{ HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID}, Limit: 10, }) if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID { t.Fatalf("peer history after deletion = %+v err=%v", history, err) } var ownerBoxes, peerBoxes, settings, contacts, notifications int if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1`, deleted.ID).Scan(&ownerBoxes); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(¬ifications); err != nil { t.Fatal(err) } var memberStatus string if err := pool.QueryRow(ctx, `SELECT status FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, deleted.ID).Scan(&memberStatus); err != nil { t.Fatal(err) } if ownerBoxes == 0 || peerBoxes != 1 || settings != 1 || contacts != 1 || notifications != 0 || memberStatus != "active" { t.Fatalf("logical-delete retained state ownerBoxes=%d peerBoxes=%d settings=%d contacts=%d notifications=%d memberStatus=%q", ownerBoxes, peerBoxes, settings, contacts, notifications, memberStatus) } var stars, ton, starClear, tonClear int64 if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil { t.Fatal(err) } if stars != 50 || ton != 100 || starClear != 0 || tonClear != 0 { t.Fatalf("logical-delete retained finances stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear) } var collectibleStatus string var collectibleOwner int64 if err := pool.QueryRow(ctx, `SELECT status, owner_user_id FROM collectible_phones WHERE id = $1`, collectiblePhoneID).Scan(&collectibleStatus, &collectibleOwner); err != nil { t.Fatal(err) } if collectibleStatus != "owned" || collectibleOwner != deleted.ID { t.Fatalf("logical-delete collectible phone status=%q owner=%d, want owned by tombstone %d", collectibleStatus, collectibleOwner, deleted.ID) } } func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() nonce := time.Now().UnixNano() users := NewUserStore(pool) ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User") freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User") pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User") ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID} t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids) _, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids) _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids) }) now := time.Now().UTC().Truncate(time.Second) if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions (user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url) VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil { t.Fatal(err) } digest := sha256.Sum256([]byte("due-pending")) if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests (user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at) VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil { t.Fatal(err) } candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10) if err != nil { t.Fatal(err) } sources := make(map[int64]domain.AccountDeletionSource, len(candidates)) for _, candidate := range candidates { sources[candidate.UserID] = candidate.Source } if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry { t.Fatalf("due sources = %+v", sources) } seen := now.Add(time.Hour) if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil { t.Fatal(err) } lifecycle := NewAccountLifecycleStore(pool) if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed { t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err) } if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil { t.Fatal(err) } if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed { t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err) } if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil { t.Fatal(err) } if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed { t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err) } var deadline time.Time if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil { t.Fatal(err) } if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second { t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want) } if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30) ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil { t.Fatal(err) } if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second { t.Fatalf("custom TTL deadline=%v want=%v", deadline, want) } } func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) { pool := testPool(t) ctx := context.Background() user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock") t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) }) if _, err := pool.Exec(ctx, `INSERT INTO account_passwords (user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b) VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil { t.Fatal(err) } var initial, afterChallenge, afterPassword time.Time if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil { t.Fatal(err) } if !afterChallenge.Equal(initial) { t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge) } if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil { t.Fatal(err) } if !afterPassword.After(afterChallenge) { t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword) } } func TestTruncateAccountDeletionReasonUTF8(t *testing.T) { got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024) if !utf8.ValidString(got) || len(got) > 1024 { t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got)) } if got == "" { t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason") } } func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte { t.Helper() var id [8]byte var value [256]byte if _, err := rand.Read(id[:]); err != nil { t.Fatal(err) } id[0] = marker if _, err := rand.Read(value[:]); err != nil { t.Fatal(err) } if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil { t.Fatalf("save lifecycle auth key: %v", err) } if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil { t.Fatalf("bind lifecycle authorization: %v", err) } return id }