diff options
Diffstat (limited to 'spec/services/integrations')
14 files changed, 1148 insertions, 2 deletions
diff --git a/spec/services/integrations/propagate_service_spec.rb b/spec/services/integrations/propagate_service_spec.rb index c971c4a0ad0..0267b1b0ed0 100644 --- a/spec/services/integrations/propagate_service_spec.rb +++ b/spec/services/integrations/propagate_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::PropagateService do +RSpec.describe Integrations::PropagateService, feature_category: :integrations do describe '.propagate' do include JiraIntegrationHelpers diff --git a/spec/services/integrations/slack_event_service_spec.rb b/spec/services/integrations/slack_event_service_spec.rb new file mode 100644 index 00000000000..17433aee329 --- /dev/null +++ b/spec/services/integrations/slack_event_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackEventService, feature_category: :integrations do + describe '#execute' do + subject(:execute) { described_class.new(params).execute } + + let(:params) do + { + type: 'event_callback', + event: { + type: 'app_home_opened', + foo: 'bar' + } + } + end + + it 'queues a worker and returns success response' do + expect(Integrations::SlackEventWorker).to receive(:perform_async) + .with( + { + slack_event: 'app_home_opened', + params: { + event: { + foo: 'bar' + } + } + } + ) + expect(execute.payload).to eq({}) + is_expected.to be_success + end + + context 'when event a url verification request' do + let(:params) { { type: 'url_verification', foo: 'bar' } } + + it 'executes the service instead of queueing a worker and returns success response' do + expect(Integrations::SlackEventWorker).not_to receive(:perform_async) + expect_next_instance_of(Integrations::SlackEvents::UrlVerificationService, { foo: 'bar' }) do |service| + expect(service).to receive(:execute).and_return({ baz: 'qux' }) + end + expect(execute.payload).to eq({ baz: 'qux' }) + is_expected.to be_success + end + end + + context 'when event is unknown' do + let(:params) { super().merge(event: { type: 'foo' }) } + + it 'raises an error' do + expect { execute }.to raise_error(described_class::UnknownEventError) + end + end + end +end diff --git a/spec/services/integrations/slack_events/app_home_opened_service_spec.rb b/spec/services/integrations/slack_events/app_home_opened_service_spec.rb new file mode 100644 index 00000000000..0eb4c019e0a --- /dev/null +++ b/spec/services/integrations/slack_events/app_home_opened_service_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackEvents::AppHomeOpenedService, feature_category: :integrations do + describe '#execute' do + let_it_be(:slack_installation) { create(:slack_integration) } + + let(:slack_workspace_id) { slack_installation.team_id } + let(:slack_user_id) { 'U0123ABCDEF' } + let(:api_url) { "#{Slack::API::BASE_URL}/views.publish" } + let(:api_response) { { ok: true } } + let(:params) do + { + team_id: slack_workspace_id, + event: { user: slack_user_id }, + event_id: 'Ev03SA75UJKB' + } + end + + subject(:execute) { described_class.new(params).execute } + + before do + stub_request(:post, api_url) + .to_return( + status: 200, + body: api_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + shared_examples 'there is no bot token' do + it 'does not call the Slack API, logs info, and returns a success response' do + expect(Gitlab::IntegrationsLogger).to receive(:info).with( + { + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id, + message: 'SlackInstallation record has no bot token' + } + ) + + is_expected.to be_success + end + end + + it 'calls the Slack API correctly and returns a success response' do + mock_view = { type: 'home', blocks: [] } + + expect_next_instance_of(Slack::BlockKit::AppHomeOpened) do |ui| + expect(ui).to receive(:build).and_return(mock_view) + end + + is_expected.to be_success + + expect(WebMock).to have_requested(:post, api_url).with( + body: { + user_id: slack_user_id, + view: mock_view + }, + headers: { + 'Authorization' => "Bearer #{slack_installation.bot_access_token}", + 'Content-Type' => 'application/json; charset=utf-8' + }) + end + + context 'when the slack installation is a legacy record' do + let_it_be(:slack_installation) { create(:slack_integration, :legacy) } + + it_behaves_like 'there is no bot token' + end + + context 'when the slack installation cannot be found' do + let(:slack_workspace_id) { non_existing_record_id } + + it_behaves_like 'there is no bot token' + end + + context 'when the Slack API call raises an HTTP exception' do + before do + allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message') + end + + it 'tracks the exception and returns an error response' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'), + { + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id + } + ) + is_expected.to be_error + end + end + + context 'when the Slack API returns an error' do + let(:api_response) { { ok: false, foo: 'bar' } } + + it 'tracks the exception and returns an error response' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + StandardError.new('Slack API returned an error'), + { + slack_user_id: slack_user_id, + slack_workspace_id: slack_workspace_id, + response: api_response.with_indifferent_access + } + ) + is_expected.to be_error + end + end + end +end diff --git a/spec/services/integrations/slack_events/url_verification_service_spec.rb b/spec/services/integrations/slack_events/url_verification_service_spec.rb new file mode 100644 index 00000000000..0d668acafb9 --- /dev/null +++ b/spec/services/integrations/slack_events/url_verification_service_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackEvents::UrlVerificationService, feature_category: :integrations do + describe '#execute' do + it 'returns the challenge' do + expect(described_class.new({ challenge: 'foo' }).execute).to eq({ challenge: 'foo' }) + end + end +end diff --git a/spec/services/integrations/slack_interaction_service_spec.rb b/spec/services/integrations/slack_interaction_service_spec.rb new file mode 100644 index 00000000000..599320c7986 --- /dev/null +++ b/spec/services/integrations/slack_interaction_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractionService, feature_category: :integrations do + describe '#execute' do + subject(:execute) { described_class.new(params).execute } + + let(:params) do + { + type: slack_interaction, + foo: 'bar' + } + end + + context 'when view is closed' do + let(:slack_interaction) { 'view_closed' } + + it 'executes the correct service' do + view_closed_service = described_class::INTERACTIONS['view_closed'] + + expect_next_instance_of(view_closed_service, { foo: 'bar' }) do |service| + expect(service).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when view is submitted' do + let(:slack_interaction) { 'view_submission' } + + it 'executes the submission service' do + view_submission_service = described_class::INTERACTIONS['view_submission'] + + expect_next_instance_of(view_submission_service, { foo: 'bar' }) do |service| + expect(service).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when block action service is submitted' do + let(:slack_interaction) { 'block_actions' } + + it 'executes the block actions service' do + block_action_service = described_class::INTERACTIONS['block_actions'] + + expect_next_instance_of(block_action_service, { foo: 'bar' }) do |service| + expect(service).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when slack_interaction is not known' do + let(:slack_interaction) { 'foo' } + + it 'raises an error and does not execute a service class' do + described_class::INTERACTIONS.each_value do |service_class| + expect(service_class).not_to receive(:new) + end + + expect { execute }.to raise_error(described_class::UnknownInteractionError) + end + end + end +end diff --git a/spec/services/integrations/slack_interactions/block_action_service_spec.rb b/spec/services/integrations/slack_interactions/block_action_service_spec.rb new file mode 100644 index 00000000000..9a188ddcfe4 --- /dev/null +++ b/spec/services/integrations/slack_interactions/block_action_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractions::BlockActionService, feature_category: :integrations do + describe '#execute' do + let_it_be(:slack_installation) { create(:slack_integration) } + + let(:params) do + { + view: { + team_id: slack_installation.team_id + }, + actions: [{ + action_id: action_id + }] + } + end + + subject(:execute) { described_class.new(params).execute } + + context 'when action_id is incident_management_project' do + let(:action_id) { 'incident_management_project' } + + it 'executes the correct handler' do + project_handler = described_class::ALLOWED_UPDATES_HANDLERS['incident_management_project'] + + expect_next_instance_of(project_handler, params, params[:actions].first) do |handler| + expect(handler).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when action_id is not known' do + let(:action_id) { 'random' } + + it 'does not execute the handlers' do + described_class::ALLOWED_UPDATES_HANDLERS.each_value do |handler_class| + expect(handler_class).not_to receive(:new) + end + + execute + end + end + end +end diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb new file mode 100644 index 00000000000..64cddf9a66b --- /dev/null +++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_closed_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalClosedService, + feature_category: :integrations do + describe '#execute' do + let_it_be(:request_body) do + { + replace_original: 'true', + text: 'Incident creation cancelled.' + } + end + + let(:params) do + { + view: { + private_metadata: 'https://api.slack.com/id/1234' + } + } + end + + let(:service) { described_class.new(params) } + + before do + allow(Gitlab::HTTP).to receive(:post).and_return({ ok: true }) + end + + context 'when executed' do + it 'makes the POST call and closes the modal' do + expect(Gitlab::HTTP).to receive(:post).with( + 'https://api.slack.com/id/1234', + body: Gitlab::Json.dump(request_body), + headers: { 'Content-Type' => 'application/json' } + ) + + service.execute + end + end + + context 'when the POST call raises an HTTP exception' do + before do + allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message') + end + + it 'tracks the exception and returns an error response' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'), + { + params: params + } + ) + + service.execute + end + end + + context 'when response is not ok' do + before do + allow(Gitlab::HTTP).to receive(:post).and_return({ ok: false }) + end + + it 'returns error response and tracks the exception' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + StandardError.new('Something went wrong while closing the incident form.'), + { + response: { ok: false }, + params: params + } + ) + + service.execute + end + end + end +end diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb new file mode 100644 index 00000000000..2a1aa0aabec --- /dev/null +++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_opened_service_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalOpenedService, + feature_category: :incident_management do + describe '#execute' do + let_it_be(:slack_installation) { create(:slack_integration) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user, developer_projects: [project]) } + let_it_be(:trigger_id) { '12345.98765.abcd2358fdea' } + + let(:slack_workspace_id) { slack_installation.team_id } + let(:response_url) { 'https://api.slack.com/id/123' } + let(:api_url) { "#{Slack::API::BASE_URL}/views.open" } + let(:mock_modal) { { type: 'modal', blocks: [] } } + let(:params) do + { + team_id: slack_workspace_id, + response_url: response_url, + trigger_id: trigger_id + } + end + + before do + response = { + id: '123', + state: { + values: { + project_and_severity_selector: { + incident_management_project: { + selected_option: { + value: project.id.to_s + } + } + } + } + } + } + stub_request(:post, api_url) + .to_return( + status: 200, + body: Gitlab::Json.dump({ ok: true, view: response }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + subject { described_class.new(slack_installation, user, params) } + + context 'when triggered' do + it 'opens the modal' do + expect_next_instance_of(Slack::BlockKit::IncidentManagement::IncidentModalOpened) do |ui| + expect(ui).to receive(:build).and_return(mock_modal) + end + + expect(Rails.cache).to receive(:write).with( + 'slack:incident_modal_opened:123', project.id.to_s, { expires_in: 5.minutes }) + + response = subject.execute + + expect(WebMock).to have_requested(:post, api_url).with( + body: { + trigger_id: trigger_id, + view: mock_modal + }, + headers: { + 'Authorization' => "Bearer #{slack_installation.bot_access_token}", + 'Content-Type' => 'application/json; charset=utf-8' + }) + + expect(response.message).to eq('Please complete the incident creation form.') + end + end + + context 'when there are no projects with slack integration' do + let(:params) do + { + team_id: 'some_random_id', + response_url: response_url, + trigger_id: trigger_id + } + end + + let(:user) { create(:user) } + + it 'does not open the modal' do + response = subject.execute + + expect(Rails.cache).not_to receive(:write) + expect(response.message).to be('You do not have access to any projects for creating incidents.') + end + end + + context 'when Slack API call raises an HTTP exception' do + before do + allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message') + end + + it 'tracks the exception and returns an error response' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'), + { + slack_workspace_id: slack_workspace_id + } + ) + + expect(Rails.cache).not_to receive(:write) + expect(subject.execute).to be_error + end + end + + context 'when api returns an error' do + before do + stub_request(:post, api_url) + .to_return( + status: 404, + body: Gitlab::Json.dump({ ok: false }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns error when called' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + StandardError.new('Something went wrong while opening the incident form.'), + { + response: { "ok" => false }, + slack_workspace_id: slack_workspace_id, + slack_user_id: slack_installation.user_id + } + ) + + expect(Rails.cache).not_to receive(:write) + response = subject.execute + + expect(response.message).to eq('Something went wrong while opening the incident form.') + end + end + end +end diff --git a/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb b/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb new file mode 100644 index 00000000000..adaeadaa997 --- /dev/null +++ b/spec/services/integrations/slack_interactions/incident_management/incident_modal_submit_service_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractions::IncidentManagement::IncidentModalSubmitService, + feature_category: :incident_management do + include Gitlab::Routing + + describe '#execute' do + let_it_be(:slack_installation) { create(:slack_integration) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:api_url) { 'https://api.slack.com/id/1234' } + + let_it_be(:chat_name) do + create(:chat_name, + user: user, + team_id: slack_installation.team_id, + chat_id: slack_installation.user_id + ) + end + + # Setting below params as they are optional, have added values wherever required in specs + let(:zoom_link) { '' } + let(:severity) { {} } + let(:status) { '' } + let(:assignee_id) { nil } + let(:selected_label_ids) { [] } + let(:label_ids) { { selected_options: selected_label_ids } } + let(:confidential_selected_options) { [] } + let(:confidential) { { selected_options: confidential_selected_options } } + let(:title) { 'Incident title' } + + let(:zoom) do + { + link: { + value: zoom_link + } + } + end + + let(:params) do + { + team: { + id: slack_installation.team_id + }, + user: { + id: slack_installation.user_id + }, + view: { + private_metadata: api_url, + state: { + values: { + title_input: { + title: { + value: title + } + }, + incident_description: { + description: { + value: 'Incident description' + } + }, + project_and_severity_selector: { + incident_management_project: { + selected_option: { + value: project.id.to_s + } + }, + severity: severity + }, + confidentiality: { + confidential: confidential + }, + zoom: zoom, + status_and_assignee_selector: { + status: { + selected_option: { + value: status + } + }, + assignee: { + selected_option: { + value: assignee_id + } + } + }, + label_selector: { + labels: label_ids + } + } + } + } + } + end + + subject(:execute_service) { described_class.new(params).execute } + + shared_examples 'error in creation' do |error_message| + it 'returns error and raises exception' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + described_class::IssueCreateError.new(error_message), + { + slack_workspace_id: slack_installation.team_id, + slack_user_id: slack_installation.user_id + } + ) + + expect(Gitlab::HTTP).to receive(:post) + .with( + api_url, + body: Gitlab::Json.dump( + { + replace_original: 'true', + text: 'There was a problem creating the incident. Please try again.' + } + ), + headers: { 'Content-Type' => 'application/json' } + ) + + response = execute_service + + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + + context 'when user has permissions to create incidents' do + let(:api_response) { '{"ok":true}' } + + before do + project.add_developer(user) + stub_request(:post, api_url) + .to_return(body: api_response, headers: { 'Content-Type' => 'application/json' }) + end + + context 'with markup string in title' do + let(:title) { '<a href="url">incident title</a>' } + let(:incident) { create(:incident, title: title, project: project) } + + before do + allow_next_instance_of(Issues::CreateService) do |service| + allow(service).to receive(:execute).and_return( + ServiceResponse.success(payload: { issue: incident, error: [] }) + ) + end + end + + it 'strips the markup and saves sends the title' do + expect(Gitlab::HTTP).to receive(:post) + .with( + api_url, + body: Gitlab::Json.dump( + { + replace_original: 'true', + text: "New incident has been created: " \ + "<#{issue_url(incident)}|#{incident.to_reference} - a href=\"url\"incident title/a>. " + } + ), + headers: { 'Content-Type' => 'application/json' } + ).and_return(api_response) + + execute_service + end + end + + context 'with non-optional params' do + it 'creates incident' do + response = execute_service + incident = response[:incident] + + expect(response).to be_success + expect(incident).not_to be_nil + expect(incident.description).to eq('Incident description') + expect(incident.author).to eq(user) + expect(incident.severity).to eq('unknown') + expect(incident.confidential).to be_falsey + expect(incident.escalation_status).to be_triggered + end + + it 'sends incident link to slack' do + execute_service + + expect(WebMock).to have_requested(:post, api_url) + end + end + + context 'with zoom_link' do + let(:zoom_link) { 'https://gitlab.zoom.us/j/1234' } + + it 'sets zoom link as quick action' do + incident = execute_service[:incident] + zoom_meeting = ZoomMeeting.find_by_issue_id(incident.id) + + expect(incident.description).to eq("Incident description") + expect(zoom_meeting.url).to eq(zoom_link) + end + end + + context 'with confidential and severity' do + let(:confidential_selected_options) { ['confidential'] } + let(:severity) do + { + selected_option: { + value: 'high' + } + } + end + + it 'sets confidential and severity' do + incident = execute_service[:incident] + + expect(incident.confidential).to be_truthy + expect(incident.severity).to eq('high') + end + end + + context 'with incident status' do + let(:status) { 'resolved' } + + it 'sets the incident status' do + incident = execute_service[:incident] + + expect(incident.escalation_status).to be_resolved + end + end + + context 'with assignee id' do + let(:assignee_id) { user.id.to_s } + + it 'assigns the incident to user' do + incident = execute_service[:incident] + + expect(incident.assignees).to contain_exactly(user) + end + + context 'when user is not a member of the project' do + let(:assignee_id) { create(:user).id.to_s } + + it 'does not assign the user' do + incident = execute_service[:incident] + + expect(incident.assignees).to be_empty + end + end + end + + context 'with label ids' do + let_it_be(:project_label1) { create(:label, project: project, title: 'Label 1') } + let_it_be(:project_label2) { create(:label, project: project, title: 'Label 2') } + + let(:selected_label_ids) do + [ + { value: project_label1.id.to_s }, + { value: project_label2.id.to_s } + ] + end + + it 'assigns the label to the incident' do + incident = execute_service[:incident] + + expect(incident.labels).to contain_exactly(project_label1, project_label2) + end + end + + context 'when response is not ok' do + let(:api_response) { '{"ok":false}' } + + it 'returns error response and tracks the exception' do + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + StandardError.new('Something went wrong when sending the incident link to Slack.'), + { + response: { 'ok' => false }, + slack_workspace_id: slack_installation.team_id, + slack_user_id: slack_installation.user_id + } + ) + + execute_service + end + end + + context 'when incident creation fails' do + let(:title) { '' } + + it_behaves_like 'error in creation', "Title can't be blank" + end + end + + context 'when user does not have permission to create incidents' do + it_behaves_like 'error in creation', 'Operation not allowed' + end + end +end diff --git a/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb b/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb new file mode 100644 index 00000000000..5edffc99977 --- /dev/null +++ b/spec/services/integrations/slack_interactions/slack_block_actions/incident_management/project_update_handler_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackInteractions::SlackBlockActions::IncidentManagement::ProjectUpdateHandler, + feature_category: :incident_management do + describe '#execute' do + let_it_be(:slack_installation) { create(:slack_integration) } + let_it_be(:old_project) { create(:project) } + let_it_be(:new_project) { create(:project) } + let_it_be(:user) { create(:user, developer_projects: [old_project, new_project]) } + let_it_be(:chat_name) { create(:chat_name, user: user) } + let_it_be(:api_url) { "#{Slack::API::BASE_URL}/views.update" } + + let(:block) do + { + block_id: 'incident_description', + element: { + initial_value: '' + } + } + end + + let(:view) do + { + id: 'V04EQH1SP27', + team_id: slack_installation.team_id, + blocks: [block] + } + end + + let(:action) do + { + selected_option: { + value: new_project.id.to_s + } + } + end + + let(:params) do + { + view: view, + user: { + id: slack_installation.user_id + } + } + end + + before do + allow_next_instance_of(ChatNames::FindUserService) do |user_service| + allow(user_service).to receive(:execute).and_return(chat_name) + end + + stub_request(:post, api_url) + .to_return( + status: 200, + body: Gitlab::Json.dump({ ok: true }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + shared_examples 'does not make api call' do + it 'does not make the api call and returns nil' do + expect(Rails.cache).to receive(:read).and_return(project.id.to_s) + expect(Rails.cache).not_to receive(:write) + + expect(execute).to be_nil + expect(WebMock).not_to have_requested(:post, api_url) + end + end + + subject(:execute) { described_class.new(params, action).execute } + + context 'when project is updated' do + it 'returns success response and updates cache' do + expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s) + expect(Rails.cache).to receive(:write).with( + "slack:incident_modal_opened:#{view[:id]}", + new_project.id.to_s, + expires_in: 5.minutes + ) + + expect(execute.message).to eq('Modal updated') + + updated_block = block.dup + updated_block[:block_id] = new_project.id.to_s + view[:blocks] = [updated_block] + + expect(WebMock).to have_requested(:post, api_url).with( + body: { + view_id: view[:id], + view: view.except!(:team_id, :id) + }, + headers: { + 'Authorization' => "Bearer #{slack_installation.bot_access_token}", + 'Content-Type' => 'application/json; charset=utf-8' + }) + end + end + + context 'when project is unchanged' do + it_behaves_like 'does not make api call' do + let(:project) { new_project } + end + end + + context 'when user does not have permission to read a project' do + it_behaves_like 'does not make api call' do + let(:project) { create(:project) } + end + end + + context 'when api response is not ok' do + before do + stub_request(:post, api_url) + .to_return( + status: 404, + body: Gitlab::Json.dump({ ok: false }), + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns error response' do + expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s) + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + StandardError.new('Something went wrong while updating the modal.'), + { + response: { "ok" => false }, + slack_workspace_id: slack_installation.team_id, + slack_user_id: slack_installation.user_id + } + ) + + expect(execute.message).to eq('Something went wrong while updating the modal.') + end + end + + context 'when Slack API call raises an HTTP exception' do + before do + allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED, 'error message') + end + + it 'tracks the exception and returns an error message' do + expect(Rails.cache).to receive(:read).and_return(old_project.id.to_s) + expect(::Gitlab::ErrorTracking).to receive(:track_exception) + .with( + Errno::ECONNREFUSED.new('HTTP exception when calling Slack API'), + { + slack_workspace_id: slack_installation.team_id + } + ) + + expect(execute).to be_error + end + end + end +end diff --git a/spec/services/integrations/slack_option_service_spec.rb b/spec/services/integrations/slack_option_service_spec.rb new file mode 100644 index 00000000000..2e114b932d2 --- /dev/null +++ b/spec/services/integrations/slack_option_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackOptionService, feature_category: :integrations do + describe '#execute' do + subject(:execute) { described_class.new(params).execute } + + let_it_be(:slack_installation) { create(:slack_integration) } + let_it_be(:user) { create(:user) } + + let_it_be(:chat_name) do + create(:chat_name, + user: user, + team_id: slack_installation.team_id, + chat_id: slack_installation.user_id + ) + end + + let(:params) do + { + action_id: action_id, + view: { + id: 'VHDFR54DSA' + }, + value: 'Search value', + team: { + id: slack_installation.team_id + }, + user: { + id: slack_installation.user_id + } + } + end + + context 'when action_id is assignee' do + let(:action_id) { 'assignee' } + + it 'executes the user search handler' do + user_search_handler = described_class::OPTIONS['assignee'] + + expect_next_instance_of(user_search_handler, chat_name, 'Search value', 'VHDFR54DSA') do |service| + expect(service).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when action_id is labels' do + let(:action_id) { 'labels' } + + it 'executes the label search handler' do + label_search_handler = described_class::OPTIONS['labels'] + + expect_next_instance_of(label_search_handler, chat_name, 'Search value', 'VHDFR54DSA') do |service| + expect(service).to receive(:execute).and_return(ServiceResponse.success) + end + + execute + end + end + + context 'when action_id is unknown' do + let(:action_id) { 'foo' } + + it 'raises an error and does not execute a service class' do + described_class::OPTIONS.each_value do |service_class| + expect(service_class).not_to receive(:new) + end + + expect { execute }.to raise_error(described_class::UnknownOptionError) + end + end + end +end diff --git a/spec/services/integrations/slack_options/label_search_handler_spec.rb b/spec/services/integrations/slack_options/label_search_handler_spec.rb new file mode 100644 index 00000000000..3b006061f1d --- /dev/null +++ b/spec/services/integrations/slack_options/label_search_handler_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackOptions::LabelSearchHandler, feature_category: :integrations do + describe '#execute' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :private, namespace: group) } + let_it_be(:current_user) { create(:user) } + let_it_be(:chat_name) { create(:chat_name, user: current_user) } + let_it_be(:project_label1) { create(:label, project: project, title: 'Label 1') } + let_it_be(:project_label2) { create(:label, project: project, title: 'Label 2') } + let_it_be(:group_label1) { create(:group_label, group: group, title: 'LabelG 1') } + let_it_be(:group_label2) { create(:group_label, group: group, title: 'glb 2') } + let_it_be(:view_id) { 'VXHD54DR' } + + let(:search_value) { 'Lab' } + + subject(:execute) { described_class.new(chat_name, search_value, view_id).execute } + + context 'when user has permission to read project and group labels' do + before do + allow(Rails.cache).to receive(:read).and_return(project.id) + project.add_developer(current_user) + end + + it 'returns the labels matching the search term' do + labels = execute.payload[:options] + label_names = labels.map { |label| label.dig(:text, :text) } + + expect(label_names).to contain_exactly( + project_label1.name, + project_label2.name, + group_label1.name + ) + end + end + + context 'when user does not have permissions to read project/group labels' do + it 'returns empty array' do + expect(LabelsFinder).not_to receive(:execute) + + expect(execute.payload).to be_empty + end + end + end +end diff --git a/spec/services/integrations/slack_options/user_search_handler_spec.rb b/spec/services/integrations/slack_options/user_search_handler_spec.rb new file mode 100644 index 00000000000..e827bf643d2 --- /dev/null +++ b/spec/services/integrations/slack_options/user_search_handler_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackOptions::UserSearchHandler, feature_category: :integrations do + describe '#execute' do + let_it_be(:project) { create(:project, :private) } + let_it_be(:current_user) { create(:user) } + let_it_be(:chat_name) { create(:chat_name, user: current_user) } + let_it_be(:user1) { create(:user, name: 'Rajendra Kadam') } + let_it_be(:user2) { create(:user, name: 'Rajesh K') } + let_it_be(:user3) { create(:user) } + let_it_be(:view_id) { 'VXHD54DR' } + + let(:search_value) { 'Raj' } + + subject(:execute) { described_class.new(chat_name, search_value, view_id).execute } + + context 'when user has permissions to read project members' do + before do + project.add_developer(current_user) + project.add_guest(user1) + project.add_reporter(user2) + project.add_maintainer(user3) + end + + it 'returns the user matching the search term' do + expect(Rails.cache).to receive(:read).and_return(project.id) + + members = execute.payload[:options] + user_names = members.map { |member| member.dig(:text, :text) } + + expect(members.count).to eq(2) + expect(user_names).to contain_exactly( + "#{user1.name} - #{user1.username}", + "#{user2.name} - #{user2.username}" + ) + end + end + + context 'when user does not have permissions to read project members' do + it 'returns empty array' do + expect(Rails.cache).to receive(:read).and_return(project.id) + expect(MembersFinder).not_to receive(:execute) + + members = execute.payload + + expect(members).to be_empty + end + end + end +end diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb index 74833686283..4f8f932fb45 100644 --- a/spec/services/integrations/test/project_service_spec.rb +++ b/spec/services/integrations/test/project_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::Test::ProjectService do +RSpec.describe Integrations::Test::ProjectService, feature_category: :integrations do include AfterNextHelpers describe '#execute' do |