Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/tasks/maintenance/discard_stale_unconfirmed_accounts_task.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

class Maintenance::DiscardStaleUnconfirmedAccountsTask < MaintenanceTasks::Task
include SemanticLogger::Loggable

SECURITY_AUDITING_START_DATE = Time.new(2024, 2, 1).utc
UNCONFIRMED_USER_RETENTION_DAYS = 30.days

def collection # rubocop:disable Metrics/MethodLength
User
Copy link
Member

@jenshenny jenshenny Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually could this happen?

  1. User creates account long ago that has gems
  2. User recently changes email within 30 days but never confirmed it
  3. We delete their account

.not_deleted
.where(email_confirmed: false)
# only consider users created after we started tracking events
.where(created_at: SECURITY_AUDITING_START_DATE..UNCONFIRMED_USER_RETENTION_DAYS.ago)
# owns no gems
.where("NOT EXISTS (
SELECT 1 FROM ownerships
WHERE ownerships.user_id = users.id
AND ownerships.confirmed_at IS NOT NULL
LIMIT 1
)")
# has never pushed gems
.where("NOT EXISTS (
SELECT 1 FROM versions
WHERE versions.pusher_id = users.id
LIMIT 1
)")
# belongs to no organizations
.where("NOT EXISTS (
SELECT 1 FROM memberships
WHERE memberships.user_id = users.id
LIMIT 1
)")
.where(totp_seed: nil)
# has never created webauthn credentials
.where("NOT EXISTS (
SELECT 1 FROM webauthn_credentials
WHERE webauthn_credentials.user_id = users.id
LIMIT 1
)")
.where(policies_acknowledged_at: nil)
# hasn't logged in or changed their password
.where("
NOT EXISTS (
SELECT 1 FROM events_user_events
WHERE events_user_events.user_id = users.id
AND events_user_events.tag IN ('user:login:success', 'user:password:changed')
LIMIT 1
)
")
end

def process(user)
logger.tagged(user_id: user.id, email: user.email) do
user.transaction do
user.discard!
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

require "test_helper"

class Maintenance::DiscardStaleUnconfirmedAccountsTaskTest < ActiveSupport::TestCase
test "#process discards stale unconfirmed users" do
stale_unconfirmed_user = create(:user, :unconfirmed, created_at: actionable_timestamp)

refute_predicate stale_unconfirmed_user, :discarded?

Maintenance::DiscardStaleUnconfirmedAccountsTask.process(stale_unconfirmed_user)

assert_predicate stale_unconfirmed_user, :discarded?
end

test "#collection returns discardable users" do
confirmed_user = create(:user, **discardable_attributes(email_confirmed: true))
stale_unconfirmed_user = create(:user, **discardable_attributes)
recent_unconfirmed_user = create(:user, **discardable_attributes(created_at: 7.days.ago))
ancient_unconfirmed_user = create(:user, **discardable_attributes(created_at: Time.new(2010, 5, 5).utc))

discarded_user = create(:user, **discardable_attributes)
discarded_user.discard!

rubygem_owner = create(:user, **discardable_attributes)
create(:ownership, user: rubygem_owner)

organization_owner = create(:user, **discardable_attributes)
create(:membership, user: organization_owner)

unconfirmed_user_with_credentials = create(:user, **discardable_attributes)
create(:webauthn_credential, user: unconfirmed_user_with_credentials)

unconfirmed_user_with_previous_push = create(:user, **discardable_attributes)
create(:version, pusher_id: unconfirmed_user_with_previous_push.id)

unconfirmed_user_with_previous_login = create(:user, **discardable_attributes)
create(:events_user_event, user: unconfirmed_user_with_previous_login)

discardable_users = Maintenance::DiscardStaleUnconfirmedAccountsTask.collection

assert_includes discardable_users, stale_unconfirmed_user

assert_not_includes discardable_users, confirmed_user
assert_not_includes discardable_users, recent_unconfirmed_user
assert_not_includes discardable_users, ancient_unconfirmed_user
assert_not_includes discardable_users, discarded_user
assert_not_includes discardable_users, rubygem_owner
assert_not_includes discardable_users, organization_owner
assert_not_includes discardable_users, unconfirmed_user_with_credentials
assert_not_includes discardable_users, unconfirmed_user_with_previous_push
assert_not_includes discardable_users, unconfirmed_user_with_previous_login
end

def discardable_attributes(overrides = {})
{
created_at: actionable_timestamp,
email_confirmed: false,
policies_acknowledged_at: nil
}.merge(overrides)
end

def actionable_timestamp
Maintenance::DiscardStaleUnconfirmedAccountsTask::UNCONFIRMED_USER_RETENTION_DAYS.ago - 1
end
end
Loading