From bbcd3c200b2be365fb506412d6bd1ee2cf978a50 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Fri, 19 Sep 2025 12:47:55 +0200 Subject: [PATCH 01/17] Add broadcast messages Create broadcast messages when webhook events are blocked or rate-limited. Changelog: other --- app/services/web_hook_service.rb | 82 +++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index e7a35a8ded489b..083dacf06f18d2 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -73,8 +73,15 @@ def execute return ServiceResponse.error(message: 'Hook disabled') if disabled? + if rate_limit! + log_rate_limited + create_broadcast_message("Webhook rate limit exceeded") + return ServiceResponse.error(message: 'Webhook rate limit exceeded') + end + if recursion_blocked? log_recursion_blocked + create_broadcast_message("Recursive webhook blocked") return ServiceResponse.error(message: 'Recursive webhook blocked') end @@ -117,8 +124,18 @@ def execute def async_execute Gitlab::ApplicationContext.with_context(hook.application_context) do break log_silent_mode_enabled if Gitlab::SilentMode.enabled? - break log_rate_limited if rate_limit! - break log_recursion_blocked if recursion_blocked? + + if rate_limit! + log_rate_limited + create_broadcast_message("Webhook rate limit exceeded") + break + end + + if recursion_blocked? + log_recursion_blocked + create_broadcast_message("Recursive webhook blocked") + break + end params = { "recursion_detection_request_uuid" => Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid, @@ -325,4 +342,65 @@ def render_custom_template(template, params) def raise_custom_webhook_template_error!(message) raise CustomWebHookTemplateError, "Error while parsing rendered custom webhook template: #{message}" end + + def create_broadcast_message(reason) + message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id})" + + return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here + + case hook + when SystemHook + # For SystemHooks - visible only to admin users on admin pages + broadcast_message = System::BroadcastMessage.new( + message: message, + starts_at: Time.current, + ends_at: 1.year.from_now, + dismissable: true, + color: '#E75E40', + font: '#FFFFFF', + target_path: "/admin*", + theme: "light-red" + ) + + when ProjectHook + # For ProjectHooks - visible on all pages of the specific project to specific roles + project = hook.project + return unless project + + broadcast_message = System::BroadcastMessage.new( + message: message, + starts_at: Time.current, + ends_at: 1.year.from_now, + dismissable: true, + color: '#E75E40', + font: '#FFFFFF', + target_path: "/#{project.full_path}/*", # All pages within this project + target_access_levels: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER], + theme: "light-red" + ) + + when GroupHook + # For GroupHooks - visible on all pages of the specific group to specific roles + group = hook.group + return unless group + + broadcast_message = System::BroadcastMessage.new( + message: message, + starts_at: Time.current, + ends_at: 1.year.from_now, + dismissable: true, + color: '#E75E40', + font: '#FFFFFF', + target_path: "/#{group.full_path}/*", # All pages within this group + target_access_levels: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER], + theme: "light-red" + ) + else + return # Unknown hook type, don't create a message + end + + return if broadcast_message.save! + + Gitlab::AppLogger.error("Failed to save broadcast message: #{broadcast_message.errors.full_messages.join(', ')}") + end end -- GitLab From f5cfc55998daf05715894da69348113b7f965f8a Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 23 Sep 2025 12:52:42 +0200 Subject: [PATCH 02/17] Add specs for broadcast messages --- app/services/web_hook_service.rb | 17 ++-- spec/services/web_hook_service_spec.rb | 132 ++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 083dacf06f18d2..4feb429b2fa0ba 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -374,8 +374,8 @@ def create_broadcast_message(reason) dismissable: true, color: '#E75E40', font: '#FFFFFF', - target_path: "/#{project.full_path}/*", # All pages within this project - target_access_levels: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER], + target_path: "/#{project.full_path}/*", + target_access_levels: [Gitlab::Access::OWNER], theme: "light-red" ) @@ -391,16 +391,19 @@ def create_broadcast_message(reason) dismissable: true, color: '#E75E40', font: '#FFFFFF', - target_path: "/#{group.full_path}/*", # All pages within this group - target_access_levels: [Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER], + target_path: "/#{group.full_path}/*", + target_access_levels: [Gitlab::Access::OWNER], theme: "light-red" ) else return # Unknown hook type, don't create a message end - return if broadcast_message.save! - - Gitlab::AppLogger.error("Failed to save broadcast message: #{broadcast_message.errors.full_messages.join(', ')}") + begin + broadcast_message.save + rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid, ActiveRecord::RecordNotUnique, + ActiveRecord::AdapterTimeout => e + Gitlab::AppLogger.error("Failed to save broadcast message for Webhook ID #{hook.id}, #{reason}, #{e}") + end end end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index d8536acc573548..cffe1567c0b2b1 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -8,6 +8,7 @@ let(:uuid_regex) { /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ } let(:ellipsis) { '…' } let_it_be(:project) { create(:project) } + let(:recursion_message) { "Recursive webhook blocked for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id})" } # rubocop:disable Layout/LineLength,Lint/RedundantCopDisableDirective -- minor infraction let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) } let(:data) do @@ -321,7 +322,7 @@ .once end - it 'blocks and logs if a recursive web hook is detected', :aggregate_failures do + it 'blocks, logs, creates broadcast message if a recursive web hook is detected', :aggregate_failures do stub_full_request(project_hook.url, method: :post) Gitlab::WebHooks::RecursionDetection.register!(project_hook) @@ -336,12 +337,16 @@ ) ) + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: recursion_message) + ).and_call_original + service_instance.execute expect(WebMock).not_to have_requested(:post, stubbed_hostname(project_hook.url)) end - it 'blocks and logs if the recursion count limit would be exceeded', :aggregate_failures do + it 'blocks, logs, creates broadcast message if the recursion count limit would be exceeded', :aggregate_failures do stub_full_request(project_hook.url, method: :post) stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) previous_hooks = create_list(:project_hook, 3) @@ -358,6 +363,10 @@ ) ) + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: recursion_message) + ).and_call_original + service_instance.execute expect(WebMock).not_to have_requested(:post, stubbed_hostname(project_hook.url)) @@ -926,6 +935,10 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) end context 'when rate limiting is configured' do + let(:rate_limit_message) do + "Webhook rate limit exceeded for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id})" + end + let_it_be(:threshold) { 3 } let_it_be(:plan_limits) { create(:plan_limits, :default_plan, web_hook_calls: threshold) } @@ -961,6 +974,10 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) ) ) + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: rate_limit_message) + ).and_call_original + service_instance.async_execute end end @@ -972,9 +989,13 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) threshold.times { service_instance.async_execute } end - it 'stops queueing workers and logs errors' do + it 'stops queueing workers, crates a single broadcast message, and logs errors' do expect(Gitlab::AuthLogger).to receive(:error).twice + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: rate_limit_message) + ).and_call_original.once + 2.times { service_instance.async_execute } end @@ -994,7 +1015,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) Gitlab::WebHooks::RecursionDetection.set_request_uuid(SecureRandom.uuid) end - it 'does not queue a worker and logs an error if the call chain limit would be exceeded' do + it 'does not queue a worker, creates broadcast message, logs error if the call chain limit would be exceeded' do stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) previous_hooks = create_list(:project_hook, 3) previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) } @@ -1014,10 +1035,14 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) ) ) + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: recursion_message) + ).and_call_original + service_instance.async_execute end - it 'does not queue a worker and logs an error if a recursive call chain is detected' do + it 'does not queue a worker, creates broadcast message, logs an error if a recursive call chain is detected' do Gitlab::WebHooks::RecursionDetection.register!(project_hook) expect(WebHookWorker).not_to receive(:perform_async) @@ -1035,6 +1060,10 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) ) ) + expect(System::BroadcastMessage).to receive(:new).with( + hash_including(message: recursion_message) + ).and_call_original + service_instance.async_execute end end @@ -1066,4 +1095,97 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) end end end + + describe '#create_broadcast_message' do + let(:data) { { key: 'value' } } + let(:hook_name) { 'push_hooks' } + + before do + allow_next_instance_of(System::BroadcastMessage) do |message| + allow(message).to receive(:save!).and_return(true) + end + allow(System::BroadcastMessage).to receive(:current_and_future_messages) + .and_return(double(find_by: nil)) + end + + context 'with a system hook' do + let(:system_hook) { create(:system_hook, name: 'Test System Hook') } + let(:service) { described_class.new(system_hook, data, hook_name) } + let(:message) { "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id})" } + + it 'creates a broadcast message with correct parameters' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/admin*" + ) + ).and_call_original + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'with a group hook' do + let(:group) { create(:group) } + let(:group_hook) { create(:group_hook, group: group, name: 'Test Group Hook') } + let(:service) { described_class.new(group_hook, data, hook_name) } + let(:message) { "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id})" } + + it 'creates a broadcast message with correct parameters' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/#{group.full_path}/*" + ) + ).and_call_original + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'with an unknown hook type' do + let(:unknown_hook) do + double('UnknownHook', + id: 123, + type: 'UnknownHook', + name: 'Unknown', + allow_local_requests?: false, + application_context: {} + ) + end + + let(:service) { described_class.new(unknown_hook, data, hook_name) } + + it 'does not create a broadcast message' do + expect(System::BroadcastMessage).not_to receive(:new) + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'when save fails' do + let(:project) { create(:project) } + let(:project_hook) { create(:project_hook, project: project, name: 'Test Project Hook') } + let(:service) { described_class.new(project_hook, data, hook_name) } + let(:broadcast_message) { build(:broadcast_message) } + let(:invalid_record) { ActiveRecord::RecordInvalid.new(broadcast_message) } + let(:errors) { double(full_messages: ['Could not save to database']) } + let(:save_error) do + "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded, #{invalid_record}" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction, + end + + before do + allow(System::BroadcastMessage).to receive(:new).and_return(broadcast_message) + allow(broadcast_message).to receive(:errors).and_return(errors) + allow(broadcast_message).to receive(:save).and_raise(invalid_record) + end + + it 'logs an error when broadcast message fails to save' do + allow(Gitlab::AppLogger).to receive(:error) + expect(Gitlab::AppLogger).to receive(:error).with(save_error) + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + end end -- GitLab From b2e190e39f422f942a93cddebe969c965cb11c77 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 23 Sep 2025 13:57:54 +0200 Subject: [PATCH 03/17] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: DANGER_GITLAB_API_TOKEN --- spec/services/web_hook_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index cffe1567c0b2b1..ee6c48c283ca1c 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -1164,7 +1164,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) end context 'when save fails' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } let(:project_hook) { create(:project_hook, project: project, name: 'Test Project Hook') } let(:service) { described_class.new(project_hook, data, hook_name) } let(:broadcast_message) { build(:broadcast_message) } -- GitLab From 800b86a390422cb97dccb248f16cf54e7d1f0a65 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 23 Sep 2025 15:21:49 +0200 Subject: [PATCH 04/17] Remove wildcard pattern from paths --- app/services/web_hook_service.rb | 6 +++--- spec/services/web_hook_service_spec.rb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 4feb429b2fa0ba..deb1a3eeee2696 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -358,7 +358,7 @@ def create_broadcast_message(reason) dismissable: true, color: '#E75E40', font: '#FFFFFF', - target_path: "/admin*", + target_path: "/admin", theme: "light-red" ) @@ -374,7 +374,7 @@ def create_broadcast_message(reason) dismissable: true, color: '#E75E40', font: '#FFFFFF', - target_path: "/#{project.full_path}/*", + target_path: "/#{project.full_path}", target_access_levels: [Gitlab::Access::OWNER], theme: "light-red" ) @@ -391,7 +391,7 @@ def create_broadcast_message(reason) dismissable: true, color: '#E75E40', font: '#FFFFFF', - target_path: "/#{group.full_path}/*", + target_path: "/#{group.full_path}", target_access_levels: [Gitlab::Access::OWNER], theme: "light-red" ) diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index ee6c48c283ca1c..0d8cb7a2585897 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -1117,7 +1117,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) expect(System::BroadcastMessage).to receive(:new).with( hash_including( message: message, - target_path: "/admin*" + target_path: "/admin" ) ).and_call_original @@ -1135,7 +1135,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) expect(System::BroadcastMessage).to receive(:new).with( hash_including( message: message, - target_path: "/#{group.full_path}/*" + target_path: "/#{group.full_path}" ) ).and_call_original -- GitLab From bd6b1a4b70e530c9f557e47127b0bf507b53d755 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Thu, 25 Sep 2025 09:32:34 +0200 Subject: [PATCH 05/17] Apply mr comments --- app/services/web_hook_service.rb | 61 +++----- spec/services/web_hook_service_spec.rb | 189 +++++++++++++------------ 2 files changed, 117 insertions(+), 133 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index deb1a3eeee2696..7d3b075b950b1f 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -348,36 +348,24 @@ def create_broadcast_message(reason) return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here - case hook - when SystemHook - # For SystemHooks - visible only to admin users on admin pages - broadcast_message = System::BroadcastMessage.new( - message: message, - starts_at: Time.current, - ends_at: 1.year.from_now, - dismissable: true, - color: '#E75E40', - font: '#FFFFFF', - target_path: "/admin", - theme: "light-red" - ) + broadcast_message_params = { + message: message, + starts_at: Time.current, + ends_at: 1.year.from_now, + dismissable: true, + theme: "light-red" + } + case hook when ProjectHook # For ProjectHooks - visible on all pages of the specific project to specific roles project = hook.project return unless project broadcast_message = System::BroadcastMessage.new( - message: message, - starts_at: Time.current, - ends_at: 1.year.from_now, - dismissable: true, - color: '#E75E40', - font: '#FFFFFF', - target_path: "/#{project.full_path}", - target_access_levels: [Gitlab::Access::OWNER], - theme: "light-red" - ) + broadcast_message_params.merge( + target_path: "/#{project.full_path}", + target_access_levels: [Gitlab::Access::OWNER])) when GroupHook # For GroupHooks - visible on all pages of the specific group to specific roles @@ -385,25 +373,18 @@ def create_broadcast_message(reason) return unless group broadcast_message = System::BroadcastMessage.new( - message: message, - starts_at: Time.current, - ends_at: 1.year.from_now, - dismissable: true, - color: '#E75E40', - font: '#FFFFFF', - target_path: "/#{group.full_path}", - target_access_levels: [Gitlab::Access::OWNER], - theme: "light-red" - ) + broadcast_message_params.merge( + target_path: "/#{group.full_path}", + target_access_levels: [Gitlab::Access::OWNER])) else - return # Unknown hook type, don't create a message + # For SystemHook and any other Hook type - visible only to admin users on admin pages + broadcast_message = System::BroadcastMessage.new( + broadcast_message_params.merge( + target_path: "/admin")) end - begin - broadcast_message.save - rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid, ActiveRecord::RecordNotUnique, - ActiveRecord::AdapterTimeout => e - Gitlab::AppLogger.error("Failed to save broadcast message for Webhook ID #{hook.id}, #{reason}, #{e}") - end + return if broadcast_message.save + + Gitlab::AppLogger.error("Failed to save broadcast message for Webhook ID #{hook.id}, #{reason}") end end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 0d8cb7a2585897..8a7453de39ef76 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -910,6 +910,102 @@ end end end + + context 'broadcast messages' do + let(:data) { { key: 'value' } } + let(:hook_name) { 'push_hooks' } + + before do + allow_next_instance_of(System::BroadcastMessage) do |message| + allow(message).to receive(:save).and_return(true) + end + allow(System::BroadcastMessage).to receive(:current_and_future_messages) + .and_return(double(find_by: nil)) + end + + context 'with a system hook' do + let(:system_hook) { create(:system_hook, name: 'Test System Hook') } + let(:service) { described_class.new(system_hook, data, hook_name) } + let(:message) { "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id})" } + + it 'creates a broadcast message with correct parameters' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/admin" + ) + ).and_call_original + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'with a group hook' do + let(:group) { create(:group) } + let(:group_hook) { create(:group_hook, group: group, name: 'Test Group Hook') } + let(:service) { described_class.new(group_hook, data, hook_name) } + let(:message) { "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id})" } + + it 'creates a broadcast message with correct parameters' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/#{group.full_path}" + ) + ).and_call_original + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'with an unknown hook type' do + let(:unknown_hook) do + double('UnknownHook', + id: 123, + type: 'UnknownHook', + name: 'Unknown', + allow_local_requests?: false, + application_context: {} + ) + end + + let(:message) { "Webhook rate limit exceeded for UnknownHook (Name: Unknown, ID: #{unknown_hook.id})" } + let(:service) { described_class.new(unknown_hook, data, hook_name) } + + it 'creates a broadcast message with correct parameters' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/admin" + ) + ).and_call_original + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + + context 'when save fails' do + let_it_be(:project) { create(:project) } + let(:project_hook) { create(:project_hook, project: project, name: 'Test Project Hook') } + let(:service) { described_class.new(project_hook, data, hook_name) } + let(:broadcast_message) { build(:broadcast_message) } + let(:save_error_message) do + "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction, + end + + before do + allow(System::BroadcastMessage).to receive(:new).and_return(broadcast_message) + allow(broadcast_message).to receive(:save).and_return(false) + end + + it 'logs an error when broadcast message fails to save' do + allow(Gitlab::AppLogger).to receive(:error) + expect(Gitlab::AppLogger).to receive(:error).with(save_error_message) + + service.send(:create_broadcast_message, "Webhook rate limit exceeded") + end + end + end end describe '#async_execute' do @@ -1095,97 +1191,4 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) end end end - - describe '#create_broadcast_message' do - let(:data) { { key: 'value' } } - let(:hook_name) { 'push_hooks' } - - before do - allow_next_instance_of(System::BroadcastMessage) do |message| - allow(message).to receive(:save!).and_return(true) - end - allow(System::BroadcastMessage).to receive(:current_and_future_messages) - .and_return(double(find_by: nil)) - end - - context 'with a system hook' do - let(:system_hook) { create(:system_hook, name: 'Test System Hook') } - let(:service) { described_class.new(system_hook, data, hook_name) } - let(:message) { "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id})" } - - it 'creates a broadcast message with correct parameters' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/admin" - ) - ).and_call_original - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'with a group hook' do - let(:group) { create(:group) } - let(:group_hook) { create(:group_hook, group: group, name: 'Test Group Hook') } - let(:service) { described_class.new(group_hook, data, hook_name) } - let(:message) { "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id})" } - - it 'creates a broadcast message with correct parameters' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/#{group.full_path}" - ) - ).and_call_original - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'with an unknown hook type' do - let(:unknown_hook) do - double('UnknownHook', - id: 123, - type: 'UnknownHook', - name: 'Unknown', - allow_local_requests?: false, - application_context: {} - ) - end - - let(:service) { described_class.new(unknown_hook, data, hook_name) } - - it 'does not create a broadcast message' do - expect(System::BroadcastMessage).not_to receive(:new) - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'when save fails' do - let_it_be(:project) { create(:project) } - let(:project_hook) { create(:project_hook, project: project, name: 'Test Project Hook') } - let(:service) { described_class.new(project_hook, data, hook_name) } - let(:broadcast_message) { build(:broadcast_message) } - let(:invalid_record) { ActiveRecord::RecordInvalid.new(broadcast_message) } - let(:errors) { double(full_messages: ['Could not save to database']) } - let(:save_error) do - "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded, #{invalid_record}" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction, - end - - before do - allow(System::BroadcastMessage).to receive(:new).and_return(broadcast_message) - allow(broadcast_message).to receive(:errors).and_return(errors) - allow(broadcast_message).to receive(:save).and_raise(invalid_record) - end - - it 'logs an error when broadcast message fails to save' do - allow(Gitlab::AppLogger).to receive(:error) - expect(Gitlab::AppLogger).to receive(:error).with(save_error) - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - end end -- GitLab From c0763aeba6a605d15ee217c5f464894dc7901fe4 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Thu, 25 Sep 2025 13:10:17 +0200 Subject: [PATCH 06/17] Apply improved message text --- app/services/web_hook_service.rb | 2 +- spec/services/web_hook_service_spec.rb | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 7d3b075b950b1f..a8b901d6f393f1 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -344,7 +344,7 @@ def raise_custom_webhook_template_error!(message) end def create_broadcast_message(reason) - message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id})" + message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id}) - Please update or delete this webhook" return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 8a7453de39ef76..f1bf0b46350d92 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -8,7 +8,7 @@ let(:uuid_regex) { /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ } let(:ellipsis) { '…' } let_it_be(:project) { create(:project) } - let(:recursion_message) { "Recursive webhook blocked for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id})" } # rubocop:disable Layout/LineLength,Lint/RedundantCopDisableDirective -- minor infraction + let(:recursion_message) { "Recursive webhook blocked for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id}) - Please update or delete this webhook" } # rubocop:disable Layout/LineLength,Lint/RedundantCopDisableDirective -- minor infraction let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) } let(:data) do @@ -926,7 +926,9 @@ context 'with a system hook' do let(:system_hook) { create(:system_hook, name: 'Test System Hook') } let(:service) { described_class.new(system_hook, data, hook_name) } - let(:message) { "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id})" } + let(:message) do + "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + end it 'creates a broadcast message with correct parameters' do expect(System::BroadcastMessage).to receive(:new).with( @@ -944,7 +946,9 @@ let(:group) { create(:group) } let(:group_hook) { create(:group_hook, group: group, name: 'Test Group Hook') } let(:service) { described_class.new(group_hook, data, hook_name) } - let(:message) { "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id})" } + let(:message) do + "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + end it 'creates a broadcast message with correct parameters' do expect(System::BroadcastMessage).to receive(:new).with( @@ -969,7 +973,10 @@ ) end - let(:message) { "Webhook rate limit exceeded for UnknownHook (Name: Unknown, ID: #{unknown_hook.id})" } + let(:message) do + "Webhook rate limit exceeded for UnknownHook (Name: Unknown, ID: #{unknown_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + end + let(:service) { described_class.new(unknown_hook, data, hook_name) } it 'creates a broadcast message with correct parameters' do @@ -990,7 +997,7 @@ let(:service) { described_class.new(project_hook, data, hook_name) } let(:broadcast_message) { build(:broadcast_message) } let(:save_error_message) do - "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction, + "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction end before do @@ -1032,7 +1039,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) context 'when rate limiting is configured' do let(:rate_limit_message) do - "Webhook rate limit exceeded for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id})" + "Webhook rate limit exceeded for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction end let_it_be(:threshold) { 3 } -- GitLab From 8a4fe898e05346e258ebe17281f30cdc2882cd5e Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Fri, 26 Sep 2025 15:20:45 +0200 Subject: [PATCH 07/17] Apply mr comments --- app/services/web_hook_service.rb | 2 +- spec/services/web_hook_service_spec.rb | 209 ++++++++++++------------- 2 files changed, 100 insertions(+), 111 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index a8b901d6f393f1..c7ff0a6d1db947 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -344,7 +344,7 @@ def raise_custom_webhook_template_error!(message) end def create_broadcast_message(reason) - message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id}) - Please update or delete this webhook" + message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id}). Update or delete this webhook." return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index f1bf0b46350d92..0482f468e47591 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -8,7 +8,12 @@ let(:uuid_regex) { /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ } let(:ellipsis) { '…' } let_it_be(:project) { create(:project) } - let(:recursion_message) { "Recursive webhook blocked for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id}) - Please update or delete this webhook" } # rubocop:disable Layout/LineLength,Lint/RedundantCopDisableDirective -- minor infraction + let(:recursion_message) do + "Recursive webhook blocked for ProjectHook " \ + "(Name: #{project_hook.name}, ID: #{project_hook.id}). " \ + "Update or delete this webhook." + end + let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) } let(:data) do @@ -223,6 +228,89 @@ end end + context 'when rate limited' do + let(:message) do + "Webhook rate limit exceeded for #{hook.class} " \ + "(Name: #{hook.name}, ID: #{hook.id}). " \ + "Update or delete this webhook." + end + + subject(:service) { described_class.new(hook, {}, hook.name) } + + before do + allow_next_instance_of(Gitlab::WebHooks::RateLimiter) do |limiter| + allow(limiter).to receive(:rate_limit!).and_return(true) + end + end + + context 'with SystemHook' do + let(:hook) { create(:system_hook, name: 'Test System Hook') } + + it 'creates a broadcast message' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/admin" + ) + ).and_call_original + + service.execute + end + end + + context 'with ProjectHook' do + let(:project) { create(:project) } + let(:hook) { create(:project_hook, project: project, name: 'Test Project Hook') } + + it 'creates a broadcast message' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/#{project.full_path}" + ) + ).and_call_original + + service.execute + end + end + + context 'with GroupHook' do + let(:group) { create(:group) } + let(:hook) { create(:group_hook, group: group, name: 'Test Group Hook') } + + it 'creates a broadcast message' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/#{group.full_path}" + ) + ).and_call_original + + service.execute + end + end + + context 'when the broadcase message fails to save' do + before do + allow_next_instance_of(System::BroadcastMessage) do |message| + allow(message).to receive(:save).and_return(false) + end + end + + let(:hook) { create(:system_hook, name: 'Test System Hook') } + let(:save_error_message) do + "Failed to save broadcast message for Webhook ID #{hook.id}, Webhook rate limit exceeded" + end + + it 'logs an error' do + allow(Gitlab::AppLogger).to receive(:error) + expect(Gitlab::AppLogger).to receive(:error).with(save_error_message) + + service.execute + end + end + end + context 'with SystemHook' do let_it_be(:system_hook) { create(:system_hook) } let(:service_instance) { described_class.new(system_hook, data, :push_hooks) } @@ -322,7 +410,7 @@ .once end - it 'blocks, logs, creates broadcast message if a recursive web hook is detected', :aggregate_failures do + it 'blocks, logs, and creates broadcast message if a recursive web hook is detected', :aggregate_failures do stub_full_request(project_hook.url, method: :post) Gitlab::WebHooks::RecursionDetection.register!(project_hook) @@ -344,9 +432,10 @@ service_instance.execute expect(WebMock).not_to have_requested(:post, stubbed_hostname(project_hook.url)) + expect(System::BroadcastMessage.last.message).to eq(recursion_message) end - it 'blocks, logs, creates broadcast message if the recursion count limit would be exceeded', :aggregate_failures do + it 'blocks, logs, and creates a broadcast message if the recursion count limit is exceeded', :aggregate_failures do stub_full_request(project_hook.url, method: :post) stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) previous_hooks = create_list(:project_hook, 3) @@ -370,6 +459,7 @@ service_instance.execute expect(WebMock).not_to have_requested(:post, stubbed_hostname(project_hook.url)) + expect(System::BroadcastMessage.last.message).to eq(recursion_message) end context 'when silent mode is enabled' do @@ -910,109 +1000,6 @@ end end end - - context 'broadcast messages' do - let(:data) { { key: 'value' } } - let(:hook_name) { 'push_hooks' } - - before do - allow_next_instance_of(System::BroadcastMessage) do |message| - allow(message).to receive(:save).and_return(true) - end - allow(System::BroadcastMessage).to receive(:current_and_future_messages) - .and_return(double(find_by: nil)) - end - - context 'with a system hook' do - let(:system_hook) { create(:system_hook, name: 'Test System Hook') } - let(:service) { described_class.new(system_hook, data, hook_name) } - let(:message) do - "Webhook rate limit exceeded for SystemHook (Name: Test System Hook, ID: #{system_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction - end - - it 'creates a broadcast message with correct parameters' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/admin" - ) - ).and_call_original - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'with a group hook' do - let(:group) { create(:group) } - let(:group_hook) { create(:group_hook, group: group, name: 'Test Group Hook') } - let(:service) { described_class.new(group_hook, data, hook_name) } - let(:message) do - "Webhook rate limit exceeded for GroupHook (Name: Test Group Hook, ID: #{group_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction - end - - it 'creates a broadcast message with correct parameters' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/#{group.full_path}" - ) - ).and_call_original - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'with an unknown hook type' do - let(:unknown_hook) do - double('UnknownHook', - id: 123, - type: 'UnknownHook', - name: 'Unknown', - allow_local_requests?: false, - application_context: {} - ) - end - - let(:message) do - "Webhook rate limit exceeded for UnknownHook (Name: Unknown, ID: #{unknown_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction - end - - let(:service) { described_class.new(unknown_hook, data, hook_name) } - - it 'creates a broadcast message with correct parameters' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/admin" - ) - ).and_call_original - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - - context 'when save fails' do - let_it_be(:project) { create(:project) } - let(:project_hook) { create(:project_hook, project: project, name: 'Test Project Hook') } - let(:service) { described_class.new(project_hook, data, hook_name) } - let(:broadcast_message) { build(:broadcast_message) } - let(:save_error_message) do - "Failed to save broadcast message for Webhook ID #{project_hook.id}, Webhook rate limit exceeded" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction - end - - before do - allow(System::BroadcastMessage).to receive(:new).and_return(broadcast_message) - allow(broadcast_message).to receive(:save).and_return(false) - end - - it 'logs an error when broadcast message fails to save' do - allow(Gitlab::AppLogger).to receive(:error) - expect(Gitlab::AppLogger).to receive(:error).with(save_error_message) - - service.send(:create_broadcast_message, "Webhook rate limit exceeded") - end - end - end end describe '#async_execute' do @@ -1039,7 +1026,9 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) context 'when rate limiting is configured' do let(:rate_limit_message) do - "Webhook rate limit exceeded for ProjectHook (Name: #{project_hook.name}, ID: #{project_hook.id}) - Please update or delete this webhook" # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + "Webhook rate limit exceeded for ProjectHook " \ + "(Name: #{project_hook.name}, ID: #{project_hook.id}). " \ + "Update or delete this webhook." end let_it_be(:threshold) { 3 } @@ -1092,7 +1081,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) threshold.times { service_instance.async_execute } end - it 'stops queueing workers, crates a single broadcast message, and logs errors' do + it 'stops queueing workers, creates a single broadcast message, and logs errors' do expect(Gitlab::AuthLogger).to receive(:error).twice expect(System::BroadcastMessage).to receive(:new).with( @@ -1118,7 +1107,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) Gitlab::WebHooks::RecursionDetection.set_request_uuid(SecureRandom.uuid) end - it 'does not queue a worker, creates broadcast message, logs error if the call chain limit would be exceeded' do + it 'does not queue worker, creates broadcast message, and logs error if call chain limit is exceeded' do stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) previous_hooks = create_list(:project_hook, 3) previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) } @@ -1145,7 +1134,7 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) service_instance.async_execute end - it 'does not queue a worker, creates broadcast message, logs an error if a recursive call chain is detected' do + it 'does not queue worker, creates broadcast message, and logs error if recursive call chain is detected' do Gitlab::WebHooks::RecursionDetection.register!(project_hook) expect(WebHookWorker).not_to receive(:perform_async) -- GitLab From 76494a9522270c4c94b52d775bc2e7754b690cf2 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Fri, 26 Sep 2025 15:29:43 +0200 Subject: [PATCH 08/17] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: DANGER_GITLAB_API_TOKEN --- spec/services/web_hook_service_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 0482f468e47591..500bb52863a5c5 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -259,7 +259,7 @@ end context 'with ProjectHook' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } let(:hook) { create(:project_hook, project: project, name: 'Test Project Hook') } it 'creates a broadcast message' do -- GitLab From f308a80621f00aba4471838e7fd05c41e2b623aa Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Mon, 29 Sep 2025 11:24:13 +0200 Subject: [PATCH 09/17] Improve message text --- app/services/web_hook_service.rb | 3 ++- spec/services/web_hook_service_spec.rb | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index c7ff0a6d1db947..11ba42587a3dc3 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -344,7 +344,8 @@ def raise_custom_webhook_template_error!(message) end def create_broadcast_message(reason) - message = "#{reason} for #{hook.type} (Name: #{hook.name}, ID: #{hook.id}). Update or delete this webhook." + hook_type = hook.type.gsub('Hook', '').downcase + message = "#{reason}. Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})." return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 500bb52863a5c5..7267de0bec58b0 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -9,9 +9,9 @@ let(:ellipsis) { '…' } let_it_be(:project) { create(:project) } let(:recursion_message) do - "Recursive webhook blocked for ProjectHook " \ - "(Name: #{project_hook.name}, ID: #{project_hook.id}). " \ - "Update or delete this webhook." + "Recursive webhook blocked. " \ + "Update or delete the following project hook: " \ + "#{project_hook.name} (ID: #{project_hook.id})." end let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) } @@ -229,10 +229,11 @@ end context 'when rate limited' do + let(:hook_type) { hook.type.gsub('Hook', '').downcase } let(:message) do - "Webhook rate limit exceeded for #{hook.class} " \ - "(Name: #{hook.name}, ID: #{hook.id}). " \ - "Update or delete this webhook." + "Webhook rate limit exceeded. " \ + "Update or delete the following #{hook_type} hook: " \ + "#{hook.name} (ID: #{hook.id})." end subject(:service) { described_class.new(hook, {}, hook.name) } @@ -1026,9 +1027,9 @@ def expect_to_rate_limit(hook, threshold:, throttled: false) context 'when rate limiting is configured' do let(:rate_limit_message) do - "Webhook rate limit exceeded for ProjectHook " \ - "(Name: #{project_hook.name}, ID: #{project_hook.id}). " \ - "Update or delete this webhook." + "Webhook rate limit exceeded. " \ + "Update or delete the following project hook: " \ + "#{project_hook.name} (ID: #{project_hook.id})." end let_it_be(:threshold) { 3 } -- GitLab From 156e2845e1fc9eacced2cf12fc86cfa744ca74ef Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Mon, 29 Sep 2025 17:00:56 +0200 Subject: [PATCH 10/17] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: James Nutt --- app/services/web_hook_service.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 11ba42587a3dc3..301bf6ceaa281e 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -367,7 +367,6 @@ def create_broadcast_message(reason) broadcast_message_params.merge( target_path: "/#{project.full_path}", target_access_levels: [Gitlab::Access::OWNER])) - when GroupHook # For GroupHooks - visible on all pages of the specific group to specific roles group = hook.group -- GitLab From 68520dd115b48336ab81b669a654e62c3b07477f Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 30 Sep 2025 16:26:15 +0200 Subject: [PATCH 11/17] Fix foss only spec failure --- app/services/web_hook_service.rb | 8 ++++---- spec/services/web_hook_service_spec.rb | 24 +++++++++++++----------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 301bf6ceaa281e..764dc558da0d64 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -359,7 +359,7 @@ def create_broadcast_message(reason) case hook when ProjectHook - # For ProjectHooks - visible on all pages of the specific project to specific roles + # For ProjectHooks - visible on root page of the project to owners project = hook.project return unless project @@ -367,8 +367,8 @@ def create_broadcast_message(reason) broadcast_message_params.merge( target_path: "/#{project.full_path}", target_access_levels: [Gitlab::Access::OWNER])) - when GroupHook - # For GroupHooks - visible on all pages of the specific group to specific roles + when defined?(GroupHook) && GroupHook + # For GroupHooks - visible on root page of the group to owners group = hook.group return unless group @@ -377,7 +377,7 @@ def create_broadcast_message(reason) target_path: "/#{group.full_path}", target_access_levels: [Gitlab::Access::OWNER])) else - # For SystemHook and any other Hook type - visible only to admin users on admin pages + # For SystemHook and any other Hook type - visible only to admin users on root admin page broadcast_message = System::BroadcastMessage.new( broadcast_message_params.merge( target_path: "/admin")) diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 7267de0bec58b0..3d1b12e2ffd999 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -275,19 +275,21 @@ end end - context 'with GroupHook' do - let(:group) { create(:group) } - let(:hook) { create(:group_hook, group: group, name: 'Test Group Hook') } + if Gitlab.ee? + context 'with GroupHook' do + let(:group) { create(:group) } + let(:hook) { create(:group_hook, group: group, name: 'Test Group Hook') } - it 'creates a broadcast message' do - expect(System::BroadcastMessage).to receive(:new).with( - hash_including( - message: message, - target_path: "/#{group.full_path}" - ) - ).and_call_original + it 'creates a broadcast message' do + expect(System::BroadcastMessage).to receive(:new).with( + hash_including( + message: message, + target_path: "/#{group.full_path}" + ) + ).and_call_original - service.execute + service.execute + end end end -- GitLab From 50e7692e860dbdcb935c9d5b75fbe9977a59b6c0 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Fri, 3 Oct 2025 16:53:22 +0200 Subject: [PATCH 12/17] Use current banner messages --- app/services/web_hook_service.rb | 49 ++++++++++++++------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 764dc558da0d64..560cab12a578bf 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -344,12 +344,26 @@ def raise_custom_webhook_template_error!(message) end def create_broadcast_message(reason) + params = broadcast_message_params(reason) + + existing_messages = System::BroadcastMessage.current_banner_messages( + current_path: params[:target_path], + user_access_level: params[:target_access_levels].first + ).select { |current_message| current_message.message == params[:message] } + + return unless existing_messages.empty? + + System::BroadcastMessage.new(params).save! + rescue StandardError => e + Gitlab::AppLogger.error("Failed to save broadcast message for Webhook ID #{hook.id}, #{reason}") + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) + end + + def broadcast_message_params(reason) hook_type = hook.type.gsub('Hook', '').downcase message = "#{reason}. Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})." - return if System::BroadcastMessage.current_and_future_messages.find_by(message: message) # rubocop:disable CodeReuse/ActiveRecord -- we need to execute this here - - broadcast_message_params = { + base_params = { message: message, starts_at: Time.current, ends_at: 1.year.from_now, @@ -358,33 +372,12 @@ def create_broadcast_message(reason) } case hook + when SystemHook + base_params.merge(target_path: "/admin", target_access_levels: []) when ProjectHook - # For ProjectHooks - visible on root page of the project to owners - project = hook.project - return unless project - - broadcast_message = System::BroadcastMessage.new( - broadcast_message_params.merge( - target_path: "/#{project.full_path}", - target_access_levels: [Gitlab::Access::OWNER])) + base_params.merge(target_path: "/#{hook.project.full_path}", target_access_levels: [Gitlab::Access::OWNER]) when defined?(GroupHook) && GroupHook - # For GroupHooks - visible on root page of the group to owners - group = hook.group - return unless group - - broadcast_message = System::BroadcastMessage.new( - broadcast_message_params.merge( - target_path: "/#{group.full_path}", - target_access_levels: [Gitlab::Access::OWNER])) - else - # For SystemHook and any other Hook type - visible only to admin users on root admin page - broadcast_message = System::BroadcastMessage.new( - broadcast_message_params.merge( - target_path: "/admin")) + base_params.merge(target_path: "/#{hook.group.full_path}", target_access_levels: [Gitlab::Access::OWNER]) end - - return if broadcast_message.save - - Gitlab::AppLogger.error("Failed to save broadcast message for Webhook ID #{hook.id}, #{reason}") end end -- GitLab From 4c43621a56fb960156c8610b0ccf15ea65291bb4 Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Mon, 6 Oct 2025 11:00:06 +0200 Subject: [PATCH 13/17] Update failing spec --- spec/services/web_hook_service_spec.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 3d1b12e2ffd999..f1c669cd71558d 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -293,21 +293,23 @@ end end - context 'when the broadcase message fails to save' do - before do - allow_next_instance_of(System::BroadcastMessage) do |message| - allow(message).to receive(:save).and_return(false) - end - end - + context 'when the broadcast message fails to save' do + let(:error) { StandardError.new("Test Error") } let(:hook) { create(:system_hook, name: 'Test System Hook') } let(:save_error_message) do "Failed to save broadcast message for Webhook ID #{hook.id}, Webhook rate limit exceeded" end + before do + allow_next_instance_of(System::BroadcastMessage) do |message| + allow(message).to receive(:save!).and_raise(error) + end + end + it 'logs an error' do allow(Gitlab::AppLogger).to receive(:error) expect(Gitlab::AppLogger).to receive(:error).with(save_error_message) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error) service.execute end -- GitLab From b57025911e429b003021bfcc6336775f1a12c9dc Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Mon, 6 Oct 2025 15:34:15 +0200 Subject: [PATCH 14/17] Delete existing messages with put --- lib/api/helpers/web_hooks_helpers.rb | 34 ++++++++++++++- .../requests/api/hooks_shared_examples.rb | 41 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/api/helpers/web_hooks_helpers.rb b/lib/api/helpers/web_hooks_helpers.rb index 5903e88ab498d4..c17d8853b9c2ef 100644 --- a/lib/api/helpers/web_hooks_helpers.rb +++ b/lib/api/helpers/web_hooks_helpers.rb @@ -53,7 +53,7 @@ def update_hook(entity:) update_params = update_hook_params(hook) hook.assign_attributes(update_params) - + delete_existing_broadcast_messages(hook) save_hook(hook, entity) end @@ -83,6 +83,38 @@ def save_hook(hook, entity) render_validation_error!(hook, 422) end end + + def delete_existing_broadcast_messages(hook) + case hook + when SystemHook + id = hook.organization_id + current_path = '/admin' + hook_type = 'system' + when GroupHook + id = hook.group_id + current_path = Group.find(id).full_path + hook_type = 'group' + when ProjectHook + id = hook.project_id + current_path = Project.find(id).full_path + hook_type = 'project' + else + id = nil + end + + return if id.nil? + + message_string = "Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})" + + current_messages = System::BroadcastMessage.current_banner_messages( + current_path: current_path, + user_access_level: Gitlab::Access::OWNER + ).select { |current_message| current_message.message.include?(message_string) } + + return if current_messages.empty? + + System::BroadcastMessage.where(id: current_messages.map(&:id)).delete_all # rubocop:disable CodeReuse/ActiveRecord -- required here + end end end end diff --git a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb index b1b6875a6c6d94..377339a8190cca 100644 --- a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb @@ -80,6 +80,47 @@ end end + context "when no broadcast messages exists for the webhook" do + let(:params) { { push_events_branch_filter: 'some--other-wildcard' } } + let!(:broadcast_message) { create(:broadcast_message, message: "Not a webhook broadcast") } + + before do + allow(System::BroadcastMessage) + .to receive(:current_banner_messages) + .with(hash_including(user_access_level: Gitlab::Access::OWNER)) + .and_return([broadcast_message]) + end + + it 'does not delete any unrelated broadcast messages' do + expect(response).to have_gitlab_http_status(:ok) + expect(System::BroadcastMessage.all.length).to eq(1) + end + end + + context 'when a broadcast message exists for the webhook' do + let(:params) { { push_events_branch_filter: 'some--other-wildcard' } } + let(:hook_type) { hook.type.gsub('Hook', '').downcase } + # let(:message) { "Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})" } + let!(:broadcast_message) do + create(:broadcast_message, + message: "Update or delete the following #{hook.type.gsub('Hook', + '').downcase} hook: #{hook.name} (ID: #{hook.id})") + end + + before do + allow(System::BroadcastMessage) + .to receive(:current_banner_messages) + .with(hash_including(user_access_level: Gitlab::Access::OWNER)) + .and_return([broadcast_message]) + end + + it 'deletes the existing broadcast message' do + expect(response).to have_gitlab_http_status(:ok) + expect(System::BroadcastMessage.all.length).to eq(0) + expect(System::BroadcastMessage.where(message: message)).to be_empty + end + end + it_behaves_like 'POST/PUT webhook API endpoints with a branch filter' end end -- GitLab From 5ec277d20b58cc7e0d2fe6325680fab6957a3b0c Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 7 Oct 2025 11:11:27 +0200 Subject: [PATCH 15/17] Revert "Delete existing messages with put" This reverts commit b57025911e429b003021bfcc6336775f1a12c9dc. --- lib/api/helpers/web_hooks_helpers.rb | 34 +-------------- .../requests/api/hooks_shared_examples.rb | 41 ------------------- 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/lib/api/helpers/web_hooks_helpers.rb b/lib/api/helpers/web_hooks_helpers.rb index c17d8853b9c2ef..5903e88ab498d4 100644 --- a/lib/api/helpers/web_hooks_helpers.rb +++ b/lib/api/helpers/web_hooks_helpers.rb @@ -53,7 +53,7 @@ def update_hook(entity:) update_params = update_hook_params(hook) hook.assign_attributes(update_params) - delete_existing_broadcast_messages(hook) + save_hook(hook, entity) end @@ -83,38 +83,6 @@ def save_hook(hook, entity) render_validation_error!(hook, 422) end end - - def delete_existing_broadcast_messages(hook) - case hook - when SystemHook - id = hook.organization_id - current_path = '/admin' - hook_type = 'system' - when GroupHook - id = hook.group_id - current_path = Group.find(id).full_path - hook_type = 'group' - when ProjectHook - id = hook.project_id - current_path = Project.find(id).full_path - hook_type = 'project' - else - id = nil - end - - return if id.nil? - - message_string = "Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})" - - current_messages = System::BroadcastMessage.current_banner_messages( - current_path: current_path, - user_access_level: Gitlab::Access::OWNER - ).select { |current_message| current_message.message.include?(message_string) } - - return if current_messages.empty? - - System::BroadcastMessage.where(id: current_messages.map(&:id)).delete_all # rubocop:disable CodeReuse/ActiveRecord -- required here - end end end end diff --git a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb index 377339a8190cca..b1b6875a6c6d94 100644 --- a/spec/support/shared_examples/requests/api/hooks_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/hooks_shared_examples.rb @@ -80,47 +80,6 @@ end end - context "when no broadcast messages exists for the webhook" do - let(:params) { { push_events_branch_filter: 'some--other-wildcard' } } - let!(:broadcast_message) { create(:broadcast_message, message: "Not a webhook broadcast") } - - before do - allow(System::BroadcastMessage) - .to receive(:current_banner_messages) - .with(hash_including(user_access_level: Gitlab::Access::OWNER)) - .and_return([broadcast_message]) - end - - it 'does not delete any unrelated broadcast messages' do - expect(response).to have_gitlab_http_status(:ok) - expect(System::BroadcastMessage.all.length).to eq(1) - end - end - - context 'when a broadcast message exists for the webhook' do - let(:params) { { push_events_branch_filter: 'some--other-wildcard' } } - let(:hook_type) { hook.type.gsub('Hook', '').downcase } - # let(:message) { "Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})" } - let!(:broadcast_message) do - create(:broadcast_message, - message: "Update or delete the following #{hook.type.gsub('Hook', - '').downcase} hook: #{hook.name} (ID: #{hook.id})") - end - - before do - allow(System::BroadcastMessage) - .to receive(:current_banner_messages) - .with(hash_including(user_access_level: Gitlab::Access::OWNER)) - .and_return([broadcast_message]) - end - - it 'deletes the existing broadcast message' do - expect(response).to have_gitlab_http_status(:ok) - expect(System::BroadcastMessage.all.length).to eq(0) - expect(System::BroadcastMessage.where(message: message)).to be_empty - end - end - it_behaves_like 'POST/PUT webhook API endpoints with a branch filter' end end -- GitLab From 3208350ce2c7dedef7af1d1d2dc66944d4bfef5d Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Tue, 7 Oct 2025 11:59:56 +0200 Subject: [PATCH 16/17] Make warning translatable --- app/services/web_hook_service.rb | 9 +++++++-- locale/gitlab.pot | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 560cab12a578bf..f7f02bf1531c9e 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -361,12 +361,17 @@ def create_broadcast_message(reason) def broadcast_message_params(reason) hook_type = hook.type.gsub('Hook', '').downcase - message = "#{reason}. Update or delete the following #{hook_type} hook: #{hook.name} (ID: #{hook.id})." + message = format(s_("WebHooks|%{reason}. Update or delete the following %{hook_type} hook: %{hook_name} (ID: %{hook_id})."), # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + reason: reason, + hook_type: hook_type, + hook_name: hook.name, + hook_id: hook.id + ).squish base_params = { message: message, starts_at: Time.current, - ends_at: 1.year.from_now, + ends_at: 3.months.from_now, dismissable: true, theme: "light-red" } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4a7b416354ca7d..95a1f104ce35e7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -73149,6 +73149,9 @@ msgstr "" msgid "WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details." msgstr "" +msgid "WebHooks|%{reason}. Update or delete the following %{hook_type} hook: %{hook_name} (ID: %{hook_id})." +msgstr "" + msgid "WebIDE|Quickly and easily edit multiple files in your project." msgstr "" -- GitLab From 62e216ca9f2c2da71db2761e5135f577f247015f Mon Sep 17 00:00:00 2001 From: Carla Drago Date: Wed, 8 Oct 2025 11:20:39 +0200 Subject: [PATCH 17/17] Remove hook_type from interpolation --- app/services/web_hook_service.rb | 8 ++++---- locale/gitlab.pot | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index f7f02bf1531c9e..00cb6c311c0b4e 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -361,12 +361,12 @@ def create_broadcast_message(reason) def broadcast_message_params(reason) hook_type = hook.type.gsub('Hook', '').downcase - message = format(s_("WebHooks|%{reason}. Update or delete the following %{hook_type} hook: %{hook_name} (ID: %{hook_id})."), # rubocop:disable Layout/LineLength, Lint/RedundantCopDisableDirective -- minor infraction + message = format(s_("WebHooks|%{reason}. Update or delete the following ") + + hook_type + + s_(" hook: %{hook_name} (ID: %{hook_id})."), reason: reason, - hook_type: hook_type, hook_name: hook.name, - hook_id: hook.id - ).squish + hook_id: hook.id).squish base_params = { message: message, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 95a1f104ce35e7..ef33dbc4df7dd0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -50,6 +50,9 @@ msgid_plural " except branches:" msgstr[0] "" msgstr[1] "" +msgid " hook: %{hook_name} (ID: %{hook_id})." +msgstr "" + msgid " or " msgstr "" @@ -73149,7 +73152,7 @@ msgstr "" msgid "WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details." msgstr "" -msgid "WebHooks|%{reason}. Update or delete the following %{hook_type} hook: %{hook_name} (ID: %{hook_id})." +msgid "WebHooks|%{reason}. Update or delete the following " msgstr "" msgid "WebIDE|Quickly and easily edit multiple files in your project." -- GitLab