From a66f81cca0877b998db220ae6d0acedb2b5b7434 Mon Sep 17 00:00:00 2001 From: Niklas Date: Tue, 5 Mar 2024 20:56:02 +0000 Subject: [PATCH 1/6] Add custom webhook headers Changelog: added --- .../components/form_custom_header_item.vue | 88 +++++++++++++++++++ .../components/form_custom_headers.vue | 82 +++++++++++++++++ .../webhooks/components/webhook_form_app.vue | 40 +++++++++ app/assets/javascripts/webhooks/index.js | 7 +- .../concerns/web_hooks/hook_actions.rb | 19 +++- app/helpers/hooks_helper.rb | 3 +- app/models/hooks/web_hook.rb | 27 ++++++ app/services/web_hook_service.rb | 13 ++- .../web_hooks_custom_headers.json | 14 +++ .../beta/custom_webhook_headers.yml | 9 ++ ...05201830_add_custom_headers_to_web_hook.rb | 11 +++ db/schema_migrations/20240305201830 | 1 + db/structure.sql | 2 + doc/user/project/integrations/webhooks.md | 14 +++ locale/gitlab.pot | 18 ++++ .../projects/hooks_controller_spec.rb | 42 +++++++++ spec/helpers/hooks_helper_spec.rb | 18 +++- spec/models/hooks/web_hook_spec.rb | 68 +++++++++++++- spec/services/web_hook_service_spec.rb | 29 ++++++ 19 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/webhooks/components/form_custom_header_item.vue create mode 100644 app/assets/javascripts/webhooks/components/form_custom_headers.vue create mode 100644 app/assets/javascripts/webhooks/components/webhook_form_app.vue create mode 100644 app/validators/json_schemas/web_hooks_custom_headers.json create mode 100644 config/feature_flags/beta/custom_webhook_headers.yml create mode 100644 db/migrate/20240305201830_add_custom_headers_to_web_hook.rb create mode 100644 db/schema_migrations/20240305201830 diff --git a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue new file mode 100644 index 00000000000000..f618254ffa7b13 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/assets/javascripts/webhooks/components/form_custom_headers.vue b/app/assets/javascripts/webhooks/components/form_custom_headers.vue new file mode 100644 index 00000000000000..b27750ddc171b5 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/form_custom_headers.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/assets/javascripts/webhooks/components/webhook_form_app.vue b/app/assets/javascripts/webhooks/components/webhook_form_app.vue new file mode 100644 index 00000000000000..6ac69e2370b483 --- /dev/null +++ b/app/assets/javascripts/webhooks/components/webhook_form_app.vue @@ -0,0 +1,40 @@ + + + diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js index 6eb7cbea72cf3e..ca4152d03a0e8a 100644 --- a/app/assets/javascripts/webhooks/index.js +++ b/app/assets/javascripts/webhooks/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import FormUrlApp from './components/form_url_app.vue'; +import WebhookFormApp from './components/webhook_form_app.vue'; import TestDropdown from './components/test_dropdown.vue'; export default () => { @@ -9,16 +9,17 @@ export default () => { return null; } - const { url: initialUrl, urlVariables } = el.dataset; + const { url: initialUrl, urlVariables, customHeaders } = el.dataset; return new Vue({ el, name: 'WebhookFormRoot', render(createElement) { - return createElement(FormUrlApp, { + return createElement(WebhookFormApp, { props: { initialUrl, initialUrlVariables: JSON.parse(urlVariables), + initialCustomHeaders: JSON.parse(customHeaders), }, }); }, diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb index aae38e67e23008..0d62254f12bc39 100644 --- a/app/controllers/concerns/web_hooks/hook_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_actions.rb @@ -10,6 +10,14 @@ module HookActions before_action :hook_logs, only: :edit feature_category :webhooks + + before_action only: :edit do + push_frontend_feature_flag(:custom_webhook_headers, hook.parent, type: :beta) + end + + before_action only: :index do + push_frontend_feature_flag(:custom_webhook_headers, @project || @group, type: :beta) + end end def index @@ -54,13 +62,14 @@ def edit def hook_params permitted = hook_param_names + trigger_values - permitted << { url_variables: [:key, :value] } + permitted << { url_variables: [:key, :value], custom_headers: [:key, :value] } ps = params.require(:hook).permit(*permitted).to_h ps.delete(:token) if action_name == 'update' && ps[:token] == WebHook::SECRET_MASK ps[:url_variables] = ps[:url_variables].to_h { [_1[:key], _1[:value].presence] } if ps.key?(:url_variables) + ps[:custom_headers] = ps[:custom_headers].to_h { [_1[:key], hook_value_from_param_or_db(_1[:key], _1[:value])] } if action_name == 'update' && ps.key?(:url_variables) supplied = ps[:url_variables] @@ -88,5 +97,13 @@ def destroy_hook(hook) def hook_logs @hook_logs ||= hook.web_hook_logs.recent.page(params[:page]).without_count end + + def hook_value_from_param_or_db(key, value) + if value == WebHook::SECRET_MASK && hook.custom_headers.key?(key) + hook.custom_headers[key] + else + value + end + end end end diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index ac1e4456bc7c12..cbab251f3af3d5 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -4,7 +4,8 @@ module HooksHelper def webhook_form_data(hook) { url: hook.url, - url_variables: Gitlab::Json.dump(hook.url_variables.keys.map { { key: _1 } }) + url_variables: Gitlab::Json.dump(hook.url_variables.keys.map { { key: _1 } }), + custom_headers: Gitlab::Json.dump(hook.custom_headers.keys.map { { key: _1, value: WebHook::SECRET_MASK } }) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 24f19b932e0e74..77351b1860b148 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -27,6 +27,15 @@ class WebHook < ApplicationRecord encode: false, encode_iv: false + attr_encrypted :custom_headers, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + marshal: true, + marshaler: ::Gitlab::Json, + encode: false, + encode_iv: false + has_many :web_hook_logs validates :url, presence: true @@ -34,9 +43,11 @@ class WebHook < ApplicationRecord validates :token, format: { without: /\n/ } after_initialize :initialize_url_variables + after_initialize :initialize_custom_headers before_validation :reset_token before_validation :reset_url_variables, unless: ->(hook) { hook.is_a?(ServiceHook) }, on: :update + before_validation :reset_custom_headers, unless: ->(hook) { hook.is_a?(ServiceHook) }, on: :update before_validation :set_branch_filter_nil, if: :branch_filter_strategy_all_branches? validates :push_events_branch_filter, untrusted_regexp: true, if: :branch_filter_strategy_regex? validates :push_events_branch_filter, "web_hooks/wildcard_branch_filter": true, if: :branch_filter_strategy_wildcard? @@ -44,6 +55,7 @@ class WebHook < ApplicationRecord validates :url_variables, json_schema: { filename: 'web_hooks_url_variables' } validate :no_missing_url_variables validates :interpolated_url, public_url: true, if: ->(hook) { hook.url_variables? && hook.errors.empty? } + validates :custom_headers, json_schema: { filename: 'web_hooks_custom_headers' } validates :custom_webhook_template, length: { maximum: 4096 } enum branch_filter_strategy: { @@ -140,6 +152,17 @@ def reset_url_variables self.url_variables = {} if url_changed? && url_variables_were.to_a.intersection(url_variables.to_a).any? end + def reset_custom_headers + return if url.nil? # checking interpolated_url with a nil url causes errors + + interpolated_url_was = interpolated_url(decrypt_url_was, url_variables_were) + return if interpolated_url_was == interpolated_url + + self.custom_headers = {} + rescue InterpolationError + # ignore -- record is invalid and won't be saved. no need to reset custom_headers + end + def decrypt_url_was self.class.decrypt_url(encrypted_url_was, iv: Base64.decode64(encrypted_url_iv_was)) end @@ -152,6 +175,10 @@ def initialize_url_variables self.url_variables = {} if encrypted_url_variables.nil? end + def initialize_custom_headers + self.custom_headers = {} if encrypted_custom_headers.nil? + end + def rate_limiter @rate_limiter ||= Gitlab::WebHooks::RateLimiter.new(self) end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 2373e1de3dd807..3c917e84b3eae4 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -138,7 +138,7 @@ def parsed_url def make_request(url, basic_auth = false) Gitlab::HTTP.post(url, body: Gitlab::Json::LimitedEncoder.encode(request_payload, limit: REQUEST_BODY_SIZE_LIMIT), - headers: build_headers, + headers: build_headers.merge(build_custom_headers), verify: hook.enable_ssl_verification, basic_auth: basic_auth, **request_options) @@ -160,7 +160,7 @@ def log_execution(response:, execution_duration:, error_message: nil, request_da url: hook.url, interpolated_url: hook.interpolated_url, execution_duration: execution_duration, - request_headers: build_headers, + request_headers: build_headers.merge(build_custom_headers(values_redacted: true)), request_data: request_data, response_headers: safe_response_headers(response), response_body: safe_response_body(response), @@ -216,6 +216,15 @@ def build_headers end end + def build_custom_headers(values_redacted: false) + return {} unless hook.custom_headers.present? + return {} unless Feature.enabled?(:custom_webhook_headers, hook.parent, type: :beta) + + return hook.custom_headers.transform_values { '[REDACTED]' } if values_redacted + + hook.custom_headers + end + # Make response headers more stylish # Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] } # This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' } diff --git a/app/validators/json_schemas/web_hooks_custom_headers.json b/app/validators/json_schemas/web_hooks_custom_headers.json new file mode 100644 index 00000000000000..fe69727a172f6c --- /dev/null +++ b/app/validators/json_schemas/web_hooks_custom_headers.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "WebHook#custom_headers", + "type": "object", + "additionalProperties": false, + "maxProperties": 20, + "patternProperties": { + "^[A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*$": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } +} diff --git a/config/feature_flags/beta/custom_webhook_headers.yml b/config/feature_flags/beta/custom_webhook_headers.yml new file mode 100644 index 00000000000000..45b46ac2746fdd --- /dev/null +++ b/config/feature_flags/beta/custom_webhook_headers.yml @@ -0,0 +1,9 @@ +--- +name: custom_webhook_headers +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/17290 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146702 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/448604 +milestone: '16.10' +group: group::import and integrate +type: beta +default_enabled: false diff --git a/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb b/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb new file mode 100644 index 00000000000000..08db99d57920ab --- /dev/null +++ b/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddCustomHeadersToWebHook < Gitlab::Database::Migration[2.2] + enable_lock_retries! + milestone '16.10' + + def change + add_column :web_hooks, :encrypted_custom_headers, :binary + add_column :web_hooks, :encrypted_custom_headers_iv, :binary + end +end diff --git a/db/schema_migrations/20240305201830 b/db/schema_migrations/20240305201830 new file mode 100644 index 00000000000000..8437fff3d55b66 --- /dev/null +++ b/db/schema_migrations/20240305201830 @@ -0,0 +1 @@ +98d913f7773e5ff2579232c0b6137b82f3b678558a1c24ab209a3e24b3e897d9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3d7fe11dc84a7a..cb8e605b64e0ed 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17539,6 +17539,8 @@ CREATE TABLE web_hooks ( description text, custom_webhook_template text, resource_access_token_events boolean DEFAULT false NOT NULL, + encrypted_custom_headers bytea, + encrypted_custom_headers_iv bytea, CONSTRAINT check_1e4d5cbdc5 CHECK ((char_length(name) <= 255)), CONSTRAINT check_23a96ad211 CHECK ((char_length(description) <= 2048)), CONSTRAINT check_69ef76ee0c CHECK ((char_length(custom_webhook_template) <= 4096)) diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 9d827ad52cea2c..5e5b144a8193d8 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -106,6 +106,20 @@ You must define the following variables: Variable names can contain only lowercase letters (`a-z`), numbers (`0-9`), or underscores (`_`). You can define URL variables directly using the REST API. +## Custom headers + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146702) in GitLab 16.10 [with a flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To hide the feature, an administrator can +[enable the feature flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. +On GitLab.com and GitLab Dedicated, this feature is not available. + +You can set up to 20 custom headers in the webhook configuration. The custom headers are sent as part of the request and can +be used to provide authentication to external services. + +The custom headers are shown in the [recent deliveries](#recently-triggered-webhook-payloads-in-gitlab-settings) with their value redacted. + ## Custom webhook template > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/142738) in GitLab 16.10 [with a flag](../../../administration/feature_flags.md) named `custom_webhook_template`. Enabled by default. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 119a15b4b0a5dc..2158c31d9db577 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -56193,6 +56193,9 @@ msgstr "" msgid "Webhooks Help" msgstr "" +msgid "Webhooks|+ Add another custom header" +msgstr "" + msgid "Webhooks|+ Mask another portion of URL" msgstr "" @@ -56265,6 +56268,9 @@ msgstr "" msgid "Webhooks|Confidential issues events" msgstr "" +msgid "Webhooks|Custom Headers" +msgstr "" + msgid "Webhooks|Custom webhook template (optional)" msgstr "" @@ -56295,6 +56301,12 @@ msgstr "" msgid "Webhooks|Go to webhooks" msgstr "" +msgid "Webhooks|Header name" +msgstr "" + +msgid "Webhooks|Header value" +msgstr "" + msgid "Webhooks|How it looks in the UI" msgstr "" @@ -56322,6 +56334,9 @@ msgstr "" msgid "Webhooks|Name (optional)" msgstr "" +msgid "Webhooks|No custom headers" +msgstr "" + msgid "Webhooks|Pipeline events" msgstr "" @@ -56406,6 +56421,9 @@ msgstr "" msgid "Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported." msgstr "" +msgid "Webhooks|With custom headers" +msgstr "" + msgid "Website" msgstr "" diff --git a/spec/controllers/projects/hooks_controller_spec.rb b/spec/controllers/projects/hooks_controller_spec.rb index 0d1a452350719f..af690c0d3a887e 100644 --- a/spec/controllers/projects/hooks_controller_spec.rb +++ b/spec/controllers/projects/hooks_controller_spec.rb @@ -66,6 +66,48 @@ 'c' => 'new' ) end + + it 'adds, updates and deletes custom headers' do + hook.update!(custom_headers: { 'a' => 'bar', 'b' => 'woo' }) + + params[:hook] = { + custom_headers: [ + { key: 'a', value: 'updated' }, + { key: 'c', value: 'new' } + ] + } + + put :update, params: params + + expect(response).to have_gitlab_http_status(:found) + expect(flash[:notice]).to include('was updated') + + expect(hook.reload.custom_headers).to eq( + 'a' => 'updated', + 'c' => 'new' + ) + end + + it 'does not update custom headers with the secret mask' do + hook.update!(custom_headers: { 'a' => 'bar' }) + + params[:hook] = { + custom_headers: [ + { key: 'a', value: WebHook::SECRET_MASK }, + { key: 'c', value: 'new' } + ] + } + + put :update, params: params + + expect(response).to have_gitlab_http_status(:found) + expect(flash[:notice]).to include('was updated') + + expect(hook.reload.custom_headers).to eq( + 'a' => 'bar', + 'c' => 'new' + ) + end end describe '#edit' do diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb index d8fa64e099af61..29abd342b88ba0 100644 --- a/spec/helpers/hooks_helper_spec.rb +++ b/spec/helpers/hooks_helper_spec.rb @@ -15,7 +15,8 @@ it 'returns proper data' do expect(subject).to match( url: project_hook.url, - url_variables: "[]" + url_variables: "[]", + custom_headers: "[]" ) end end @@ -26,7 +27,20 @@ it 'returns proper data' do expect(subject).to match( url: project_hook.url, - url_variables: Gitlab::Json.dump([{ key: 'abc' }, { key: 'def' }]) + url_variables: Gitlab::Json.dump([{ key: 'abc' }, { key: 'def' }]), + custom_headers: "[]" + ) + end + end + + context 'when there are custom headers' do + let(:project_hook) { build_stubbed(:project_hook, project: project, custom_headers: { test: 'blub' }) } + + it 'returns proper data' do + expect(subject).to match( + url: project_hook.url, + url_variables: "[]", + custom_headers: Gitlab::Json.dump([{ key: 'test', value: WebHook::SECRET_MASK }]) ) end end diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 4b5ab3277db554..761cd3a8175244 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -57,6 +57,38 @@ it { is_expected.not_to allow_value({ 'x..y' => 'foo' }).for(:url_variables) } end + describe 'custom_headers' do + it { is_expected.to allow_value({}).for(:custom_headers) } + it { is_expected.to allow_value({ 'foo' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'FOO' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'MY_TOKEN' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'foo2' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'x' => 'y' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'x' => ('a' * 2048) }).for(:custom_headers) } + it { is_expected.to allow_value({ 'foo' => 'bar', 'bar' => 'baz' }).for(:custom_headers) } + it { is_expected.to allow_value((1..20).to_h { ["k#{_1}", 'value'] }).for(:custom_headers) } + it { is_expected.to allow_value({ 'MY-TOKEN' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'my_secr3t-token' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'x-y-z' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'x_y_z' => 'bar' }).for(:custom_headers) } + it { is_expected.to allow_value({ 'f.o.o' => 'bar' }).for(:custom_headers) } + + it { is_expected.not_to allow_value([]).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'foo' => 1 }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'bar' => :baz }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'bar' => nil }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'foo' => '' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'foo' => ('a' * 2049) }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'has spaces' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ '' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ '1foo' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value((1..21).to_h { ["k#{_1}", 'value'] }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'MY--TOKEN' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'MY__SECRET' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'x-_y' => 'foo' }).for(:custom_headers) } + it { is_expected.not_to allow_value({ 'x..y' => 'foo' }).for(:custom_headers) } + end + describe 'url' do it { is_expected.to allow_value('http://example.com').for(:url) } it { is_expected.to allow_value('https://example.com').for(:url) } @@ -287,6 +319,40 @@ end end + describe 'before_validation :reset_custom_headers' do + subject(:hook) { build_stubbed(:project_hook, :url_variables, project: project, url: 'http://example.com/{abc}', custom_headers: { test: 'blub' }) } + + it 'resets custom headers if url changed' do + hook.url = 'http://example.com/new-hook' + + expect(hook).to be_valid + expect(hook.custom_headers).to eq({}) + end + + it 'resets custom headers if url and url variables changed' do + hook.url = 'http://example.com/{something}' + hook.url_variables = { 'something' => 'testing-around' } + + expect(hook).to be_valid + expect(hook.custom_headers).to eq({}) + end + + it 'does not reset custom headers if url stayed the same' do + hook.url = 'http://example.com/{abc}' + + expect(hook).to be_valid + expect(hook.custom_headers).to eq({ test: 'blub' }) + end + + it 'does not reset custom headers if url and url variables changed and evaluate to the same url' do + hook.url = 'http://example.com/{def}' + hook.url_variables = { 'def' => 'supers3cret' } + + expect(hook).to be_valid + expect(hook.custom_headers).to eq({ test: 'blub' }) + end + end + it "only consider these branch filter strategies are valid" do expected_valid_types = %w[all_branches regex wildcard] expect(described_class.branch_filter_strategies.keys).to contain_exactly(*expected_valid_types) @@ -296,7 +362,7 @@ describe 'encrypted attributes' do subject { described_class.attr_encrypted_attributes.keys } - it { is_expected.to contain_exactly(:token, :url, :url_variables) } + it { is_expected.to contain_exactly(:token, :url, :url_variables, :custom_headers) } end describe 'execute' do diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index c3230dcd64e62b..b96c720b3fabba 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -451,6 +451,35 @@ end end + context 'when custom_headers are set' do + let(:custom_headers) { { testing: 'blub', 'more-testing': 'whoops' } } + + before do + stub_full_request(project_hook.url, method: :post) + project_hook.custom_headers = custom_headers + end + + it 'sends request with custom headers' do + service_instance.execute + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with(headers: headers.merge(custom_headers)) + end + + context 'when custom_webhook_headers feature flag is disabled' do + before do + stub_feature_flags(custom_webhook_headers: false) + end + + it 'sends request without custom headers' do + service_instance.execute + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with(headers: headers) + end + end + end + it 'handles 200 status code' do stub_full_request(project_hook.url, method: :post).to_return(status: 200, body: 'Success') -- GitLab From 63b78ac4bdaef1fa3900ea1eb457c6852ed47a6b Mon Sep 17 00:00:00 2001 From: Niklas Date: Tue, 12 Mar 2024 16:37:57 +0000 Subject: [PATCH 2/6] Reviewer feedback, bugfixes and frontend specs --- .../components/form_custom_header_item.vue | 31 ++-- .../components/form_custom_headers.vue | 71 +++++--- .../webhooks/components/webhook_form_app.vue | 2 +- .../concerns/web_hooks/hook_actions.rb | 2 +- ...05201830_add_custom_headers_to_web_hook.rb | 2 +- doc/user/project/integrations/webhooks.md | 10 +- locale/gitlab.pot | 11 +- .../form_custom_header_item_spec.js | 102 +++++++++++ .../components/form_custom_headers_spec.js | 165 ++++++++++++++++++ 9 files changed, 346 insertions(+), 50 deletions(-) create mode 100644 spec/frontend/webhooks/components/form_custom_header_item_spec.js create mode 100644 spec/frontend/webhooks/components/form_custom_headers_spec.js diff --git a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue index f618254ffa7b13..9364f74f0bd764 100644 --- a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue +++ b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue @@ -11,21 +11,15 @@ export default { GlFormInput, }, props: { - initialHeaderKey: { + headerKey: { type: String, required: true, }, - initialHeaderValue: { + headerValue: { type: String, required: true, }, }, - data() { - return { - headerKey: this.initialHeaderKey, - headerValue: this.initialHeaderValue, - }; - }, computed: { valueIsHidden() { return MASK_ITEM_VALUE_HIDDEN === this.headerValue; @@ -36,6 +30,12 @@ export default { valueInputId() { return `webhook-custom-header-value-${this.headerKey}`; }, + keyIsValid() { + return !isEmpty(this.headerKey); + }, + valueIsValid() { + return !isEmpty(this.headerValue); + }, }, methods: { isEmpty, @@ -56,13 +56,15 @@ export default { :label-for="keyInputId" :invalid-feedback="$options.i18n.inputRequired" class="gl-flex-grow-1 gl-mb-0" + data-testid="custom-header-item-key" > diff --git a/app/assets/javascripts/webhooks/components/form_custom_headers.vue b/app/assets/javascripts/webhooks/components/form_custom_headers.vue index b27750ddc171b5..0ede123355bce5 100644 --- a/app/assets/javascripts/webhooks/components/form_custom_headers.vue +++ b/app/assets/javascripts/webhooks/components/form_custom_headers.vue @@ -1,6 +1,8 @@ diff --git a/app/assets/javascripts/webhooks/components/webhook_form_app.vue b/app/assets/javascripts/webhooks/components/webhook_form_app.vue index 6ac69e2370b483..8b6527ee2dd159 100644 --- a/app/assets/javascripts/webhooks/components/webhook_form_app.vue +++ b/app/assets/javascripts/webhooks/components/webhook_form_app.vue @@ -21,7 +21,7 @@ export default { default: null, }, initialCustomHeaders: { - type: Object, + type: Array, required: false, default: null, }, diff --git a/app/controllers/concerns/web_hooks/hook_actions.rb b/app/controllers/concerns/web_hooks/hook_actions.rb index 0d62254f12bc39..e3a88487e4af20 100644 --- a/app/controllers/concerns/web_hooks/hook_actions.rb +++ b/app/controllers/concerns/web_hooks/hook_actions.rb @@ -11,7 +11,7 @@ module HookActions before_action :hook_logs, only: :edit feature_category :webhooks - before_action only: :edit do + before_action only: %i[edit update] do push_frontend_feature_flag(:custom_webhook_headers, hook.parent, type: :beta) end diff --git a/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb b/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb index 08db99d57920ab..4bf4fef0453c7a 100644 --- a/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb +++ b/db/migrate/20240305201830_add_custom_headers_to_web_hook.rb @@ -2,7 +2,7 @@ class AddCustomHeadersToWebHook < Gitlab::Database::Migration[2.2] enable_lock_retries! - milestone '16.10' + milestone '16.11' def change add_column :web_hooks, :encrypted_custom_headers, :binary diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 5e5b144a8193d8..5aea98e433428b 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -108,17 +108,17 @@ You can define URL variables directly using the REST API. ## Custom headers -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146702) in GitLab 16.10 [with a flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. Disabled by default. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146702) in GitLab 16.11 [with a flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. Disabled by default. FLAG: -On self-managed GitLab, by default this feature is not available. To hide the feature, an administrator can +On self-managed GitLab, by default this feature is not available. To make it available, an administrator can [enable the feature flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. On GitLab.com and GitLab Dedicated, this feature is not available. -You can set up to 20 custom headers in the webhook configuration. The custom headers are sent as part of the request and can -be used to provide authentication to external services. +You can set up to 20 custom headers in the webhook configuration as part of the request. +You can use these custom headers for authentication to external services. -The custom headers are shown in the [recent deliveries](#recently-triggered-webhook-payloads-in-gitlab-settings) with their value redacted. +Custom headers appear in [recent deliveries](#recently-triggered-webhook-payloads-in-gitlab-settings) with masked values. ## Custom webhook template diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2158c31d9db577..34641264939494 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -56268,7 +56268,7 @@ msgstr "" msgid "Webhooks|Confidential issues events" msgstr "" -msgid "Webhooks|Custom Headers" +msgid "Webhooks|Custom headers" msgstr "" msgid "Webhooks|Custom webhook template (optional)" @@ -56289,6 +56289,9 @@ msgstr "" msgid "Webhooks|Enable SSL verification" msgstr "" +msgid "Webhooks|Enable custom headers" +msgstr "" + msgid "Webhooks|Failed to connect" msgstr "" @@ -56334,9 +56337,6 @@ msgstr "" msgid "Webhooks|Name (optional)" msgstr "" -msgid "Webhooks|No custom headers" -msgstr "" - msgid "Webhooks|Pipeline events" msgstr "" @@ -56421,9 +56421,6 @@ msgstr "" msgid "Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported." msgstr "" -msgid "Webhooks|With custom headers" -msgstr "" - msgid "Website" msgstr "" diff --git a/spec/frontend/webhooks/components/form_custom_header_item_spec.js b/spec/frontend/webhooks/components/form_custom_header_item_spec.js new file mode 100644 index 00000000000000..4a403b346cd95b --- /dev/null +++ b/spec/frontend/webhooks/components/form_custom_header_item_spec.js @@ -0,0 +1,102 @@ +import { nextTick } from 'vue'; +import { GlButton, GlFormInput } from '@gitlab/ui'; + +import FormCustomHeaderItem from '~/webhooks/components/form_custom_header_item.vue'; +import { MASK_ITEM_VALUE_HIDDEN } from '~/webhooks/constants'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('FormCustomHeaderItem', () => { + let wrapper; + + const createComponent = ({ props } = {}) => { + wrapper = shallowMountExtended(FormCustomHeaderItem, { + propsData: props, + }); + }; + + const findCustomHeaderItemKey = () => wrapper.findByTestId('custom-header-item-key'); + const findCustomHeaderItemValue = () => wrapper.findByTestId('custom-header-item-value'); + const findRemoveButton = () => wrapper.findComponent(GlButton); + + it('renders input for key and value', () => { + const headerKey = 'key'; + const headerValue = 'value'; + createComponent({ props: { headerKey, headerValue } }); + + const keyInput = findCustomHeaderItemKey(); + const valueInput = findCustomHeaderItemValue(); + + expect(keyInput.attributes()).toMatchObject({ + label: FormCustomHeaderItem.i18n.keyLabel, + }); + expect(keyInput.findComponent(GlFormInput).attributes()).toMatchObject({ + name: 'hook[custom_headers][][key]', + value: headerKey, + state: 'true', + }); + + expect(valueInput.attributes()).toMatchObject({ + label: FormCustomHeaderItem.i18n.valueLabel, + }); + expect(valueInput.findComponent(GlFormInput).attributes()).toMatchObject({ + name: 'hook[custom_headers][][value]', + value: headerValue, + state: 'true', + }); + }); + + describe('when value is the secret mask', () => { + it('renders readonly key and value', () => { + createComponent({ props: { headerKey: 'key', headerValue: MASK_ITEM_VALUE_HIDDEN } }); + + expect( + findCustomHeaderItemKey().findComponent(GlFormInput).attributes('readonly'), + ).toBeDefined(); + expect( + findCustomHeaderItemValue().findComponent(GlFormInput).attributes('readonly'), + ).toBeDefined(); + }); + }); + + it('renders remove button', () => { + createComponent({ props: { headerKey: 'key', headerValue: 'value' } }); + + expect(findRemoveButton().props('icon')).toBe('remove'); + }); + + describe('when remove button is clicked', () => { + it('emits remove event', async () => { + createComponent({ props: { headerKey: 'key', headerValue: 'value' } }); + + findRemoveButton().vm.$emit('click'); + await nextTick(); + + expect(wrapper.emitted('remove')).toEqual([[]]); + }); + }); + + describe('events', () => { + const headerKey = 'key'; + const headerValue = 'value'; + const mockInput = 'input'; + + it('update:header-key on key input', async () => { + createComponent({ props: { headerKey, headerValue } }); + + findCustomHeaderItemKey().findComponent(GlFormInput).vm.$emit('input', mockInput); + await nextTick(); + + expect(wrapper.emitted('update:header-key')).toEqual([[mockInput]]); + }); + + it('update:header-value on value input', async () => { + createComponent({ props: { headerKey, headerValue } }); + + findCustomHeaderItemValue().findComponent(GlFormInput).vm.$emit('input', mockInput); + await nextTick(); + + expect(wrapper.emitted('update:header-value')).toEqual([[mockInput]]); + }); + }); +}); diff --git a/spec/frontend/webhooks/components/form_custom_headers_spec.js b/spec/frontend/webhooks/components/form_custom_headers_spec.js new file mode 100644 index 00000000000000..56ade69f7f63b1 --- /dev/null +++ b/spec/frontend/webhooks/components/form_custom_headers_spec.js @@ -0,0 +1,165 @@ +import { nextTick } from 'vue'; +import { GlFormCheckbox, GlLink } from '@gitlab/ui'; + +import { scrollToElement } from '~/lib/utils/common_utils'; +import FormCustomHeaders from '~/webhooks/components/form_custom_headers.vue'; +import FormCustomHeaderItem from '~/webhooks/components/form_custom_header_item.vue'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; + +jest.mock('~/lib/utils/common_utils'); + +describe('FormCustomHeaders', () => { + let wrapper; + + const createEmptyCustomHeaders = () => ({ + initialCustomHeaders: [], + }); + + const createFilledCustomHeaders = () => ({ + initialCustomHeaders: [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + ], + }); + + const createComponent = ({ props } = {}) => { + wrapper = shallowMountExtended(FormCustomHeaders, { + propsData: props, + }); + }; + + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); + const findCustomHeadersSection = () => wrapper.findByTestId('custom-headers-section'); + const findAllCustomHeaderItems = () => wrapper.findAllComponents(FormCustomHeaderItem); + const findAddItem = () => wrapper.findComponent(GlLink); + + describe('template', () => { + it('renders checkbox for custom headers', () => { + createComponent({ props: createEmptyCustomHeaders() }); + + expect(findCheckbox().text()).toBe(FormCustomHeaders.i18n.enableCustomHeaders); + }); + + it('does not render custom headers section', () => { + createComponent({ props: createEmptyCustomHeaders() }); + + expect(findCustomHeadersSection().exists()).toBe(false); + }); + + describe('on checkbox change', () => { + beforeEach(async () => { + createComponent({ props: createEmptyCustomHeaders() }); + + findCheckbox().vm.$emit('input', true); + await nextTick(); + }); + + it('renders custom header section', () => { + expect(findCustomHeadersSection().exists()).toBe(true); + }); + + it('renders an empty custom header item by default', () => { + expect(findAllCustomHeaderItems()).toHaveLength(1); + + const firstItem = findAllCustomHeaderItems().at(0); + expect(firstItem.props()).toMatchObject({ + headerKey: '', + headerValue: '', + }); + }); + }); + + describe('when add item is clicked', () => { + it('adds custom header item', async () => { + createComponent({ props: createFilledCustomHeaders() }); + + findAddItem().vm.$emit('click'); + await nextTick(); + + expect(findAllCustomHeaderItems()).toHaveLength(3); + + const lastItem = findAllCustomHeaderItems().at(-1); + expect(lastItem.props()).toMatchObject({ + headerKey: '', + headerValue: '', + }); + }); + }); + + describe('when remove item is clicked', () => { + it('removes the correct custom header item', async () => { + createComponent({ props: createFilledCustomHeaders() }); + + const firstItem = findAllCustomHeaderItems().at(0); + firstItem.vm.$emit('remove'); + await nextTick(); + + expect(findAllCustomHeaderItems()).toHaveLength(1); + + const newFirstItem = findAllCustomHeaderItems().at(0); + expect(newFirstItem.props()).toMatchObject({ + headerKey: 'key2', + headerValue: 'value2', + }); + }); + }); + + describe('when maximum headers are reached', () => { + it('does not render add item button', () => { + createComponent({ + props: { + initialCustomHeaders: Array.from(20, (i) => ({ key: `key${i}`, value: `value${i}` })), + }, + }); + + expect(findAddItem().exists()).toBe(false); + }); + }); + }); + + describe('validation', () => { + beforeEach(() => { + setHTMLFixture('
'); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + const findFormEl = () => document.querySelector('.js-webhook-form'); + const submitForm = (event) => findFormEl().dispatchEvent(event); + + const createFakeSubmitEvent = () => { + const event = new Event('submit'); + event.preventDefault = jest.fn(); + event.stopPropagation = jest.fn(); + return event; + }; + + it('prevents submit event when form is invalid', async () => { + createComponent({ props: { initialCustomHeaders: [{ key: 'key', value: '' }] } }); + + const fakeEvent = createFakeSubmitEvent(); + submitForm(fakeEvent); + await nextTick(); + + expect(fakeEvent.preventDefault).toHaveBeenCalled(); + expect(fakeEvent.stopPropagation).toHaveBeenCalled(); + expect(scrollToElement).toHaveBeenCalledTimes(1); + }); + + it('does not prevent submit event when form is valid', async () => { + createComponent({ props: { initialCustomHeaders: [{ key: 'key', value: 'value' }] } }); + + const fakeEvent = createFakeSubmitEvent(); + submitForm(fakeEvent); + await nextTick(); + + expect(fakeEvent.preventDefault).not.toHaveBeenCalled(); + expect(fakeEvent.stopPropagation).not.toHaveBeenCalled(); + expect(scrollToElement).not.toHaveBeenCalled(); + }); + }); +}); -- GitLab From 5c0e721da57205f7b649b244633ebecb3fb7ede2 Mon Sep 17 00:00:00 2001 From: Niklas Date: Wed, 13 Mar 2024 20:05:49 +0000 Subject: [PATCH 3/6] More review feedback --- .../components/form_custom_header_item.vue | 18 +++++++++++++++--- .../components/form_custom_headers.vue | 13 ++++++++++++- app/assets/javascripts/webhooks/constants.js | 2 ++ .../beta/custom_webhook_headers.yml | 2 +- doc/user/project/integrations/webhooks.md | 2 +- locale/gitlab.pot | 6 ++++++ .../components/form_custom_header_item_spec.js | 2 +- 7 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue index 9364f74f0bd764..51da0d30fc8519 100644 --- a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue +++ b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue @@ -2,7 +2,7 @@ import { isEmpty } from 'lodash'; import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { MASK_ITEM_VALUE_HIDDEN } from '../constants'; +import { MASK_ITEM_VALUE_HIDDEN, CUSTOM_HEADER_KEY_PATTERN } from '../constants'; export default { components: { @@ -31,7 +31,18 @@ export default { return `webhook-custom-header-value-${this.headerKey}`; }, keyIsValid() { - return !isEmpty(this.headerKey); + return !this.keyIsEmpty && this.keyHasValidPattern; + }, + keyIsEmpty() { + return isEmpty(this.headerKey); + }, + keyHasValidPattern() { + return CUSTOM_HEADER_KEY_PATTERN.test(this.headerKey); + }, + keyErrorFeedback() { + return this.keyIsEmpty + ? this.$options.i18n.inputRequired + : this.$options.i18n.invalidHeaderName; }, valueIsValid() { return !isEmpty(this.headerValue); @@ -44,6 +55,7 @@ export default { keyLabel: s__('Webhooks|Header name'), valueLabel: s__('Webhooks|Header value'), inputRequired: __('This field is required.'), + invalidHeaderName: s__('Webhooks|Invalid header name.'), removeButton: __('Remove'), }, }; @@ -54,7 +66,7 @@ export default { diff --git a/app/assets/javascripts/webhooks/components/form_custom_headers.vue b/app/assets/javascripts/webhooks/components/form_custom_headers.vue index 0ede123355bce5..34b54ae13143a0 100644 --- a/app/assets/javascripts/webhooks/components/form_custom_headers.vue +++ b/app/assets/javascripts/webhooks/components/form_custom_headers.vue @@ -3,6 +3,7 @@ import { isEmpty } from 'lodash'; import { GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import { scrollToElement } from '~/lib/utils/common_utils'; +import { CUSTOM_HEADER_KEY_PATTERN } from '../constants'; import FormCustomHeaderItem from './form_custom_header_item.vue'; const MAXIMUM_CUSTOM_HEADERS = 20; @@ -72,11 +73,18 @@ export default { } }, isInvalid(customHeaderItem) { - return isEmpty(customHeaderItem.key) || isEmpty(customHeaderItem.value); + return ( + isEmpty(customHeaderItem.key) || + isEmpty(customHeaderItem.value) || + !CUSTOM_HEADER_KEY_PATTERN.test(customHeaderItem.key) + ); }, }, i18n: { addItem: s__('Webhooks|+ Add another custom header'), + maximumCustomHeadersReached: s__( + "Webhooks|You've reached the maximum number of custom headers.", + ), enableCustomHeaders: s__('Webhooks|Enable custom headers'), customHeaders: s__('Webhooks|Custom headers'), }, @@ -103,6 +111,9 @@ export default { {{ $options.i18n.addItem }} + + {{ $options.i18n.maximumCustomHeadersReached }} + diff --git a/app/assets/javascripts/webhooks/constants.js b/app/assets/javascripts/webhooks/constants.js index 96632b47e6b8c7..94b176e5ea00c5 100644 --- a/app/assets/javascripts/webhooks/constants.js +++ b/app/assets/javascripts/webhooks/constants.js @@ -17,3 +17,5 @@ export const descriptionText = { }; export const MASK_ITEM_VALUE_HIDDEN = '************'; + +export const CUSTOM_HEADER_KEY_PATTERN = /^[A-Za-z]+[0-9]*(?:[._-][A-Za-z0-9]+)*$/; diff --git a/config/feature_flags/beta/custom_webhook_headers.yml b/config/feature_flags/beta/custom_webhook_headers.yml index 45b46ac2746fdd..18b36f173fd8fb 100644 --- a/config/feature_flags/beta/custom_webhook_headers.yml +++ b/config/feature_flags/beta/custom_webhook_headers.yml @@ -3,7 +3,7 @@ name: custom_webhook_headers feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/17290 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146702 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/448604 -milestone: '16.10' +milestone: '16.11' group: group::import and integrate type: beta default_enabled: false diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 5aea98e433428b..e6568e25d0b99c 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -115,7 +115,7 @@ On self-managed GitLab, by default this feature is not available. To make it ava [enable the feature flag](../../../administration/feature_flags.md) named `custom_webhook_headers`. On GitLab.com and GitLab Dedicated, this feature is not available. -You can set up to 20 custom headers in the webhook configuration as part of the request. +You can add up to 20 custom headers in the webhook configuration as part of the request. You can use these custom headers for authentication to external services. Custom headers appear in [recent deliveries](#recently-triggered-webhook-payloads-in-gitlab-settings) with masked values. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 34641264939494..6b098990dbbe0d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -56316,6 +56316,9 @@ msgstr "" msgid "Webhooks|How to create a custom webhook template?" msgstr "" +msgid "Webhooks|Invalid header name." +msgstr "" + msgid "Webhooks|Issues events" msgstr "" @@ -56421,6 +56424,9 @@ msgstr "" msgid "Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported." msgstr "" +msgid "Webhooks|You've reached the maximum number of custom headers." +msgstr "" + msgid "Website" msgstr "" diff --git a/spec/frontend/webhooks/components/form_custom_header_item_spec.js b/spec/frontend/webhooks/components/form_custom_header_item_spec.js index 4a403b346cd95b..b2a9360bde2da1 100644 --- a/spec/frontend/webhooks/components/form_custom_header_item_spec.js +++ b/spec/frontend/webhooks/components/form_custom_header_item_spec.js @@ -72,7 +72,7 @@ describe('FormCustomHeaderItem', () => { findRemoveButton().vm.$emit('click'); await nextTick(); - expect(wrapper.emitted('remove')).toEqual([[]]); + expect(wrapper.emitted('remove')).toHaveLength(1); }); }); -- GitLab From 4fa15f1b72d7510ca07445008920701e7f25528b Mon Sep 17 00:00:00 2001 From: Niklas Date: Wed, 20 Mar 2024 21:15:24 +0000 Subject: [PATCH 4/6] UI changes and review feedback --- .../components/form_custom_header_item.vue | 72 +++++----- .../components/form_custom_headers.vue | 129 +++++++++++------- app/models/hooks/web_hook.rb | 14 +- locale/gitlab.pot | 12 +- .../form_custom_header_item_spec.js | 8 +- .../components/form_custom_headers_spec.js | 48 ++----- 6 files changed, 147 insertions(+), 136 deletions(-) diff --git a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue index 51da0d30fc8519..94ca3f91b97d30 100644 --- a/app/assets/javascripts/webhooks/components/form_custom_header_item.vue +++ b/app/assets/javascripts/webhooks/components/form_custom_header_item.vue @@ -1,8 +1,8 @@