From 0edb497de9b595ad907491b884f10007c27de5a1 Mon Sep 17 00:00:00 2001
From: Simon Knox <simon@gitlab.com>
Date: Wed, 25 Sep 2019 10:51:10 +0000
Subject: [PATCH] Add AlertsService to projects

This adds a generic alerts service to a project's settings. Enabling it generates a new token to be used when calling the alert service.
---
 .rubocop.yml                                  |   6 +
 ee/app/models/ee/project.rb                   |  22 ++--
 ee/app/models/ee/service.rb                   |   1 +
 .../models/project_services/alerts_service.rb |  78 ++++++++++++
 .../project_services/alerts_service_data.rb   |  14 +++
 .../projects/services/alerts/_help.html.haml  |   2 +-
 .../unreleased/generic-alertsettings-befe.yml |   5 +
 ee/lib/ee/api/helpers/services_helpers.rb     |   1 +
 .../services/user_activates_alerts_spec.rb    | 112 ++++++++++++++++++
 .../project_services/alerts_service_spec.rb   | 109 +++++++++++++++++
 ee/spec/models/project_spec.rb                |  15 +++
 ee/spec/models/service_spec.rb                |  11 +-
 lib/api/helpers/services_helpers.rb           |   2 +
 locale/gitlab.pot                             |   6 +
 spec/lib/gitlab/import_export/all_models.yml  |   1 +
 .../services_shared_context.rb                |  28 ++++-
 16 files changed, 396 insertions(+), 17 deletions(-)
 create mode 100644 ee/app/models/project_services/alerts_service.rb
 create mode 100644 ee/app/models/project_services/alerts_service_data.rb
 create mode 100644 ee/changelogs/unreleased/generic-alertsettings-befe.yml
 create mode 100644 ee/spec/features/projects/services/user_activates_alerts_spec.rb
 create mode 100644 ee/spec/models/project_services/alerts_service_spec.rb

diff --git a/.rubocop.yml b/.rubocop.yml
index 693ee0ae8473..45464ca3318a 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -218,6 +218,12 @@ ActiveRecordAssociationReload:
     - 'spec/**/*'
     - 'ee/spec/**/*'
 
+Naming/PredicateName:
+  Enabled: true
+  Exclude:
+    - 'spec/**/*'
+    - 'ee/spec/**/*'
+
 RSpec/FactoriesInMigrationSpecs:
   Enabled: true
   Include:
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index ad8542706088..71e47e638cf6 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -39,10 +39,13 @@ module Project
       has_one :project_registry, class_name: 'Geo::ProjectRegistry', inverse_of: :project
       has_one :push_rule, ->(project) { project&.feature_available?(:push_rules) ? all : none }
       has_one :index_status
+
       has_one :jenkins_service
       has_one :jenkins_deprecated_service
       has_one :github_service
       has_one :gitlab_slack_application_service
+      has_one :alerts_service
+
       has_one :tracing_setting, class_name: 'ProjectTracingSetting'
       has_one :alerting_setting, inverse_of: :project, class_name: 'Alerting::ProjectAlertingSetting'
       has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
@@ -487,17 +490,11 @@ def merge_requests_ff_only_enabled
     override :disabled_services
     def disabled_services
       strong_memoize(:disabled_services) do
-        disabled_services = []
-
-        unless feature_available?(:jenkins_integration)
-          disabled_services.push('jenkins', 'jenkins_deprecated')
-        end
-
-        unless feature_available?(:github_project_service_integration)
-          disabled_services.push('github')
+        [].tap do |services|
+          services.push('jenkins', 'jenkins_deprecated') unless feature_available?(:jenkins_integration)
+          services.push('github') unless feature_available?(:github_project_service_integration)
+          services.push('alerts') unless alerts_service_available?
         end
-
-        disabled_services
       end
     end
 
@@ -613,6 +610,11 @@ def design_repository
       @design_repository ||= DesignManagement::Repository.new(self)
     end
 
+    def alerts_service_available?
+      ::Feature.enabled?(:generic_alert_endpoint, self) &&
+        feature_available?(:incident_management)
+    end
+
     def package_already_taken?(package_name)
       namespace.root_ancestor.all_projects
         .joins(:packages)
diff --git a/ee/app/models/ee/service.rb b/ee/app/models/ee/service.rb
index e6753d8cbf6d..9bd0f6b24007 100644
--- a/ee/app/models/ee/service.rb
+++ b/ee/app/models/ee/service.rb
@@ -13,6 +13,7 @@ def available_services_names
           github
           jenkins
           jenkins_deprecated
+          alerts
         ]
 
         if ::Gitlab.dev_env_or_com?
diff --git a/ee/app/models/project_services/alerts_service.rb b/ee/app/models/project_services/alerts_service.rb
new file mode 100644
index 000000000000..2f7902d96176
--- /dev/null
+++ b/ee/app/models/project_services/alerts_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+
+class AlertsService < Service
+  has_one :data, class_name: 'AlertsServiceData', autosave: true,
+    inverse_of: :service, foreign_key: :service_id
+
+  attribute :token, :string
+  delegate :token, :token=, :token_changed?, :token_was, to: :data
+
+  validates :token, presence: true, if: :activated?
+
+  before_validation :prevent_token_assignment
+  before_validation :ensure_token, if: :activated?
+
+  def url
+    url_helpers.project_alerts_notify_url(project, format: :json)
+  end
+
+  def json_fields
+    super + %w(token)
+  end
+
+  def editable?
+    false
+  end
+
+  def show_active_box?
+    false
+  end
+
+  def can_test?
+    false
+  end
+
+  def title
+    _('Alerts endpoint')
+  end
+
+  def description
+    _('Receive alerts on GitLab from any source')
+  end
+
+  def detailed_description
+    description
+  end
+
+  def self.to_param
+    'alerts'
+  end
+
+  def self.supported_events
+    %w()
+  end
+
+  def data
+    super || build_data
+  end
+
+  private
+
+  def prevent_token_assignment
+    self.token = token_was if token.present? && token_changed?
+  end
+
+  def ensure_token
+    self.token = generate_token if token.blank?
+  end
+
+  def generate_token
+    SecureRandom.hex
+  end
+
+  def url_helpers
+    Gitlab::Routing.url_helpers
+  end
+end
diff --git a/ee/app/models/project_services/alerts_service_data.rb b/ee/app/models/project_services/alerts_service_data.rb
new file mode 100644
index 000000000000..5a52ed83455e
--- /dev/null
+++ b/ee/app/models/project_services/alerts_service_data.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+
+class AlertsServiceData < ApplicationRecord
+  belongs_to :service, class_name: 'AlertsService'
+
+  validates :service, presence: true
+
+  attr_encrypted :token,
+    mode: :per_attribute_iv,
+    key: Settings.attr_encrypted_db_key_base_truncated,
+    algorithm: 'aes-256-gcm'
+end
diff --git a/ee/app/views/projects/services/alerts/_help.html.haml b/ee/app/views/projects/services/alerts/_help.html.haml
index 1fa0350665eb..acab6c75e657 100644
--- a/ee/app/views/projects/services/alerts/_help.html.haml
+++ b/ee/app/views/projects/services/alerts/_help.html.haml
@@ -1,4 +1,4 @@
-- return unless Feature.enabled?(:generic_alert_endpoint, @project)
+- return unless @project.alerts_service_available?
 
 .js-alerts-service-settings{ data: { activated: @service.activated?.to_s,
   form_path: project_service_path(@project, @service.to_param),
diff --git a/ee/changelogs/unreleased/generic-alertsettings-befe.yml b/ee/changelogs/unreleased/generic-alertsettings-befe.yml
new file mode 100644
index 000000000000..9680e25ed974
--- /dev/null
+++ b/ee/changelogs/unreleased/generic-alertsettings-befe.yml
@@ -0,0 +1,5 @@
+---
+title: Add Alerts Service to Projects
+merge_request: 16117
+author:
+type: added
diff --git a/ee/lib/ee/api/helpers/services_helpers.rb b/ee/lib/ee/api/helpers/services_helpers.rb
index 34b1129dc3dc..9210828d7c7a 100644
--- a/ee/lib/ee/api/helpers/services_helpers.rb
+++ b/ee/lib/ee/api/helpers/services_helpers.rb
@@ -81,6 +81,7 @@ def service_classes
               ::GithubService,
               ::JenkinsService,
               ::JenkinsDeprecatedService,
+              ::AlertsService,
               *super
             ]
           end
diff --git a/ee/spec/features/projects/services/user_activates_alerts_spec.rb b/ee/spec/features/projects/services/user_activates_alerts_spec.rb
new file mode 100644
index 000000000000..2d1cf0660de3
--- /dev/null
+++ b/ee/spec/features/projects/services/user_activates_alerts_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User activates Alerts' do
+  set(:project) { create(:project) }
+  set(:user) { create(:user) }
+
+  let(:service_name) { 'alerts' }
+  let(:service_title) { 'Alerts endpoint' }
+
+  before do
+    sign_in(user)
+    project.add_maintainer(user)
+  end
+
+  shared_examples 'no service' do
+    it 'cannot see the service' do
+      visit_project_services
+
+      expect(page).not_to have_link(service_title)
+    end
+  end
+
+  context 'when feature available', :js do
+    before do
+      stub_licensed_features(incident_management: true)
+      stub_feature_flags(generic_alert_endpoint: true)
+    end
+
+    context 'when service is deactivated' do
+      it 'activates service' do
+        visit_project_services
+
+        expect(page).to have_link(service_title)
+        click_link(service_title)
+
+        expect(page).not_to have_active_service
+
+        click_activate_service
+        wait_for_requests
+
+        expect(page).to have_active_service
+      end
+    end
+
+    context 'when service is activated' do
+      before do
+        visit_alerts_service
+        click_activate_service
+      end
+
+      it 're-generates key' do
+        expect(reset_key.value).to be_blank
+
+        click_reset_key
+        click_confirm_reset_key
+        wait_for_requests
+
+        expect(reset_key.value).to be_present
+      end
+    end
+
+    context 'when feature flag `generic_alert_endpoint` disabled' do
+      before do
+        stub_feature_flags(generic_alert_endpoint: false)
+      end
+
+      it_behaves_like 'no service'
+    end
+  end
+
+  context 'when feature unavailable' do
+    before do
+      stub_licensed_features(incident_management: false)
+    end
+
+    it_behaves_like 'no service'
+  end
+
+  private
+
+  def visit_project_services
+    visit(project_settings_integrations_path(project))
+  end
+
+  def visit_alerts_service
+    visit(edit_project_service_path(project, service_name))
+  end
+
+  def click_activate_service
+    find('#activated').click
+  end
+
+  def click_reset_key
+    click_button('Reset key')
+  end
+
+  def click_confirm_reset_key
+    within '.modal-content' do
+      click_reset_key
+    end
+  end
+
+  def reset_key
+    find_field('Authorization key')
+  end
+
+  def have_active_service
+    have_selector('.js-service-active-status[data-value="true"]')
+  end
+end
diff --git a/ee/spec/models/project_services/alerts_service_spec.rb b/ee/spec/models/project_services/alerts_service_spec.rb
new file mode 100644
index 000000000000..12c5d0ea91ed
--- /dev/null
+++ b/ee/spec/models/project_services/alerts_service_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe AlertsService do
+  set(:project) { create(:project) }
+  let(:service_params) { { project: project, active: active } }
+  let(:active) { true }
+  let(:service) { described_class.new(service_params) }
+
+  shared_context 'when active' do
+    let(:active) { true }
+  end
+
+  shared_context 'when inactive' do
+    let(:active) { false }
+  end
+
+  shared_context 'when persisted' do
+    before do
+      service.save!
+      service.reload
+    end
+  end
+
+  describe '#url' do
+    include Gitlab::Routing
+
+    subject { service.url }
+
+    it { is_expected.to eq(project_alerts_notify_url(project, format: :json)) }
+  end
+
+  describe '#json_fields' do
+    subject { service.json_fields }
+
+    it { is_expected.to eq(%w(active token)) }
+  end
+
+  describe '#as_json' do
+    subject { service.as_json(only: service.json_fields) }
+
+    it { is_expected.to eq('active' => true, 'token' => nil) }
+  end
+
+  describe '#token' do
+    shared_context 'reset token' do
+      before do
+        service.token = ''
+        service.valid?
+      end
+    end
+
+    shared_context 'assign token' do |token|
+      before do
+        service.token = token
+        service.valid?
+      end
+    end
+
+    shared_examples 'valid token' do
+      it { is_expected.to match(/\A\h{32}\z/) }
+    end
+
+    shared_examples 'no token' do
+      it { is_expected.to be_blank }
+    end
+
+    subject { service.token }
+
+    context 'when active' do
+      include_context 'when active'
+
+      context 'when resetting' do
+        let!(:previous_token) { service.token }
+
+        include_context 'reset token'
+
+        it_behaves_like 'valid token'
+
+        it { is_expected.not_to eq(previous_token) }
+      end
+
+      context 'when assigning' do
+        include_context 'assign token', 'random token'
+
+        it_behaves_like 'valid token'
+      end
+    end
+
+    context 'when inactive' do
+      include_context 'when inactive'
+
+      context 'when resetting' do
+        let!(:previous_token) { service.token }
+
+        include_context 'reset token'
+
+        it_behaves_like 'no token'
+      end
+    end
+
+    context 'when persisted' do
+      include_context 'when persisted'
+
+      it_behaves_like 'valid token'
+    end
+  end
+end
diff --git a/ee/spec/models/project_spec.rb b/ee/spec/models/project_spec.rb
index ebd27bf9e9ab..42baa45ebaff 100644
--- a/ee/spec/models/project_spec.rb
+++ b/ee/spec/models/project_spec.rb
@@ -970,6 +970,7 @@
     where(:license_feature, :disabled_services) do
       :jenkins_integration                | %w(jenkins jenkins_deprecated)
       :github_project_service_integration | %w(github)
+      :incident_management                | %w(alerts)
     end
 
     with_them do
@@ -989,6 +990,20 @@
         it { is_expected.to include(*disabled_services) }
       end
     end
+
+    context 'when incident_management is available' do
+      before do
+        stub_licensed_features(incident_management: true)
+      end
+
+      context 'when feature flag generic_alert_endpoint is disabled' do
+        before do
+          stub_feature_flags(generic_alert_endpoint: false)
+        end
+
+        it { is_expected.to include('alerts') }
+      end
+    end
   end
 
   describe '#pull_mirror_available?' do
diff --git a/ee/spec/models/service_spec.rb b/ee/spec/models/service_spec.rb
index 9d8cfa59d595..eeb0750b4b0d 100644
--- a/ee/spec/models/service_spec.rb
+++ b/ee/spec/models/service_spec.rb
@@ -4,6 +4,15 @@
 
 describe Service do
   describe 'Available services' do
-    it { expect(described_class.available_services_names).to include("jenkins", "jira") }
+    let(:ee_services) do
+      %w(
+        github
+        jenkins
+        jenkins_deprecated
+        alerts
+      )
+    end
+
+    it { expect(described_class.available_services_names).to include(*ee_services) }
   end
 end
diff --git a/lib/api/helpers/services_helpers.rb b/lib/api/helpers/services_helpers.rb
index eba4ebb4b6e0..2475e384a50a 100644
--- a/lib/api/helpers/services_helpers.rb
+++ b/lib/api/helpers/services_helpers.rb
@@ -155,6 +155,7 @@ def self.chat_notification_events
 
       def self.services
         {
+          'alerts' => [],
           'asana' => [
             {
               required: true,
@@ -696,6 +697,7 @@ def self.services
 
       def self.service_classes
         [
+          ::AlertsService,
           ::AsanaService,
           ::AssemblaService,
           ::BambooService,
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5ac4a2f1e32c..ca62f137ac3d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1234,6 +1234,9 @@ msgstr ""
 msgid "Alerts"
 msgstr ""
 
+msgid "Alerts endpoint"
+msgstr ""
+
 msgid "All"
 msgstr ""
 
@@ -12760,6 +12763,9 @@ msgstr ""
 msgid "Receive alerts from manually configured Prometheus servers."
 msgstr ""
 
+msgid "Receive alerts on GitLab from any source"
+msgstr ""
+
 msgid "Receive notifications about your own activity"
 msgstr ""
 
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 3315dd3b974a..d9272afadf2e 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -411,6 +411,7 @@ project:
 - project_aliases
 - external_pull_requests
 - pages_metadatum
+- alerts_service
 award_emoji:
 - awardable
 - user
diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb
index 4d176ab5fcab..113bcc2af9cc 100644
--- a/spec/support/shared_contexts/services_shared_context.rb
+++ b/spec/support/shared_contexts/services_shared_context.rb
@@ -28,12 +28,17 @@
       end
     end
 
+    let(:licensed_features) do
+      {
+        'github' => :github_project_service_integration,
+        'jenkins' => :jenkins_integration,
+        'jenkins_deprecated' => :jenkins_integration,
+        'alerts' => :incident_management
+      }
+    end
+
     before do
-      if service == 'github' && respond_to?(:stub_licensed_features)
-        stub_licensed_features(github_project_service_integration: true)
-        project.clear_memoization(:disabled_services)
-        project.clear_memoization(:licensed_feature_available)
-      end
+      enable_license_for_service(service)
     end
 
     def initialize_service(service)
@@ -42,5 +47,18 @@ def initialize_service(service)
       service_item.save!
       service_item
     end
+
+    private
+
+    def enable_license_for_service(service)
+      return unless respond_to?(:stub_licensed_features)
+
+      licensed_feature = licensed_features[service]
+      return unless licensed_feature
+
+      stub_licensed_features(licensed_feature => true)
+      project.clear_memoization(:disabled_services)
+      project.clear_memoization(:licensed_feature_available)
+    end
   end
 end
-- 
GitLab