summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-12-08 06:08:02 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-12-08 06:08:02 +0000
commit935bc5955fb3d740f1cc2b380665bbc8875a68b6 (patch)
tree4d5cf7a58a2685ceab8d4359bce6b8dae0ff5020
parentbaa5da6de5e05bfa0f0d96d26f3ace54041aaf92 (diff)
downloadgitlab-ce-935bc5955fb3d740f1cc2b380665bbc8875a68b6.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/controllers/jira_connect/installations_controller.rb12
-rw-r--r--app/models/jira_connect_installation.rb12
-rw-r--r--app/services/jira_connect/create_asymmetric_jwt_service.rb11
-rw-r--r--app/services/jira_connect_installations/proxy_lifecycle_event_service.rb91
-rw-r--r--app/services/jira_connect_installations/update_service.rb67
-rw-r--r--app/views/dashboard/issues.html.haml1
-rw-r--r--app/views/dashboard/merge_requests.html.haml1
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--app/workers/jira_connect/send_uninstalled_hook_worker.rb22
-rw-r--r--config/feature_flags/development/hide_public_email_on_profile.yml2
-rw-r--r--doc/administration/external_pipeline_validation.md3
-rw-r--r--spec/models/jira_connect_installation_spec.rb14
-rw-r--r--spec/requests/jira_connect/installations_controller_spec.rb81
-rw-r--r--spec/services/jira_connect/create_asymmetric_jwt_service_spec.rb25
-rw-r--r--spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb154
-rw-r--r--spec/services/jira_connect_installations/update_service_spec.rb176
-rw-r--r--spec/workers/jira_connect/send_uninstalled_hook_worker_spec.rb29
17 files changed, 675 insertions, 35 deletions
diff --git a/app/controllers/jira_connect/installations_controller.rb b/app/controllers/jira_connect/installations_controller.rb
index 401bc4f9c87..44dbf90f5fb 100644
--- a/app/controllers/jira_connect/installations_controller.rb
+++ b/app/controllers/jira_connect/installations_controller.rb
@@ -6,11 +6,12 @@ class JiraConnect::InstallationsController < JiraConnect::ApplicationController
end
def update
- if current_jira_installation.update(installation_params)
+ result = update_installation
+ if result.success?
render json: installation_json(current_jira_installation)
else
render(
- json: { errors: current_jira_installation.errors },
+ json: { errors: result.message },
status: :unprocessable_entity
)
end
@@ -18,6 +19,13 @@ class JiraConnect::InstallationsController < JiraConnect::ApplicationController
private
+ def update_installation
+ JiraConnectInstallations::UpdateService.execute(
+ current_jira_installation,
+ installation_params
+ )
+ end
+
def installation_json(installation)
{
gitlab_com: installation.instance_url.blank?,
diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb
index 23813fa138f..0e88d1ceae9 100644
--- a/app/models/jira_connect_installation.rb
+++ b/app/models/jira_connect_installation.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class JiraConnectInstallation < ApplicationRecord
+ include Gitlab::Routing
+
attr_encrypted :shared_secret,
mode: :per_attribute_iv,
algorithm: 'aes-256-gcm',
@@ -37,13 +39,19 @@ class JiraConnectInstallation < ApplicationRecord
def audience_url
return unless proxy?
- Gitlab::Utils.append_path(instance_url, '/-/jira_connect')
+ Gitlab::Utils.append_path(instance_url, jira_connect_base_path)
end
def audience_installed_event_url
return unless proxy?
- Gitlab::Utils.append_path(instance_url, '/-/jira_connect/events/installed')
+ Gitlab::Utils.append_path(instance_url, jira_connect_events_installed_path)
+ end
+
+ def audience_uninstalled_event_url
+ return unless proxy?
+
+ Gitlab::Utils.append_path(instance_url, jira_connect_events_uninstalled_path)
end
def proxy?
diff --git a/app/services/jira_connect/create_asymmetric_jwt_service.rb b/app/services/jira_connect/create_asymmetric_jwt_service.rb
index 71aba6feddd..0f24128c20b 100644
--- a/app/services/jira_connect/create_asymmetric_jwt_service.rb
+++ b/app/services/jira_connect/create_asymmetric_jwt_service.rb
@@ -4,10 +4,11 @@ module JiraConnect
class CreateAsymmetricJwtService
ARGUMENT_ERROR_MESSAGE = 'jira_connect_installation is not a proxy installation'
- def initialize(jira_connect_installation)
+ def initialize(jira_connect_installation, event: :installed)
raise ArgumentError, ARGUMENT_ERROR_MESSAGE unless jira_connect_installation.proxy?
@jira_connect_installation = jira_connect_installation
+ @event = event
end
def execute
@@ -30,12 +31,18 @@ module JiraConnect
def qsh_claim
Atlassian::Jwt.create_query_string_hash(
- @jira_connect_installation.audience_installed_event_url,
+ audience_event_url,
'POST',
@jira_connect_installation.audience_url
)
end
+ def audience_event_url
+ return @jira_connect_installation.audience_uninstalled_event_url if @event == :uninstalled
+
+ @jira_connect_installation.audience_installed_event_url
+ end
+
def private_key
@private_key ||= OpenSSL::PKey::RSA.generate(3072)
end
diff --git a/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb
new file mode 100644
index 00000000000..d94d9e1324e
--- /dev/null
+++ b/app/services/jira_connect_installations/proxy_lifecycle_event_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module JiraConnectInstallations
+ class ProxyLifecycleEventService
+ SUPPOERTED_EVENTS = %i[installed uninstalled].freeze
+
+ def self.execute(installation, event, instance_url)
+ new(installation, event, instance_url).execute
+ end
+
+ def initialize(installation, event, instance_url)
+ # To ensure the event is sent to the right instance, this makes
+ # a copy of the installation and assigns the instance_url
+ #
+ # The installation might be modified already with a new instance_url.
+ # This can be the case for an uninstalled event.
+ # The installation is updated first, and the uninstalled event has to be sent to
+ # the old instance_url.
+ @installation = installation.dup
+ @installation.instance_url = instance_url
+
+ @event = event.to_sym
+
+ raise(ArgumentError, "Unknown event '#{@event}'") unless SUPPOERTED_EVENTS.include?(@event)
+ end
+
+ def execute
+ result = send_hook
+
+ return ServiceResponse.new(status: :success) if result.code == 200
+
+ log_unsuccessful_response(result.code, result.body)
+
+ ServiceResponse.error(message: { type: :response_error, code: result.code })
+ rescue *Gitlab::HTTP::HTTP_ERRORS => error
+ ServiceResponse.error(message: { type: :network_error, message: error.message })
+ end
+
+ private
+
+ attr_reader :installation, :event
+
+ def send_hook
+ Gitlab::HTTP.post(hook_uri, body: body)
+ end
+
+ def hook_uri
+ case event
+ when :installed
+ installation.audience_installed_event_url
+ when :uninstalled
+ installation.audience_uninstalled_event_url
+ end
+ end
+
+ def body
+ return base_body unless event == :installed
+
+ base_body.merge(installed_body)
+ end
+
+ def base_body
+ {
+ clientKey: installation.client_key,
+ jwt: jwt_token,
+ eventType: event
+ }
+ end
+
+ def installed_body
+ {
+ sharedSecret: installation.shared_secret,
+ baseUrl: installation.base_url
+ }
+ end
+
+ def jwt_token
+ @jwt_token ||= JiraConnect::CreateAsymmetricJwtService.new(@installation, event: event).execute
+ end
+
+ def log_unsuccessful_response(status_code, body)
+ Gitlab::IntegrationsLogger.info(
+ integration: 'JiraConnect',
+ message: 'Proxy lifecycle event received error response',
+ event_type: event,
+ status_code: status_code,
+ body: body
+ )
+ end
+ end
+end
diff --git a/app/services/jira_connect_installations/update_service.rb b/app/services/jira_connect_installations/update_service.rb
new file mode 100644
index 00000000000..ba54d3494d5
--- /dev/null
+++ b/app/services/jira_connect_installations/update_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module JiraConnectInstallations
+ class UpdateService
+ def self.execute(installation, update_params)
+ new(installation, update_params).execute
+ end
+
+ def initialize(installation, update_params)
+ @installation = installation
+ @update_params = update_params
+ end
+
+ def execute
+ return update_error unless @installation.update(@update_params)
+
+ if instance_url_changed?
+ if new_instance_url_present?
+ hook_result = ProxyLifecycleEventService.execute(@installation, :installed, @installation.instance_url)
+
+ if hook_result.error?
+ @installation.update!(instance_url: @installation.instance_url_before_last_save)
+
+ return instance_installation_creation_error(hook_result.message)
+ end
+ end
+
+ send_uninstalled_hook
+ end
+
+ ServiceResponse.new(status: :success)
+ end
+
+ private
+
+ def new_instance_url_present?
+ @installation.instance_url.present?
+ end
+
+ def instance_url_changed?
+ @installation.instance_url_before_last_save != @installation.instance_url
+ end
+
+ def send_uninstalled_hook
+ return if @installation.instance_url_before_last_save.blank?
+
+ JiraConnect::SendUninstalledHookWorker.perform_async(
+ @installation.id,
+ @installation.instance_url_before_last_save
+ )
+ end
+
+ def instance_installation_creation_error(error_message)
+ message = if error_message[:type] == :response_error
+ "Could not be installed on the instance. Error response code #{error_message[:code]}"
+ else
+ 'Could not be installed on the instance. Network error'
+ end
+
+ ServiceResponse.error(message: { instance_url: [message] })
+ end
+
+ def update_error
+ ServiceResponse.error(message: @installation.errors)
+ end
+ end
+end
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index c707f39337d..5293f685d06 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _("Issues")
- @breadcrumb_link = issues_dashboard_path(assignee_username: current_user.username)
+- add_page_specific_style 'page_bundles/issuable_list'
- add_page_specific_style 'page_bundles/dashboard'
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 8a639d08a27..c921375edd1 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- page_title _("Merge requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
+- add_page_specific_style 'page_bundles/issuable_list'
= render_dashboard_ultimate_trial(current_user)
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index b3bda801fad..f8590165df3 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1398,6 +1398,15 @@
:weight: 1
:idempotent: false
:tags: []
+- :name: jira_connect:jira_connect_send_uninstalled_hook
+ :worker_name: JiraConnect::SendUninstalledHookWorker
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: jira_connect:jira_connect_sync_branch
:worker_name: JiraConnect::SyncBranchWorker
:feature_category: :integrations
diff --git a/app/workers/jira_connect/send_uninstalled_hook_worker.rb b/app/workers/jira_connect/send_uninstalled_hook_worker.rb
new file mode 100644
index 00000000000..530ef4a8b8a
--- /dev/null
+++ b/app/workers/jira_connect/send_uninstalled_hook_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SendUninstalledHookWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+ queue_namespace :jira_connect
+ feature_category :integrations
+ urgency :low
+
+ idempotent!
+
+ worker_has_external_dependencies!
+
+ def perform(installation_id, instance_url)
+ installation = JiraConnectInstallation.find_by_id(installation_id)
+
+ JiraConnectInstallations::ProxyLifecycleEventService.execute(installation, :uninstalled, instance_url)
+ end
+ end
+end
diff --git a/config/feature_flags/development/hide_public_email_on_profile.yml b/config/feature_flags/development/hide_public_email_on_profile.yml
index 87ed700c359..acf8e4e9ca7 100644
--- a/config/feature_flags/development/hide_public_email_on_profile.yml
+++ b/config/feature_flags/development/hide_public_email_on_profile.yml
@@ -4,5 +4,5 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79717
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/351731
milestone: '14.8'
type: development
-group: group::optimize
+group: group::workspace
default_enabled: false
diff --git a/doc/administration/external_pipeline_validation.md b/doc/administration/external_pipeline_validation.md
index e6f825f7cb8..2dec3857f75 100644
--- a/doc/administration/external_pipeline_validation.md
+++ b/doc/administration/external_pipeline_validation.md
@@ -9,9 +9,6 @@ type: reference, howto
You can use an external service to validate a pipeline before it's created.
-WARNING:
-This is an experimental feature and subject to change without notice.
-
GitLab sends a POST request to the external service URL with the pipeline
data as payload. The response code from the external service determines if GitLab
should accept or reject the pipeline. If the response is:
diff --git a/spec/models/jira_connect_installation_spec.rb b/spec/models/jira_connect_installation_spec.rb
index f1dc170dea9..525690fa6b7 100644
--- a/spec/models/jira_connect_installation_spec.rb
+++ b/spec/models/jira_connect_installation_spec.rb
@@ -124,6 +124,20 @@ RSpec.describe JiraConnectInstallation, feature_category: :integrations do
end
end
+ describe 'audience_uninstalled_event_url' do
+ let(:installation) { build(:jira_connect_installation) }
+
+ subject(:audience) { installation.audience_uninstalled_event_url }
+
+ it { is_expected.to eq(nil) }
+
+ context 'when proxy installation' do
+ let(:installation) { build(:jira_connect_installation, instance_url: 'https://example.com') }
+
+ it { is_expected.to eq('https://example.com/-/jira_connect/events/uninstalled') }
+ end
+ end
+
describe 'proxy?' do
let(:installation) { build(:jira_connect_installation) }
diff --git a/spec/requests/jira_connect/installations_controller_spec.rb b/spec/requests/jira_connect/installations_controller_spec.rb
index 42abc1e4542..67544bbca2e 100644
--- a/spec/requests/jira_connect/installations_controller_spec.rb
+++ b/spec/requests/jira_connect/installations_controller_spec.rb
@@ -47,16 +47,18 @@ RSpec.describe JiraConnect::InstallationsController, feature_category: :integrat
end
describe 'PUT /-/jira_connect/installations' do
- before do
+ subject(:do_request) do
put '/-/jira_connect/installations', params: { jwt: jwt, installation: { instance_url: update_instance_url } }
end
- let(:update_instance_url) { 'https://example.com' }
+ let(:update_instance_url) { nil }
context 'without JWT' do
let(:jwt) { nil }
it 'returns 403' do
+ do_request
+
expect(response).to have_gitlab_http_status(:forbidden)
end
end
@@ -66,28 +68,69 @@ RSpec.describe JiraConnect::InstallationsController, feature_category: :integrat
let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
it 'returns 200' do
+ do_request
+
expect(response).to have_gitlab_http_status(:ok)
end
- it 'updates the instance_url' do
- expect(json_response).to eq({
- 'gitlab_com' => false,
- 'instance_url' => 'https://example.com'
- })
- end
+ context 'with instance_url param' do
+ let(:update_instance_url) { 'https://example.com' }
- context 'invalid URL' do
- let(:update_instance_url) { 'invalid url' }
+ context 'instance response with success' do
+ before do
+ stub_request(:post, 'https://example.com/-/jira_connect/events/installed')
+ end
- it 'returns 422 and errors', :aggregate_failures do
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(json_response).to eq({
- 'errors' => {
- 'instance_url' => [
- 'is blocked: Only allowed schemes are http, https'
- ]
- }
- })
+ it 'updates the instance_url' do
+ do_request
+
+ expect(json_response).to eq({
+ 'gitlab_com' => false,
+ 'instance_url' => 'https://example.com'
+ })
+ end
+
+ it 'sends an installed event to the self-managed instance' do
+ do_request
+
+ expect(WebMock).to have_requested(:post, 'https://example.com/-/jira_connect/events/installed')
+ end
+ end
+
+ context 'instance response with error' do
+ before do
+ stub_request(:post, 'https://example.com/-/jira_connect/events/installed').to_return(status: 422)
+ end
+
+ it 'returns 422 and errors', :aggregate_failures do
+ do_request
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({
+ 'errors' => {
+ 'instance_url' => [
+ 'Could not be installed on the instance. Error response code 422'
+ ]
+ }
+ })
+ end
+ end
+
+ context 'invalid URL' do
+ let(:update_instance_url) { 'invalid url' }
+
+ it 'returns 422 and errors', :aggregate_failures do
+ do_request
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({
+ 'errors' => {
+ 'instance_url' => [
+ 'is blocked: Only allowed schemes are http, https'
+ ]
+ }
+ })
+ end
end
end
end
diff --git a/spec/services/jira_connect/create_asymmetric_jwt_service_spec.rb b/spec/services/jira_connect/create_asymmetric_jwt_service_spec.rb
index 5dbbc7fb0a2..bb96e327307 100644
--- a/spec/services/jira_connect/create_asymmetric_jwt_service_spec.rb
+++ b/spec/services/jira_connect/create_asymmetric_jwt_service_spec.rb
@@ -19,27 +19,40 @@ RSpec.describe JiraConnect::CreateAsymmetricJwtService, feature_category: :integ
let(:public_key_id) { Atlassian::Jwt.decode(jwt_token, nil, false, algorithm: 'RS256').last['kid'] }
let(:public_key_cdn) { 'https://gitlab.com/-/jira_connect/public_keys/' }
+ let(:event_url) { 'https://gitlab.test/-/jira_connect/events/installed' }
let(:jwt_verification_claims) do
{
aud: 'https://gitlab.test/-/jira_connect',
iss: jira_connect_installation.client_key,
- qsh: Atlassian::Jwt.create_query_string_hash('https://gitlab.test/-/jira_connect/events/installed', 'POST', 'https://gitlab.test/-/jira_connect')
+ qsh: Atlassian::Jwt.create_query_string_hash(event_url, 'POST', 'https://gitlab.test/-/jira_connect')
}
end
subject(:jwt_token) { service.execute }
+ shared_examples 'produces a valid JWT' do
+ it 'produces a valid JWT' do
+ public_key = OpenSSL::PKey.read(JiraConnect::PublicKey.find(public_key_id).key)
+ options = jwt_verification_claims.except(:qsh).merge({ verify_aud: true, verify_iss: true,
+ algorithm: 'RS256' })
+
+ decoded_token = Atlassian::Jwt.decode(jwt_token, public_key, true, options).first
+
+ expect(decoded_token).to eq(jwt_verification_claims.stringify_keys)
+ end
+ end
+
it 'stores the public key' do
expect { JiraConnect::PublicKey.find(public_key_id) }.not_to raise_error
end
- it 'is produces a valid JWT' do
- public_key = OpenSSL::PKey.read(JiraConnect::PublicKey.find(public_key_id).key)
- options = jwt_verification_claims.except(:qsh).merge({ verify_aud: true, verify_iss: true, algorithm: 'RS256' })
+ it_behaves_like 'produces a valid JWT'
- decoded_token = Atlassian::Jwt.decode(jwt_token, public_key, true, options).first
+ context 'with uninstalled event option' do
+ let(:service) { described_class.new(jira_connect_installation, event: :uninstalled) }
+ let(:event_url) { 'https://gitlab.test/-/jira_connect/events/uninstalled' }
- expect(decoded_token).to eq(jwt_verification_claims.stringify_keys)
+ it_behaves_like 'produces a valid JWT'
end
end
end
diff --git a/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb b/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb
new file mode 100644
index 00000000000..c621388a734
--- /dev/null
+++ b/spec/services/jira_connect_installations/proxy_lifecycle_event_service_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectInstallations::ProxyLifecycleEventService, feature_category: :integrations do
+ describe '.execute' do
+ let(:installation) { create(:jira_connect_installation) }
+
+ it 'creates an instance and calls execute' do
+ expect_next_instance_of(described_class, installation, 'installed', 'https://test.gitlab.com') do |update_service|
+ expect(update_service).to receive(:execute)
+ end
+
+ described_class.execute(installation, 'installed', 'https://test.gitlab.com')
+ end
+ end
+
+ describe '.new' do
+ let_it_be(:installation) { create(:jira_connect_installation, instance_url: nil) }
+
+ let(:event) { :installed }
+
+ subject(:service) { described_class.new(installation, event, 'https://test.gitlab.com') }
+
+ it 'creates an internal duplicate of the installation and sets the instance_url' do
+ expect(service.instance_variable_get(:@installation).instance_url).to eq('https://test.gitlab.com')
+ end
+
+ context 'with unknown event' do
+ let(:event) { 'test' }
+
+ it 'raises an error' do
+ expect { service }.to raise_error(ArgumentError, 'Unknown event \'test\'')
+ end
+ end
+ end
+
+ describe '#execute' do
+ let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'https://old_instance_url.example.com') }
+
+ let(:service) { described_class.new(installation, evnet_type, 'https://gitlab.example.com') }
+ let(:service_instance_installation) { service.instance_variable_get(:@installation) }
+
+ before do
+ allow_next_instance_of(JiraConnect::CreateAsymmetricJwtService) do |create_asymmetric_jwt_service|
+ allow(create_asymmetric_jwt_service).to receive(:execute).and_return('123456')
+ end
+
+ stub_request(:post, hook_url)
+ end
+
+ subject(:execute_service) { service.execute }
+
+ shared_examples 'sends the event hook' do
+ it 'returns a ServiceResponse' do
+ expect(execute_service).to be_kind_of(ServiceResponse)
+ expect(execute_service[:status]).to eq(:success)
+ end
+
+ it 'sends an installed event to the instance' do
+ execute_service
+
+ expect(WebMock).to have_requested(:post, hook_url).with(body: expected_request_body)
+ end
+
+ it 'creates the JWT token with the event and installation' do
+ expect_next_instance_of(
+ JiraConnect::CreateAsymmetricJwtService,
+ service_instance_installation,
+ event: evnet_type
+ ) do |create_asymmetric_jwt_service|
+ expect(create_asymmetric_jwt_service).to receive(:execute).and_return('123456')
+ end
+
+ expect(execute_service[:status]).to eq(:success)
+ end
+
+ context 'and the instance responds with an error' do
+ before do
+ stub_request(:post, hook_url).to_return(
+ status: 422,
+ body: 'Error message',
+ headers: {}
+ )
+ end
+
+ it 'returns an error ServiceResponse', :aggregate_failures do
+ expect(execute_service).to be_kind_of(ServiceResponse)
+ expect(execute_service[:status]).to eq(:error)
+ expect(execute_service[:message]).to eq( { type: :response_error, code: 422 } )
+ end
+
+ it 'logs the error response' do
+ expect(Gitlab::IntegrationsLogger).to receive(:info).with(
+ integration: 'JiraConnect',
+ message: 'Proxy lifecycle event received error response',
+ event_type: evnet_type,
+ status_code: 422,
+ body: 'Error message'
+ )
+
+ execute_service
+ end
+ end
+
+ context 'and the request raises an error' do
+ before do
+ allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message')
+ end
+
+ it 'returns an error ServiceResponse', :aggregate_failures do
+ expect(execute_service).to be_kind_of(ServiceResponse)
+ expect(execute_service[:status]).to eq(:error)
+ expect(execute_service[:message]).to eq(
+ {
+ type: :network_error,
+ message: 'Connection refused - error message'
+ }
+ )
+ end
+ end
+ end
+
+ context 'when installed event' do
+ let(:evnet_type) { :installed }
+ let(:hook_url) { 'https://gitlab.example.com/-/jira_connect/events/installed' }
+ let(:expected_request_body) do
+ {
+ clientKey: installation.client_key,
+ sharedSecret: installation.shared_secret,
+ baseUrl: installation.base_url,
+ jwt: '123456',
+ eventType: 'installed'
+ }
+ end
+
+ it_behaves_like 'sends the event hook'
+ end
+
+ context 'when uninstalled event' do
+ let(:evnet_type) { :uninstalled }
+ let(:hook_url) { 'https://gitlab.example.com/-/jira_connect/events/uninstalled' }
+ let(:expected_request_body) do
+ {
+ clientKey: installation.client_key,
+ jwt: '123456',
+ eventType: 'uninstalled'
+ }
+ end
+
+ it_behaves_like 'sends the event hook'
+ end
+ end
+end
diff --git a/spec/services/jira_connect_installations/update_service_spec.rb b/spec/services/jira_connect_installations/update_service_spec.rb
new file mode 100644
index 00000000000..000c854b495
--- /dev/null
+++ b/spec/services/jira_connect_installations/update_service_spec.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnectInstallations::UpdateService, feature_category: :integrations do
+ describe '.execute' do
+ it 'creates an instance and calls execute' do
+ expect_next_instance_of(described_class, 'param1', 'param2') do |update_service|
+ expect(update_service).to receive(:execute)
+ end
+
+ described_class.execute('param1', 'param2')
+ end
+ end
+
+ describe '#execute' do
+ let_it_be_with_reload(:installation) { create(:jira_connect_installation) }
+ let(:update_params) { { client_key: 'new_client_key' } }
+
+ subject(:execute_service) { described_class.new(installation, update_params).execute }
+
+ it 'returns a ServiceResponse' do
+ expect(execute_service).to be_kind_of(ServiceResponse)
+ expect(execute_service[:status]).to eq(:success)
+ end
+
+ it 'updates the installation' do
+ expect { execute_service }.to change { installation.client_key }.to('new_client_key')
+ end
+
+ it 'returns a successful result' do
+ expect(execute_service.success?).to eq(true)
+ end
+
+ context 'and model validation fails' do
+ let(:update_params) { { instance_url: 'invalid' } }
+
+ it 'returns an error result' do
+ expect(execute_service.error?).to eq(true)
+ expect(execute_service.message).to eq(installation.errors)
+ end
+ end
+
+ context 'and instance_url is updated' do
+ let(:update_params) { { instance_url: 'https://gitlab.example.com' } }
+
+ it 'sends an installed event to the instance and updates instance_url' do
+ expect_next_instance_of(JiraConnectInstallations::ProxyLifecycleEventService, installation, :installed,
+'https://gitlab.example.com') do |proxy_lifecycle_events_service|
+ expect(proxy_lifecycle_events_service).to receive(:execute).and_return(ServiceResponse.new(status: :success))
+ end
+
+ expect(JiraConnect::SendUninstalledHookWorker).not_to receive(:perform_async)
+
+ execute_service
+
+ expect(installation.instance_url).to eq(update_params[:instance_url])
+ end
+
+ context 'and the instance installation cannot be created' do
+ before do
+ allow_next_instance_of(
+ JiraConnectInstallations::ProxyLifecycleEventService,
+ installation,
+ :installed,
+ 'https://gitlab.example.com'
+ ) do |proxy_lifecycle_events_service|
+ allow(proxy_lifecycle_events_service).to receive(:execute).and_return(
+ ServiceResponse.error(
+ message: {
+ type: :response_error,
+ code: '422'
+ }
+ )
+ )
+ end
+ end
+
+ it 'does not change instance_url' do
+ expect { execute_service }.not_to change { installation.instance_url }
+ end
+
+ it 'returns an error message' do
+ expect(execute_service[:status]).to eq(:error)
+ expect(execute_service[:message]).to eq(
+ {
+ instance_url: ["Could not be installed on the instance. Error response code 422"]
+ }
+ )
+ end
+
+ context 'and the installation had a previous instance_url' do
+ let(:installation) { build(:jira_connect_installation, instance_url: 'https://other_gitlab.example.com') }
+
+ it 'does not send the uninstalled hook to the previous instance_url' do
+ expect(JiraConnect::SendUninstalledHookWorker).not_to receive(:perform_async)
+
+ execute_service
+ end
+ end
+
+ context 'when failure because of a network error' do
+ before do
+ allow_next_instance_of(
+ JiraConnectInstallations::ProxyLifecycleEventService,
+ installation,
+ :installed,
+ 'https://gitlab.example.com'
+ ) do |proxy_lifecycle_events_service|
+ allow(proxy_lifecycle_events_service).to receive(:execute).and_return(
+ ServiceResponse.error(
+ message: {
+ type: :network_error,
+ message: 'Connection refused - error message'
+ }
+ )
+ )
+ end
+ end
+
+ it 'returns an error message' do
+ expect(execute_service[:status]).to eq(:error)
+ expect(execute_service[:message]).to eq(
+ {
+ instance_url: ["Could not be installed on the instance. Network error"]
+ }
+ )
+ end
+ end
+ end
+
+ context 'and the installation had a previous instance_url' do
+ let_it_be_with_reload(:installation) { create(:jira_connect_installation, instance_url: 'https://other_gitlab.example.com') }
+
+ before do
+ stub_request(:post, 'https://other_gitlab.example.com/-/jira_connect/events/uninstalled')
+ end
+
+ it 'starts an async worker to send an uninstalled event to the previous instance' do
+ expect(JiraConnect::SendUninstalledHookWorker).to receive(:perform_async).with(installation.id, 'https://other_gitlab.example.com')
+
+ expect_next_instance_of(
+ JiraConnectInstallations::ProxyLifecycleEventService,
+ installation, :installed,
+ 'https://gitlab.example.com'
+ ) do |proxy_lifecycle_events_service|
+ expect(proxy_lifecycle_events_service).to receive(:execute)
+ .and_return(ServiceResponse.new(status: :success))
+ end
+
+ execute_service
+
+ expect(installation.instance_url).to eq(update_params[:instance_url])
+ end
+
+ context 'and the new instance_url is empty' do
+ let(:update_params) { { instance_url: nil } }
+
+ it 'starts an async worker to send an uninstalled event to the previous instance' do
+ expect(JiraConnect::SendUninstalledHookWorker).to receive(:perform_async).with(installation.id, 'https://other_gitlab.example.com')
+
+ execute_service
+
+ expect(installation.instance_url).to eq(nil)
+ end
+
+ it 'does not send an installed event' do
+ expect(JiraConnectInstallations::ProxyLifecycleEventService).not_to receive(:new)
+
+ execute_service
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/jira_connect/send_uninstalled_hook_worker_spec.rb b/spec/workers/jira_connect/send_uninstalled_hook_worker_spec.rb
new file mode 100644
index 00000000000..d8ca8dee54d
--- /dev/null
+++ b/spec/workers/jira_connect/send_uninstalled_hook_worker_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SendUninstalledHookWorker, feature_category: :integrations do
+ describe '#perform' do
+ let_it_be(:jira_connect_installation) { create(:jira_connect_installation) }
+ let(:instance_url) { 'http://example.com' }
+ let(:attempts) { 3 }
+ let(:service_response) { ServiceResponse.new(status: :success) }
+ let(:job_args) { [jira_connect_installation.id, instance_url] }
+
+ before do
+ allow(JiraConnectInstallations::ProxyLifecycleEventService).to receive(:execute).and_return(service_response)
+ end
+
+ include_examples 'an idempotent worker' do
+ it 'calls the ProxyLifecycleEventService service' do
+ expect(JiraConnectInstallations::ProxyLifecycleEventService).to receive(:execute).with(
+ jira_connect_installation,
+ :uninstalled,
+ instance_url
+ ).twice
+
+ subject
+ end
+ end
+ end
+end