diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go index df718903..4c78b3b6 100644 --- a/cmd/telesrv-admin/readstore.go +++ b/cmd/telesrv-admin/readstore.go @@ -837,18 +837,21 @@ 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, - auth.last_active_at, auth.device_count, + 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 -JOIN auth ON auth.user_id = u.id +-- 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 ($1::bigint = 0 OR (auth.last_active_at, u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) -ORDER BY auth.last_active_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) 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 {