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.
This commit is contained in:
Astra 2026-09-09 14:56:10 +01:00
parent 686adc1e10
commit c939f7c92e
2 changed files with 47 additions and 6 deletions

View file

@ -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, 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(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake,
COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, 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(NULLIF(u.username, ''), p.username_lower, '') AS display_username,
COALESCE(ap.login_email, ''), COALESCE(ap.login_email, ''),
`+accountCollectibleUsernamesColumn+` AS collectibles `+accountCollectibleUsernamesColumn+` AS collectibles
FROM users u 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 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 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 LEFT JOIN account_passwords ap ON ap.user_id = u.id
WHERE NOT u.is_bot 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)) 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 auth.last_active_at DESC, u.id DESC 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) LIMIT $3`, beforeActiveUS, beforeID, limit+1)
if err != nil { if err != nil {
return nil, false, fmt.Errorf("list accounts: %w", err) return nil, false, fmt.Errorf("list accounts: %w", err)

View file

@ -47,8 +47,8 @@ VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
userID, userID, "+1889"+suffix, editable); err != nil { userID, userID, "+1889"+suffix, editable); err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
} }
// The list query joins authorizations, so an account with no device never // Give this account a device so its device_count / last_active columns are
// appears there at all; an authorization in turn needs its auth key to exist. // exercised; an authorization needs its auth key to exist first.
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil { INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
t.Fatalf("seed auth key: %v", err) 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) { func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
t.Helper() t.Helper()
if row.Username != editable { if row.Username != editable {