Commit 7eb4d992 authored by Sean McGivern's avatar Sean McGivern

Merge branch '276897-add-creating_external-issue-links-in-graphql' into 'master'

Add creating Vulnerability External Issue Link using GraphQL

See merge request gitlab-org/gitlab!48687
parents ce7471bf bb69a402
...@@ -14732,6 +14732,8 @@ type Mutation { ...@@ -14732,6 +14732,8 @@ type Mutation {
updateSnippet(input: UpdateSnippetInput!): UpdateSnippetPayload updateSnippet(input: UpdateSnippetInput!): UpdateSnippetPayload
vulnerabilityConfirm(input: VulnerabilityConfirmInput!): VulnerabilityConfirmPayload vulnerabilityConfirm(input: VulnerabilityConfirmInput!): VulnerabilityConfirmPayload
vulnerabilityDismiss(input: VulnerabilityDismissInput!): VulnerabilityDismissPayload vulnerabilityDismiss(input: VulnerabilityDismissInput!): VulnerabilityDismissPayload
vulnerabilityExternalIssueLinkCreate(input: VulnerabilityExternalIssueLinkCreateInput!): VulnerabilityExternalIssueLinkCreatePayload
vulnerabilityExternalIssueLinkDestroy(input: VulnerabilityExternalIssueLinkDestroyInput!): VulnerabilityExternalIssueLinkDestroyPayload
vulnerabilityResolve(input: VulnerabilityResolveInput!): VulnerabilityResolvePayload vulnerabilityResolve(input: VulnerabilityResolveInput!): VulnerabilityResolvePayload
vulnerabilityRevertToDetected(input: VulnerabilityRevertToDetectedInput!): VulnerabilityRevertToDetectedPayload vulnerabilityRevertToDetected(input: VulnerabilityRevertToDetectedInput!): VulnerabilityRevertToDetectedPayload
} }
...@@ -25277,6 +25279,81 @@ type VulnerabilityExternalIssueLinkConnection { ...@@ -25277,6 +25279,81 @@ type VulnerabilityExternalIssueLinkConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
"""
Autogenerated input type of VulnerabilityExternalIssueLinkCreate
"""
input VulnerabilityExternalIssueLinkCreateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
External tracker type of the external issue link.
"""
externalTracker: VulnerabilityExternalIssueLinkExternalTracker!
"""
ID of the vulnerability.
"""
id: VulnerabilityID!
"""
Type of the external issue link.
"""
linkType: VulnerabilityExternalIssueLinkType!
}
"""
Autogenerated return type of VulnerabilityExternalIssueLinkCreate
"""
type VulnerabilityExternalIssueLinkCreatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The created external issue link.
"""
externalIssueLink: VulnerabilityExternalIssueLink
}
"""
Autogenerated input type of VulnerabilityExternalIssueLinkDestroy
"""
input VulnerabilityExternalIssueLinkDestroyInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The global ID of the vulnerability external issue link.
"""
id: VulnerabilitiesExternalIssueLinkID!
}
"""
Autogenerated return type of VulnerabilityExternalIssueLinkDestroy
"""
type VulnerabilityExternalIssueLinkDestroyPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
""" """
An edge in a connection. An edge in a connection.
""" """
...@@ -25292,6 +25369,16 @@ type VulnerabilityExternalIssueLinkEdge { ...@@ -25292,6 +25369,16 @@ type VulnerabilityExternalIssueLinkEdge {
node: VulnerabilityExternalIssueLink node: VulnerabilityExternalIssueLink
} }
"""
The external tracker of the external issue link related to a vulnerability
"""
enum VulnerabilityExternalIssueLinkExternalTracker {
"""
Jira external tracker
"""
JIRA
}
""" """
The type of the external issue link related to a vulnerability The type of the external issue link related to a vulnerability
""" """
......
...@@ -43910,6 +43910,60 @@ ...@@ -43910,6 +43910,60 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "vulnerabilityExternalIssueLinkCreate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "VulnerabilityExternalIssueLinkCreateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkCreatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "vulnerabilityExternalIssueLinkDestroy",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "VulnerabilityExternalIssueLinkDestroyInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkDestroyPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "vulnerabilityResolve", "name": "vulnerabilityResolve",
"description": null, "description": null,
...@@ -73579,6 +73633,224 @@ ...@@ -73579,6 +73633,224 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "VulnerabilityExternalIssueLinkCreateInput",
"description": "Autogenerated input type of VulnerabilityExternalIssueLinkCreate",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "ID of the vulnerability.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "VulnerabilityID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "linkType",
"description": "Type of the external issue link.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "externalTracker",
"description": "External tracker type of the external issue link.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkExternalTracker",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkCreatePayload",
"description": "Autogenerated return type of VulnerabilityExternalIssueLinkCreate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalIssueLink",
"description": "The created external issue link.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "VulnerabilityExternalIssueLinkDestroyInput",
"description": "Autogenerated input type of VulnerabilityExternalIssueLinkDestroy",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The global ID of the vulnerability external issue link.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "VulnerabilitiesExternalIssueLinkID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkDestroyPayload",
"description": "Autogenerated return type of VulnerabilityExternalIssueLinkDestroy",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkEdge", "name": "VulnerabilityExternalIssueLinkEdge",
...@@ -73624,6 +73896,23 @@ ...@@ -73624,6 +73896,23 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkExternalTracker",
"description": "The external tracker of the external issue link related to a vulnerability",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "JIRA",
"description": "Jira external tracker",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "ENUM", "kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType", "name": "VulnerabilityExternalIssueLinkType",
...@@ -3816,6 +3816,25 @@ Represents an external issue link of a vulnerability. ...@@ -3816,6 +3816,25 @@ Represents an external issue link of a vulnerability.
| `id` | VulnerabilitiesExternalIssueLinkID! | GraphQL ID of the external issue link | | `id` | VulnerabilitiesExternalIssueLinkID! | GraphQL ID of the external issue link |
| `linkType` | VulnerabilityExternalIssueLinkType! | Type of the external issue link | | `linkType` | VulnerabilityExternalIssueLinkType! | Type of the external issue link |
### VulnerabilityExternalIssueLinkCreatePayload
Autogenerated return type of VulnerabilityExternalIssueLinkCreate.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `externalIssueLink` | VulnerabilityExternalIssueLink | The created external issue link. |
### VulnerabilityExternalIssueLinkDestroyPayload
Autogenerated return type of VulnerabilityExternalIssueLinkDestroy.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### VulnerabilityIdentifier ### VulnerabilityIdentifier
Represents a vulnerability identifier. Represents a vulnerability identifier.
...@@ -4822,6 +4841,14 @@ Possible states of a user. ...@@ -4822,6 +4841,14 @@ Possible states of a user.
| `private` | | | `private` | |
| `public` | | | `public` | |
### VulnerabilityExternalIssueLinkExternalTracker
The external tracker of the external issue link related to a vulnerability.
| Value | Description |
| ----- | ----------- |
| `JIRA` | Jira external tracker |
### VulnerabilityExternalIssueLinkType ### VulnerabilityExternalIssueLinkType
The type of the external issue link related to a vulnerability. The type of the external issue link related to a vulnerability.
......
...@@ -30,6 +30,8 @@ module EE ...@@ -30,6 +30,8 @@ module EE
mount_mutation ::Mutations::Vulnerabilities::Resolve mount_mutation ::Mutations::Vulnerabilities::Resolve
mount_mutation ::Mutations::Vulnerabilities::Confirm mount_mutation ::Mutations::Vulnerabilities::Confirm
mount_mutation ::Mutations::Vulnerabilities::RevertToDetected mount_mutation ::Mutations::Vulnerabilities::RevertToDetected
mount_mutation ::Mutations::Vulnerabilities::CreateExternalIssueLink
mount_mutation ::Mutations::Vulnerabilities::DestroyExternalIssueLink
mount_mutation ::Mutations::Boards::Update mount_mutation ::Mutations::Boards::Update
mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics mount_mutation ::Mutations::Boards::Lists::UpdateLimitMetrics
mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences mount_mutation ::Mutations::Boards::UpdateEpicUserPreferences
......
# frozen_string_literal: true
module Mutations
module Vulnerabilities
class CreateExternalIssueLink < BaseMutation
graphql_name 'VulnerabilityExternalIssueLinkCreate'
authorize :admin_vulnerability
field :external_issue_link, Types::Vulnerability::ExternalIssueLinkType,
null: true,
description: 'The created external issue link.'
argument :id,
::Types::GlobalIDType[::Vulnerability],
required: true,
description: 'ID of the vulnerability.'
argument :link_type,
::Types::Vulnerability::ExternalIssueLinkTypeEnum,
required: true,
description: 'Type of the external issue link.'
argument :external_tracker,
::Types::Vulnerability::ExternalIssueLinkExternalTrackerEnum,
required: true,
description: 'External tracker type of the external issue link.'
def resolve(id:, link_type:, external_tracker:)
vulnerability = authorized_find!(id: id)
result = create_external_issue_link(vulnerability, link_type, external_tracker)
{
external_issue_link: result.success? ? result.payload[:record] : nil,
errors: result.errors
}
end
private
def create_external_issue_link(vulnerability, link_type, external_tracker)
::VulnerabilityExternalIssueLinks::CreateService.new(current_user, vulnerability, external_tracker, link_type: link_type).execute
end
def find_object(id:)
# TODO: remove this line once the compatibility layer is removed.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Vulnerability].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
# frozen_string_literal: true
module Mutations
module Vulnerabilities
class DestroyExternalIssueLink < BaseMutation
graphql_name 'VulnerabilityExternalIssueLinkDestroy'
authorize :admin_vulnerability_external_issue_link
ERROR_MSG = 'Error deleting the vulnerability external issue link'
argument :id, ::Types::GlobalIDType[::Vulnerabilities::ExternalIssueLink],
required: true,
description: 'The global ID of the vulnerability external issue link.'
def resolve(id:)
vulnerability_external_issue_link = authorized_find!(id: id)
response = ::VulnerabilityExternalIssueLinks::DestroyService.new(vulnerability_external_issue_link).execute
errors = response.destroyed? ? [] : [ERROR_MSG]
{
errors: errors
}
end
private
def find_object(id:)
# TODO: remove this line once the compatibility layer is removed.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Vulnerabilities::ExternalIssueLink].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
...@@ -7,13 +7,15 @@ module Resolvers ...@@ -7,13 +7,15 @@ module Resolvers
type Types::ExternalIssueType, null: true type Types::ExternalIssueType, null: true
def resolve def resolve
return serialize_external_issue(object.external_issue, object.external_type) if object.external_issue.present?
BatchLoader::GraphQL.for(object.external_issue_key).batch(key: object.external_type) do |external_issue_keys, loader, args| BatchLoader::GraphQL.for(object.external_issue_key).batch(key: object.external_type) do |external_issue_keys, loader, args|
case args[:key] case args[:key]
when 'jira' when 'jira'
jira_issues(external_issue_keys).each do |external_issue| jira_issues(external_issue_keys).each do |external_issue|
loader.call( loader.call(
external_issue.id, external_issue.id,
::Integrations::Jira::IssueSerializer.new.represent(external_issue, project: object.vulnerability.project) serialize_external_issue(external_issue, args[:key])
) )
end end
end end
...@@ -30,5 +32,11 @@ module Resolvers ...@@ -30,5 +32,11 @@ module Resolvers
result[:issues] result[:issues]
end end
def serialize_external_issue(external_issue, external_type)
case external_type
when 'jira' then ::Integrations::Jira::IssueSerializer.new.represent(external_issue, project: object.vulnerability.project)
end
end
end end
end end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkExternalTrackerEnum < BaseEnum
graphql_name 'VulnerabilityExternalIssueLinkExternalTracker'
description 'The external tracker of the external issue link related to a vulnerability'
::Vulnerabilities::ExternalIssueLink.external_types.keys.each do |external_type|
value external_type.to_s.upcase, value: external_type.to_s, description: "#{external_type.titleize} external tracker"
end
end
end
end
...@@ -26,6 +26,10 @@ module EE ...@@ -26,6 +26,10 @@ module EE
issues_enabled || vulnerabilities_enabled issues_enabled || vulnerabilities_enabled
end end
def configured_to_create_issues_from_vulnerabilities?
active? && project_key.present? && vulnerabilities_issuetype.present? && jira_vulnerabilities_integration_enabled?
end
def issue_types def issue_types
client client
.Issuetype .Issuetype
...@@ -54,7 +58,7 @@ module EE ...@@ -54,7 +58,7 @@ module EE
jira_request do jira_request do
issue = client.Issue.build issue = client.Issue.build
issue.save!( issue.save(
fields: { fields: {
project: { id: jira_project_id }, project: { id: jira_project_id },
issuetype: { id: vulnerabilities_issuetype }, issuetype: { id: vulnerabilities_issuetype },
...@@ -63,9 +67,6 @@ module EE ...@@ -63,9 +67,6 @@ module EE
} }
) )
issue issue
rescue JIRA::HTTPError => e
issue.attrs[:errors] = ::Gitlab::Json.parse(e.response.read_body)
issue
end end
end end
......
...@@ -18,5 +18,7 @@ module Vulnerabilities ...@@ -18,5 +18,7 @@ module Vulnerabilities
message: N_('already has a "created" issue link') message: N_('already has a "created" issue link')
}, },
if: :created? if: :created?
attr_accessor :external_issue
end end
end end
...@@ -45,6 +45,13 @@ class VulnerabilityPresenter < Gitlab::View::Presenter::Delegated ...@@ -45,6 +45,13 @@ class VulnerabilityPresenter < Gitlab::View::Presenter::Delegated
finding.scan || {} finding.scan || {}
end end
def jira_issue_description
ApplicationController.render(
template: 'vulnerabilities/jira_issue_description.md.erb',
locals: { vulnerability: self }
)
end
private private
def location_link_for(path) def location_link_for(path)
......
# frozen_string_literal: true
module VulnerabilityExternalIssueLinks
class CreateService < BaseService
def initialize(user, vulnerability, external_provider, link_type: Vulnerabilities::ExternalIssueLink.link_types[:created])
@user = user
@vulnerability = vulnerability
@external_provider = external_provider
@link_type = link_type
@external_provider_service = prepare_external_provider_service
@external_issue_link = prepare_external_issue_link
end
def execute
return error(['External provider service is not configured to create issues.']) unless external_provider_service&.configured_to_create_issues_from_vulnerabilities?
return error(external_issue_link.errors.full_messages) unless external_issue_link.valid?
external_issue = create_external_issue
if external_issue.has_errors?
error(external_issue.errors.values)
else
if external_issue_link.update(external_issue_key: external_issue.id)
external_issue_link.external_issue = external_issue
success
else
error(external_issue_link.errors.full_messages)
end
end
end
private
attr_reader :user, :vulnerability, :link_type, :external_provider, :external_provider_service, :external_issue_link
delegate :project, to: :vulnerability
def create_external_issue
summary = _('Investigate vulnerability: %{title}') % { title: vulnerability.title }
description = vulnerability.present.jira_issue_description
external_provider_service.create_issue(summary, description)
end
def prepare_external_provider_service
case external_provider
when 'jira' then project&.jira_service
end
end
def prepare_external_issue_link
Vulnerabilities::ExternalIssueLink.new(
author: user,
vulnerability: vulnerability,
link_type: link_type,
external_type: external_provider,
external_project_key: external_provider_service&.project_key,
external_issue_key: 0
)
end
def success
ServiceResponse.success(payload: { record: external_issue_link })
end
def error(message)
ServiceResponse.error(message: message)
end
def result_payload
{ record: issue_link }
end
end
end
# frozen_string_literal: true
module VulnerabilityExternalIssueLinks
class DestroyService < BaseService
def initialize(vulnerability_external_issue_link)
@vulnerability_external_issue_link = vulnerability_external_issue_link
end
def execute
vulnerability_external_issue_link.destroy
end
private
attr_reader :vulnerability_external_issue_link
end
end
---
title: Add creating Vulnerability External Issue Link using GraphQL
merge_request: 48687
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Vulnerabilities::CreateExternalIssueLink do
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
let_it_be(:vulnerability) { create(:vulnerability, :with_findings) }
let_it_be(:user) { create(:user) }
context 'for JIRA external tracker and CREATED issue link' do
subject { mutation.resolve(id: GitlabSchema.id_from_object(vulnerability), link_type: 'created', external_tracker: 'jira') }
context 'when the project can have external issue links' do
before do
stub_licensed_features(security_dashboard: true)
end
context 'when user does not have access to the project' do
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when user has access to the project' do
before do
vulnerability.project.add_developer(user)
allow_next_instance_of(::VulnerabilityExternalIssueLinks::CreateService) do |create_service|
allow(create_service).to receive(:execute).and_return(result)
end
end
context 'when issue creation fails' do
let(:result) { double(success?: false, payload: {}, errors: ['Error when creating issue in Jira']) }
it 'returns empty external issue link' do
expect(subject[:external_issue_link]).to be_nil
end
it 'returns error collection' do
expect(subject[:errors]).to eq(['Error when creating issue in Jira'])
end
end
context 'when issue creation succeeds' do
let_it_be(:external_issue_link) { build(:vulnerabilities_external_issue_link) }
let(:result) { double(success?: true, payload: { record: external_issue_link }, errors: []) }
it 'returns empty external issue link' do
expect(subject[:external_issue_link]).to eq(external_issue_link)
end
it 'returns empty error collection' do
expect(subject[:errors]).to be_empty
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Vulnerabilities::DestroyExternalIssueLink do
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
let_it_be(:vulnerability_external_issue_link) { create(:vulnerabilities_external_issue_link) }
let_it_be(:user) { create(:user) }
subject { mutation.resolve(id: GitlabSchema.id_from_object(vulnerability_external_issue_link)) }
before do
stub_licensed_features(security_dashboard: true)
end
context 'when user does not have permissions to destroy external issue link' do
it { expect {subject}.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) }
end
context 'when user has permission to destroy external issue link' do
before do
vulnerability_external_issue_link.vulnerability.project.add_developer(user)
end
context 'when destroy succeeds' do
before do
allow_next_instance_of(::VulnerabilityExternalIssueLinks::DestroyService) do |destroy_service|
allow(destroy_service).to receive(:execute).and_return(double(destroyed?: true))
end
end
it { is_expected.to eq(errors: []) }
end
context 'when destroy fails' do
before do
allow_next_instance_of(::VulnerabilityExternalIssueLinks::DestroyService) do |destroy_service|
allow(destroy_service).to receive(:execute).and_return(double(destroyed?: false))
end
end
it { is_expected.to eq(errors: ['Error deleting the vulnerability external issue link']) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLinkExternalTracker'] do
let(:expected_values) { %w[JIRA] }
subject { described_class.values.keys }
it { is_expected.to contain_exactly(*expected_values) }
end
...@@ -172,7 +172,69 @@ RSpec.describe JiraService do ...@@ -172,7 +172,69 @@ RSpec.describe JiraService do
expect(WebMock).to have_requested(:post, 'http://jira.example.com/rest/api/2/issue').with( expect(WebMock).to have_requested(:post, 'http://jira.example.com/rest/api/2/issue').with(
body: { fields: { project: { id: '11223' }, issuetype: { id: '10001' }, summary: '', description: "*ID*: 2\n_Issue_: !" } }.to_json body: { fields: { project: { id: '11223' }, issuetype: { id: '10001' }, summary: '', description: "*ID*: 2\n_Issue_: !" } }.to_json
).once ).once
expect(issue.attrs[:errors]).to eq(errors) expect(issue.errors).to eq('summary' => 'You must specify a summary of the issue.')
end
end
end
describe '#configured_to_create_issues_from_vulnerabilities?' do
subject(:configured_to_create_issues_from_vulnerabilities) { jira_service.configured_to_create_issues_from_vulnerabilities? }
context 'when is not active' do
before do
allow(jira_service).to receive(:active?).and_return(false)
end
it { is_expected.to be_falsey }
end
context 'when is active' do
before do
allow(jira_service).to receive(:active?).and_return(true)
end
context 'and jira_vulnerabilities_integration is disabled' do
before do
allow(jira_service).to receive(:jira_vulnerabilities_integration_enabled?).and_return(false)
end
it { is_expected.to be_falsey }
end
context 'and jira_vulnerabilities_integration is enabled' do
before do
allow(jira_service).to receive(:jira_vulnerabilities_integration_enabled?).and_return(true)
end
context 'and project key is missing' do
before do
allow(jira_service).to receive(:project_key).and_return('')
end
it { is_expected.to be_falsey }
end
context 'and project key is not missing' do
before do
allow(jira_service).to receive(:project_key).and_return('GV')
end
context 'and vulnerabilities issue type is missing' do
before do
allow(jira_service).to receive(:vulnerabilities_issuetype).and_return('')
end
it { is_expected.to be_falsey }
end
context 'and vulnerabilities issue type is not missing' do
before do
allow(jira_service).to receive(:vulnerabilities_issuetype).and_return('10001')
end
it { is_expected.to be_truthy }
end
end
end end
end end
end end
......
...@@ -28,4 +28,34 @@ RSpec.describe VulnerabilityPresenter do ...@@ -28,4 +28,34 @@ RSpec.describe VulnerabilityPresenter do
expect(path).to include("#L#{finding.location['start_line']}") expect(path).to include("#L#{finding.location['start_line']}")
end end
end end
describe '#jira_issue_description' do
let(:expected_jira_issue_description) do
<<-JIRA.strip_heredoc
Issue created from vulnerability [#{finding.vulnerability.id}|http://localhost/#{project.full_path}/-/security/vulnerabilities/#{finding.vulnerability.id}]
h3. Description:
Description of #{finding.vulnerability.title}
* Severity: high
* Confidence: medium
* Location: [aws-key.py:5|http://localhost/#{project.full_path}/-/blob/b83d6e391c22777fca1ed3012fce84f633d7fed0/aws-key.py#L5]
h3. Scanner:
* Name: Find Security Bugs
JIRA
end
it 'returns the jira description in string format' do
jira_issue_description = subject.jira_issue_description
expect(jira_issue_description).to eq(expected_jira_issue_description)
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating an External Issue Link' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
let_it_be(:finding) { create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high) }
let_it_be(:vulnerability) { create(:vulnerability, title: 'My vulnerability', project: project, findings: [finding]) }
let_it_be(:external_provider) { 'jira' }
let(:mutation) do
params = { id: vulnerability.to_global_id.to_s, link_type: 'CREATED', external_tracker: 'JIRA' }
graphql_mutation(:vulnerability_external_issue_link_create, params)
end
def mutation_response
graphql_mutation_response(:vulnerability_external_issue_link_create)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(security_dashboard: true, jira_vulnerabilities_integration: true)
end
it_behaves_like 'a mutation that returns a top-level access error'
it 'does not create external issue link' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Vulnerabilities::ExternalIssueLink, :count)
end
end
context 'when the user has permission' do
before do
vulnerability.project.add_developer(current_user)
end
context 'when security_dashboard is disabled' do
before do
stub_licensed_features(security_dashboard: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not '\
'exist or you don\'t have permission to perform this action']
end
context 'when security_dashboard is enabled' do
before do
stub_licensed_features(security_dashboard: true, jira_vulnerabilities_integration: true)
end
context 'when jira is not configured' do
it 'responds with error' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(['External provider service is not configured to create issues.'])
end
end
context 'when jira is configured' do
let!(:jira_service) { create(:jira_service, project: vulnerability.project, vulnerabilities_enabled: true, project_key: 'GV', vulnerabilities_issuetype: '10000') }
context 'when issue creation succeeds' do
before do
stub_request(:get, 'https://jira.example.com/rest/api/2/project/GV').to_return(status: 200, body: { 'id' => '10000' }.to_json)
stub_request(:post, 'https://jira.example.com/rest/api/2/issue')
.to_return(
status: 200,
body: jira_created_issue.to_json
)
end
let(:jira_created_issue) do
{
'id' => external_issue_id,
fields: {
project: { id: '11223' },
issuetype: { id: '10001' },
summary: 'Special Summary!?',
description: "*ID*: 2\n_Issue_: !",
created: '2020-06-25T15:39:30.000+0000',
updated: '2020-06-26T15:38:32.000+0000',
resolutiondate: '2020-06-27T13:23:51.000+0000',
labels: ['backend'],
status: { name: 'To Do' },
key: 'GV-5',
assignee: nil,
reporter: { name: 'admin', displayName: 'Admin' }
}
}
end
context 'and saving external issue link succeeds' do
let(:external_issue_id) { '10000' }
it 'creates the external issue link', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(Vulnerabilities::ExternalIssueLink, :count).by(1)
expect(mutation_response['errors']).to be_empty
expect(mutation_response.dig('externalIssueLink', 'externalIssue', 'relativeReference')).to eq('GV-5')
end
end
context 'and saving external issue link fails' do
let(:external_issue_id) { nil }
it 'creates the external issue link' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Vulnerabilities::ExternalIssueLink, :count)
end
end
end
context 'when issue creation fails' do
before do
stub_request(:get, 'https://jira.example.com/rest/api/2/project/GV').to_return(status: 200, body: { 'id' => '10000' }.to_json)
stub_request(:post, 'https://jira.example.com/rest/api/2/issue').to_return(status: 400, body: { 'errors' => ['bad request'] }.to_json)
end
it 'does not create the external issue link' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Vulnerabilities::ExternalIssueLink, :count)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating an External Issue Link' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:vulnerability_external_issue_link) { create(:vulnerabilities_external_issue_link) }
let(:mutation) do
params = { id: vulnerability_external_issue_link.to_global_id.to_s }
graphql_mutation(:vulnerability_external_issue_link_destroy, params)
end
def mutation_response
graphql_mutation_response(:vulnerability_external_issue_link_destroy)
end
context 'when the user does not have permission' do
before do
stub_licensed_features(security_dashboard: true)
end
it_behaves_like 'a mutation that returns a top-level access error'
it 'does not destroy external issue link' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Vulnerabilities::ExternalIssueLink, :count)
end
end
context 'when the user has permission' do
before do
vulnerability_external_issue_link.vulnerability.project.add_developer(current_user)
end
context 'when security_dashboard is disabled' do
before do
stub_licensed_features(security_dashboard: false)
end
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not '\
'exist or you don\'t have permission to perform this action']
end
context 'when security_dashboard is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
it 'destroys the external issue link' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(Vulnerabilities::ExternalIssueLink, :count).by(-1)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe VulnerabilityExternalIssueLinks::CreateService do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, :success, project: project) }
let(:finding) { create(:vulnerabilities_finding, pipelines: [pipeline], project: project, severity: :high) }
let(:vulnerability) { create(:vulnerability, title: 'My vulnerability', project: project, findings: [finding]) }
let(:external_provider) { 'jira' }
let(:service) { described_class.new(user, vulnerability, external_provider) }
subject(:create_external_issue_link) { service.execute }
context 'for jira' do
let(:configured_to_create_issues_from_vulnerabilities) { false }
let(:jira_service) { double(configured_to_create_issues_from_vulnerabilities?: configured_to_create_issues_from_vulnerabilities, project_key: 'GV') }
before do
allow(project).to receive(:jira_service).and_return(jira_service)
project.add_developer(user)
end
context 'when jira service is not configured to create issues' do
it { is_expected.not_to be_success }
it 'returns response with error messages' do
expect(subject.message).to eq(['External provider service is not configured to create issues.'])
end
end
context 'when jira service is configured to create issues' do
let(:configured_to_create_issues_from_vulnerabilities) { true }
context 'and there is already created external issue link for given vulnerability' do
before do
create(:vulnerabilities_external_issue_link, vulnerability: vulnerability)
end
it { is_expected.not_to be_success }
it 'returns response with error messages' do
expect(subject.message).to eq(['Vulnerability already has a "created" issue link'])
end
end
context 'and there is no external issue link created for given vulnerability' do
let(:jira_issue_id) { nil }
let(:errors) { {} }
let(:jira_issue) { double(has_errors?: errors.present?, id: jira_issue_id, errors: errors) }
before do
allow(jira_service).to receive(:create_issue).and_return(jira_issue)
end
it 'creates issue using jira service' do
expect(jira_service).to receive(:create_issue).with("Investigate vulnerability: #{vulnerability.title}", kind_of(String))
subject
end
context 'and issue creation fails in Jira' do
let(:errors) { { 'summary' => 'The issue summary is invalid.' } }
it { is_expected.not_to be_success }
it 'returns response with error messages' do
expect(subject.message).to eq(['The issue summary is invalid.'])
end
end
context 'and issue creation succeeds in Jira' do
context 'and external issue link creation fails' do
let(:jira_issue_id) { nil }
it { is_expected.not_to be_success }
it 'returns response with error messages' do
expect(subject.message).to eq(['External issue key can\'t be blank'])
end
end
context 'and external issue link creation succeeds' do
let(:jira_issue_id) { '10001' }
it { is_expected.to be_success }
it 'creates external issue link in database' do
expect {subject}.to change {::Vulnerabilities::ExternalIssueLink.count}.by(1)
end
it 'responds with created external issue link in payload', :aggregate_failures do
external_issue_link = subject.payload[:record]
expect(external_issue_link).to be_a(::Vulnerabilities::ExternalIssueLink)
expect(external_issue_link).to have_attributes(
external_type: 'jira',
external_project_key: 'GV',
external_issue_key: '10001',
link_type: 'created',
author_id: user.id,
vulnerability_id: vulnerability.id
)
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe VulnerabilityExternalIssueLinks::DestroyService do
let_it_be(:project) { create(:project) }
let_it_be(:vulnerability_external_issue_link, refind: true) { create(:vulnerabilities_external_issue_link, project: project) }
let(:service) { described_class.new(vulnerability_external_issue_link) }
subject(:delete_external_issue_link) { service.execute }
it 'deletes the specified vulnerability-external issue link' do
expect { delete_external_issue_link }.to change { Vulnerabilities::ExternalIssueLink.count }.by(-1)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment