diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index aa198172c33f977fbe0d9573ae6875bc99e6843b..805f6a506b7de96482a2724a90aeea69d9740338 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2882,6 +2882,28 @@ Input type: `HttpIntegrationUpdateInput` | <a id="mutationhttpintegrationupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationhttpintegrationupdateintegration"></a>`integration` | [`AlertManagementHttpIntegration`](#alertmanagementhttpintegration) | HTTP integration. | +### `Mutation.issuableResourceLinkCreate` + +Input type: `IssuableResourceLinkCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationissuableresourcelinkcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationissuableresourcelinkcreateid"></a>`id` | [`IssueID!`](#issueid) | Incident id to associate the resource link with. | +| <a id="mutationissuableresourcelinkcreatelink"></a>`link` | [`String!`](#string) | Link of the resource. | +| <a id="mutationissuableresourcelinkcreatelinktext"></a>`linkText` | [`String`](#string) | Link text of the resource. | +| <a id="mutationissuableresourcelinkcreatelinktype"></a>`linkType` | [`IssuableResourceLinkType`](#issuableresourcelinktype) | Link type of the resource. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationissuableresourcelinkcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationissuableresourcelinkcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationissuableresourcelinkcreateissuableresourcelink"></a>`issuableResourceLink` | [`IssuableResourceLink`](#issuableresourcelink) | Issuable resource link. | + ### `Mutation.issueMove` Input type: `IssueMoveInput` @@ -12572,6 +12594,20 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount). | <a id="instancesecuritydashboardvulnerabilityseveritiescountseverity"></a>`severity` | [`[VulnerabilitySeverity!]`](#vulnerabilityseverity) | Filter vulnerabilities by severity. | | <a id="instancesecuritydashboardvulnerabilityseveritiescountstate"></a>`state` | [`[VulnerabilityState!]`](#vulnerabilitystate) | Filter vulnerabilities by state. | +### `IssuableResourceLink` + +Describes an issuable resource link for incident issues. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="issuableresourcelinkid"></a>`id` | [`IncidentManagementIssuableResourceLinkID!`](#incidentmanagementissuableresourcelinkid) | ID of the Issuable resource link. | +| <a id="issuableresourcelinkissue"></a>`issue` | [`Issue!`](#issue) | Incident of the resource link. | +| <a id="issuableresourcelinklink"></a>`link` | [`String!`](#string) | Web Link to the resource. | +| <a id="issuableresourcelinklinktext"></a>`linkText` | [`String`](#string) | Optional text for the link. | +| <a id="issuableresourcelinklinktype"></a>`linkType` | [`IssuableResourceLinkType!`](#issuableresourcelinktype) | Type of the resource link. | + ### `Issue` #### Fields @@ -18980,6 +19016,16 @@ Health status of an issue or epic. | <a id="healthstatusneedsattention"></a>`needsAttention` | Needs attention. | | <a id="healthstatusontrack"></a>`onTrack` | On track. | +### `IssuableResourceLinkType` + +Issuable resource link type enum. + +| Value | Description | +| ----- | ----------- | +| <a id="issuableresourcelinktypegeneral"></a>`general` | General link type. | +| <a id="issuableresourcelinktypeslack"></a>`slack` | Slack link type. | +| <a id="issuableresourcelinktypezoom"></a>`zoom` | Zoom link type. | + ### `IssuableSearchableField` Fields to perform the search in. @@ -20385,6 +20431,12 @@ A `IncidentManagementEscalationRuleID` is a global ID. It is encoded as a string An example `IncidentManagementEscalationRuleID` is: `"gid://gitlab/IncidentManagement::EscalationRule/1"`. +### `IncidentManagementIssuableResourceLinkID` + +A `IncidentManagementIssuableResourceLinkID` is a global ID. It is encoded as a string. + +An example `IncidentManagementIssuableResourceLinkID` is: `"gid://gitlab/IncidentManagement::IssuableResourceLink/1"`. + ### `IncidentManagementOncallParticipantID` A `IncidentManagementOncallParticipantID` is a global ID. It is encoded as a string. diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 7a5d2ec65986ccb7f3de373b0e5f5ee4b162718b..efd3409fb18e93c663b25be7e9432acc9ef7d9cd 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -76,6 +76,7 @@ module MutationType mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Create mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy + mount_mutation ::Mutations::IncidentManagement::IssuableResourceLink::Create mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create mount_mutation ::Mutations::Projects::SetComplianceFramework mount_mutation ::Mutations::SecurityPolicy::CommitScanExecutionPolicy diff --git a/ee/app/graphql/mutations/incident_management/issuable_resource_link/base.rb b/ee/app/graphql/mutations/incident_management/issuable_resource_link/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..4877cee2e3c50abf2ef1512a584420c646f0c572 --- /dev/null +++ b/ee/app/graphql/mutations/incident_management/issuable_resource_link/base.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module IncidentManagement + module IssuableResourceLink + class Base < BaseMutation + field :issuable_resource_link, + ::Types::IncidentManagement::IssuableResourceLinkType, + null: true, + description: 'Issuable resource link.' + + authorize :admin_issuable_resource_link + + private + + def response(result) + { + issuable_resource_link: result.payload[:issuable_resource_link], + errors: result.errors + } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Issue).sync + end + end + end + end +end diff --git a/ee/app/graphql/mutations/incident_management/issuable_resource_link/create.rb b/ee/app/graphql/mutations/incident_management/issuable_resource_link/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..482182a3533897f4ac5280e8f11d4fab9dfd97b1 --- /dev/null +++ b/ee/app/graphql/mutations/incident_management/issuable_resource_link/create.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module IncidentManagement + module IssuableResourceLink + class Create < Base + graphql_name 'IssuableResourceLinkCreate' + + argument :id, Types::GlobalIDType[::Issue], + required: true, + description: 'Incident id to associate the resource link with.' + + argument :link, GraphQL::Types::String, + required: true, + description: 'Link of the resource.' + + argument :link_text, GraphQL::Types::String, + required: false, + description: 'Link text of the resource.' + + argument :link_type, Types::IncidentManagement::IssuableResourceLinkTypeEnum, + required: false, + description: 'Link type of the resource.' + + def resolve(id:, **args) + incident = authorized_find!(id: id) + + response ::IncidentManagement::IssuableResourceLinks::CreateService.new(incident, current_user, args).execute + end + end + end + end +end diff --git a/ee/app/graphql/types/incident_management/issuable_resource_link_type.rb b/ee/app/graphql/types/incident_management/issuable_resource_link_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..c932a704de7b0034510dcc10f0ad0acc93a6cd51 --- /dev/null +++ b/ee/app/graphql/types/incident_management/issuable_resource_link_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module IncidentManagement + class IssuableResourceLinkType < BaseObject + graphql_name 'IssuableResourceLink' + description 'Describes an issuable resource link for incident issues' + + authorize :admin_issuable_resource_link + + field :id, + Types::GlobalIDType[::IncidentManagement::IssuableResourceLink], + null: false, + description: 'ID of the Issuable resource link.' + + field :issue, + Types::IssueType, + null: false, + description: 'Incident of the resource link.' + + field :link, + GraphQL::Types::String, + null: false, + description: 'Web Link to the resource.' + + field :link_text, + GraphQL::Types::String, + null: true, + description: 'Optional text for the link.' + + field :link_type, + Types::IncidentManagement::IssuableResourceLinkTypeEnum, + null: false, + description: 'Type of the resource link.' + end + end +end diff --git a/ee/app/graphql/types/incident_management/issuable_resource_link_type_enum.rb b/ee/app/graphql/types/incident_management/issuable_resource_link_type_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..947cbe67c88a64734165165238f7ce78eab799c8 --- /dev/null +++ b/ee/app/graphql/types/incident_management/issuable_resource_link_type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module IncidentManagement + class IssuableResourceLinkTypeEnum < BaseEnum + graphql_name 'IssuableResourceLinkType' + description 'Issuable resource link type enum' + + ::IncidentManagement::IssuableResourceLink.link_types.keys.each do |link_type| + value link_type, value: link_type, description: "#{link_type.titleize} link type" + end + end + end +end diff --git a/ee/app/models/concerns/ee/issuable.rb b/ee/app/models/concerns/ee/issuable.rb index 5ddcf2a94b17806227f43a38c30a9a81bed8a8f0..46fa5a16181600e8763daa3eecf615d9fa182079 100644 --- a/ee/app/models/concerns/ee/issuable.rb +++ b/ee/app/models/concerns/ee/issuable.rb @@ -39,6 +39,11 @@ def metric_images_available? supports_metric_images? end + def issuable_resource_links_available? + supports_resource_links? && + ::Gitlab::IncidentManagement.issuable_resource_links_available?(project) + end + def supports_sla? incident? end @@ -47,6 +52,10 @@ def supports_metric_images? incident? end + def supports_resource_links? + incident? + end + def supports_iterations? false end diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index f7ec01630bb87ec6b01d859a5c23cc199ff21451..7e63ffea311811809c171d86bcacb231d1653f62 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -157,6 +157,7 @@ class Features export_user_permissions zentao_issues_integration coverage_check_approval_rule + issuable_resource_links ].freeze ULTIMATE_FEATURES = %i[ diff --git a/ee/app/models/incident_management/issuable_resource_link.rb b/ee/app/models/incident_management/issuable_resource_link.rb index ffde0c5b67ddff13e84e7c298153c76bf549eae6..2a8baa663b65696b1990d1ca7f44cf7891b00a85 100644 --- a/ee/app/models/incident_management/issuable_resource_link.rb +++ b/ee/app/models/incident_management/issuable_resource_link.rb @@ -2,6 +2,8 @@ module IncidentManagement class IssuableResourceLink < ApplicationRecord + DEFAULT_LINK_TYPE = 'general' + self.table_name = 'issuable_resource_links' belongs_to :issue, inverse_of: :issuable_resource_links @@ -9,7 +11,7 @@ class IssuableResourceLink < ApplicationRecord enum link_type: { general: 0, zoom: 1, slack: 2 } # 'general' is the default type validates :issue, presence: true - validates :link, presence: true, length: { maximum: 2200 } + validates :link, presence: true, length: { maximum: 2200 }, addressable_url: { schemes: %w(http https) } validates :link_text, length: { maximum: 255 } end end diff --git a/ee/app/policies/ee/issuable_policy.rb b/ee/app/policies/ee/issuable_policy.rb index bb3f04eb0dfde6732fa1daaa409a9bc61eacf420..f962dc4a9e286e17c83df1a3cdc53fd0ff006957 100644 --- a/ee/app/policies/ee/issuable_policy.rb +++ b/ee/app/policies/ee/issuable_policy.rb @@ -9,6 +9,11 @@ module IssuablePolicy @user && @subject.author_id == @user.id end + with_scope :subject + condition(:issuable_resource_links_available) do + @subject.issuable_resource_links_available? + end + rule { can?(:read_issue) }.policy do enable :read_issuable_metric_image end @@ -27,6 +32,10 @@ module IssuablePolicy prevent :update_issuable_metric_image prevent :destroy_issuable_metric_image end + + rule { can?(:read_issue) & can?(:reporter_access) & issuable_resource_links_available }.policy do + enable :admin_issuable_resource_link + end end end end diff --git a/ee/app/policies/incident_management/issuable_resource_link_policy.rb b/ee/app/policies/incident_management/issuable_resource_link_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..2f9f55ad9badd603db3bbbc21dfc214f10484d20 --- /dev/null +++ b/ee/app/policies/incident_management/issuable_resource_link_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module IncidentManagement + class IssuableResourceLinkPolicy < ::BasePolicy + delegate { @subject.issue } + end +end diff --git a/ee/app/services/incident_management/issuable_resource_links/base_service.rb b/ee/app/services/incident_management/issuable_resource_links/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..602be5fe4470f5409b9af48c2dc502ebff6d7d65 --- /dev/null +++ b/ee/app/services/incident_management/issuable_resource_links/base_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module IncidentManagement + module IssuableResourceLinks + class BaseService + def allowed? + user&.can?(:admin_issuable_resource_link, incident) + end + + def success(issuable_resource_link) + ServiceResponse.success(payload: { + issuable_resource_link: issuable_resource_link + }) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def error_no_permissions + error(_('You have insufficient permissions to manage resource links for this incident')) + end + + def error_in_save(issuable_resource_link) + error(issuable_resource_link.errors.full_messages.to_sentence) + end + end + end +end diff --git a/ee/app/services/incident_management/issuable_resource_links/create_service.rb b/ee/app/services/incident_management/issuable_resource_links/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..dbb06773c6783f804d3a26439dfb2f644a158834 --- /dev/null +++ b/ee/app/services/incident_management/issuable_resource_links/create_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module IncidentManagement + module IssuableResourceLinks + class CreateService < IssuableResourceLinks::BaseService + def initialize(incident, user, params) + @incident = incident + @user = user + @params = params + end + + def execute + return error_no_permissions unless allowed? + + issuable_resource_link_params = params.merge({ issue: incident }) + issuable_resource_link = IncidentManagement::IssuableResourceLink.new(issuable_resource_link_params) + + if issuable_resource_link.save + success(issuable_resource_link) + else + error_in_save(issuable_resource_link) + end + end + + private + + attr_reader :incident, :user, :params + end + end +end diff --git a/ee/config/feature_flags/development/incident_resource_links_widget.yml b/ee/config/feature_flags/development/incident_resource_links_widget.yml new file mode 100644 index 0000000000000000000000000000000000000000..c6697275c50309d26ac101c102a5cb8e362f42cb --- /dev/null +++ b/ee/config/feature_flags/development/incident_resource_links_widget.yml @@ -0,0 +1,8 @@ +--- +name: incident_resource_links_widget +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88826 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364755 +milestone: '15.1' +type: development +group: group::respond +default_enabled: false diff --git a/ee/lib/gitlab/incident_management.rb b/ee/lib/gitlab/incident_management.rb index 0aeeaf7729307a698e2f1866b0d7e2a3f69e3a01..04f13e286f5660a45fb2beca6f47015ab022f8c7 100644 --- a/ee/lib/gitlab/incident_management.rb +++ b/ee/lib/gitlab/incident_management.rb @@ -9,5 +9,10 @@ def self.oncall_schedules_available?(project) def self.escalation_policies_available?(project) oncall_schedules_available?(project) && project.licensed_feature_available?(:escalation_policies) end + + def self.issuable_resource_links_available?(project) + Feature.enabled?(:incident_resource_links_widget, project) && + project.licensed_feature_available?(:issuable_resource_links) + end end end diff --git a/ee/spec/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb b/ee/spec/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3b3492af3a166425d6db2c5202fa7bcc5248f06c --- /dev/null +++ b/ee/spec/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::IncidentManagement::IssuableResourceLink::Create do + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:incident) { create(:incident, project: project) } + let_it_be(:link_text) { 'Text about link' } + + let(:link_type) { :zoom } + let(:link) { 'http://gitlab.foo.com/zoom_link' } + let(:args) { { link: link, link_text: link_text, link_type: link_type } } + + specify { expect(described_class).to require_graphql_authorizations(:admin_issuable_resource_link) } + + before do + stub_licensed_features(issuable_resource_links: true) + end + + describe '#resolve' do + subject(:resolve) { mutation_for(project, current_user).resolve(id: incident.to_global_id, **args) } + + context 'when a user has permissions to create a resource link' do + before do + project.add_reporter(current_user) + end + + context 'when IssuableResourceLink::CreateService responds with success' do + it 'adds issuable resource link to database' do + expect { resolve }.to change(IncidentManagement::IssuableResourceLink, :count).by(1) + end + + it 'adds associated resource link with incident' do + resolve + + expect(incident.issuable_resource_links.size).to eq(1) + end + end + + context 'when IssuableResourceLink::CreateService responds with an error' do + let(:args) { {} } + + it 'returns error' do + expect(resolve).to eq( + issuable_resource_link: nil, + errors: ["Link can't be blank and Link must be a valid URL"]) + end + end + + context 'when incorrect link is passed' do + let(:link) { 'ftp://random-ftp-url' } + + it 'returns error' do + expect(resolve).to eq( + issuable_resource_link: nil, + errors: ["Link is blocked: Only allowed schemes are http, https"]) + end + end + + context 'when incorrect link type is passed' do + let(:link_type) { :some_random_link_type } + + it 'raises an error' do + expect { resolve }.to raise_error(ArgumentError) + end + end + + context 'when issue type is not incident' do + let(:incident) { create(:issue, project: project) } + + it 'raises an error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + end + + it 'raises an error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end + + context 'when a user has no permissions to create issuable resource link' do + before do + project.add_guest(current_user) + end + + it 'raises an error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when issuable resource link feature is not avaiable' do + before do + stub_licensed_features(issuable_resource_links: false) + end + + it 'raises an error' do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end + + private + + def mutation_for(project, user) + described_class.new(object: project, context: { current_user: user }, field: nil) + end +end diff --git a/ee/spec/graphql/types/incident_management/issuable_resource_link_type_enum_spec.rb b/ee/spec/graphql/types/incident_management/issuable_resource_link_type_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..02ad3cb534749ca5101f6568523078a39a094988 --- /dev/null +++ b/ee/spec/graphql/types/incident_management/issuable_resource_link_type_enum_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::IncidentManagement::IssuableResourceLinkTypeEnum do + specify { expect(described_class.graphql_name).to eq('IssuableResourceLinkType') } + + it 'exposes all the existing issuable resource link types values' do + expect(described_class.values.keys).to contain_exactly( + *%w[general zoom slack] + ) + end +end diff --git a/ee/spec/graphql/types/incident_management/issuable_resource_link_type_spec.rb b/ee/spec/graphql/types/incident_management/issuable_resource_link_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ae0af546293abb8cea76b79361729a7864210c1 --- /dev/null +++ b/ee/spec/graphql/types/incident_management/issuable_resource_link_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['IssuableResourceLink'] do + specify { expect(described_class.graphql_name).to eq('IssuableResourceLink') } + + specify { expect(described_class).to require_graphql_authorizations(:admin_issuable_resource_link)} + + it 'exposes expected fields' do + expected_fields = %i[ + id + issue + link + link_text + link_type + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/ee/spec/lib/gitlab/incident_management_spec.rb b/ee/spec/lib/gitlab/incident_management_spec.rb index 5d0e4adba2fc34ec2626667b7e5fe5cf97f1d6f1..92b66b45315047694d2d652c39d7d7a4902edff4 100644 --- a/ee/spec/lib/gitlab/incident_management_spec.rb +++ b/ee/spec/lib/gitlab/incident_management_spec.rb @@ -48,4 +48,30 @@ it { is_expected.to be_falsey } end end + + describe '.issuable_resource_links_available?' do + subject { described_class.issuable_resource_links_available?(project) } + + before do + stub_licensed_features(issuable_resource_links: true) + end + + it { is_expected.to be_truthy } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + end + + it { is_expected.to be_falsey } + end + + context 'when feature is not avaiable' do + before do + stub_licensed_features(issuable_resource_links: false) + end + + it { is_expected.to be_falsey } + end + end end diff --git a/ee/spec/models/incident_management/issuable_resource_link_spec.rb b/ee/spec/models/incident_management/issuable_resource_link_spec.rb index d0fbbc54ed41052d828c65ea45cd43a2e15d1b2c..af899ee4ba66384a668f4f61e8a5ea86ad104b8c 100644 --- a/ee/spec/models/incident_management/issuable_resource_link_spec.rb +++ b/ee/spec/models/incident_management/issuable_resource_link_spec.rb @@ -14,6 +14,33 @@ it { is_expected.to validate_presence_of(:link) } it { is_expected.to validate_length_of(:link).is_at_most(2200) } it { is_expected.to validate_length_of(:link_text).is_at_most(255) } + + context 'when link is invalid' do + let(:issuable_resource_link) { build(:issuable_resource_link, link: 'some-invalid-url') } + + it 'will be invalid' do + expect(issuable_resource_link).to be_invalid + end + end + end + + describe 'link protocols' do + using RSpec::Parameterized::TableSyntax + + let(:issuable_resource_link) { build(:issuable_resource_link) } + + where(:protocol, :result) do + 'http' | be_valid + 'https' | be_valid + 'ftp' | be_invalid + end + + with_them do + specify do + issuable_resource_link.link = protocol + '://assets.com/download' + expect(issuable_resource_link).to result + end + end end describe 'enums' do diff --git a/ee/spec/policies/issuable_policy_spec.rb b/ee/spec/policies/issuable_policy_spec.rb index 9abd05310cab2c70635ee6157cfaf672c07d77d7..a053f3d8f95ffea5d3049b0b7ddfc156ce3c0e2a 100644 --- a/ee/spec/policies/issuable_policy_spec.rb +++ b/ee/spec/policies/issuable_policy_spec.rb @@ -6,13 +6,16 @@ let_it_be(:non_member) { create(:user) } let_it_be(:guest) { create(:user) } let_it_be(:reporter) { create(:user) } + let_it_be(:developer) { create(:user) } let(:guest_issue) { create(:issue, project: project, author: guest) } let(:reporter_issue) { create(:issue, project: project, author: reporter) } + let(:incident_issue) { create(:incident, project: project, author: developer) } before do project.add_guest(guest) project.add_reporter(reporter) + project.add_developer(developer) end def permissions(user, issue) @@ -20,6 +23,23 @@ def permissions(user, issue) end describe '#rules' do + shared_examples 'issuable resource links access' do + it 'disallows non members' do + expect(permissions(non_member, incident_issue)).to be_disallowed(:admin_issuable_resource_link) + end + + it 'disallows guests' do + expect(permissions(guest, incident_issue)).to be_disallowed(:admin_issuable_resource_link) + end + + it 'disallows all on non-incident issue type' do + expect(permissions(non_member, issue)).to be_disallowed(:admin_issuable_resource_link) + expect(permissions(guest, issue)).to be_disallowed(:admin_issuable_resource_link) + expect(permissions(developer, issue)).to be_disallowed(:admin_issuable_resource_link) + expect(permissions(reporter, issue)).to be_disallowed(:admin_issuable_resource_link) + end + end + context 'in a public project' do let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project) } @@ -40,6 +60,40 @@ def permissions(user, issue) expect(permissions(reporter, issue)).to be_allowed(:read_issuable_metric_image, :upload_issuable_metric_image, :update_issuable_metric_image, :destroy_issuable_metric_image) expect(permissions(reporter, reporter_issue)).to be_allowed(:read_issuable_metric_image, :upload_issuable_metric_image, :update_issuable_metric_image, :destroy_issuable_metric_image) end + + context 'Create, read, delete issuable resource links' do + context 'when available' do + before do + allow(::Gitlab::IncidentManagement).to receive(:issuable_resource_links_available?).with(project).and_return(true) + end + + it_behaves_like 'issuable resource links access' + + it 'allows developers' do + expect(permissions(developer, incident_issue)).to be_allowed(:admin_issuable_resource_link) + end + + it 'allows reporters' do + expect(permissions(reporter, incident_issue)).to be_allowed(:admin_issuable_resource_link) + end + end + + context 'when not available' do + before do + allow(::Gitlab::IncidentManagement).to receive(:issuable_resource_links_available?).with(project).and_return(false) + end + + it_behaves_like 'issuable resource links access' + + it 'disallows developers' do + expect(permissions(developer, incident_issue)).to be_disallowed(:admin_issuable_resource_link) + end + + it 'disallows reporters' do + expect(permissions(reporter, incident_issue)).to be_disallowed(:admin_issuable_resource_link) + end + end + end end context 'in a private project' do @@ -61,6 +115,24 @@ def permissions(user, issue) expect(permissions(reporter, issue)).to be_allowed(:read_issuable_metric_image, :upload_issuable_metric_image, :update_issuable_metric_image, :destroy_issuable_metric_image) expect(permissions(reporter, reporter_issue)).to be_allowed(:read_issuable_metric_image, :upload_issuable_metric_image, :update_issuable_metric_image, :destroy_issuable_metric_image) end + + context 'Create, read, delete issuable resource links' do + context 'when available' do + before do + allow(::Gitlab::IncidentManagement).to receive(:issuable_resource_links_available?).with(project).and_return(true) + end + + it_behaves_like 'issuable resource links access' + + it 'allows developers' do + expect(permissions(developer, incident_issue)).to be_allowed(:admin_issuable_resource_link) + end + + it 'allows reporters' do + expect(permissions(reporter, incident_issue)).to be_allowed(:admin_issuable_resource_link) + end + end + end end end end diff --git a/ee/spec/requests/api/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb b/ee/spec/requests/api/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ea0ddfeaa3e87062ba2910380224a5a77b7e749e --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/incident_management/issuable_resource_link/create_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Creating an issuable resource link' do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:incident) { create(:incident, project: project) } + let_it_be(:link_text) { 'Incident zoom link' } + + let(:link) { 'https://gitlab.zoom.us/incident_link' } + let(:link_type) { :zoom } + let(:input) { { id: incident.to_global_id.to_s, link: link, link_text: link_text, link_type: link_type } } + let(:mutation) do + graphql_mutation(:issuable_resource_link_create, input) do + <<~QL + clientMutationId + errors + issuableResourceLink { + id + issue { id title } + link + linkText + linkType + } + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:issuable_resource_link_create) } + + before do + stub_licensed_features(issuable_resource_links: true) + project.add_reporter(user) + end + + it 'creates issuable resource link', :aggregate_failures do + post_graphql_mutation(mutation, current_user: user) + + issuable_resource_link = mutation_response['issuableResourceLink'] + + expect(response).to have_gitlab_http_status(:success) + expect(issuable_resource_link).to include( + 'issue' => { + 'id' => incident.to_global_id.to_s, + 'title' => incident.title + }, + 'link' => link, + 'linkType' => link_type.to_s, + 'linkText' => link_text + ) + end + + context 'returns error' do + context 'when link is invalid' do + let(:link) { 'ftp://file_service.link' } + + it 'returns nil' do + post_graphql_mutation(mutation, current_user: user) + + issuable_resource_link = mutation_response['issuableResourceLink'] + + expect(issuable_resource_link).to be_nil + expect(mutation_response['errors']).to contain_exactly('Link is blocked: Only allowed schemes are http, https') + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_resource_links_widget: false) + end + + it 'returns nil' do + post_graphql_mutation(mutation, current_user: user) + + expect(mutation_response).to be_nil + end + end + end +end diff --git a/ee/spec/services/incident_management/issuable_resource_links/create_service_spec.rb b/ee/spec/services/incident_management/issuable_resource_links/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b20321e1fe8b50073ccd8c55035e32522cf0ef0f --- /dev/null +++ b/ee/spec/services/incident_management/issuable_resource_links/create_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IncidentManagement::IssuableResourceLinks::CreateService do + let_it_be(:user_with_permissions) { create(:user) } + let_it_be(:user_without_permissions) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:error_message) { 'You have insufficient permissions to manage resource links for this incident' } + let_it_be_with_refind(:incident) { create(:incident, project: project) } + + let(:current_user) { user_with_permissions } + let(:link) { 'https://gitlab.zoom.us' } + let(:link_type) { :zoom } + let(:link_text) { 'Incident zoom link' } + let(:args) { { link: link, link_type: link_type, link_text: link_text } } + let(:service) { described_class.new(incident, current_user, args) } + + before do + stub_licensed_features(issuable_resource_links: true) + end + + before_all do + project.add_reporter(user_with_permissions) + project.add_guest(user_without_permissions) + end + + describe '#execute' do + shared_examples 'error_message' do |message| + it 'has an informative message' do + error_message_string = message.nil? ? error_message : message + + expect(execute).to be_error + expect(execute.message).to eq(error_message_string) + end + end + + shared_examples 'success_response' do + it 'has issuable resource link' do + expect(execute).to be_success + + result = execute.payload[:issuable_resource_link] + expect(result).to be_a(::IncidentManagement::IssuableResourceLink) + expect(result.link).to eq(link) + expect(result.issue).to eq(incident) + expect(result.link_text).to eq(link_text) + expect(result.link_type).to eq(link_type.to_s) + end + end + + subject(:execute) { service.execute } + + context 'when current user is blank' do + let(:current_user) { nil } + + it_behaves_like 'error_message' + end + + context 'when user does not have permissions to create issuable resource links' do + let(:current_user) { user_without_permissions } + + it_behaves_like 'error_message' + end + + context 'when feature is not available' do + before do + stub_licensed_features(issuable_resource_links: false) + end + + it_behaves_like 'error_message' + end + + context 'when error occurs during creation' do + let(:args) { {} } + + it_behaves_like 'error_message', "Link can't be blank and Link must be a valid URL" + end + + context 'when a valid request' do + it_behaves_like 'success_response' + end + + it 'successfully creates a database record', :aggregate_failures do + expect { execute }.to change { ::IncidentManagement::IssuableResourceLink.count }.by(1) + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7ac6545dd44ddf34059934af3028f89a742c48cb..59a53eadc09db2ac60b0fd9bdf92d29ce4f1ff60 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43866,6 +43866,9 @@ msgstr "" msgid "You have insufficient permissions to create an on-call schedule for this project" msgstr "" +msgid "You have insufficient permissions to manage resource links for this incident" +msgstr "" + msgid "You have insufficient permissions to manage timeline events for this incident" msgstr ""