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
24 changes: 24 additions & 0 deletions app/controllers/my/heartbeats_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module My
class HeartbeatsController < ApplicationController
EXPORT_COOLDOWN = 10.minutes

before_action :ensure_current_user
before_action :ensure_no_ban, only: [ :export ]

Expand All @@ -9,10 +11,14 @@ def export
end

if params[:all_data] == "true"
return if export_rate_limited?

HeartbeatExportJob.perform_later(current_user.id, all_data: true)
else
date_range = export_date_range_from_params
return if date_range.nil?
return if export_rate_limited?

HeartbeatExportJob.perform_later(
current_user.id,
all_data: false,
Expand Down Expand Up @@ -58,5 +64,23 @@ def parse_iso8601_date(value:, default_value:)
redirect_to my_settings_imports_exports_path, alert: "Invalid date format. Please use YYYY-MM-DD."
nil
end

def export_rate_limited?
return false unless recent_export_requested?

redirect_to my_settings_imports_exports_path, alert: "Export requests are limited to once every 10 minutes."
true
end

def recent_export_requested?
GoodJob::Job
.where(job_class: "HeartbeatExportJob")
.where("created_at >= ?", EXPORT_COOLDOWN.ago)
.where(
"serialized_params -> 'arguments' -> 0 = to_jsonb(?::bigint)",
current_user.id
)
.exists?
Comment on lines +68 to +83
end
Comment on lines +75 to +84
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Rate limit applies to failed/discarded jobs

recent_export_requested? queries all GoodJob::Job records (pending, running, finished, and failed) within the cooldown window. If a user's export job fails or is dropped by the GoodJob concurrency guard, they'll still be blocked for the full 10 minutes with no indication that anything went wrong. Consider filtering to non-failed states (e.g. excluding error_event IS NOT NULL or discarded_at IS NOT NULL) so that a failed export doesn't silently consume the user's rate limit slot.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/controllers/my/heartbeats_controller.rb
Line: 75-84

Comment:
**Rate limit applies to failed/discarded jobs**

`recent_export_requested?` queries all `GoodJob::Job` records (pending, running, finished, and failed) within the cooldown window. If a user's export job fails or is dropped by the GoodJob concurrency guard, they'll still be blocked for the full 10 minutes with no indication that anything went wrong. Consider filtering to non-failed states (e.g. excluding `error_event` IS NOT NULL or `discarded_at` IS NOT NULL) so that a failed export doesn't silently consume the user's rate limit slot.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +69 to +84
end
end
3 changes: 2 additions & 1 deletion app/javascript/pages/Users/Settings/ImportsExports.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@
</div>

<p class="mt-3 text-sm text-muted">
Comment on lines 339 to 340
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Hardcoded cooldown duration in UI copy

The "10 minutes" string is hardcoded here while the authoritative value lives in My::HeartbeatsController::EXPORT_COOLDOWN. If the cooldown is ever adjusted, this copy will silently mismatch. Passing the cooldown value as an Inertia prop (e.g. export_cooldown_minutes) and interpolating it here would keep the two in sync automatically.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/javascript/pages/Users/Settings/ImportsExports.svelte
Line: 339-340

Comment:
**Hardcoded cooldown duration in UI copy**

The "10 minutes" string is hardcoded here while the authoritative value lives in `My::HeartbeatsController::EXPORT_COOLDOWN`. If the cooldown is ever adjusted, this copy will silently mismatch. Passing the cooldown value as an Inertia prop (e.g. `export_cooldown_minutes`) and interpolating it here would keep the two in sync automatically.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Exports are generated in the background and emailed to you.
Exports are generated in the background and emailed to you. You can
request one export every 10 minutes.
</p>

<div class="mt-4 space-y-3">
Expand Down
8 changes: 8 additions & 0 deletions app/jobs/heartbeat_export_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
class HeartbeatExportJob < ApplicationJob
queue_as :default

include GoodJob::ActiveJobExtensions::Concurrency

good_job_control_concurrency_with(
total_limit: 1,
key: -> { "heartbeat_export_job_#{arguments.first}" },
drop: true
)

HEARTBEAT_EXPORT_FIELDS = %i[
id entity type category project language editor operating_system machine
branch user_agent is_write line_additions line_deletions lineno lines
Expand Down
22 changes: 22 additions & 0 deletions test/controllers/my/heartbeats_controller_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "test_helper"

class My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest
setup do
GoodJob::Job.delete_all
end

test "export rejects banned users" do
user = User.create!(trust_level: :red)
user.email_addresses.create!(email: "banned-export@example.com", source: :signing_in)
Expand Down Expand Up @@ -44,4 +48,22 @@ class My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to my_settings_imports_exports_path
assert_equal "Start date must be on or before end date.", flash[:alert]
end

test "export rate limits repeated requests" do
user = User.create!
user.email_addresses.create!(email: "rate-limited-export@example.com", source: :signing_in)
sign_in_as(user)

assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportJob").count }, +1 do
post export_my_heartbeats_path, params: { all_data: "true" }
end

assert_no_difference -> { GoodJob::Job.where(job_class: "HeartbeatExportJob").count } do
post export_my_heartbeats_path, params: { all_data: "true" }
end

assert_response :redirect
assert_redirected_to my_settings_imports_exports_path
assert_equal "Export requests are limited to once every 10 minutes.", flash[:alert]
end
end
24 changes: 22 additions & 2 deletions test/system/heartbeat_export_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class HeartbeatExportTest < ApplicationSystemTestCase
test "clicking export all heartbeats enqueues job and shows notice" do
visit my_settings_imports_exports_path

assert_text "Export all heartbeats"
wait_for_export_controls

assert_difference -> { export_job_count }, 1 do
click_on "Export all heartbeats"
Expand All @@ -27,7 +27,7 @@ class HeartbeatExportTest < ApplicationSystemTestCase
test "submitting export date range enqueues job and shows notice" do
visit my_settings_imports_exports_path

assert_text "Export all heartbeats" # wait till it's loaded
wait_for_export_controls

start_date = 7.days.ago.to_date.iso8601
end_date = Date.current.iso8601
Expand All @@ -46,6 +46,22 @@ class HeartbeatExportTest < ApplicationSystemTestCase
)
end

test "repeated export requests are rate limited" do
visit my_settings_imports_exports_path

wait_for_export_controls

assert_difference -> { export_job_count }, 1 do
click_on "Export all heartbeats"
assert_text "Your export is being prepared and will be emailed to you"
end

assert_no_difference -> { export_job_count } do
click_on "Export all heartbeats"
assert_text "Export requests are limited to once every 10 minutes."
end
end

test "export is not available for restricted users" do
@user.update!(trust_level: :red)
visit my_settings_imports_exports_path
Expand Down Expand Up @@ -110,4 +126,8 @@ def set_date_input(field_name, value)
input.dispatchEvent(new Event("change", { bubbles: true }));
JS
end

def wait_for_export_controls
assert_button "Export all heartbeats", wait: 15
end
end
Loading