summaryrefslogtreecommitdiff
path: root/spec/models/integrations
diff options
context:
space:
mode:
Diffstat (limited to 'spec/models/integrations')
-rw-r--r--spec/models/integrations/assembla_spec.rb6
-rw-r--r--spec/models/integrations/bamboo_spec.rb42
-rw-r--r--spec/models/integrations/base_chat_notification_spec.rb296
-rw-r--r--spec/models/integrations/base_issue_tracker_spec.rb34
-rw-r--r--spec/models/integrations/bugzilla_spec.rb35
-rw-r--r--spec/models/integrations/buildkite_spec.rb151
-rw-r--r--spec/models/integrations/campfire_spec.rb8
-rw-r--r--spec/models/integrations/chat_message/wiki_page_message_spec.rb46
-rw-r--r--spec/models/integrations/confluence_spec.rb6
-rw-r--r--spec/models/integrations/custom_issue_tracker_spec.rb35
-rw-r--r--spec/models/integrations/discord_spec.rb82
-rw-r--r--spec/models/integrations/drone_ci_spec.rb148
-rw-r--r--spec/models/integrations/ewm_spec.rb61
-rw-r--r--spec/models/integrations/external_wiki_spec.rb59
-rw-r--r--spec/models/integrations/flowdock_spec.rb58
-rw-r--r--spec/models/integrations/hangouts_chat_spec.rb15
-rw-r--r--spec/models/integrations/irker_spec.rb76
-rw-r--r--spec/models/integrations/issue_tracker_data_spec.rb9
-rw-r--r--spec/models/integrations/jenkins_spec.rb255
-rw-r--r--spec/models/integrations/jira_spec.rb1081
-rw-r--r--spec/models/integrations/jira_tracker_data_spec.rb19
-rw-r--r--spec/models/integrations/mattermost_slash_commands_spec.rb132
-rw-r--r--spec/models/integrations/mattermost_spec.rb7
-rw-r--r--spec/models/integrations/microsoft_teams_spec.rb360
-rw-r--r--spec/models/integrations/open_project_spec.rb35
-rw-r--r--spec/models/integrations/open_project_tracker_data_spec.rb19
-rw-r--r--spec/models/integrations/packagist_spec.rb48
-rw-r--r--spec/models/integrations/pipelines_email_spec.rb305
-rw-r--r--spec/models/integrations/pivotaltracker_spec.rb101
-rw-r--r--spec/models/integrations/pushover_spec.rb71
-rw-r--r--spec/models/integrations/redmine_spec.rb53
-rw-r--r--spec/models/integrations/slack_slash_commands_spec.rb50
-rw-r--r--spec/models/integrations/slack_spec.rb133
-rw-r--r--spec/models/integrations/teamcity_spec.rb334
-rw-r--r--spec/models/integrations/unify_circuit_spec.rb16
-rw-r--r--spec/models/integrations/webex_teams_spec.rb14
-rw-r--r--spec/models/integrations/youtrack_spec.rb45
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