0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/spec/jobs/export_csv_file_spec.rb
Arpit Jalan b55494454d
FIX: re-validate permissions when running CSV export job (#41464)
Authorization for a CSV export was only checked when the export was
requested, not when the background job actually ran. This adds a
permission check inside `Jobs::ExportCsvFile` so that access always
reflects the user's current privileges at the time the data is
generated. If the user is not allowed to export the entity, the job
stops before any data is produced.

The discourse-calendar plugin is updated with a matching
`Guardian#can_export_entity?` extension so that legitimate `post_event`
exports keep working under the new check.
2026-07-06 16:50:06 +05:30

555 lines
18 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Jobs::ExportCsvFile do
describe "#execute" do
let(:other_user) { Fabricate(:user) }
let(:admin) { Fabricate(:admin) }
let(:action_log) { StaffActionLogger.new(admin).log_revoke_moderation(other_user) }
it "raises an error when the entity is missing" do
expect { Jobs::ExportCsvFile.new.execute(user_id: admin.id) }.to raise_error(
Discourse::InvalidParameters,
)
end
context "when re-validating permissions at execution time" do
it "exports the user list for an admin" do
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
end.to change { Upload.count }.by(1)
ensure
admin.uploads.each(&:destroy!)
end
it "raises an error when the admin was demoted after enqueueing" do
admin.revoke_admin!
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: admin.id)).to be_empty
end
it "raises an error when a regular user attempts a privileged export" do
user = Fabricate(:user)
expect do
Jobs::ExportCsvFile.new.execute(user_id: user.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: user.id)).to be_empty
end
it "raises an error when a moderator attempts an admin-only export" do
moderator = Fabricate(:moderator)
expect do
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "user_list")
end.to raise_error(Discourse::InvalidAccess)
expect(UserExport.where(user_id: moderator.id)).to be_empty
end
end
it "works" do
action_log
begin
expect do
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
end.to change { Upload.count }.by(1)
system_message = admin.topics_allowed.last
expect(system_message.title).to eq(
I18n.t(
"system_messages.csv_export_succeeded.subject_template",
export_title: "Staff Action",
),
)
upload = system_message.first_post.uploads.first
expect(system_message.first_post.raw).to eq(
I18n.t(
"system_messages.csv_export_succeeded.text_body_template",
download_link: UploadMarkdown.new(upload).attachment_markdown,
).chomp,
)
expect(system_message.id).to eq(UserExport.last.topic_id)
expect(system_message.closed).to eq(true)
files = []
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
zip_file.each { |entry| files << entry.name }
end
expect(files.size).to eq(1)
ensure
admin.uploads.each(&:destroy!)
end
end
it "generates csv export failed message if upload is too large" do
action_log
SiteSetting.max_export_file_size_kb = 0
begin
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
system_message = admin.topics_allowed.last
expect(system_message.title).to eq(
I18n.t(
"system_messages.csv_export_failed.subject_template",
export_title: "Data export failed",
),
)
ensure
admin.uploads.each(&:destroy!)
end
end
context "when exporting staff action" do
it "exports staff action logs with date filters" do
freeze_time
(1..10).each do |i|
Fabricate(
:user_history,
action: UserHistory.actions[:suspend_user],
created_at: i.days.ago,
)
end
expect do
Jobs::ExportCsvFile.new.execute(
user_id: admin.id,
entity: "staff_action",
args: {
# Fine-tuning for 1 minute to ensure we capture the correct range
start_date: (5.days.ago - 1.minute).iso8601,
end_date: (2.days.ago + 1.minute).iso8601,
},
)
end.to change { Upload.count }.by(1)
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
zip_file.each do |entry|
content = zip_file.read(entry)
expect(CSV.parse(content).size).to eq(5) # [header, 2, 3, 4, 5]
end
end
end
it "delegates to UserHistory.staff_action_records" do
Fabricate(:user_history, action: UserHistory.actions[:suspend_user])
Fabricate(:user_history, action: UserHistory.actions[:change_site_setting])
Fabricate(:user_history, action: UserHistory.actions[:delete_theme])
res =
UserHistory.staff_action_records(
admin,
action_id: UserHistory.actions[:suspend_user].to_s,
)
UserHistory
.expects(:staff_action_records)
.with(
admin,
ActiveSupport::HashWithIndifferentAccess.new(
"action_id" => UserHistory.actions[:suspend_user].to_s,
),
)
.returns(res)
Jobs::ExportCsvFile.new.execute(
user_id: admin.id,
entity: "staff_action",
args: {
"action_id" => UserHistory.actions[:suspend_user].to_s,
},
)
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
zip_file.each do |entry|
content = zip_file.read(entry)
expect(CSV.parse(content).size).to eq(2)
end
end
end
it "redacts details and context for moderators who cannot see the log content" do
category = Fabricate(:private_category, group: Fabricate(:group))
topic = Fabricate(:topic, category: category)
post = Fabricate(:post, topic: topic)
moderator = Fabricate(:moderator)
action_log =
StaffActionLogger.new(admin).log_post_edit(
post,
old_raw: "#{post.raw} old",
context: "secret context",
)
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "staff_action")
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
expect(row).to be_present
expect(row["details"]).to eq(I18n.t("staff_action_logs.redacted"))
expect(row["context"]).to be_blank
action_log.destroy!
end
it "does not redact details and context for admins" do
post = Fabricate(:post)
StaffActionLogger.new(admin).log_post_edit(
post,
old_raw: "#{post.raw} old",
context: "visible context",
)
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
expect(row).to be_present
expect(row["details"]).to include("---")
expect(row["context"]).to include("visible context")
end
end
end
describe ".report_export" do
let(:user) { Fabricate(:admin) }
let(:exporter) do
exporter = Jobs::ExportCsvFile.new
exporter.entity = "report"
exporter.extra =
ActiveSupport::HashWithIndifferentAccess.new(
start_date: "2010-01-01",
end_date: "2011-01-01",
)
exporter.current_user = User.find_by(id: user.id)
exporter
end
it "does not throw an error when the dates are invalid" do
Jobs::ExportCsvFile.new.execute(
entity: "report",
user_id: user.id,
args: {
start_date: "asdfasdf",
end_date: "not-a-date",
name: "dau_by_mau",
},
)
end
it "works with single-column reports" do
user.user_visits.create!(visited_at: "2010-01-01", posts_read: 42)
Fabricate(:user).user_visits.create!(visited_at: "2010-01-03", posts_read: 420)
exporter.extra["name"] = "dau_by_mau"
report = export_report
expect(report.first).to contain_exactly("Day", "Percent")
expect(report.second).to contain_exactly("2010-01-01", "100.0")
expect(report.third).to contain_exactly("2010-01-03", "50.0")
end
it "works with filters" do
user.user_visits.create!(visited_at: "2010-01-01", posts_read: 42)
group = Fabricate(:group)
user1 = Fabricate(:user)
Fabricate(:group_user, group: group, user: user1)
user1.user_visits.create!(visited_at: "2010-01-03", posts_read: 420)
exporter.extra["name"] = "visits"
exporter.extra["group"] = group.id
report = export_report
expect(report.length).to eq(2)
expect(report.first).to contain_exactly("Day", "Desktop", "Mobile")
expect(report.second).to contain_exactly("2010-01-03", "1", "")
end
it "works with single-column reports with default label" do
user.user_visits.create!(visited_at: "2010-01-01", mobile: true)
Fabricate(:user).user_visits.create!(visited_at: "2010-01-03", mobile: true)
exporter.extra["name"] = "mobile_visits"
report = export_report
expect(report.first).to contain_exactly("Day", "Count")
expect(report.second).to contain_exactly("2010-01-01", "1")
expect(report.third).to contain_exactly("2010-01-03", "1")
end
it "works with multi-columns reports" do
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
user.user_auth_token_logs.create!(
action: "login",
client_ip: "1.1.1.1",
created_at: "2010-01-01",
)
exporter.extra["name"] = "admin_logins"
report = export_report
expect(report.first).to contain_exactly("User", "Location", "Login at")
expect(report.second).to contain_exactly(user.username, "Earth", "2010-01-01 00:00:00 UTC")
end
it "exports suspicious login IP details when allowed" do
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
user.user_auth_token_logs.create!(
action: "suspicious",
client_ip: "1.1.1.1",
user_agent: "Mozilla/5.0",
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "suspicious_logins"
report = export_report
expect(report.second[0, 3]).to eq([user.username, "1.1.1.1", "Earth"])
end
context "when the current user cannot view IPs" do
fab!(:moderator_without_ip_access, :moderator)
let(:user) { moderator_without_ip_access }
it "redacts suspicious login IP address while retaining location" do
SiteSetting.moderators_view_ips = false
DiscourseIpInfo.stubs(:get).returns(location: "Earth")
moderator_without_ip_access.user_auth_token_logs.create!(
action: "suspicious",
client_ip: "1.1.1.1",
user_agent: "Mozilla/5.0",
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "suspicious_logins"
report = export_report
expect(report.second[0]).to eq(moderator_without_ip_access.username)
expect(report.second[1]).to eq("")
expect(report.second[2]).to eq("Earth")
end
end
it "works with topic reports" do
freeze_time DateTime.parse("2010-01-01 6:00")
exporter.extra["name"] = "top_referred_topics"
post1 = Fabricate(:post)
Fabricate(:post)
IncomingLink.add(
host: "a.com",
referer: "http://twitter.com",
post_id: post1.id,
ip_address: "1.1.1.1",
)
report = export_report
expect(report.first).to contain_exactly("Topic", "Clicks")
expect(report.second).to contain_exactly(post1.topic.id.to_s, "1")
end
it "works with stacked_chart reports" do
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_logged_in", count: 2)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_logged_in", count: 3)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_anon", count: 5)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_anon", count: 6)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_crawler", count: 8)
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_crawler", count: 9)
exporter.extra["name"] = "consolidated_page_views"
report = export_report
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users", "Crawlers")
expect(report[1]).to contain_exactly("2010-01-01", "1", "4", "7")
expect(report[2]).to contain_exactly("2010-01-02", "2", "5", "8")
expect(report[3]).to contain_exactly("2010-01-03", "3", "6", "9")
end
it "works with stacked_chart reports and hidden_labels" do
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
exporter.extra["name"] = "consolidated_page_views"
exporter.extra["hidden_labels"] = "page_view_crawler"
report = export_report
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users")
expect(report[1]).to contain_exactly("2010-01-01", "1", "4")
end
it "works with posts reports and filters" do
category = Fabricate(:category)
subcategory = Fabricate(:category, parent_category: category)
Fabricate(
:post,
topic: Fabricate(:topic, category: category),
created_at: "2010-01-01 12:00:00 UTC",
)
Fabricate(
:post,
topic: Fabricate(:topic, category: subcategory),
created_at: "2010-01-01 12:00:00 UTC",
)
exporter.extra["name"] = "posts"
exporter.extra["category"] = category.id
report = export_report
expect(report[0]).to contain_exactly("Count", "Day")
expect(report[1]).to contain_exactly("1", "2010-01-01")
exporter.extra["include_subcategories"] = true
report = export_report
expect(report[0]).to contain_exactly("Count", "Day")
expect(report[1]).to contain_exactly("2", "2010-01-01")
end
def export_report
report = []
exporter.report_export { |entry| report << entry }
report
end
end
let(:user_list_header) do
%w[
id
name
username
email
title
created_at
last_seen_at
last_posted_at
last_emailed_at
trust_level
approved
suspended_at
suspended_till
blocked
active
admin
moderator
ip_address
staged
secondary_emails
topics_entered
posts_read_count
time_read
topic_count
post_count
likes_given
likes_received
location
website
views
]
end
let(:user_list_export) do
exported_data = []
Jobs::ExportCsvFile.new.user_list_export { |entry| exported_data << entry }
exported_data
end
def to_hash(row)
Hash[*user_list_header.zip(row).flatten]
end
def parse_staff_action_rows(upload)
rows = []
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
zip_file.each do |entry|
csv_rows = CSV.parse(zip_file.read(entry), headers: true)
rows.concat(csv_rows.map(&:to_h))
end
end
rows
end
it "exports secondary emails" do
user = Fabricate(:user)
Fabricate(:secondary_email, user: user, primary: false)
secondary_emails = user.secondary_emails
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(user["secondary_emails"].split(";")).to match_array(secondary_emails)
end
it "exports sso data" do
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
SiteSetting.discourse_connect_secret = "x" * 10
SiteSetting.enable_discourse_connect = true
user = Fabricate(:user)
user.user_profile.update_column(:location, "La,La Land")
user.create_single_sign_on_record(
external_id: "123",
last_payload: "xxx",
external_email: "test@test.com",
)
user_list_header.push(
"external_id",
"external_email",
"external_username",
"external_name",
"external_avatar_url",
)
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(user["location"]).to eq('"La,La Land"')
expect(user["external_id"]).to eq("123")
expect(user["external_email"]).to eq("test@test.com")
end
it "exports user fields" do
user_field_1 = Fabricate(:user_field, name: "custom field 1")
user_field_2 = Fabricate(:user_field, name: "custom field 2", field_type: "confirm")
user_field_3 = Fabricate(:user_field, name: "custom field 3", field_type: "confirm")
user = Fabricate(:user)
user.set_user_field(user_field_1.id, "Answer custom 1")
user.set_user_field(user_field_2.id, true)
user.set_user_field(user_field_3.id, false)
user.save!
user_list_header.push(
"custom field 1 (custom user field)",
"custom field 2 (custom user field)",
"custom field 3 (custom user field)",
)
export_user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
expect(export_user["custom field 1 (custom user field)"]).to eq("Answer custom 1")
expect(export_user["custom field 2 (custom user field)"]).to eq("true")
expect(export_user["custom field 3 (custom user field)"]).to eq("false")
end
end