From 5a89a83caf08752eb66bf37309c993f8c89c7fb5 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 14:56:10 +0100 Subject: [PATCH] admin: list accounts that have no active sessions The Accounts tab (readStore.ListAccounts) inner-joined the authorizations aggregate, so any account with zero authorization rows was silently hidden - accounts that never finished login, had all sessions revoked, or were frozen then unfrozen. CountAccounts and SearchAccounts already LEFT JOIN, so the count and search disagreed with the list. Switch ListAccounts to LEFT JOIN auth and COALESCE the null last_active_at / device_count (sessionless accounts sort last), matching SearchAccounts. --- cmd/telesrv-admin/readstore.go | 9 ++-- .../readstore_accounts_integration_test.go | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go index b459a032..711ec610 100644 --- a/cmd/telesrv-admin/readstore.go +++ b/cmd/telesrv-admin/readstore.go @@ -872,19 +872,22 @@ WITH auth AS ( SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, - COALESCE(auth.last_active_at, u.created_at), COALESCE(auth.device_count, 0), + COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username, COALESCE(ap.login_email, ''), `+accountCollectibleUsernamesColumn+` AS collectibles FROM users u +-- LEFT JOIN, not JOIN: an account with no authorizations (never finished login, +-- all sessions revoked, frozen-then-unfrozen) must still appear here, matching +-- CountAccounts and SearchAccounts. LEFT JOIN auth ON auth.user_id = u.id LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable LEFT JOIN account_passwords ap ON ap.user_id = u.id WHERE NOT u.is_bot AND NOT (u.id = ANY($4::bigint[])) - AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, u.created_at), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) -ORDER BY COALESCE(auth.last_active_at, u.created_at) DESC, u.id DESC + AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) +ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC LIMIT $3`, beforeActiveUS, beforeID, limit+1, domain.SystemUserIDs()) if err != nil { return nil, false, fmt.Errorf("list accounts: %w", err) diff --git a/cmd/telesrv-admin/readstore_accounts_integration_test.go b/cmd/telesrv-admin/readstore_accounts_integration_test.go index 59db4ee6..94f65e27 100644 --- a/cmd/telesrv-admin/readstore_accounts_integration_test.go +++ b/cmd/telesrv-admin/readstore_accounts_integration_test.go @@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`, userID, userID, "+1889"+suffix, editable); err != nil { t.Fatalf("seed user: %v", err) } - // The list query joins authorizations, so an account with no device never - // appears there at all; an authorization in turn needs its auth key to exist. + // Give this account a device so its device_count / last_active columns are + // exercised; an authorization needs its auth key to exist first. if _, err := pool.Exec(ctx, ` INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil { t.Fatalf("seed auth key: %v", err) @@ -123,6 +123,44 @@ WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID); } } +// An account with no authorizations (never finished login, all sessions revoked, +// frozen-then-unfrozen) must still show up in the Accounts tab - it did not, +// because ListAccounts inner-joined the authorizations aggregate. +func TestReadStoreListAccountsIncludesAccountsWithoutSessions(t *testing.T) { + store, pool := verificationReadStore(t) + ctx := context.Background() + suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000) + userID := 3_700_000_000 + time.Now().UnixNano()%1_000_000 + + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID) + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at) +VALUES ($1, $2, $3, 'Sessionless', '', '', now(), now())`, + userID, userID, "+42777"+suffix); err != nil { + t.Fatalf("seed user: %v", err) + } + // Deliberately no auth_keys / authorizations rows. + + rows, _, err := store.ListAccounts(ctx, 0, 0, 500) + if err != nil { + t.Fatalf("ListAccounts: %v", err) + } + found := false + for i := range rows { + if rows[i].ID == userID { + found = true + if rows[i].DeviceCount != 0 { + t.Fatalf("device count = %d, want 0 for a sessionless account", rows[i].DeviceCount) + } + } + } + if !found { + t.Fatalf("sessionless account %d absent from ListAccounts (%d rows)", userID, len(rows)) + } +} + func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) { t.Helper() if row.Username != editable {