mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 17:53:55 +08:00
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.
555 lines
18 KiB
Ruby
Vendored
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
|