diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /spec/models/integrations | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'spec/models/integrations')
37 files changed, 4196 insertions, 49 deletions
diff --git a/spec/models/integrations/assembla_spec.rb b/spec/models/integrations/assembla_spec.rb index bf9033416e9..e5972bce95d 100644 --- a/spec/models/integrations/assembla_spec.rb +++ b/spec/models/integrations/assembla_spec.rb @@ -15,8 +15,8 @@ RSpec.describe Integrations::Assembla do let(:project) { create(:project, :repository) } before do - @assembla_service = described_class.new - allow(@assembla_service).to receive_messages( + @assembla_integration = described_class.new + allow(@assembla_integration).to receive_messages( project_id: project.id, project: project, service_hook: true, @@ -29,7 +29,7 @@ RSpec.describe Integrations::Assembla do end it "calls Assembla API" do - @assembla_service.execute(@sample_data) + @assembla_integration.execute(@sample_data) expect(WebMock).to have_requested(:post, stubbed_hostname(@api_url)).with( body: /#{@sample_data[:before]}.*#{@sample_data[:after]}.*#{project.path}/ ).once diff --git a/spec/models/integrations/bamboo_spec.rb b/spec/models/integrations/bamboo_spec.rb index 0ba1595bbd8..39966f7978d 100644 --- a/spec/models/integrations/bamboo_spec.rb +++ b/spec/models/integrations/bamboo_spec.rb @@ -82,45 +82,45 @@ RSpec.describe Integrations::Bamboo, :use_clean_rails_memory_store_caching do describe 'before_update :reset_password' do context 'when a password was previously set' do it 'resets password if url changed' do - bamboo_service = service + bamboo_integration = service - bamboo_service.bamboo_url = 'http://gitlab1.com' - bamboo_service.save! + bamboo_integration.bamboo_url = 'http://gitlab1.com' + bamboo_integration.save! - expect(bamboo_service.password).to be_nil + expect(bamboo_integration.password).to be_nil end it 'does not reset password if username changed' do - bamboo_service = service + bamboo_integration = service - bamboo_service.username = 'some_name' - bamboo_service.save! + bamboo_integration.username = 'some_name' + bamboo_integration.save! - expect(bamboo_service.password).to eq('password') + expect(bamboo_integration.password).to eq('password') end it "does not reset password if new url is set together with password, even if it's the same password" do - bamboo_service = service + bamboo_integration = service - bamboo_service.bamboo_url = 'http://gitlab_edited.com' - bamboo_service.password = 'password' - bamboo_service.save! + bamboo_integration.bamboo_url = 'http://gitlab_edited.com' + bamboo_integration.password = 'password' + bamboo_integration.save! - expect(bamboo_service.password).to eq('password') - expect(bamboo_service.bamboo_url).to eq('http://gitlab_edited.com') + expect(bamboo_integration.password).to eq('password') + expect(bamboo_integration.bamboo_url).to eq('http://gitlab_edited.com') end end it 'saves password if new url is set together with password when no password was previously set' do - bamboo_service = service - bamboo_service.password = nil + bamboo_integration = service + bamboo_integration.password = nil - bamboo_service.bamboo_url = 'http://gitlab_edited.com' - bamboo_service.password = 'password' - bamboo_service.save! + bamboo_integration.bamboo_url = 'http://gitlab_edited.com' + bamboo_integration.password = 'password' + bamboo_integration.save! - expect(bamboo_service.password).to eq('password') - expect(bamboo_service.bamboo_url).to eq('http://gitlab_edited.com') + expect(bamboo_integration.password).to eq('password') + expect(bamboo_integration.bamboo_url).to eq('http://gitlab_edited.com') end end end diff --git a/spec/models/integrations/base_chat_notification_spec.rb b/spec/models/integrations/base_chat_notification_spec.rb new file mode 100644 index 00000000000..656eaa3bbdd --- /dev/null +++ b/spec/models/integrations/base_chat_notification_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::BaseChatNotification do + describe 'Associations' do + before do + allow(subject).to receive(:activated?).and_return(true) + end + + it { is_expected.to validate_presence_of :webhook } + end + + describe 'validations' do + it { is_expected.to validate_inclusion_of(:labels_to_be_notified_behavior).in_array(%w[match_any match_all]).allow_blank } + end + + describe '#can_test?' do + context 'with empty repository' do + it 'returns true' do + subject.project = create(:project, :empty_repo) + + expect(subject.can_test?).to be true + end + end + + context 'with repository' do + it 'returns true' do + subject.project = create(:project, :repository) + + expect(subject.can_test?).to be true + end + end + end + + describe '#execute' do + subject(:chat_service) { described_class.new } + + let_it_be(:project) { create(:project, :repository) } + + let(:user) { create(:user) } + let(:webhook_url) { 'https://example.gitlab.com/' } + let(:data) { Gitlab::DataBuilder::Push.build_sample(subject.project, user) } + + before do + allow(chat_service).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + + subject.active = true + end + + context 'with a repository' do + it 'returns true' do + expect(chat_service).to receive(:notify).and_return(true) + expect(chat_service.execute(data)).to be true + end + end + + context 'with an empty repository' do + it 'returns true' do + subject.project = create(:project, :empty_repo) + + expect(chat_service).to receive(:notify).and_return(true) + expect(chat_service.execute(data)).to be true + end + end + + context 'with a project with name containing spaces' do + it 'does not remove spaces' do + allow(project).to receive(:full_name).and_return('Project Name') + + expect(chat_service).to receive(:get_message).with(any_args, hash_including(project_name: 'Project Name')) + chat_service.execute(data) + end + end + + context 'when the data object has a label' do + let_it_be(:label) { create(:label, name: 'Bug') } + let_it_be(:label_2) { create(:label, name: 'Community contribution') } + let_it_be(:label_3) { create(:label, name: 'Backend') } + let_it_be(:issue) { create(:labeled_issue, project: project, labels: [label, label_2, label_3]) } + let_it_be(:note) { create(:note, noteable: issue, project: project) } + + let(:data) { Gitlab::DataBuilder::Note.build(note, user) } + + shared_examples 'notifies the chat service' do + specify do + expect(chat_service).to receive(:notify).with(any_args) + + chat_service.execute(data) + end + end + + shared_examples 'does not notify the chat service' do + specify do + expect(chat_service).not_to receive(:notify).with(any_args) + + chat_service.execute(data) + end + end + + it_behaves_like 'notifies the chat service' + + context 'with label filter' do + subject(:chat_service) { described_class.new(labels_to_be_notified: '~Bug') } + + it_behaves_like 'notifies the chat service' + + context 'MergeRequest events' do + let(:data) { create(:merge_request, labels: [label]).to_hook_data(user) } + + it_behaves_like 'notifies the chat service' + end + + context 'Issue events' do + let(:data) { issue.to_hook_data(user) } + + it_behaves_like 'notifies the chat service' + end + end + + context 'when labels_to_be_notified_behavior is not defined' do + subject(:chat_service) { described_class.new(labels_to_be_notified: label_filter) } + + context 'no matching labels' do + let(:label_filter) { '~some random label' } + + it_behaves_like 'does not notify the chat service' + end + + context 'only one label matches' do + let(:label_filter) { '~some random label, ~Bug' } + + it_behaves_like 'notifies the chat service' + end + end + + context 'when labels_to_be_notified_behavior is blank' do + subject(:chat_service) { described_class.new(labels_to_be_notified: label_filter, labels_to_be_notified_behavior: '') } + + context 'no matching labels' do + let(:label_filter) { '~some random label' } + + it_behaves_like 'does not notify the chat service' + end + + context 'only one label matches' do + let(:label_filter) { '~some random label, ~Bug' } + + it_behaves_like 'notifies the chat service' + end + end + + context 'when labels_to_be_notified_behavior is match_any' do + subject(:chat_service) do + described_class.new( + labels_to_be_notified: label_filter, + labels_to_be_notified_behavior: 'match_any' + ) + end + + context 'no label filter' do + let(:label_filter) { nil } + + it_behaves_like 'notifies the chat service' + end + + context 'no matching labels' do + let(:label_filter) { '~some random label' } + + it_behaves_like 'does not notify the chat service' + end + + context 'only one label matches' do + let(:label_filter) { '~some random label, ~Bug' } + + it_behaves_like 'notifies the chat service' + end + end + + context 'when labels_to_be_notified_behavior is match_all' do + subject(:chat_service) do + described_class.new( + labels_to_be_notified: label_filter, + labels_to_be_notified_behavior: 'match_all' + ) + end + + context 'no label filter' do + let(:label_filter) { nil } + + it_behaves_like 'notifies the chat service' + end + + context 'no matching labels' do + let(:label_filter) { '~some random label' } + + it_behaves_like 'does not notify the chat service' + end + + context 'only one label matches' do + let(:label_filter) { '~some random label, ~Bug' } + + it_behaves_like 'does not notify the chat service' + end + + context 'labels matches exactly' do + let(:label_filter) { '~Bug, ~Backend, ~Community contribution' } + + it_behaves_like 'notifies the chat service' + end + + context 'labels matches but object has more' do + let(:label_filter) { '~Bug, ~Backend' } + + it_behaves_like 'notifies the chat service' + end + + context 'labels are distributed on multiple objects' do + let(:label_filter) { '~Bug, ~Backend' } + let(:data) do + Gitlab::DataBuilder::Note.build(note, user).merge({ + issue: { + labels: [ + { title: 'Bug' } + ] + }, + merge_request: { + labels: [ + { + title: 'Backend' + } + ] + } + }) + end + + it_behaves_like 'does not notify the chat service' + end + end + end + + context 'with "channel" property' do + before do + allow(chat_service).to receive(:channel).and_return(channel) + end + + context 'empty string' do + let(:channel) { '' } + + it 'does not include the channel' do + expect(chat_service).to receive(:notify).with(any_args, hash_excluding(:channel)).and_return(true) + expect(chat_service.execute(data)).to be(true) + end + end + + context 'empty spaces' do + let(:channel) { ' ' } + + it 'does not include the channel' do + expect(chat_service).to receive(:notify).with(any_args, hash_excluding(:channel)).and_return(true) + expect(chat_service.execute(data)).to be(true) + end + end + end + + shared_examples 'with channel specified' do |channel, expected_channels| + before do + allow(chat_service).to receive(:push_channel).and_return(channel) + end + + it 'notifies all channels' do + expect(chat_service).to receive(:notify).with(any_args, hash_including(channel: expected_channels)).and_return(true) + expect(chat_service.execute(data)).to be(true) + end + end + + context 'with single channel specified' do + it_behaves_like 'with channel specified', 'slack-integration', ['slack-integration'] + end + + context 'with multiple channel names specified' do + it_behaves_like 'with channel specified', 'slack-integration,#slack-test', ['slack-integration', '#slack-test'] + end + + context 'with multiple channel names with spaces specified' do + it_behaves_like 'with channel specified', 'slack-integration, #slack-test, @UDLP91W0A', ['slack-integration', '#slack-test', '@UDLP91W0A'] + end + end +end diff --git a/spec/models/integrations/base_issue_tracker_spec.rb b/spec/models/integrations/base_issue_tracker_spec.rb new file mode 100644 index 00000000000..0f1bc39929a --- /dev/null +++ b/spec/models/integrations/base_issue_tracker_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::BaseIssueTracker do + describe 'Validations' do + let(:project) { create :project } + + describe 'only one issue tracker per project' do + let(:service) { Integrations::Redmine.new(project: project, active: true, issue_tracker_data: build(:issue_tracker_data)) } + + before do + create(:custom_issue_tracker_integration, project: project) + end + + context 'when service is changed manually by user' do + it 'executes the validation' do + valid = service.valid?(:manual_change) + + expect(valid).to be_falsey + expect(service.errors[:base]).to include( + 'Another issue tracker is already in use. Only one issue tracker service can be active at a time' + ) + end + end + + context 'when service is changed internally' do + it 'does not execute the validation' do + expect(service.valid?).to be_truthy + end + end + end + end +end diff --git a/spec/models/integrations/bugzilla_spec.rb b/spec/models/integrations/bugzilla_spec.rb new file mode 100644 index 00000000000..e75fa8dd4d4 --- /dev/null +++ b/spec/models/integrations/bugzilla_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Bugzilla do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:issues_url) } + it { is_expected.to validate_presence_of(:new_issue_url) } + it_behaves_like 'issue tracker service URL attribute', :project_url + it_behaves_like 'issue tracker service URL attribute', :issues_url + it_behaves_like 'issue tracker service URL attribute', :new_issue_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:issues_url) } + it { is_expected.not_to validate_presence_of(:new_issue_url) } + end + end +end diff --git a/spec/models/integrations/buildkite_spec.rb b/spec/models/integrations/buildkite_spec.rb new file mode 100644 index 00000000000..7dc81da7003 --- /dev/null +++ b/spec/models/integrations/buildkite_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Buildkite, :use_clean_rails_memory_store_caching do + include ReactiveCachingHelpers + include StubRequests + + let(:project) { create(:project) } + + subject(:service) do + described_class.create!( + project: project, + properties: { + service_hook: true, + project_url: 'https://buildkite.com/organization-name/example-pipeline', + token: 'secret-sauce-webhook-token:secret-sauce-status-token' + } + ) + end + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:token) } + it_behaves_like 'issue tracker service URL attribute', :project_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:token) } + end + end + + describe '.supported_events' do + it 'supports push, merge_request, and tag_push events' do + expect(service.supported_events).to eq %w(push merge_request tag_push) + end + end + + describe 'commits methods' do + before do + allow(project).to receive(:default_branch).and_return('default-brancho') + end + + it 'always activates SSL verification after saved' do + service.create_service_hook(enable_ssl_verification: false) + + service.enable_ssl_verification = false + service.active = true + + expect { service.save! } + .to change { service.service_hook.enable_ssl_verification }.from(false).to(true) + end + + describe '#webhook_url' do + it 'returns the webhook url' do + expect(service.webhook_url).to eq( + 'https://webhook.buildkite.com/deliver/secret-sauce-webhook-token' + ) + end + end + + describe '#commit_status_path' do + it 'returns the correct status page' do + expect(service.commit_status_path('2ab7834c')).to eq( + 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=2ab7834c' + ) + end + end + + describe '#build_page' do + it 'returns the correct build page' do + expect(service.build_page('2ab7834c', nil)).to eq( + 'https://buildkite.com/organization-name/example-pipeline/builds?commit=2ab7834c' + ) + end + end + + describe '#commit_status' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { commit_status: 'foo' }, 'sha', 'ref') + + expect(service.commit_status('sha', 'ref')).to eq('foo') + end + end + + describe '#calculate_reactive_cache' do + describe '#commit_status' do + let(:buildkite_full_url) do + 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=123' + end + + subject { service.calculate_reactive_cache('123', 'unused')[:commit_status] } + + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) + + is_expected.to eq(:error) + end + + it 'sets commit status to :error when status is 404' do + stub_request(status: 404) + + is_expected.to eq(:error) + end + + it 'passes through build status untouched when status is 200' do + stub_request(body: %q({"status":"Great Success"})) + + is_expected.to eq('Great Success') + end + + Gitlab::HTTP::HTTP_ERRORS.each do |http_error| + it "sets commit status to :error with a #{http_error.name} error" do + WebMock.stub_request(:get, buildkite_full_url) + .to_raise(http_error) + + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with(instance_of(http_error), project_id: project.id) + + is_expected.to eq(:error) + end + end + end + end + end + + def stub_request(status: 200, body: nil) + body ||= %q({"status":"success"}) + + stub_full_request(buildkite_full_url) + .to_return(status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body) + end +end diff --git a/spec/models/integrations/campfire_spec.rb b/spec/models/integrations/campfire_spec.rb index b23edf03e8a..d68f8e0bd4e 100644 --- a/spec/models/integrations/campfire_spec.rb +++ b/spec/models/integrations/campfire_spec.rb @@ -33,8 +33,8 @@ RSpec.describe Integrations::Campfire do let(:project) { create(:project, :repository) } before do - @campfire_service = described_class.new - allow(@campfire_service).to receive_messages( + @campfire_integration = described_class.new + allow(@campfire_integration).to receive_messages( project_id: project.id, project: project, service_hook: true, @@ -62,7 +62,7 @@ RSpec.describe Integrations::Campfire do speak_url = 'https://project-name.campfirenow.com/room/123/speak.json' stub_full_request(speak_url, method: :post).with(basic_auth: @auth) - @campfire_service.execute(@sample_data) + @campfire_integration.execute(@sample_data) expect(WebMock).to have_requested(:get, stubbed_hostname(@rooms_url)).once expect(WebMock).to have_requested(:post, stubbed_hostname(speak_url)) @@ -78,7 +78,7 @@ RSpec.describe Integrations::Campfire do headers: @headers ) - @campfire_service.execute(@sample_data) + @campfire_integration.execute(@sample_data) expect(WebMock).to have_requested(:get, 'https://8.8.8.9/rooms.json').once expect(WebMock).not_to have_requested(:post, '*/room/.*/speak.json') diff --git a/spec/models/integrations/chat_message/wiki_page_message_spec.rb b/spec/models/integrations/chat_message/wiki_page_message_spec.rb index e8672a0f9c8..ded467dc910 100644 --- a/spec/models/integrations/chat_message/wiki_page_message_spec.rb +++ b/spec/models/integrations/chat_message/wiki_page_message_spec.rb @@ -5,20 +5,30 @@ require 'spec_helper' RSpec.describe Integrations::ChatMessage::WikiPageMessage do subject { described_class.new(args) } + let(:name) { 'Test User' } + let(:username) { 'test.user' } + let(:avatar_url) { 'http://someavatar.com' } + let(:project_name) { 'project_name' } + let(:project_url) {'http://somewhere.com' } + let(:url) { 'http://url.com' } + let(:diff_url) { 'http://url.com/diff?version_id=1234' } + let(:wiki_page_title) { 'Wiki page title' } + let(:commit_message) { 'Wiki page commit message' } let(:args) do { user: { - name: 'Test User', - username: 'test.user', - avatar_url: 'http://someavatar.com' + name: name, + username: username, + avatar_url: avatar_url }, - project_name: 'project_name', - project_url: 'http://somewhere.com', + project_name: project_name, + project_url: project_url, object_attributes: { - title: 'Wiki page title', - url: 'http://url.com', + title: wiki_page_title, + url: url, content: 'Wiki page content', - message: 'Wiki page commit message' + message: commit_message, + diff_url: diff_url } } end @@ -32,8 +42,8 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns a message that a new wiki page was created' do expect(subject.pretext).to eq( - 'Test User (test.user) created <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ - '*Wiki page title*') + "#{name} (#{username}) created <#{url}|wiki page> (<#{diff_url}|Compare changes>) in <#{project_url}|#{project_name}>: "\ + "*#{wiki_page_title}*") end end @@ -44,8 +54,8 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns a message that a wiki page was updated' do expect(subject.pretext).to eq( - 'Test User (test.user) edited <http://url.com|wiki page> in <http://somewhere.com|project_name>: '\ - '*Wiki page title*') + "#{name} (#{username}) edited <#{url}|wiki page> (<#{diff_url}|Compare changes>) in <#{project_url}|#{project_name}>: "\ + "*#{wiki_page_title}*") end end end @@ -61,7 +71,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns the commit message for a new wiki page' do expect(subject.attachments).to eq([ { - text: "Wiki page commit message", + text: commit_message, color: color } ]) @@ -76,7 +86,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns the commit message for an updated wiki page' do expect(subject.attachments).to eq([ { - text: "Wiki page commit message", + text: commit_message, color: color } ]) @@ -98,7 +108,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns a message that a new wiki page was created' do expect(subject.pretext).to eq( - 'Test User (test.user) created [wiki page](http://url.com) in [project_name](http://somewhere.com): *Wiki page title*') + "#{name} (#{username}) created [wiki page](#{url}) ([Compare changes](#{diff_url})) in [#{project_name}](#{project_url}): *#{wiki_page_title}*") end end @@ -109,7 +119,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do it 'returns a message that a wiki page was updated' do expect(subject.pretext).to eq( - 'Test User (test.user) edited [wiki page](http://url.com) in [project_name](http://somewhere.com): *Wiki page title*') + "#{name} (#{username}) edited [wiki page](#{url}) ([Compare changes](#{diff_url})) in [#{project_name}](#{project_url}): *#{wiki_page_title}*") end end end @@ -121,7 +131,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do end it 'returns the commit message for a new wiki page' do - expect(subject.attachments).to eq('Wiki page commit message') + expect(subject.attachments).to eq(commit_message) end end @@ -131,7 +141,7 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do end it 'returns the commit message for an updated wiki page' do - expect(subject.attachments).to eq('Wiki page commit message') + expect(subject.attachments).to eq(commit_message) end end end diff --git a/spec/models/integrations/confluence_spec.rb b/spec/models/integrations/confluence_spec.rb index c217573f48d..08e18c99376 100644 --- a/spec/models/integrations/confluence_spec.rb +++ b/spec/models/integrations/confluence_spec.rb @@ -72,19 +72,19 @@ RSpec.describe Integrations::Confluence do subject { project.project_setting.has_confluence? } it 'sets the property to true when service is active' do - create(:confluence_service, project: project, active: true) + create(:confluence_integration, project: project, active: true) is_expected.to be(true) end it 'sets the property to false when service is not active' do - create(:confluence_service, project: project, active: false) + create(:confluence_integration, project: project, active: false) is_expected.to be(false) end it 'creates a project_setting record if one was not already created' do - expect { create(:confluence_service) }.to change { ProjectSetting.count }.by(1) + expect { create(:confluence_integration) }.to change(ProjectSetting, :count).by(1) end end end diff --git a/spec/models/integrations/custom_issue_tracker_spec.rb b/spec/models/integrations/custom_issue_tracker_spec.rb new file mode 100644 index 00000000000..25f2648e738 --- /dev/null +++ b/spec/models/integrations/custom_issue_tracker_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::CustomIssueTracker do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:issues_url) } + it { is_expected.to validate_presence_of(:new_issue_url) } + it_behaves_like 'issue tracker service URL attribute', :project_url + it_behaves_like 'issue tracker service URL attribute', :issues_url + it_behaves_like 'issue tracker service URL attribute', :new_issue_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:issues_url) } + it { is_expected.not_to validate_presence_of(:new_issue_url) } + end + end +end diff --git a/spec/models/integrations/discord_spec.rb b/spec/models/integrations/discord_spec.rb new file mode 100644 index 00000000000..bff6a8ee5b2 --- /dev/null +++ b/spec/models/integrations/discord_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Integrations::Discord do + it_behaves_like "chat integration", "Discord notifications" do + let(:client) { Discordrb::Webhooks::Client } + let(:client_arguments) { { url: webhook_url } } + let(:payload) do + { + embeds: [ + include( + author: include(name: be_present), + description: be_present + ) + ] + } + end + end + + describe '#execute' do + include StubRequests + + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:webhook_url) { "https://example.gitlab.com/" } + + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + before do + allow(subject).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + end + + it 'uses the right embed parameters' do + builder = Discordrb::Webhooks::Builder.new + + allow_next_instance_of(Discordrb::Webhooks::Client) do |client| + allow(client).to receive(:execute).and_yield(builder) + end + + subject.execute(sample_data) + + expect(builder.to_json_hash[:embeds].first).to include( + description: start_with("#{user.name} pushed to branch [master](http://localhost/#{project.namespace.path}/#{project.path}/commits/master) of"), + author: hash_including( + icon_url: start_with('https://www.gravatar.com/avatar/'), + name: user.name + ) + ) + end + + context 'DNS rebind to local address' do + before do + stub_dns(webhook_url, ip_address: '192.168.2.120') + end + + it 'does not allow DNS rebinding' do + expect { subject.execute(sample_data) }.to raise_error(ArgumentError, /is blocked/) + end + end + + context 'when the Discord request fails' do + before do + WebMock.stub_request(:post, webhook_url).to_return(status: 400) + end + + it 'logs an error and returns false' do + expect(subject).to receive(:log_error).with('400 Bad Request') + expect(subject.execute(sample_data)).to be(false) + end + end + end +end diff --git a/spec/models/integrations/drone_ci_spec.rb b/spec/models/integrations/drone_ci_spec.rb new file mode 100644 index 00000000000..137f078edca --- /dev/null +++ b/spec/models/integrations/drone_ci_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::DroneCi, :use_clean_rails_memory_store_caching do + include ReactiveCachingHelpers + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to have_one(:service_hook) } + end + + describe 'validations' do + context 'active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:token) } + it { is_expected.to validate_presence_of(:drone_url) } + it_behaves_like 'issue tracker service URL attribute', :drone_url + end + + context 'inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:token) } + it { is_expected.not_to validate_presence_of(:drone_url) } + end + end + + shared_context :drone_ci_integration do + let(:drone) { described_class.new } + let(:project) { create(:project, :repository, name: 'project') } + let(:path) { project.full_path } + let(:drone_url) { 'http://drone.example.com' } + let(:sha) { '2ab7834c' } + let(:branch) { 'dev' } + let(:token) { 'secret' } + let(:iid) { rand(1..9999) } + + # URLs + let(:build_page) { "#{drone_url}/gitlab/#{path}/redirect/commits/#{sha}?branch=#{branch}" } + let(:commit_status_path) { "#{drone_url}/gitlab/#{path}/commits/#{sha}?branch=#{branch}&access_token=#{token}" } + + before do + allow(drone).to receive_messages( + project_id: project.id, + project: project, + active: true, + drone_url: drone_url, + token: token + ) + end + + def stub_request(status: 200, body: nil) + body ||= %q({"status":"success"}) + + WebMock.stub_request(:get, commit_status_path).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body + ) + end + end + + describe "service page/path methods" do + include_context :drone_ci_integration + + it { expect(drone.build_page(sha, branch)).to eq(build_page) } + it { expect(drone.commit_status_path(sha, branch)).to eq(commit_status_path) } + end + + describe '#commit_status' do + include_context :drone_ci_integration + + it 'returns the contents of the reactive cache' do + stub_reactive_cache(drone, { commit_status: 'foo' }, 'sha', 'ref') + + expect(drone.commit_status('sha', 'ref')).to eq('foo') + end + end + + describe '#calculate_reactive_cache' do + include_context :drone_ci_integration + + describe '#commit_status' do + subject { drone.calculate_reactive_cache(sha, branch)[:commit_status] } + + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) + + is_expected.to eq(:error) + end + + it 'sets commit status to :error when status is 404' do + stub_request(status: 404) + + is_expected.to eq(:error) + end + + Gitlab::HTTP::HTTP_ERRORS.each do |http_error| + it "sets commit status to :error with a #{http_error.name} error" do + WebMock.stub_request(:get, commit_status_path) + .to_raise(http_error) + + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with(instance_of(http_error), project_id: project.id) + + is_expected.to eq(:error) + end + end + + { + "killed" => :canceled, + "failure" => :failed, + "error" => :failed, + "success" => "success" + }.each do |drone_status, our_status| + it "sets commit status to #{our_status.inspect} when returned status is #{drone_status.inspect}" do + stub_request(body: %Q({"status":"#{drone_status}"})) + + is_expected.to eq(our_status) + end + end + end + end + + describe "execute" do + include_context :drone_ci_integration + + let(:user) { create(:user, username: 'username') } + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + it do + service_hook = double + expect(service_hook).to receive(:execute) + expect(drone).to receive(:service_hook).and_return(service_hook) + + drone.execute(push_sample_data) + end + end +end diff --git a/spec/models/integrations/ewm_spec.rb b/spec/models/integrations/ewm_spec.rb new file mode 100644 index 00000000000..38897adb447 --- /dev/null +++ b/spec/models/integrations/ewm_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Ewm do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:issues_url) } + it { is_expected.to validate_presence_of(:new_issue_url) } + it_behaves_like 'issue tracker service URL attribute', :project_url + it_behaves_like 'issue tracker service URL attribute', :issues_url + it_behaves_like 'issue tracker service URL attribute', :new_issue_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:issues_url) } + it { is_expected.not_to validate_presence_of(:new_issue_url) } + end + end + + describe "ReferencePatternValidation" do + it "extracts bug" do + expect(described_class.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123") + end + + it "extracts task" do + expect(described_class.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123") + end + + it "extracts work item" do + expect(described_class.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123") + end + + it "extracts workitem" do + expect(described_class.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123") + end + + it "extracts defect" do + expect(described_class.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123") + end + + it "extracts rtcwi" do + expect(described_class.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123") + end + end +end diff --git a/spec/models/integrations/external_wiki_spec.rb b/spec/models/integrations/external_wiki_spec.rb new file mode 100644 index 00000000000..8c20b810301 --- /dev/null +++ b/spec/models/integrations/external_wiki_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::ExternalWiki do + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:external_wiki_url) } + it_behaves_like 'issue tracker service URL attribute', :external_wiki_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:external_wiki_url) } + end + end + + describe 'test' do + before do + subject.properties['external_wiki_url'] = url + end + + let(:url) { 'http://foo' } + let(:data) { nil } + let(:result) { subject.test(data) } + + context 'the URL is not reachable' do + before do + WebMock.stub_request(:get, url).to_return(status: 404, body: 'not a page') + end + + it 'is not successful' do + expect(result[:success]).to be_falsey + end + end + + context 'the URL is reachable' do + before do + WebMock.stub_request(:get, url).to_return(status: 200, body: 'foo') + end + + it 'is successful' do + expect(result[:success]).to be_truthy + end + end + end +end diff --git a/spec/models/integrations/flowdock_spec.rb b/spec/models/integrations/flowdock_spec.rb new file mode 100644 index 00000000000..2de6f7dd2f1 --- /dev/null +++ b/spec/models/integrations/flowdock_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Flowdock do + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:token) } + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:token) } + end + end + + describe "Execute" do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + @flowdock_service = described_class.new + allow(@flowdock_service).to receive_messages( + project_id: project.id, + project: project, + service_hook: true, + token: 'verySecret' + ) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) + @api_url = 'https://api.flowdock.com/v1/messages' + WebMock.stub_request(:post, @api_url) + end + + it "calls FlowDock API" do + @flowdock_service.execute(@sample_data) + @sample_data[:commits].each do |commit| + # One request to Flowdock per new commit + next if commit[:id] == @sample_data[:before] + + expect(WebMock).to have_requested(:post, @api_url).with( + body: /#{commit[:id]}.*#{project.path}/ + ).once + end + end + end +end diff --git a/spec/models/integrations/hangouts_chat_spec.rb b/spec/models/integrations/hangouts_chat_spec.rb new file mode 100644 index 00000000000..17b40c484f5 --- /dev/null +++ b/spec/models/integrations/hangouts_chat_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Integrations::HangoutsChat do + it_behaves_like "chat integration", "Hangouts Chat" do + let(:client) { HangoutsChat::Sender } + let(:client_arguments) { webhook_url } + let(:payload) do + { + text: be_present + } + end + end +end diff --git a/spec/models/integrations/irker_spec.rb b/spec/models/integrations/irker_spec.rb new file mode 100644 index 00000000000..a69be1292e0 --- /dev/null +++ b/spec/models/integrations/irker_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'socket' +require 'json' + +RSpec.describe Integrations::Irker do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:recipients) } + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:recipients) } + end + end + + describe 'Execute' do + let(:irker) { described_class.new } + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + let(:recipients) { '#commits irc://test.net/#test ftp://bad' } + let(:colorize_messages) { '1' } + + before do + @irker_server = TCPServer.new 'localhost', 0 + + allow(irker).to receive_messages( + active: true, + project: project, + project_id: project.id, + service_hook: true, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], + default_irc_uri: 'irc://chat.freenode.net/', + recipients: recipients, + colorize_messages: colorize_messages) + + irker.valid? + end + + after do + @irker_server.close + end + + it 'sends valid JSON messages to an Irker listener', :sidekiq_might_not_need_inline do + irker.execute(sample_data) + + conn = @irker_server.accept + conn.each_line do |line| + msg = Gitlab::Json.parse(line.chomp("\n")) + expect(msg.keys).to match_array(%w(to privmsg)) + expect(msg['to']).to match_array(["irc://chat.freenode.net/#commits", + "irc://test.net/#test"]) + end + conn.close + end + end +end diff --git a/spec/models/integrations/issue_tracker_data_spec.rb b/spec/models/integrations/issue_tracker_data_spec.rb new file mode 100644 index 00000000000..597df237c67 --- /dev/null +++ b/spec/models/integrations/issue_tracker_data_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::IssueTrackerData do + describe 'associations' do + it { is_expected.to belong_to :integration } + end +end diff --git a/spec/models/integrations/jenkins_spec.rb b/spec/models/integrations/jenkins_spec.rb new file mode 100644 index 00000000000..2374dfe4480 --- /dev/null +++ b/spec/models/integrations/jenkins_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Jenkins do + let(:project) { create(:project) } + let(:jenkins_url) { 'http://jenkins.example.com/' } + let(:jenkins_hook_url) { jenkins_url + 'project/my_project' } + let(:jenkins_username) { 'u$er name%2520' } + let(:jenkins_password) { 'pas$ word' } + + let(:jenkins_params) do + { + active: true, + project: project, + properties: { + password: jenkins_password, + username: jenkins_username, + jenkins_url: jenkins_url, + project_name: 'my_project' + } + } + end + + let(:jenkins_authorization) { "Basic " + ::Base64.strict_encode64(jenkins_username + ':' + jenkins_password) } + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'username validation' do + before do + @jenkins_service = described_class.create!( + active: active, + project: project, + properties: { + jenkins_url: 'http://jenkins.example.com/', + password: 'password', + username: 'username', + project_name: 'my_project' + } + ) + end + + subject { @jenkins_service } + + context 'when the service is active' do + let(:active) { true } + + context 'when password was not touched' do + before do + allow(subject).to receive(:password_touched?).and_return(false) + end + + it { is_expected.not_to validate_presence_of :username } + end + + context 'when password was touched' do + before do + allow(subject).to receive(:password_touched?).and_return(true) + end + + it { is_expected.to validate_presence_of :username } + end + + context 'when password is blank' do + it 'does not validate the username' do + expect(subject).not_to validate_presence_of :username + + subject.password = '' + subject.save! + end + end + end + + context 'when the service is inactive' do + let(:active) { false } + + it { is_expected.not_to validate_presence_of :username } + end + end + + describe '#hook_url' do + let(:username) { nil } + let(:password) { nil } + let(:jenkins_service) do + described_class.new( + project: project, + properties: { + jenkins_url: jenkins_url, + project_name: 'my_project', + username: username, + password: password + } + ) + end + + subject { jenkins_service.hook_url } + + context 'when the jenkins_url has no relative path' do + let(:jenkins_url) { 'http://jenkins.example.com/' } + + it { is_expected.to eq('http://jenkins.example.com/project/my_project') } + end + + context 'when the jenkins_url has relative path' do + let(:jenkins_url) { 'http://organization.example.com/jenkins' } + + it { is_expected.to eq('http://organization.example.com/jenkins/project/my_project') } + end + + context 'userinfo is missing and username and password are set' do + let(:jenkins_url) { 'http://organization.example.com/jenkins' } + let(:username) { 'u$ername' } + let(:password) { 'pas$ word' } + + it { is_expected.to eq('http://u%24ername:pas%24%20word@organization.example.com/jenkins/project/my_project') } + end + + context 'userinfo is provided and username and password are set' do + let(:jenkins_url) { 'http://u:p@organization.example.com/jenkins' } + let(:username) { 'username' } + let(:password) { 'password' } + + it { is_expected.to eq('http://username:password@organization.example.com/jenkins/project/my_project') } + end + + context 'userinfo is provided username and password are not set' do + let(:jenkins_url) { 'http://u:p@organization.example.com/jenkins' } + + it { is_expected.to eq('http://u:p@organization.example.com/jenkins/project/my_project') } + end + end + + describe '#test' do + it 'returns the right status' do + user = create(:user, username: 'username') + project = create(:project, name: 'project') + push_sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) + jenkins_service = described_class.create!(jenkins_params) + stub_request(:post, jenkins_hook_url).with(headers: { 'Authorization' => jenkins_authorization }) + + result = jenkins_service.test(push_sample_data) + + expect(result).to eq({ success: true, result: '' }) + end + end + + describe '#execute' do + let(:user) { create(:user, username: 'username') } + let(:namespace) { create(:group, :private) } + let(:project) { create(:project, :private, name: 'project', namespace: namespace) } + let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + let(:jenkins_service) { described_class.create!(jenkins_params) } + + before do + stub_request(:post, jenkins_hook_url) + end + + it 'invokes the Jenkins API' do + jenkins_service.execute(push_sample_data) + + expect(a_request(:post, jenkins_hook_url)).to have_been_made.once + end + + it 'adds default web hook headers to the request' do + jenkins_service.execute(push_sample_data) + + expect( + a_request(:post, jenkins_hook_url) + .with(headers: { 'X-Gitlab-Event' => 'Push Hook', 'Authorization' => jenkins_authorization }) + ).to have_been_made.once + end + + it 'request url contains properly serialized username and password' do + jenkins_service.execute(push_sample_data) + + expect( + a_request(:post, 'http://jenkins.example.com/project/my_project') + .with(headers: { 'Authorization' => jenkins_authorization }) + ).to have_been_made.once + end + end + + describe 'Stored password invalidation' do + let(:project) { create(:project) } + + context 'when a password was previously set' do + before do + @jenkins_service = described_class.create!( + project: project, + properties: { + jenkins_url: 'http://jenkins.example.com/', + username: 'jenkins', + password: 'password' + } + ) + end + + it 'resets password if url changed' do + @jenkins_service.jenkins_url = 'http://jenkins-edited.example.com/' + @jenkins_service.save! + expect(@jenkins_service.password).to be_nil + end + + it 'resets password if username is blank' do + @jenkins_service.username = '' + @jenkins_service.save! + expect(@jenkins_service.password).to be_nil + end + + it 'does not reset password if username changed' do + @jenkins_service.username = 'some_name' + @jenkins_service.save! + expect(@jenkins_service.password).to eq('password') + end + + it 'does not reset password if new url is set together with password, even if it\'s the same password' do + @jenkins_service.jenkins_url = 'http://jenkins_edited.example.com/' + @jenkins_service.password = 'password' + @jenkins_service.save! + expect(@jenkins_service.password).to eq('password') + expect(@jenkins_service.jenkins_url).to eq('http://jenkins_edited.example.com/') + end + + it 'resets password if url changed, even if setter called multiple times' do + @jenkins_service.jenkins_url = 'http://jenkins1.example.com/' + @jenkins_service.jenkins_url = 'http://jenkins1.example.com/' + @jenkins_service.save! + expect(@jenkins_service.password).to be_nil + end + end + + context 'when no password was previously set' do + before do + @jenkins_service = described_class.create!( + project: create(:project), + properties: { + jenkins_url: 'http://jenkins.example.com/', + username: 'jenkins' + } + ) + end + + it 'saves password if new url is set together with password' do + @jenkins_service.jenkins_url = 'http://jenkins_edited.example.com/' + @jenkins_service.password = 'password' + @jenkins_service.save! + expect(@jenkins_service.password).to eq('password') + expect(@jenkins_service.jenkins_url).to eq('http://jenkins_edited.example.com/') + end + end + end +end diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb new file mode 100644 index 00000000000..f6310866773 --- /dev/null +++ b/spec/models/integrations/jira_spec.rb @@ -0,0 +1,1081 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Jira do + include AssetsHelpers + + let_it_be(:project) { create(:project, :repository) } + + let(:current_user) { build_stubbed(:user) } + let(:url) { 'http://jira.example.com' } + let(:api_url) { 'http://api-jira.example.com' } + let(:username) { 'jira-username' } + let(:password) { 'jira-password' } + let(:transition_id) { 'test27' } + let(:server_info_results) { { 'deploymentType' => 'Cloud' } } + let(:jira_service) do + described_class.new( + project: project, + url: url, + username: username, + password: password + ) + end + + before do + WebMock.stub_request(:get, /serverInfo/).to_return(body: server_info_results.to_json ) + end + + describe '#options' do + let(:options) do + { + project: project, + active: true, + username: 'username', + password: 'test', + jira_issue_transition_id: 24, + url: 'http://jira.test.com:1234/path/' + } + end + + let(:integration) { described_class.create!(options) } + + it 'sets the URL properly' do + # jira-ruby gem parses the URI and handles trailing slashes fine: + # https://github.com/sumoheavy/jira-ruby/blob/v1.7.0/lib/jira/http_client.rb#L62 + expect(integration.options[:site]).to eq('http://jira.test.com:1234') + end + + it 'leaves out trailing slashes in context' do + expect(integration.options[:context_path]).to eq('/path') + end + + context 'URL without a path' do + before do + integration.url = 'http://jira.test.com/' + end + + it 'leaves out trailing slashes in context' do + expect(integration.options[:site]).to eq('http://jira.test.com') + expect(integration.options[:context_path]).to eq('') + end + end + + context 'URL with query string parameters' do + before do + integration.url << '?nosso&foo=bar' + end + + it 'removes query string parameters' do + expect(integration.options[:site]).to eq('http://jira.test.com:1234') + expect(integration.options[:context_path]).to eq('/path') + end + end + + context 'username with trailing whitespaces' do + before do + options.merge!(username: 'username ') + end + + it 'leaves out trailing whitespaces in username' do + expect(integration.options[:username]).to eq('username') + end + end + + it 'provides additional cookies to allow basic auth with oracle webgate' do + expect(integration.options[:use_cookies]).to eq(true) + expect(integration.options[:additional_cookies]).to eq(['OBBasicAuth=fromDialog']) + end + + context 'using api URL' do + before do + options.merge!(api_url: 'http://jira.test.com/api_path/') + end + + it 'leaves out trailing slashes in context' do + expect(integration.options[:context_path]).to eq('/api_path') + end + end + end + + describe '#fields' do + let(:service) { create(:jira_service) } + + subject(:fields) { service.fields } + + it 'returns custom fields' do + expect(fields.pluck(:name)).to eq(%w[url api_url username password]) + end + end + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe '.reference_pattern' do + using RSpec::Parameterized::TableSyntax + + where(:key, :result) do + '#123' | '' + '1#23#12' | '' + 'JIRA-1234A' | 'JIRA-1234' + 'JIRA-1234-some_tag' | 'JIRA-1234' + 'JIRA-1234_some_tag' | 'JIRA-1234' + 'EXT_EXT-1234' | 'EXT_EXT-1234' + 'EXT3_EXT-1234' | 'EXT3_EXT-1234' + '3EXT_EXT-1234' | '' + end + + with_them do + specify do + expect(described_class.reference_pattern.match(key).to_s).to eq(result) + end + end + end + + describe '#create' do + let(:params) do + { + project: project, + url: url, + api_url: api_url, + username: username, password: password, + jira_issue_transition_id: transition_id + } + end + + subject { described_class.create!(params) } + + it 'does not store data into properties' do + expect(subject.properties).to be_nil + end + + it 'stores data in data_fields correctly' do + service = subject + + expect(service.jira_tracker_data.url).to eq(url) + expect(service.jira_tracker_data.api_url).to eq(api_url) + expect(service.jira_tracker_data.username).to eq(username) + expect(service.jira_tracker_data.password).to eq(password) + expect(service.jira_tracker_data.jira_issue_transition_id).to eq(transition_id) + expect(service.jira_tracker_data.deployment_cloud?).to be_truthy + end + + context 'when loading serverInfo' do + let(:jira_service) { subject } + + context 'from a Cloud instance' do + let(:server_info_results) { { 'deploymentType' => 'Cloud' } } + + it 'is detected' do + expect(jira_service.jira_tracker_data.deployment_cloud?).to be_truthy + end + end + + context 'from a Server instance' do + let(:server_info_results) { { 'deploymentType' => 'Server' } } + + it 'is detected' do + expect(jira_service.jira_tracker_data.deployment_server?).to be_truthy + end + end + + context 'from an Unknown instance' do + let(:server_info_results) { { 'deploymentType' => 'FutureCloud' } } + + context 'and URL ends in .atlassian.net' do + let(:api_url) { 'http://example-api.atlassian.net' } + + it 'deployment_type is set to cloud' do + expect(jira_service.jira_tracker_data.deployment_cloud?).to be_truthy + end + end + + context 'and URL is something else' do + let(:api_url) { 'http://my-jira-api.someserver.com' } + + it 'deployment_type is set to server' do + expect(jira_service.jira_tracker_data.deployment_server?).to be_truthy + end + end + end + + context 'and no ServerInfo response is received' do + let(:server_info_results) { {} } + + context 'and URL ends in .atlassian.net' do + let(:api_url) { 'http://example-api.atlassian.net' } + + it 'deployment_type is set to cloud' do + expect(Gitlab::AppLogger).to receive(:warn).with(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: server_info_results, url: api_url) + expect(jira_service.jira_tracker_data.deployment_cloud?).to be_truthy + end + end + + context 'and URL is something else' do + let(:api_url) { 'http://my-jira-api.someserver.com' } + + it 'deployment_type is set to server' do + expect(Gitlab::AppLogger).to receive(:warn).with(message: "Jira API returned no ServerInfo, setting deployment_type from URL", server_info: server_info_results, url: api_url) + expect(jira_service.jira_tracker_data.deployment_server?).to be_truthy + end + end + end + end + end + + # we need to make sure we are able to read both from properties and jira_tracker_data table + # TODO: change this as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 + context 'overriding properties' do + let(:access_params) do + { url: url, api_url: api_url, username: username, password: password, + jira_issue_transition_id: transition_id } + end + + let(:data_params) do + { + url: url, api_url: api_url, + username: username, password: password, + jira_issue_transition_id: transition_id + } + end + + shared_examples 'handles jira fields' do + let(:data_params) do + { + url: url, api_url: api_url, + username: username, password: password, + jira_issue_transition_id: transition_id + } + end + + context 'reading data' do + it 'reads data correctly' do + expect(service.url).to eq(url) + expect(service.api_url).to eq(api_url) + expect(service.username).to eq(username) + expect(service.password).to eq(password) + expect(service.jira_issue_transition_id).to eq(transition_id) + end + end + + describe '#update' do + context 'basic update' do + let_it_be(:new_username) { 'new_username' } + let_it_be(:new_url) { 'http://jira-new.example.com' } + + before do + service.update!(username: new_username, url: new_url) + end + + it 'leaves properties field emtpy' do + # expect(service.reload.properties).to be_empty + end + + it 'stores updated data in jira_tracker_data table' do + data = service.jira_tracker_data.reload + + expect(data.url).to eq(new_url) + expect(data.api_url).to eq(api_url) + expect(data.username).to eq(new_username) + expect(data.password).to eq(password) + expect(data.jira_issue_transition_id).to eq(transition_id) + end + end + + context 'when updating the url, api_url, username, or password' do + context 'when updating the integration' do + it 'updates deployment type' do + service.update!(url: 'http://first.url') + service.jira_tracker_data.update!(deployment_type: 'server') + + expect(service.jira_tracker_data.deployment_server?).to be_truthy + + service.update!(api_url: 'http://another.url') + service.jira_tracker_data.reload + + expect(service.jira_tracker_data.deployment_cloud?).to be_truthy + expect(WebMock).to have_requested(:get, /serverInfo/).twice + end + end + + context 'when removing the integration' do + let(:server_info_results) { {} } + + it 'updates deployment type' do + service.update!(url: nil, api_url: nil, active: false) + + service.jira_tracker_data.reload + + expect(service.jira_tracker_data.deployment_unknown?).to be_truthy + end + end + + it 'calls serverInfo for url' do + service.update!(url: 'http://first.url') + + expect(WebMock).to have_requested(:get, /serverInfo/) + end + + it 'calls serverInfo for api_url' do + service.update!(api_url: 'http://another.url') + + expect(WebMock).to have_requested(:get, /serverInfo/) + end + + it 'calls serverInfo for username' do + service.update!(username: 'test-user') + + expect(WebMock).to have_requested(:get, /serverInfo/) + end + + it 'calls serverInfo for password' do + service.update!(password: 'test-password') + + expect(WebMock).to have_requested(:get, /serverInfo/) + end + end + + context 'when not updating the url, api_url, username, or password' do + it 'does not update deployment type' do + expect {service.update!(jira_issue_transition_id: 'jira_issue_transition_id')}.to raise_error(ActiveRecord::RecordInvalid) + + expect(WebMock).not_to have_requested(:get, /serverInfo/) + end + end + + context 'when not allowed to test an instance or group' do + it 'does not update deployment type' do + allow(service).to receive(:can_test?).and_return(false) + + service.update!(url: 'http://first.url') + + expect(WebMock).not_to have_requested(:get, /serverInfo/) + end + end + + context 'stored password invalidation' do + context 'when a password was previously set' do + context 'when only web url present' do + let(:data_params) do + { + url: url, api_url: nil, + username: username, password: password, + jira_issue_transition_id: transition_id + } + end + + it 'resets password if url changed' do + service + service.url = 'http://jira_edited.example.com' + service.save! + + expect(service.reload.url).to eq('http://jira_edited.example.com') + expect(service.password).to be_nil + end + + it 'does not reset password if url "changed" to the same url as before' do + service.url = 'http://jira.example.com' + service.save! + + expect(service.reload.url).to eq('http://jira.example.com') + expect(service.password).not_to be_nil + end + + it 'resets password if url not changed but api url added' do + service.api_url = 'http://jira_edited.example.com/rest/api/2' + service.save! + + expect(service.reload.api_url).to eq('http://jira_edited.example.com/rest/api/2') + expect(service.password).to be_nil + end + + it 'does not reset password if new url is set together with password, even if it\'s the same password' do + service.url = 'http://jira_edited.example.com' + service.password = password + service.save! + + expect(service.password).to eq(password) + expect(service.url).to eq('http://jira_edited.example.com') + end + + it 'resets password if url changed, even if setter called multiple times' do + service.url = 'http://jira1.example.com/rest/api/2' + service.url = 'http://jira1.example.com/rest/api/2' + service.save! + + expect(service.password).to be_nil + end + + it 'does not reset password if username changed' do + service.username = 'some_name' + service.save! + + expect(service.reload.password).to eq(password) + end + + it 'does not reset password if password changed' do + service.url = 'http://jira_edited.example.com' + service.password = 'new_password' + service.save! + + expect(service.reload.password).to eq('new_password') + end + + it 'does not reset password if the password is touched and same as before' do + service.url = 'http://jira_edited.example.com' + service.password = password + service.save! + + expect(service.reload.password).to eq(password) + end + end + + context 'when both web and api url present' do + let(:data_params) do + { + url: url, api_url: 'http://jira.example.com/rest/api/2', + username: username, password: password, + jira_issue_transition_id: transition_id + } + end + + it 'resets password if api url changed' do + service.api_url = 'http://jira_edited.example.com/rest/api/2' + service.save! + + expect(service.password).to be_nil + end + + it 'does not reset password if url changed' do + service.url = 'http://jira_edited.example.com' + service.save! + + expect(service.password).to eq(password) + end + + it 'resets password if api url set to empty' do + service.update!(api_url: '') + + expect(service.reload.password).to be_nil + end + end + end + + context 'when no password was previously set' do + let(:data_params) do + { + url: url, username: username + } + end + + it 'saves password if new url is set together with password' do + service.url = 'http://jira_edited.example.com/rest/api/2' + service.password = 'password' + service.save! + expect(service.reload.password).to eq('password') + expect(service.reload.url).to eq('http://jira_edited.example.com/rest/api/2') + end + end + end + end + end + + # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 + context 'when data are stored in properties' do + let(:properties) { data_params } + let!(:service) do + create(:jira_service, :without_properties_callback, properties: properties.merge(additional: 'something')) + end + + it_behaves_like 'handles jira fields' + end + + context 'when data are stored in separated fields' do + let(:service) do + create(:jira_service, data_params.merge(properties: {})) + end + + it_behaves_like 'handles jira fields' + end + + context 'when data are stored in both properties and separated fields' do + let(:properties) { data_params } + let(:service) do + create(:jira_service, :without_properties_callback, active: false, properties: properties).tap do |integration| + create(:jira_tracker_data, data_params.merge(integration: integration)) + end + end + + it_behaves_like 'handles jira fields' + end + end + + describe '#find_issue' do + let(:issue_key) { 'JIRA-123' } + let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}" } + + before do + stub_request(:get, issue_url).with(basic_auth: [username, password]) + end + + it 'call the Jira API to get the issue' do + jira_service.find_issue(issue_key) + + expect(WebMock).to have_requested(:get, issue_url) + end + + context 'with options' do + let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}?expand=renderedFields,transitions" } + + it 'calls the Jira API with the options to get the issue' do + jira_service.find_issue(issue_key, rendered_fields: true, transitions: true) + + expect(WebMock).to have_requested(:get, issue_url) + end + end + end + + describe '#close_issue' do + let(:custom_base_url) { 'http://custom_url' } + + shared_examples 'close_issue' do + let(:issue_key) { 'JIRA-123' } + let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}" } + let(:transitions_url) { "#{issue_url}/transitions" } + let(:comment_url) { "#{issue_url}/comment" } + let(:remote_link_url) { "#{issue_url}/remotelink" } + let(:transitions) { nil } + + let(:issue_fields) do + { + id: issue_key, + self: issue_url, + transitions: transitions + } + end + + subject(:close_issue) do + jira_service.close_issue(resource, ExternalIssue.new(issue_key, project)) + end + + before do + jira_service.jira_issue_transition_id = '999' + + # These stubs are needed to test Integrations::Jira#close_issue. + # We close the issue then do another request to API to check if it got closed. + # Here is stubbed the API return with a closed and an opened issues. + open_issue = JIRA::Resource::Issue.new(jira_service.client, attrs: issue_fields.deep_stringify_keys) + closed_issue = open_issue.dup + allow(open_issue).to receive(:resolution).and_return(false) + allow(closed_issue).to receive(:resolution).and_return(true) + allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) + + allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return(issue_key) + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) + + WebMock.stub_request(:get, issue_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, comment_url).with(basic_auth: %w(jira-username jira-password)) + WebMock.stub_request(:post, remote_link_url).with(basic_auth: %w(jira-username jira-password)) + end + + let(:external_issue) { ExternalIssue.new('JIRA-123', project) } + + def close_issue + jira_service.close_issue(resource, external_issue, current_user) + end + + it 'calls Jira API' do + close_issue + + expect(WebMock).to have_requested(:post, comment_url).with( + body: /Issue solved with/ + ).once + end + + it 'tracks usage' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter) + .to receive(:track_event) + .with('i_ecosystem_jira_service_close_issue', values: current_user.id) + + close_issue + end + + it 'does not fail if remote_link.all on issue returns nil' do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return(nil) + + expect { close_issue }.not_to raise_error + end + + # Check https://developer.atlassian.com/jiradev/jira-platform/guides/other/guide-jira-remote-issue-links/fields-in-remote-issue-links + # for more information + it 'creates Remote Link reference in Jira for comment' do + close_issue + + favicon_path = "http://localhost/assets/#{find_asset('favicon.png').digest_path}" + + # Creates comment + expect(WebMock).to have_requested(:post, comment_url) + # Creates Remote Link in Jira issue fields + expect(WebMock).to have_requested(:post, remote_link_url).with( + body: hash_including( + GlobalID: 'GitLab', + relationship: 'mentioned on', + object: { + url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/#{commit_id}", + title: "Solved by commit #{commit_id}.", + icon: { title: 'GitLab', url16x16: favicon_path }, + status: { resolved: true } + } + ) + ).once + end + + context 'when "comment_on_event_enabled" is set to false' do + it 'creates Remote Link reference but does not create comment' do + allow(jira_service).to receive_messages(comment_on_event_enabled: false) + close_issue + + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).to have_requested(:post, remote_link_url) + end + end + + context 'when Remote Link already exists' do + let(:remote_link) do + double( + 'remote link', + object: { + url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/#{commit_id}" + }.with_indifferent_access + ) + end + + it 'does not create comment' do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([remote_link]) + + expect(remote_link).to receive(:save!) + + close_issue + + expect(WebMock).not_to have_requested(:post, comment_url) + end + end + + it 'does not send comment or remote links to issues already closed' do + allow_any_instance_of(JIRA::Resource::Issue).to receive(:resolution).and_return(true) + + close_issue + + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) + end + + it 'does not send comment or remote links to issues with unknown resolution' do + allow_any_instance_of(JIRA::Resource::Issue).to receive(:respond_to?).with(:resolution).and_return(false) + + close_issue + + expect(WebMock).not_to have_requested(:post, comment_url) + expect(WebMock).not_to have_requested(:post, remote_link_url) + end + + it 'references the GitLab commit' do + stub_config_setting(base_url: custom_base_url) + + close_issue + + expect(WebMock).to have_requested(:post, comment_url).with( + body: %r{#{custom_base_url}/#{project.full_path}/-/commit/#{commit_id}} + ).once + end + + it 'references the GitLab commit' do + stub_config_setting(relative_url_root: '/gitlab') + stub_config_setting(url: Settings.send(:build_gitlab_url)) + + allow(described_class).to receive(:default_url_options) do + { script_name: '/gitlab' } + end + + close_issue + + expect(WebMock).to have_requested(:post, comment_url).with( + body: %r{#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/#{commit_id}} + ).once + end + + it 'logs exception when transition id is not valid' do + allow(jira_service).to receive(:log_error) + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).and_raise("Bad Request") + + close_issue + + expect(jira_service).to have_received(:log_error).with( + "Issue transition failed", + error: hash_including( + exception_class: 'StandardError', + exception_message: "Bad Request" + ), + client_url: "http://jira.example.com" + ) + end + + it 'calls the api with jira_issue_transition_id' do + close_issue + + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"999"/ + ).once + end + + context 'when custom transition IDs are blank' do + before do + jira_service.jira_issue_transition_id = '' + end + + it 'does not transition the issue' do + close_issue + + expect(WebMock).not_to have_requested(:post, transitions_url) + end + end + + context 'when using automatic issue transitions' do + let(:transitions) do + [ + { id: '1' }, + { id: '2', to: { statusCategory: { key: 'new' } } }, + { id: '3', to: { statusCategory: { key: 'done' } } }, + { id: '4', to: { statusCategory: { key: 'done' } } } + ] + end + + before do + jira_service.jira_issue_transition_automatic = true + + close_issue + end + + it 'uses the next transition with a status category of done' do + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"3"/ + ).once + end + + context 'when no done transition is available' do + let(:transitions) do + [ + { id: '1', to: { statusCategory: { key: 'new' } } } + ] + end + + it 'does not attempt to transition' do + expect(WebMock).not_to have_requested(:post, transitions_url) + end + end + + context 'when no valid transitions are returned' do + let(:transitions) { 'foo' } + + it 'does not attempt to transition' do + expect(WebMock).not_to have_requested(:post, transitions_url) + end + end + end + + context 'when using multiple transition ids' do + before do + allow(jira_service).to receive_messages(jira_issue_transition_id: '1,2,3') + end + + it 'calls the api with transition ids separated by comma' do + close_issue + + 1.upto(3) do |transition_id| + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ + ).once + end + + expect(WebMock).to have_requested(:post, comment_url) + end + + it 'calls the api with transition ids separated by semicolon' do + allow(jira_service).to receive_messages(jira_issue_transition_id: '1;2;3') + + close_issue + + 1.upto(3) do |transition_id| + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ + ).once + end + + expect(WebMock).to have_requested(:post, comment_url) + end + + context 'when a transition fails' do + before do + WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).to_return do |request| + { status: request.body.include?('"id":"2"') ? 500 : 200 } + end + end + + it 'stops the sequence' do + close_issue + + 1.upto(2) do |transition_id| + expect(WebMock).to have_requested(:post, transitions_url).with( + body: /"id":"#{transition_id}"/ + ) + end + + expect(WebMock).not_to have_requested(:post, transitions_url).with( + body: /"id":"3"/ + ) + + expect(WebMock).not_to have_requested(:post, comment_url) + end + end + end + end + + context 'when resource is a merge request' do + let(:resource) { create(:merge_request) } + let(:commit_id) { resource.diff_head_sha } + + it_behaves_like 'close_issue' + end + + context 'when resource is a commit' do + let(:resource) { project.commit('master') } + let(:commit_id) { resource.id } + + it_behaves_like 'close_issue' + end + end + + describe '#create_cross_reference_note' do + let_it_be(:user) { build_stubbed(:user) } + + let(:jira_issue) { ExternalIssue.new('JIRA-123', project) } + + subject { jira_service.create_cross_reference_note(jira_issue, resource, user) } + + shared_examples 'creates a comment on Jira' do + let(:issue_url) { "#{url}/rest/api/2/issue/JIRA-123" } + let(:comment_url) { "#{issue_url}/comment" } + let(:remote_link_url) { "#{issue_url}/remotelink" } + + before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) + stub_request(:get, issue_url).with(basic_auth: [username, password]) + stub_request(:post, comment_url).with(basic_auth: [username, password]) + stub_request(:post, remote_link_url).with(basic_auth: [username, password]) + end + + it 'creates a comment on Jira' do + subject + + expect(WebMock).to have_requested(:post, comment_url).with( + body: /mentioned this issue in/ + ).once + end + + it 'tracks usage' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter) + .to receive(:track_event) + .with('i_ecosystem_jira_service_cross_reference', values: user.id) + + subject + end + end + + context 'when resource is a commit' do + let(:resource) { project.commit('master') } + + context 'when disabled' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:commit_events) { false } + end + end + + it { is_expected.to eq('Events for commits are disabled.') } + end + + context 'when enabled' do + it_behaves_like 'creates a comment on Jira' + end + end + + context 'when resource is a merge request' do + let(:resource) { build_stubbed(:merge_request, source_project: project) } + + context 'when disabled' do + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:merge_requests_events) { false } + end + end + + it { is_expected.to eq('Events for merge requests are disabled.') } + end + + context 'when enabled' do + it_behaves_like 'creates a comment on Jira' + end + end + end + + describe '#test' do + let(:server_info_results) { { 'url' => 'http://url', 'deploymentType' => 'Cloud' } } + + def server_info + jira_service.test(nil) + end + + context 'when the test succeeds' do + it 'gets Jira project with URL when API URL not set' do + expect(server_info).to eq(success: true, result: server_info_results) + expect(WebMock).to have_requested(:get, /jira.example.com/) + end + + it 'gets Jira project with API URL if set' do + jira_service.update!(api_url: 'http://jira.api.com') + + expect(server_info).to eq(success: true, result: server_info_results) + expect(WebMock).to have_requested(:get, /jira.api.com/) + end + end + + context 'when the test fails' do + it 'returns result with the error' do + test_url = 'http://jira.example.com/rest/api/2/serverInfo' + error_message = 'Some specific failure.' + + WebMock.stub_request(:get, test_url).with(basic_auth: [username, password]) + .to_raise(JIRA::HTTPError.new(double(message: error_message))) + + expect(jira_service).to receive(:log_error).with( + 'Error sending message', + client_url: 'http://jira.example.com', + error: error_message + ) + + expect(jira_service.test(nil)).to eq(success: false, result: error_message) + end + end + end + + describe 'project and issue urls' do + context 'when gitlab.yml was initialized' do + it 'is prepopulated with the settings' do + settings = { + 'jira' => { + 'url' => 'http://jira.sample/projects/project_a', + 'api_url' => 'http://jira.sample/api' + } + } + allow(Gitlab.config).to receive(:issues_tracker).and_return(settings) + + service = project.create_jira_service(active: true) + + expect(service.url).to eq('http://jira.sample/projects/project_a') + expect(service.api_url).to eq('http://jira.sample/api') + end + end + + it 'removes trailing slashes from url' do + service = described_class.new(url: 'http://jira.test.com/path/') + + expect(service.url).to eq('http://jira.test.com/path') + end + end + + describe 'favicon urls' do + it 'includes the standard favicon' do + props = described_class.new.send(:build_remote_link_props, url: 'http://example.com', title: 'title') + expect(props[:object][:icon][:url16x16]).to match %r{^http://localhost/assets/favicon(?:-\h+).png$} + end + + it 'includes returns the custom favicon' do + create :appearance, favicon: fixture_file_upload('spec/fixtures/dk.png') + + props = described_class.new.send(:build_remote_link_props, url: 'http://example.com', title: 'title') + expect(props[:object][:icon][:url16x16]).to match %r{^http://localhost/uploads/-/system/appearance/favicon/\d+/dk.png$} + end + end + + context 'generating external URLs' do + let(:integration) { described_class.new(url: 'http://jira.test.com/path/') } + + describe '#web_url' do + it 'handles paths, slashes, and query string' do + expect(integration.web_url).to eq(integration.url) + expect(integration.web_url('subpath/')).to eq('http://jira.test.com/path/subpath') + expect(integration.web_url('/subpath/')).to eq('http://jira.test.com/path/subpath') + expect(integration.web_url('subpath', foo: :bar)).to eq('http://jira.test.com/path/subpath?foo=bar') + end + + it 'preserves existing query string' do + integration.url = 'http://jira.test.com/path/?nosso&foo=bar%20bar' + + expect(integration.web_url).to eq("http://jira.test.com/path?foo=bar%20bar&nosso") + expect(integration.web_url('subpath/')).to eq('http://jira.test.com/path/subpath?foo=bar%20bar&nosso') + expect(integration.web_url('/subpath/')).to eq('http://jira.test.com/path/subpath?foo=bar%20bar&nosso') + expect(integration.web_url('subpath', bar: 'baz baz')).to eq('http://jira.test.com/path/subpath?bar=baz%20baz&foo=bar%20bar&nosso') + end + + it 'includes Atlassian referrer for gitlab.com' do + allow(Gitlab).to receive(:com?).and_return(true) + + expect(integration.web_url).to eq("http://jira.test.com/path?#{described_class::ATLASSIAN_REFERRER_GITLAB_COM.to_query}") + + allow(Gitlab).to receive(:staging?).and_return(true) + + expect(integration.web_url).to eq(integration.url) + end + + it 'includes Atlassian referrer for self-managed' do + allow(Gitlab).to receive(:dev_or_test_env?).and_return(false) + + expect(integration.web_url).to eq("http://jira.test.com/path?#{described_class::ATLASSIAN_REFERRER_SELF_MANAGED.to_query}") + end + end + + describe '#issues_url' do + it 'returns the correct URL' do + expect(integration.issues_url).to eq('http://jira.test.com/path/browse/:id') + end + end + + describe '#new_issue_url' do + it 'returns the correct URL' do + expect(integration.new_issue_url).to eq('http://jira.test.com/path/secure/CreateIssue!default.jspa') + end + end + end + + describe '#issue_transition_enabled?' do + it 'returns true if automatic transitions are enabled' do + jira_service.jira_issue_transition_automatic = true + + expect(jira_service.issue_transition_enabled?).to be(true) + end + + it 'returns true if custom transitions are set' do + jira_service.jira_issue_transition_id = '1, 2, 3' + + expect(jira_service.issue_transition_enabled?).to be(true) + end + + it 'returns false if automatic and custom transitions are disabled' do + expect(jira_service.issue_transition_enabled?).to be(false) + end + end +end diff --git a/spec/models/integrations/jira_tracker_data_spec.rb b/spec/models/integrations/jira_tracker_data_spec.rb new file mode 100644 index 00000000000..5430dd2eb52 --- /dev/null +++ b/spec/models/integrations/jira_tracker_data_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::JiraTrackerData do + describe 'associations' do + it { is_expected.to belong_to(:integration) } + end + + describe 'deployment_type' do + it { is_expected.to define_enum_for(:deployment_type).with_values([:unknown, :server, :cloud]).with_prefix(:deployment) } + end + + describe 'encrypted attributes' do + subject { described_class.encrypted_attributes.keys } + + it { is_expected.to contain_exactly(:api_url, :password, :url, :username) } + end +end diff --git a/spec/models/integrations/mattermost_slash_commands_spec.rb b/spec/models/integrations/mattermost_slash_commands_spec.rb new file mode 100644 index 00000000000..c8a6584591c --- /dev/null +++ b/spec/models/integrations/mattermost_slash_commands_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::MattermostSlashCommands do + it_behaves_like Integrations::BaseSlashCommands + + context 'Mattermost API' do + let(:project) { create(:project) } + let(:service) { project.build_mattermost_slash_commands_service } + let(:user) { create(:user) } + + before do + session = ::Mattermost::Session.new(nil) + session.base_uri = 'http://mattermost.example.com' + + allow_any_instance_of(::Mattermost::Client).to receive(:with_session) + .and_yield(session) + end + + describe '#configure' do + subject do + service.configure(user, team_id: 'abc', + trigger: 'gitlab', url: 'http://trigger.url', + icon_url: 'http://icon.url/icon.png') + end + + context 'the requests succeeds' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v4/commands') + .with(body: { + team_id: 'abc', + trigger: 'gitlab', + url: 'http://trigger.url', + icon_url: 'http://icon.url/icon.png', + auto_complete: true, + auto_complete_desc: "Perform common operations on: #{project.full_name}", + auto_complete_hint: '[help]', + description: "Perform common operations on: #{project.full_name}", + display_name: "GitLab / #{project.full_name}", + method: 'P', + username: 'GitLab' + }.to_json) + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: { token: 'token' }.to_json + ) + end + + it 'saves the service' do + expect { subject }.to change { project.integrations.count }.by(1) + end + + it 'saves the token' do + subject + + expect(service.reload.token).to eq('token') + end + end + + context 'an error is received' do + before do + stub_request(:post, 'http://mattermost.example.com/api/v4/commands') + .to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + id: 'api.command.duplicate_trigger.app_error', + message: 'This trigger word is already in use. Please choose another word.', + detailed_error: '', + request_id: 'obc374man7bx5r3dbc1q5qhf3r', + status_code: 500 + }.to_json + ) + end + + it 'shows error messages' do + succeeded, message = subject + + expect(succeeded).to be(false) + expect(message).to eq('This trigger word is already in use. Please choose another word.') + end + end + end + + describe '#list_teams' do + subject do + service.list_teams(user) + end + + context 'the requests succeeds' do + before do + stub_request(:get, 'http://mattermost.example.com/api/v4/users/me/teams') + .to_return( + status: 200, + headers: { 'Content-Type' => 'application/json' }, + body: [{ id: 'test_team_id' }].to_json + ) + end + + it 'returns a list of teams' do + expect(subject).not_to be_empty + end + end + + context 'an error is received' do + before do + stub_request(:get, 'http://mattermost.example.com/api/v4/users/me/teams') + .to_return( + status: 500, + headers: { 'Content-Type' => 'application/json' }, + body: { + message: 'Failed to get team list.' + }.to_json + ) + end + + it 'shows error messages' do + expect(subject).to eq([[], "Failed to get team list."]) + end + end + end + + describe '#chat_responder' do + it 'returns the responder to use for Mattermost' do + expect(described_class.new.chat_responder) + .to eq(Gitlab::Chat::Responder::Mattermost) + end + end + end +end diff --git a/spec/models/integrations/mattermost_spec.rb b/spec/models/integrations/mattermost_spec.rb new file mode 100644 index 00000000000..f7702846b6c --- /dev/null +++ b/spec/models/integrations/mattermost_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Mattermost do + it_behaves_like Integrations::SlackMattermostNotifier, "Mattermost" +end diff --git a/spec/models/integrations/microsoft_teams_spec.rb b/spec/models/integrations/microsoft_teams_spec.rb new file mode 100644 index 00000000000..2f1be233eb2 --- /dev/null +++ b/spec/models/integrations/microsoft_teams_spec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::MicrosoftTeams do + let(:chat_service) { described_class.new } + let(:webhook_url) { 'https://example.gitlab.com/' } + + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:webhook) } + it_behaves_like 'issue tracker service URL attribute', :webhook + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:webhook) } + end + end + + describe '.supported_events' do + it 'does not support deployment_events' do + expect(described_class.supported_events).not_to include('deployment') + end + end + + describe "#execute" do + let(:user) { create(:user) } + + let_it_be(:project) { create(:project, :repository, :wiki_repo) } + + before do + allow(chat_service).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + end + + context 'with push events' do + let(:push_sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + it "calls Microsoft Teams API for push events" do + chat_service.execute(push_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + + it 'specifies the webhook when it is configured' do + expect(::MicrosoftTeams::Notifier).to receive(:new).with(webhook_url).and_return(double(:microsoft_teams_service).as_null_object) + + chat_service.execute(push_sample_data) + end + end + + context 'with issue events' do + let(:opts) { { title: 'Awesome issue', description: 'please fix' } } + let(:issues_sample_data) do + service = Issues::CreateService.new(project: project, current_user: user, params: opts) + issue = service.execute + service.hook_data(issue, 'open') + end + + it "calls Microsoft Teams API" do + chat_service.execute(issues_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with merge events' do + let(:opts) do + { + title: 'Awesome merge_request', + description: 'please fix', + source_branch: 'feature', + target_branch: 'master' + } + end + + let(:merge_sample_data) do + service = MergeRequests::CreateService.new(project: project, current_user: user, params: opts) + merge_request = service.execute + service.hook_data(merge_request, 'open') + end + + before do + project.add_developer(user) + end + + it "calls Microsoft Teams API" do + chat_service.execute(merge_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'with wiki page events' do + let(:opts) do + { + title: "Awesome wiki_page", + content: "Some text describing some thing or another", + format: "md", + message: "user created page: Awesome wiki_page" + } + end + + let(:wiki_page) { create(:wiki_page, wiki: project.wiki, **opts) } + let(:wiki_page_sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } + + it "calls Microsoft Teams API" do + chat_service.execute(wiki_page_sample_data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + end + + describe "Note events" do + let(:user) { create(:user) } + let(:project) { create(:project, :repository, creator: user) } + + before do + allow(chat_service).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + webhook: webhook_url + ) + + WebMock.stub_request(:post, webhook_url) + end + + context 'when commit comment event executed' do + let(:commit_note) do + create(:note_on_commit, author: user, + project: project, + commit_id: project.repository.commit.id, + note: 'a comment on a commit') + end + + it "calls Microsoft Teams API for commit comment events" do + data = Gitlab::DataBuilder::Note.build(commit_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when merge request comment event executed' do + let(:merge_request_note) do + create(:note_on_merge_request, project: project, + note: "merge request note") + end + + it "calls Microsoft Teams API for merge request comment events" do + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when issue comment event executed' do + let(:issue_note) do + create(:note_on_issue, project: project, note: "issue note") + end + + it "calls Microsoft Teams API for issue comment events" do + data = Gitlab::DataBuilder::Note.build(issue_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + + context 'when snippet comment event executed' do + let(:snippet_note) do + create(:note_on_project_snippet, project: project, + note: "snippet note") + end + + it "calls Microsoft Teams API for snippet comment events" do + data = Gitlab::DataBuilder::Note.build(snippet_note, user) + + chat_service.execute(data) + + expect(WebMock).to have_requested(:post, webhook_url).once + end + end + end + + describe 'Pipeline events' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, status: status, + sha: project.commit.sha, ref: project.default_branch) + end + + before do + allow(chat_service).to receive_messages( + project: project, + service_hook: true, + webhook: webhook_url + ) + end + + shared_examples 'call Microsoft Teams API' do |branches_to_be_notified: nil| + before do + WebMock.stub_request(:post, webhook_url) + chat_service.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + end + + it 'calls Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + data[:markdown] = true + + chat_service.execute(data) + + message = Integrations::ChatMessage::PipelineMessage.new(data) + + expect(WebMock).to have_requested(:post, webhook_url) + .with(body: hash_including({ summary: message.summary })) + .once + end + end + + shared_examples 'does not call Microsoft Teams API' do |branches_to_be_notified: nil| + before do + chat_service.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + end + it 'does not call Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + result = chat_service.execute(data) + + expect(result).to be_falsy + end + end + + context 'with failed pipeline' do + let(:status) { 'failed' } + + it_behaves_like 'call Microsoft Teams API' + end + + context 'with succeeded pipeline' do + let(:status) { 'success' } + + context 'with default to notify_only_broken_pipelines' do + it 'does not call Microsoft Teams API for pipeline events' do + data = Gitlab::DataBuilder::Pipeline.build(pipeline) + result = chat_service.execute(data) + + expect(result).to be_falsy + end + end + + context 'with setting notify_only_broken_pipelines to false' do + before do + chat_service.notify_only_broken_pipelines = false + end + + it_behaves_like 'call Microsoft Teams API' + end + end + + context 'with default branch' do + let(:pipeline) do + create(:ci_pipeline, project: project, status: 'failed', sha: project.commit.sha, ref: project.default_branch) + end + + context 'only notify for the default branch' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "default" + end + + context 'notify for only protected branches' do + it_behaves_like 'does not call Microsoft Teams API', branches_to_be_notified: "protected" + end + + context 'notify for only default and protected branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "default_and_protected" + end + + context 'notify for all branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "all" + end + end + + context 'with protected branch' do + before do + create(:protected_branch, project: project, name: 'a-protected-branch') + end + + let(:pipeline) do + create(:ci_pipeline, project: project, status: 'failed', sha: project.commit.sha, ref: 'a-protected-branch') + end + + context 'only notify for the default branch' do + it_behaves_like 'does not call Microsoft Teams API', branches_to_be_notified: "default" + end + + context 'notify for only protected branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "protected" + end + + context 'notify for only default and protected branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "default_and_protected" + end + + context 'notify for all branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "all" + end + end + + context 'with neither protected nor default branch' do + let(:pipeline) do + create(:ci_pipeline, project: project, status: 'failed', sha: project.commit.sha, ref: 'a-random-branch') + end + + context 'only notify for the default branch' do + it_behaves_like 'does not call Microsoft Teams API', branches_to_be_notified: "default" + end + + context 'notify for only protected branches' do + it_behaves_like 'does not call Microsoft Teams API', branches_to_be_notified: "protected" + end + + context 'notify for only default and protected branches' do + it_behaves_like 'does not call Microsoft Teams API', branches_to_be_notified: "default_and_protected" + end + + context 'notify for all branches' do + it_behaves_like 'call Microsoft Teams API', branches_to_be_notified: "all" + end + end + end +end diff --git a/spec/models/integrations/open_project_spec.rb b/spec/models/integrations/open_project_spec.rb new file mode 100644 index 00000000000..e5b976dc91d --- /dev/null +++ b/spec/models/integrations/open_project_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::OpenProject do + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:url) } + it { is_expected.to validate_presence_of(:token) } + it { is_expected.to validate_presence_of(:project_identifier_code) } + + it_behaves_like 'issue tracker service URL attribute', :url + it_behaves_like 'issue tracker service URL attribute', :api_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:url) } + it { is_expected.not_to validate_presence_of(:token) } + it { is_expected.not_to validate_presence_of(:project_identifier_code) } + end + end + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end +end diff --git a/spec/models/integrations/open_project_tracker_data_spec.rb b/spec/models/integrations/open_project_tracker_data_spec.rb new file mode 100644 index 00000000000..41c913f978c --- /dev/null +++ b/spec/models/integrations/open_project_tracker_data_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::OpenProjectTrackerData do + describe 'associations' do + it { is_expected.to belong_to(:integration) } + end + + describe 'closed_status_id' do + it 'returns the set value' do + expect(build(:open_project_tracker_data).closed_status_id).to eq('15') + end + + it 'returns the default value if not set' do + expect(build(:open_project_tracker_data, closed_status_id: nil).closed_status_id).to eq('13') + end + end +end diff --git a/spec/models/integrations/packagist_spec.rb b/spec/models/integrations/packagist_spec.rb new file mode 100644 index 00000000000..48f7e81adca --- /dev/null +++ b/spec/models/integrations/packagist_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Packagist do + let(:packagist_params) do + { + active: true, + project: project, + properties: { + username: packagist_username, + token: packagist_token, + server: packagist_server + } + } + end + + let(:packagist_hook_url) do + "#{packagist_server}/api/update-package?username=#{packagist_username}&apiToken=#{packagist_token}" + end + + let(:packagist_token) { 'verySecret' } + let(:packagist_username) { 'theUser' } + let(:packagist_server) { 'https://packagist.example.com' } + let(:project) { create(:project) } + + describe "Associations" do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + let(:packagist_service) { described_class.create!(packagist_params) } + + before do + stub_request(:post, packagist_hook_url) + end + + it 'calls Packagist API' do + packagist_service.execute(push_sample_data) + + expect(a_request(:post, packagist_hook_url)).to have_been_made.once + end + end +end diff --git a/spec/models/integrations/pipelines_email_spec.rb b/spec/models/integrations/pipelines_email_spec.rb new file mode 100644 index 00000000000..90055b04bb8 --- /dev/null +++ b/spec/models/integrations/pipelines_email_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::PipelinesEmail, :mailer do + let(:pipeline) do + create(:ci_pipeline, :failed, + project: project, + sha: project.commit('master').sha, + ref: project.default_branch + ) + end + + let(:project) { create(:project, :repository) } + let(:recipients) { 'test@gitlab.com' } + let(:receivers) { [recipients] } + + let(:data) do + Gitlab::DataBuilder::Pipeline.build(pipeline) + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:recipients) } + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:recipients) } + end + end + + shared_examples 'sending email' do |branches_to_be_notified: nil| + before do + subject.recipients = recipients + subject.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + + perform_enqueued_jobs do + run + end + end + + it 'sends email' do + emails = receivers.map { |r| double(notification_email: r) } + + should_only_email(*emails, kind: :bcc) + end + end + + shared_examples 'not sending email' do |branches_to_be_notified: nil| + before do + subject.recipients = recipients + subject.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + + perform_enqueued_jobs do + run + end + end + + it 'does not send email' do + should_not_email_anyone + end + end + + describe '#test' do + def run + subject.test(data) + end + + context 'when pipeline is failed and on default branch' do + it_behaves_like 'sending email' + end + + context 'when pipeline is succeeded' do + before do + data[:object_attributes][:status] = 'success' + pipeline.update!(status: 'success') + end + + it_behaves_like 'sending email' + end + + context 'when the pipeline failed' do + context 'on default branch' do + before do + data[:object_attributes][:ref] = project.default_branch + pipeline.update!(ref: project.default_branch) + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + + context 'on a protected branch' do + before do + create(:protected_branch, project: project, name: 'a-protected-branch') + data[:object_attributes][:ref] = 'a-protected-branch' + pipeline.update!(ref: 'a-protected-branch') + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + + context 'on a neither protected nor default branch' do + before do + data[:object_attributes][:ref] = 'a-random-branch' + pipeline.update!(ref: 'a-random-branch') + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + end + end + + describe '#execute' do + before do + subject.project = project + end + + def run + subject.execute(data) + end + + context 'with recipients' do + context 'with failed pipeline' do + it_behaves_like 'sending email' + end + + context 'with succeeded pipeline' do + before do + data[:object_attributes][:status] = 'success' + pipeline.update!(status: 'success') + end + + it_behaves_like 'not sending email' + end + + context 'with notify_only_broken_pipelines on' do + before do + subject.notify_only_broken_pipelines = true + end + + context 'with failed pipeline' do + it_behaves_like 'sending email' + end + + context 'with succeeded pipeline' do + before do + data[:object_attributes][:status] = 'success' + pipeline.update!(status: 'success') + end + + it_behaves_like 'not sending email' + end + end + + context 'when the pipeline failed' do + context 'on default branch' do + before do + data[:object_attributes][:ref] = project.default_branch + pipeline.update!(ref: project.default_branch) + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'not sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + + context 'on a protected branch' do + before do + create(:protected_branch, project: project, name: 'a-protected-branch') + data[:object_attributes][:ref] = 'a-protected-branch' + pipeline.update!(ref: 'a-protected-branch') + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'not sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + + context 'on a neither protected nor default branch' do + before do + data[:object_attributes][:ref] = 'a-random-branch' + pipeline.update!(ref: 'a-random-branch') + end + + context 'notifications are enabled only for default branch' do + it_behaves_like 'not sending email', branches_to_be_notified: "default" + end + + context 'notifications are enabled only for protected branch' do + it_behaves_like 'not sending email', branches_to_be_notified: "protected" + end + + context 'notifications are enabled only for default and protected branches ' do + it_behaves_like 'not sending email', branches_to_be_notified: "default_and_protected" + end + + context 'notifications are enabled only for all branches' do + it_behaves_like 'sending email', branches_to_be_notified: "all" + end + end + end + end + + context 'with empty recipients list' do + let(:recipients) { ' ,, ' } + + context 'with failed pipeline' do + before do + data[:object_attributes][:status] = 'failed' + pipeline.update!(status: 'failed') + end + + it_behaves_like 'not sending email' + end + end + + context 'with recipients list separating with newlines' do + let(:recipients) { "\ntest@gitlab.com, \r\nexample@gitlab.com\rother@gitlab.com" } + let(:receivers) { %w[test@gitlab.com example@gitlab.com other@gitlab.com] } + + context 'with failed pipeline' do + before do + data[:object_attributes][:status] = 'failed' + pipeline.update!(status: 'failed') + end + + it_behaves_like 'sending email' + end + end + end +end diff --git a/spec/models/integrations/pivotaltracker_spec.rb b/spec/models/integrations/pivotaltracker_spec.rb new file mode 100644 index 00000000000..2ce90b6f739 --- /dev/null +++ b/spec/models/integrations/pivotaltracker_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Pivotaltracker do + include StubRequests + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:token) } + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:token) } + end + end + + describe 'Execute' do + let(:service) do + described_class.new.tap do |service| + service.token = 'secret_api_token' + end + end + + let(:url) { described_class::API_ENDPOINT } + + def push_data(branch: 'master') + { + object_kind: 'push', + ref: "refs/heads/#{branch}", + commits: [ + { + id: '21c12ea', + author: { + name: 'Some User' + }, + url: 'https://example.com/commit', + message: 'commit message' + } + ] + } + end + + before do + stub_full_request(url, method: :post) + end + + it 'posts correct message' do + service.execute(push_data) + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).with( + body: { + 'source_commit' => { + 'commit_id' => '21c12ea', + 'author' => 'Some User', + 'url' => 'https://example.com/commit', + 'message' => 'commit message' + } + }, + headers: { + 'Content-Type' => 'application/json', + 'X-TrackerToken' => 'secret_api_token' + } + ).once + end + + context 'when allowed branches is specified' do + let(:service) do + super().tap do |service| + service.restrict_to_branch = 'master,v10' + end + end + + it 'posts message if branch is in the list' do + service.execute(push_data(branch: 'master')) + service.execute(push_data(branch: 'v10')) + + expect(WebMock).to have_requested(:post, stubbed_hostname(url)).twice + end + + it 'does not post message if branch is not in the list' do + service.execute(push_data(branch: 'mas')) + service.execute(push_data(branch: 'v11')) + + expect(WebMock).not_to have_requested(:post, stubbed_hostname(url)) + end + end + end +end diff --git a/spec/models/integrations/pushover_spec.rb b/spec/models/integrations/pushover_spec.rb new file mode 100644 index 00000000000..be8dc5634a0 --- /dev/null +++ b/spec/models/integrations/pushover_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Pushover do + include StubRequests + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:api_key) } + it { is_expected.to validate_presence_of(:user_key) } + it { is_expected.to validate_presence_of(:priority) } + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:api_key) } + it { is_expected.not_to validate_presence_of(:user_key) } + it { is_expected.not_to validate_presence_of(:priority) } + end + end + + describe 'Execute' do + let(:pushover) { described_class.new } + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:sample_data) do + Gitlab::DataBuilder::Push.build_sample(project, user) + end + + let(:api_key) { 'verySecret' } + let(:user_key) { 'verySecret' } + let(:device) { 'myDevice' } + let(:priority) { 0 } + let(:sound) { 'bike' } + let(:api_url) { 'https://api.pushover.net/1/messages.json' } + + before do + allow(pushover).to receive_messages( + project: project, + project_id: project.id, + service_hook: true, + api_key: api_key, + user_key: user_key, + device: device, + priority: priority, + sound: sound + ) + + stub_full_request(api_url, method: :post, ip_address: '8.8.8.8') + end + + it 'calls Pushover API' do + pushover.execute(sample_data) + + expect(WebMock).to have_requested(:post, 'https://8.8.8.8/1/messages.json').once + end + end +end diff --git a/spec/models/integrations/redmine_spec.rb b/spec/models/integrations/redmine_spec.rb new file mode 100644 index 00000000000..083585d4fed --- /dev/null +++ b/spec/models/integrations/redmine_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Redmine do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + # if redmine is set in setting the urls are set to defaults + # therefore the validation passes as the values are not nil + before do + settings = { + 'redmine' => {} + } + allow(Gitlab.config).to receive(:issues_tracker).and_return(settings) + end + + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:issues_url) } + it { is_expected.to validate_presence_of(:new_issue_url) } + + it_behaves_like 'issue tracker service URL attribute', :project_url + it_behaves_like 'issue tracker service URL attribute', :issues_url + it_behaves_like 'issue tracker service URL attribute', :new_issue_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:issues_url) } + it { is_expected.not_to validate_presence_of(:new_issue_url) } + end + end + + describe '.reference_pattern' do + it_behaves_like 'allows project key on reference pattern' + + it 'does allow # on the reference' do + expect(described_class.reference_pattern.match('#123')[:issue]).to eq('123') + end + end +end diff --git a/spec/models/integrations/slack_slash_commands_spec.rb b/spec/models/integrations/slack_slash_commands_spec.rb new file mode 100644 index 00000000000..a9d3c820a3c --- /dev/null +++ b/spec/models/integrations/slack_slash_commands_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackSlashCommands do + it_behaves_like Integrations::BaseSlashCommands + + describe '#trigger' do + context 'when an auth url is generated' do + let(:project) { create(:project) } + let(:params) do + { + team_domain: 'http://domain.tld', + team_id: 'T3423423', + user_id: 'U234234', + user_name: 'mepmep', + token: 'token' + } + end + + let(:service) do + project.create_slack_slash_commands_service( + properties: { token: 'token' }, + active: true + ) + end + + let(:authorize_url) do + 'http://authorize.example.com/' + end + + before do + allow(service).to receive(:authorize_chat_name_url).and_return(authorize_url) + end + + it 'uses slack compatible links' do + response = service.trigger(params) + + expect(response[:text]).to include("<#{authorize_url}|connect your GitLab account>") + end + end + end + + describe '#chat_responder' do + it 'returns the responder to use for Slack' do + expect(described_class.new.chat_responder) + .to eq(Gitlab::Chat::Responder::Slack) + end + end +end diff --git a/spec/models/integrations/slack_spec.rb b/spec/models/integrations/slack_spec.rb new file mode 100644 index 00000000000..e598c528967 --- /dev/null +++ b/spec/models/integrations/slack_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Slack do + it_behaves_like Integrations::SlackMattermostNotifier, "Slack" + + describe '#execute' do + before do + stub_request(:post, "https://slack.service.url/") + end + + let_it_be(:slack_service) { create(:slack_service, branches_to_be_notified: 'all') } + + it 'uses only known events', :aggregate_failures do + described_class::SUPPORTED_EVENTS_FOR_USAGE_LOG.each do |action| + expect(Gitlab::UsageDataCounters::HLLRedisCounter.known_event?("i_ecosystem_slack_service_#{action}_notification")).to be true + end + end + + context 'hook data includes a user object' do + let_it_be(:user) { create_default(:user) } + let_it_be(:project) { create_default(:project, :repository, :wiki_repo) } + + shared_examples 'increases the usage data counter' do |event_name| + it 'increases the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: user.id).and_call_original + + slack_service.execute(data) + end + end + + context 'event is not supported for usage log' do + let_it_be(:pipeline) { create(:ci_pipeline) } + + let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } + + it 'does not increase the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with('i_ecosystem_slack_service_pipeline_notification', values: user.id) + + slack_service.execute(data) + end + end + + context 'issue notification' do + let_it_be(:issue) { create(:issue) } + + let(:data) { issue.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_issue_notification' + end + + context 'push notification' do + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_push_notification' + end + + context 'deployment notification' do + let_it_be(:deployment) { create(:deployment, user: user) } + + let(:data) { Gitlab::DataBuilder::Deployment.build(deployment, Time.current) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_deployment_notification' + end + + context 'wiki_page notification' do + let(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') } + + let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } + + before do + # Skip this method that is not relevant to this test to prevent having + # to update project which is frozen + allow(project.wiki).to receive(:after_wiki_activity) + end + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_wiki_page_notification' + end + + context 'merge_request notification' do + let_it_be(:merge_request) { create(:merge_request) } + + let(:data) { merge_request.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_merge_request_notification' + end + + context 'note notification' do + let_it_be(:issue_note) { create(:note_on_issue, note: 'issue note') } + + let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_note_notification' + end + + context 'tag_push notification' do + let(:oldrev) { Gitlab::Git::BLANK_SHA } + let(:newrev) { '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b' } # gitlab-test: git rev-parse refs/tags/v1.1.0 + let(:ref) { 'refs/tags/v1.1.0' } + let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_tag_push_notification' + end + + context 'confidential note notification' do + let_it_be(:confidential_issue_note) { create(:note_on_issue, note: 'issue note', confidential: true) } + + let(:data) { Gitlab::DataBuilder::Note.build(confidential_issue_note, user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_note_notification' + end + + context 'confidential issue notification' do + let_it_be(:issue) { create(:issue, confidential: true) } + + let(:data) { issue.to_hook_data(user) } + + it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_issue_notification' + end + end + + context 'hook data does not include a user' do + let(:data) { Gitlab::DataBuilder::Pipeline.build(create(:ci_pipeline)) } + + it 'does not increase the usage data counter' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) + + slack_service.execute(data) + end + end + end +end diff --git a/spec/models/integrations/teamcity_spec.rb b/spec/models/integrations/teamcity_spec.rb new file mode 100644 index 00000000000..b88a4722ad4 --- /dev/null +++ b/spec/models/integrations/teamcity_spec.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Teamcity, :use_clean_rails_memory_store_caching do + include ReactiveCachingHelpers + include StubRequests + + let(:teamcity_url) { 'http://gitlab.com/teamcity' } + let(:teamcity_full_url) { 'http://gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,revision:123' } + let(:project) { create(:project) } + + subject(:service) do + described_class.create!( + project: project, + properties: { + teamcity_url: teamcity_url, + username: 'mic', + password: 'password', + build_type: 'foo' + } + ) + end + + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:build_type) } + it { is_expected.to validate_presence_of(:teamcity_url) } + it_behaves_like 'issue tracker service URL attribute', :teamcity_url + + describe '#username' do + it 'does not validate the presence of username if password is nil' do + subject.password = nil + + expect(subject).not_to validate_presence_of(:username) + end + + it 'validates the presence of username if password is present' do + subject.password = 'secret' + + expect(subject).to validate_presence_of(:username) + end + end + + describe '#password' do + it 'does not validate the presence of password if username is nil' do + subject.username = nil + + expect(subject).not_to validate_presence_of(:password) + end + + it 'validates the presence of password if username is present' do + subject.username = 'john' + + expect(subject).to validate_presence_of(:password) + end + end + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:build_type) } + it { is_expected.not_to validate_presence_of(:teamcity_url) } + it { is_expected.not_to validate_presence_of(:username) } + it { is_expected.not_to validate_presence_of(:password) } + end + end + + describe 'Callbacks' do + describe 'before_update :reset_password' do + context 'when a password was previously set' do + it 'resets password if url changed' do + teamcity_service = service + + teamcity_service.teamcity_url = 'http://gitlab1.com' + teamcity_service.save! + + expect(teamcity_service.password).to be_nil + end + + it 'does not reset password if username changed' do + teamcity_service = service + + teamcity_service.username = 'some_name' + teamcity_service.save! + + expect(teamcity_service.password).to eq('password') + end + + it "does not reset password if new url is set together with password, even if it's the same password" do + teamcity_service = service + + teamcity_service.teamcity_url = 'http://gitlab_edited.com' + teamcity_service.password = 'password' + teamcity_service.save! + + expect(teamcity_service.password).to eq('password') + expect(teamcity_service.teamcity_url).to eq('http://gitlab_edited.com') + end + end + + it 'saves password if new url is set together with password when no password was previously set' do + teamcity_service = service + teamcity_service.password = nil + + teamcity_service.teamcity_url = 'http://gitlab_edited.com' + teamcity_service.password = 'password' + teamcity_service.save! + + expect(teamcity_service.password).to eq('password') + expect(teamcity_service.teamcity_url).to eq('http://gitlab_edited.com') + end + end + end + + describe '#build_page' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { build_page: 'foo' }, 'sha', 'ref') + + expect(service.build_page('sha', 'ref')).to eq('foo') + end + end + + describe '#commit_status' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { commit_status: 'foo' }, 'sha', 'ref') + + expect(service.commit_status('sha', 'ref')).to eq('foo') + end + end + + describe '#calculate_reactive_cache' do + context 'build_page' do + subject { service.calculate_reactive_cache('123', 'unused')[:build_page] } + + it 'returns a specific URL when status is 500' do + stub_request(status: 500) + + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildTypeId=foo') + end + + it 'returns a build URL when teamcity_url has no trailing slash' do + stub_request(body: %q({"build":{"id":"666"}})) + + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') + end + + context 'teamcity_url has trailing slash' do + let(:teamcity_url) { 'http://gitlab.com/teamcity/' } + + it 'returns a build URL' do + stub_request(body: %q({"build":{"id":"666"}})) + + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') + end + end + + it 'returns the teamcity_url when teamcity is unreachable' do + stub_full_request(teamcity_full_url).to_raise(Errno::ECONNREFUSED) + + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with(instance_of(Errno::ECONNREFUSED), project_id: project.id) + + is_expected.to eq(teamcity_url) + end + end + + context 'commit_status' do + subject { service.calculate_reactive_cache('123', 'unused')[:commit_status] } + + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) + + is_expected.to eq(:error) + end + + it 'sets commit status to "pending" when status is 404' do + stub_request(status: 404) + + is_expected.to eq('pending') + end + + it 'sets commit status to "success" when build status contains SUCCESS' do + stub_request(build_status: 'YAY SUCCESS!') + + is_expected.to eq('success') + end + + it 'sets commit status to "failed" when build status contains FAILURE' do + stub_request(build_status: 'NO FAILURE!') + + is_expected.to eq('failed') + end + + it 'sets commit status to "pending" when build status contains Pending' do + stub_request(build_status: 'NO Pending!') + + is_expected.to eq('pending') + end + + it 'sets commit status to :error when build status is unknown' do + stub_request(build_status: 'FOO BAR!') + + is_expected.to eq(:error) + end + + it 'sets commit status to :error when teamcity is unreachable' do + stub_full_request(teamcity_full_url).to_raise(Errno::ECONNREFUSED) + + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with(instance_of(Errno::ECONNREFUSED), project_id: project.id) + + is_expected.to eq(:error) + end + end + end + + describe '#execute' do + context 'when push' do + let(:data) do + { + object_kind: 'push', + ref: 'refs/heads/dev-123_branch', + after: '0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e', + total_commits_count: 1 + } + end + + it 'handles push request correctly' do + stub_post_to_build_queue(branch: 'dev-123_branch') + + expect(service.execute(data)).to include('Ok') + end + + it 'returns nil when ref is blank' do + data[:after] = Gitlab::Git::BLANK_SHA + + expect(service.execute(data)).to be_nil + end + + it 'returns nil when there is no content' do + data[:total_commits_count] = 0 + + expect(service.execute(data)).to be_nil + end + + it 'returns nil when a merge request is opened for the same ref' do + create(:merge_request, source_project: project, source_branch: 'dev-123_branch') + + expect(service.execute(data)).to be_nil + end + end + + context 'when merge_request' do + let(:data) do + { + object_kind: 'merge_request', + ref: 'refs/heads/dev-123_branch', + after: '0220c11b9a3e6c69dc8fd35321254ca9a7b98f7e', + total_commits_count: 1, + object_attributes: { + state: 'opened', + source_branch: 'dev-123_branch', + merge_status: 'unchecked' + } + } + end + + it 'handles merge request correctly' do + stub_post_to_build_queue(branch: 'dev-123_branch') + + expect(service.execute(data)).to include('Ok') + end + + it 'returns nil when merge request is not opened' do + data[:object_attributes][:state] = 'closed' + + expect(service.execute(data)).to be_nil + end + + it 'returns nil unless merge request is marked as unchecked' do + data[:object_attributes][:merge_status] = 'can_be_merged' + + expect(service.execute(data)).to be_nil + end + end + + it 'returns nil when event is not supported' do + data = { object_kind: 'foo' } + + expect(service.execute(data)).to be_nil + end + end + + def stub_post_to_build_queue(branch:) + teamcity_full_url = 'http://gitlab.com/teamcity/httpAuth/app/rest/buildQueue' + body ||= %Q(<build branchName=\"#{branch}\"><buildType id=\"foo\"/></build>) + auth = %w(mic password) + + stub_full_request(teamcity_full_url, method: :post).with( + basic_auth: auth, + body: body, + headers: { + 'Content-Type' => 'application/xml' + } + ).to_return(status: 200, body: 'Ok', headers: {}) + end + + def stub_request(status: 200, body: nil, build_status: 'success') + auth = %w(mic password) + + body ||= %Q({"build":{"status":"#{build_status}","id":"666"}}) + + stub_full_request(teamcity_full_url).with(basic_auth: auth).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body + ) + end +end diff --git a/spec/models/integrations/unify_circuit_spec.rb b/spec/models/integrations/unify_circuit_spec.rb new file mode 100644 index 00000000000..7a91b2d3c11 --- /dev/null +++ b/spec/models/integrations/unify_circuit_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Integrations::UnifyCircuit do + it_behaves_like "chat integration", "Unify Circuit" do + let(:client_arguments) { webhook_url } + let(:payload) do + { + subject: project.full_name, + text: be_present, + markdown: true + } + end + end +end diff --git a/spec/models/integrations/webex_teams_spec.rb b/spec/models/integrations/webex_teams_spec.rb new file mode 100644 index 00000000000..b5cba6762aa --- /dev/null +++ b/spec/models/integrations/webex_teams_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Integrations::WebexTeams do + it_behaves_like "chat integration", "Webex Teams" do + let(:client_arguments) { webhook_url } + let(:payload) do + { + markdown: be_present + } + end + end +end diff --git a/spec/models/integrations/youtrack_spec.rb b/spec/models/integrations/youtrack_spec.rb new file mode 100644 index 00000000000..314204f6fb4 --- /dev/null +++ b/spec/models/integrations/youtrack_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Youtrack do + describe 'Associations' do + it { is_expected.to belong_to :project } + it { is_expected.to have_one :service_hook } + end + + describe 'Validations' do + context 'when service is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:project_url) } + it { is_expected.to validate_presence_of(:issues_url) } + + it_behaves_like 'issue tracker service URL attribute', :project_url + it_behaves_like 'issue tracker service URL attribute', :issues_url + end + + context 'when service is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:project_url) } + it { is_expected.not_to validate_presence_of(:issues_url) } + end + end + + describe '.reference_pattern' do + it_behaves_like 'allows project key on reference pattern' + + it 'does allow project prefix on the reference' do + expect(described_class.reference_pattern.match('YT-123')[:issue]).to eq('YT-123') + end + + it 'allows lowercase project key on the reference' do + expect(described_class.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') + end + end +end |