0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/services/user_anonymizer_spec.rb
Régis Hanol 7a15be0366
FIX: Only anonymize staff log fields that contain the username (#37242)
Follow-up to 1fd17242d1.

The previous implementation replaced all four UserHistory fields
(context, details, previous_value, new_value) whenever any one of them
contained the username. This caused confusing output in the staff action
logs UI - for example, a "check email" action that only had the username
in the context field would display:

    New: [removed due to user anonymization]
    Previous: [removed due to user anonymization]
    [removed due to user anonymization]

...even though those fields were originally empty.

Now each field is updated independently using SQL CASE expressions, so
only fields that actually contain the username get replaced. Fields with
unrelated content or nil values are preserved. This keeps the
anonymization effective while avoiding the cluttered display.

Ref - https://meta.discourse.org/t/-/387500
2026-01-27 17:39:21 +01:00

490 lines
16 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe UserAnonymizer do
let(:admin) { Fabricate(:admin) }
describe "event" do
subject(:make_anonymous) do
described_class.make_anonymous(user, admin, anonymize_ip: "2.2.2.2")
end
let(:user) { Fabricate(:user, username: "edward") }
it "triggers the event" do
events = DiscourseEvent.track_events { make_anonymous }
anon_event = events.detect { |e| e[:event_name] == :user_anonymized }
expect(anon_event).to be_present
params_hash = anon_event[:params][0]
expect(params_hash[:user]).to eq(user)
expect(params_hash[:opts][:anonymize_ip]).to eq("2.2.2.2")
end
end
describe ".make_anonymous" do
subject(:make_anonymous) { described_class.make_anonymous(user, admin) }
let(:original_email) { "edward@example.net" }
let(:user) { Fabricate(:user, username: "edward", email: original_email) }
fab!(:another_user, :evil_trout)
it "changes username" do
make_anonymous
expect(user.reload.username).to match(/^anon\d{3,}$/)
end
it "changes the primary email address" do
make_anonymous
expect(user.reload.email).to eq("#{user.username}@anonymized.invalid")
end
it "changes the primary email normalized email address" do
make_anonymous
primary_email = user.reload.primary_email
expect(primary_email.normalized_email).to eq("#{user.username}@anonymized.invalid")
end
it "changes the primary email address when there is an email domain allowlist" do
SiteSetting.allowed_email_domains = "example.net|wayne.com|discourse.org"
make_anonymous
expect(user.reload.email).to eq("#{user.username}@anonymized.invalid")
end
it "deletes secondary email addresses" do
Fabricate(:secondary_email, user: user, email: "secondary_email@example.com")
make_anonymous
expect(user.reload.secondary_emails).to be_blank
end
it "turns off all notifications" do
user.user_option.update_columns(
email_level: UserOption.email_level_types[:always],
email_messages_level: UserOption.email_level_types[:always],
)
make_anonymous
user.reload
expect(user.user_option.email_digests).to eq(false)
expect(user.user_option.email_level).to eq(UserOption.email_level_types[:never])
expect(user.user_option.email_messages_level).to eq(UserOption.email_level_types[:never])
expect(user.user_option.mailing_list_mode).to eq(false)
end
context "when Site Settings do not require full name" do
before { SiteSetting.full_name_requirement = "optional_at_signup" }
it "resets profile to default values" do
user.update!(name: "Bibi", date_of_birth: 19.years.ago, title: "Super Star")
profile = user.reload.user_profile
upload = Fabricate(:upload)
profile.update!(
location: "Moose Jaw",
website: "http://www.bim.com",
bio_raw: "I'm Bibi from Moosejaw. I sing and dance.",
bio_cooked: "I'm Bibi from Moosejaw. I sing and dance.",
profile_background_upload: upload,
bio_cooked_version: 2,
card_background_upload: upload,
)
prev_username = user.username
UserAuthToken.generate!(user_id: user.id)
make_anonymous
user.reload
expect(user.username).not_to eq(prev_username)
expect(user.name).not_to be_present
expect(user.date_of_birth).to eq(nil)
expect(user.title).not_to be_present
expect(user.user_auth_tokens.count).to eq(0)
profile = user.reload.user_profile
expect(profile.location).to eq(nil)
expect(profile.website).to eq(nil)
expect(profile.bio_cooked).to eq(nil)
expect(profile.profile_background_upload).to eq(nil)
expect(profile.bio_cooked_version).to eq(UserProfile::BAKED_VERSION)
expect(profile.card_background_upload).to eq(nil)
end
end
it "clears existing user status" do
user_status = Fabricate(:user_status, user: user)
expect do
make_anonymous
user.reload
end.to change { user.user_status }.from(user_status).to(nil)
end
context "when Site Settings require full name" do
before { SiteSetting.full_name_requirement = "required_at_signup" }
it "changes name to anonymized username" do
prev_username = user.username
user.update(name: "Bibi", date_of_birth: 19.years.ago, title: "Super Star")
make_anonymous
user.reload
expect(user.name).not_to eq(prev_username)
expect(user.name).to eq(user.username)
end
end
it "removes the avatar" do
upload = Fabricate(:upload, user: user)
user.user_avatar = UserAvatar.new(user_id: user.id, custom_upload_id: upload.id)
user.uploaded_avatar_id = upload.id # chosen in user preferences
user.save!
make_anonymous
user.reload
expect(user.user_avatar).to eq(nil)
expect(user.uploaded_avatar_id).to eq(nil)
end
it "updates the avatar in posts" do
Jobs.run_immediately!
upload = Fabricate(:upload, user: user)
user.user_avatar = UserAvatar.new(user_id: user.id, custom_upload_id: upload.id)
user.uploaded_avatar_id = upload.id # chosen in user preferences
user.save!
topic = Fabricate(:topic, user: user)
quoted_post = create_post(user: user, topic: topic, post_number: 1, raw: "quoted post")
stub_image_size
post = create_post(raw: <<~RAW)
Lorem ipsum
[quote="#{quoted_post.username}, post:1, topic:#{quoted_post.topic.id}"]
quoted post
[/quote]
RAW
old_avatar_url = user.avatar_template.gsub("{size}", "48")
expect(post.cooked).to include(old_avatar_url)
make_anonymous
post.reload
new_avatar_url = user.reload.avatar_template.gsub("{size}", "48")
expect(post.cooked).to_not include(old_avatar_url)
expect(post.cooked).to include(new_avatar_url)
end
it "logs the action with the original details" do
SiteSetting.log_anonymizer_details = true
helper = UserAnonymizer.new(user, admin)
orig_email = user.email
orig_username = user.username
helper.make_anonymous
history = helper.user_history
expect(history).to be_present
expect(history.email).to eq(orig_email)
expect(history.details).to match(orig_username)
end
it "logs the action without the original details" do
SiteSetting.log_anonymizer_details = false
helper = UserAnonymizer.new(user, admin)
orig_email = user.email
orig_username = user.username
helper.make_anonymous
history = helper.user_history
expect(history).to be_present
expect(history.email).not_to eq(orig_email)
expect(history.details).not_to match(orig_username)
end
it "removes external auth associations" do
user.user_associated_accounts = [
UserAssociatedAccount.create(
user_id: user.id,
provider_uid: "example",
provider_name: "facebook",
),
]
user.single_sign_on_record =
SingleSignOnRecord.create(
user_id: user.id,
external_id: "example",
last_payload: "looks good",
)
make_anonymous
user.reload
expect(user.user_associated_accounts).to be_empty
expect(user.single_sign_on_record).to eq(nil)
end
it "removes api key" do
ApiKey.create!(user_id: user.id)
expect { make_anonymous }.to change { ApiKey.count }.by(-1)
user.reload
expect(user.api_keys).to be_empty
end
it "removes user api key" do
user_api_key = Fabricate(:user_api_key, user: user)
expect { make_anonymous }.to change { UserApiKey.count }.by(-1)
user.reload
expect(user.user_api_keys).to be_empty
end
it "removes user auth tokens" do
UserAuthToken.generate!(user_id: user.id)
expect { make_anonymous }.to change { UserAuthToken.count }.by(-1)
expect(user.reload.user_auth_tokens).to be_empty
end
it "removes second factor credentials" do
Fabricate(:user_second_factor_totp, user: user)
expect { make_anonymous }.to change { UserSecondFactor.count }.by(-1)
expect(user.reload.user_second_factors).to be_empty
end
it "removes security keys" do
Fabricate(:user_security_key_with_random_credential, user: user)
expect { make_anonymous }.to change { UserSecurityKey.count }.by(-1)
expect(UserSecurityKey.where(user_id: user.id).count).to eq(0)
end
it "removes push subscriptions" do
Fabricate(:push_subscription, user: user)
expect { make_anonymous }.to change { PushSubscription.count }.by(-1)
expect(user.reload.push_subscriptions).to be_empty
end
it "removes post reply keys" do
post = Fabricate(:post)
PostReplyKey.create!(user_id: user.id, post_id: post.id)
expect { make_anonymous }.to change { PostReplyKey.count }.by(-1)
expect(PostReplyKey.where(user_id: user.id).count).to eq(0)
end
context "when executing jobs" do
before { Jobs.run_immediately! }
it "removes invites" do
Fabricate(:invited_user, invite: Fabricate(:invite), user: user)
Fabricate(:invited_user, invite: Fabricate(:invite), user: another_user)
expect { make_anonymous }.to change { InvitedUser.count }.by(-1)
expect(InvitedUser.where(user_id: user.id).count).to eq(0)
end
it "removes email tokens" do
Fabricate(:email_token, user: user)
Fabricate(:email_token, user: another_user)
expect { make_anonymous }.to change { EmailToken.count }.by(-1)
expect(EmailToken.where(user_id: user.id).count).to eq(0)
end
it "removes email log entries" do
Fabricate(:email_log, user: user)
Fabricate(:email_log, user: another_user)
expect { make_anonymous }.to change { EmailLog.count }.by(-1)
expect(EmailLog.where(user_id: user.id).count).to eq(0)
end
it "removes incoming emails" do
Fabricate(:incoming_email, user: user, from_address: user.email)
Fabricate(:incoming_email, from_address: user.email, error: "Some error")
Fabricate(:incoming_email, user: another_user, from_address: another_user.email)
expect { make_anonymous }.to change { IncomingEmail.count }.by(-2)
expect(IncomingEmail.where(user_id: user.id).count).to eq(0)
expect(IncomingEmail.where(from_address: original_email).count).to eq(0)
end
it "removes raw email from posts" do
post1 = Fabricate(:post, user: user, via_email: true, raw_email: "raw email from user")
post2 =
Fabricate(
:post,
user: another_user,
via_email: true,
raw_email: "raw email from another user",
)
make_anonymous
expect(post1.reload).to have_attributes(via_email: true, raw_email: nil)
expect(post2.reload).to have_attributes(
via_email: true,
raw_email: "raw email from another user",
)
end
it "does not delete profile views" do
UserProfileView.add(user.id, "127.0.0.1", another_user.id, Time.now, true)
expect { make_anonymous }.to_not change { UserProfileView.count }
end
it "removes user field values" do
field1 = Fabricate(:user_field)
field2 = Fabricate(:user_field)
user.custom_fields = {
some_field: "123",
"user_field_#{field1.id}": "foo",
"user_field_#{field2.id}": "bar",
another_field: "456",
}
expect { make_anonymous }.to change { user.custom_fields }
expect(user.reload.custom_fields).to eq("some_field" => "123", "another_field" => "456")
end
context "when log_anonymizer_details is disabled" do
before { SiteSetting.log_anonymizer_details = false }
it "anonymizes username only in fields that contain it" do
StaffActionLogger.new(admin).log_check_email(
user,
context: "/admin/users/#{user.id}/#{user.username}",
)
StaffActionLogger.new(admin).log_username_change(user, user.username, "newname")
make_anonymous
reason = I18n.t("user.anonymized")
check_email =
UserHistory.find_by(action: UserHistory.actions[:check_email], target_user_id: user.id)
username_change =
UserHistory.find_by(
action: UserHistory.actions[:change_username],
target_user_id: user.id,
)
expect(check_email.context).to eq(reason)
expect(check_email.details).to be_nil
expect(check_email.previous_value).to be_nil
expect(check_email.new_value).to be_nil
expect(username_change.previous_value).to eq(reason)
expect(username_change.new_value).to eq("newname")
end
it "does not affect records without the username" do
StaffActionLogger.new(admin).log_check_email(user, context: "/some/other/path")
make_anonymous
expect(UserHistory.find_by(target_user_id: user.id).context).to eq("/some/other/path")
end
end
end
end
describe "anonymize_ip" do
let(:old_ip) { "1.2.3.4" }
let(:anon_ip) { "0.0.0.0" }
let(:user) { Fabricate(:user, ip_address: old_ip, registration_ip_address: old_ip) }
fab!(:post)
let(:topic) { post.topic }
it "doesn't anonymize ips by default" do
UserAnonymizer.make_anonymous(user, admin)
expect(user.ip_address).to eq(old_ip)
end
it "is called if you pass an option" do
UserAnonymizer.make_anonymous(user, admin, anonymize_ip: anon_ip)
user.reload
expect(user.ip_address).to eq(anon_ip)
end
it "exhaustively replaces all user ips" do
Jobs.run_immediately!
link = IncomingLink.create!(current_user_id: user.id, ip_address: old_ip, post_id: post.id)
screened_email = ScreenedEmail.create!(email: user.email, ip_address: old_ip)
search_log =
SearchLog.create!(
term: "wat",
search_type: SearchLog.search_types[:header],
user_id: user.id,
ip_address: old_ip,
)
topic_link =
TopicLink.create!(
user_id: admin.id,
topic_id: topic.id,
url: "https://discourse.org",
domain: "discourse.org",
)
topic_link_click =
TopicLinkClick.create!(topic_link_id: topic_link.id, user_id: user.id, ip_address: old_ip)
user_profile_view =
UserProfileView.create!(
user_id: user.id,
user_profile_id: admin.user_profile.id,
ip_address: old_ip,
viewed_at: Time.now,
)
TopicViewItem.create!(
topic_id: topic.id,
user_id: user.id,
ip_address: old_ip,
viewed_at: Time.now,
)
delete_history = StaffActionLogger.new(admin).log_user_deletion(user)
user_history = StaffActionLogger.new(user).log_backup_create
ip_address_history = UserIpAddressHistory.create!(user_id: user.id, ip_address: old_ip)
UserAnonymizer.make_anonymous(user, admin, anonymize_ip: anon_ip)
expect(user.registration_ip_address).to eq(anon_ip)
expect(link.reload.ip_address).to eq(anon_ip)
expect(screened_email.reload.ip_address).to eq(anon_ip)
expect(search_log.reload.ip_address).to eq(anon_ip)
expect(topic_link_click.reload.ip_address).to eq(anon_ip)
topic_view = TopicViewItem.where(topic_id: topic.id, user_id: user.id).first
expect(topic_view.ip_address).to eq(anon_ip)
expect(delete_history.reload.ip_address).to eq(anon_ip)
expect(user_history.reload.ip_address).to eq(anon_ip)
expect(user_profile_view.reload.ip_address).to eq(anon_ip)
expect(UserIpAddressHistory.exists?(id: ip_address_history.id)).to eq(false)
end
end
describe "anonymize_emails" do
it "destroys all associated invites" do
invite = Fabricate(:invite, email: "test@example.com")
user = invite.redeem
Jobs.run_immediately!
described_class.make_anonymous(user, admin)
expect(user.email).not_to eq("test@example.com")
expect(Invite.exists?(id: invite.id)).to eq(false)
end
end
end