Commit 334beb40 authored by Alan (Maciej) Paruszewski's avatar Alan (Maciej) Paruszewski Committed by Mikołaj Wawrzyniak

Extend Vulnerability GraphQL API with External Issue Links

This change adds externalIssueLinks to Vulnerability GraphQL API to
fetch linked issues in external tracker.
parent 24bf932b
......@@ -8817,6 +8817,46 @@ enum EpicWildcardId {
NONE
}
"""
Represents an external issue
"""
type ExternalIssue {
"""
Timestamp of when the issue was created
"""
createdAt: Time
"""
Type of external tracker
"""
externalTracker: String
"""
Relative reference of the issue in the external tracker
"""
relativeReference: String
"""
Status of the issue in the external tracker
"""
status: String
"""
Title of the issue in the external tracker
"""
title: String
"""
Timestamp of when the issue was updated
"""
updatedAt: Time
"""
URL to the issue in the external tracker
"""
webUrl: String
}
type GeoNode {
"""
The maximum concurrency of container repository sync for this secondary node
......@@ -24757,6 +24797,11 @@ type VulnerabilitiesCountByDayEdge {
node: VulnerabilitiesCountByDay
}
"""
Identifier of Vulnerabilities::ExternalIssueLink
"""
scalar VulnerabilitiesExternalIssueLinkID
"""
Represents a vulnerability
"""
......@@ -24801,6 +24846,31 @@ type Vulnerability implements Noteable {
last: Int
): DiscussionConnection!
"""
List of external issue links related to the vulnerability
"""
externalIssueLinks(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): VulnerabilityExternalIssueLinkConnection!
"""
GraphQL ID of the vulnerability
"""
......@@ -25039,6 +25109,71 @@ type VulnerabilityEdge {
node: Vulnerability
}
"""
Represents an external issue link of a vulnerability
"""
type VulnerabilityExternalIssueLink {
"""
The external issue attached to the issue link
"""
externalIssue: ExternalIssue
"""
GraphQL ID of the external issue link
"""
id: VulnerabilitiesExternalIssueLinkID!
"""
Type of the external issue link
"""
linkType: VulnerabilityExternalIssueLinkType!
}
"""
The connection type for VulnerabilityExternalIssueLink.
"""
type VulnerabilityExternalIssueLinkConnection {
"""
A list of edges.
"""
edges: [VulnerabilityExternalIssueLinkEdge]
"""
A list of nodes.
"""
nodes: [VulnerabilityExternalIssueLink]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type VulnerabilityExternalIssueLinkEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: VulnerabilityExternalIssueLink
}
"""
The type of the external issue link related to a vulnerability
"""
enum VulnerabilityExternalIssueLinkType {
"""
Created link type
"""
CREATED
}
"""
The grade of the vulnerable project
"""
......@@ -25307,6 +25442,11 @@ type VulnerabilityPermissions {
"""
adminVulnerability: Boolean!
"""
Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource
"""
adminVulnerabilityExternalIssueLink: Boolean!
"""
Indicates the user can perform `admin_vulnerability_issue_link` on this resource
"""
......
......@@ -24704,6 +24704,117 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ExternalIssue",
"description": "Represents an external issue",
"fields": [
{
"name": "createdAt",
"description": "Timestamp of when the issue was created",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalTracker",
"description": "Type of external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "relativeReference",
"description": "Relative reference of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "Status of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": "Title of the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp of when the issue was updated",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "webUrl",
"description": "URL to the issue in the external tracker",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Float",
......@@ -72114,6 +72225,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "VulnerabilitiesExternalIssueLinkID",
"description": "Identifier of Vulnerabilities::ExternalIssueLink",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Vulnerability",
......@@ -72222,6 +72343,63 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "externalIssueLinks",
"description": "List of external issue links related to the vulnerability",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "GraphQL ID of the vulnerability",
......@@ -72908,6 +73086,198 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"description": "Represents an external issue link of a vulnerability",
"fields": [
{
"name": "externalIssue",
"description": "The external issue attached to the issue link",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ExternalIssue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "GraphQL ID of the external issue link",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "VulnerabilitiesExternalIssueLinkID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "linkType",
"description": "Type of the external issue link",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkConnection",
"description": "The connection type for VulnerabilityExternalIssueLink.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLinkEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "VulnerabilityExternalIssueLink",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "VulnerabilityExternalIssueLinkType",
"description": "The type of the external issue link related to a vulnerability",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "CREATED",
"description": "Created link type",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "VulnerabilityGrade",
......@@ -73708,6 +74078,24 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "adminVulnerabilityExternalIssueLink",
"description": "Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "adminVulnerabilityIssueLink",
"description": "Indicates the user can perform `admin_vulnerability_issue_link` on this resource",
......@@ -1495,6 +1495,20 @@ Autogenerated return type of EpicTreeReorder.
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### ExternalIssue
Represents an external issue.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `createdAt` | Time | Timestamp of when the issue was created |
| `externalTracker` | String | Type of external tracker |
| `relativeReference` | String | Relative reference of the issue in the external tracker |
| `status` | String | Status of the issue in the external tracker |
| `title` | String | Title of the issue in the external tracker |
| `updatedAt` | Time | Timestamp of when the issue was updated |
| `webUrl` | String | URL to the issue in the external tracker |
### GeoNode
| Field | Type | Description |
......@@ -3731,6 +3745,7 @@ Represents a vulnerability.
| `description` | String | Description of the vulnerability |
| `detectedAt` | Time! | Timestamp of when the vulnerability was first detected |
| `discussions` | DiscussionConnection! | All discussions on this noteable |
| `externalIssueLinks` | VulnerabilityExternalIssueLinkConnection! | List of external issue links related to the vulnerability |
| `id` | ID! | GraphQL ID of the vulnerability |
| `identifiers` | VulnerabilityIdentifier! => Array | Identifiers of the vulnerability. |
| `issueLinks` | VulnerabilityIssueLinkConnection! | List of issue links related to the vulnerability |
......@@ -3768,6 +3783,16 @@ Autogenerated return type of VulnerabilityDismiss.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `vulnerability` | Vulnerability | The vulnerability after dismissal |
### VulnerabilityExternalIssueLink
Represents an external issue link of a vulnerability.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `externalIssue` | ExternalIssue | The external issue attached to the issue link |
| `id` | VulnerabilitiesExternalIssueLinkID! | GraphQL ID of the external issue link |
| `linkType` | VulnerabilityExternalIssueLinkType! | Type of the external issue link |
### VulnerabilityIdentifier
Represents a vulnerability identifier.
......@@ -3862,6 +3887,7 @@ Check permissions for the current user on a vulnerability.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `adminVulnerability` | Boolean! | Indicates the user can perform `admin_vulnerability` on this resource |
| `adminVulnerabilityExternalIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_external_issue_link` on this resource |
| `adminVulnerabilityIssueLink` | Boolean! | Indicates the user can perform `admin_vulnerability_issue_link` on this resource |
| `createVulnerability` | Boolean! | Indicates the user can perform `create_vulnerability` on this resource |
| `createVulnerabilityExport` | Boolean! | Indicates the user can perform `create_vulnerability_export` on this resource |
......@@ -4764,6 +4790,14 @@ Possible states of a user.
| `private` | |
| `public` | |
### VulnerabilityExternalIssueLinkType
The type of the external issue link related to a vulnerability.
| Value | Description |
| ----- | ----------- |
| `CREATED` | Created link type |
### VulnerabilityGrade
The grade of the vulnerable project.
......
# frozen_string_literal: true
module Projects
module Integrations
module Jira
class ByIdsFinder
include ReactiveService
self.reactive_cache_key = ->(finder) { [finder.model_name] }
self.reactive_cache_work_type = :external_dependency
self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) }
attr_reader :project, :jira_issue_ids
def self.from_cache(project_id, jira_issue_ids)
project = Project.find(project_id)
new(project, jira_issue_ids)
end
def initialize(project, jira_issue_ids)
@project = project
@jira_issue_ids = jira_issue_ids
end
def execute
with_reactive_cache(*cache_args) { |issues| issues }
end
def calculate_reactive_cache(*)
# rubocop: disable CodeReuse/Finder
::Projects::Integrations::Jira::IssuesFinder
.new(project, issue_ids: jira_issue_ids)
.execute
.then { |issues| { issues: issues, error: nil } }
rescue ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, ::Projects::Integrations::Jira::IssuesFinder::RequestError => error
{ issues: [], error: error.message }
# rubocop: enable CodeReuse/Finder
end
def clear_cache!
clear_reactive_cache!(*cache_args)
end
def model_name
self.class.name.underscore.tr('/', '_')
end
def cache_args
[project.id, jira_issue_ids]
end
private
def id
nil
end
end
end
end
end
......@@ -13,7 +13,7 @@ module Projects
def valid_params
@valid_params ||= %i[page per_page search state status author_username assignee_username]
# to permit array params you need to init them to an empty array
@valid_params << { labels: [], vulnerability_ids: [] }
@valid_params << { labels: [], vulnerability_ids: [], issue_ids: [] }
end
end
......
# frozen_string_literal: true
module Resolvers
class ExternalIssueResolver < BaseResolver
description 'Retrieve a single issue from external tracker'
type Types::ExternalIssueType, null: true
def resolve
BatchLoader::GraphQL.for(object.external_issue_key).batch(key: object.external_type) do |external_issue_keys, loader, args|
case args[:key]
when 'jira'
jira_issues(external_issue_keys).each do |external_issue|
loader.call(
external_issue.id,
::Integrations::Jira::IssueSerializer.new.represent(external_issue, project: object.vulnerability.project)
)
end
end
end
end
private
def jira_issues(issue_ids)
result = ::Projects::Integrations::Jira::ByIdsFinder.new(object.vulnerability.project, issue_ids).execute
return [] if result.nil?
raise GraphQL::ExecutionError, result[:error] if result[:error].present?
result[:issues]
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class ExternalIssueType < BaseObject
graphql_name 'ExternalIssue'
description 'Represents an external issue'
field :title, GraphQL::STRING_TYPE, null: true,
description: 'Title of the issue in the external tracker'
field :relative_reference, GraphQL::STRING_TYPE, null: true,
description: 'Relative reference of the issue in the external tracker'
field :status, GraphQL::STRING_TYPE, null: true,
description: 'Status of the issue in the external tracker'
field :external_tracker, GraphQL::STRING_TYPE, null: true,
description: 'Type of external tracker'
field :web_url, GraphQL::STRING_TYPE, null: true,
description: 'URL to the issue in the external tracker'
field :created_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was created'
field :updated_at, Types::TimeType, null: true,
description: 'Timestamp of when the issue was updated'
def relative_reference
object.dig(:references, :relative)
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
......@@ -8,7 +8,7 @@ module Types
abilities :read_vulnerability_feedback, :create_vulnerability_feedback, :destroy_vulnerability_feedback,
:update_vulnerability_feedback, :create_vulnerability, :create_vulnerability_export,
:admin_vulnerability, :admin_vulnerability_issue_link
:admin_vulnerability, :admin_vulnerability_issue_link, :admin_vulnerability_external_issue_link
end
end
end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkType < BaseObject
graphql_name 'VulnerabilityExternalIssueLink'
description 'Represents an external issue link of a vulnerability'
authorize :read_vulnerability
field :id, GlobalIDType[::Vulnerabilities::ExternalIssueLink], null: false,
description: 'GraphQL ID of the external issue link'
field :link_type, ::Types::Vulnerability::ExternalIssueLinkTypeEnum, null: false,
description: 'Type of the external issue link'
field :external_issue, ::Types::ExternalIssueType, null: true,
description: 'The external issue attached to the issue link',
resolver: Resolvers::ExternalIssueResolver
end
end
end
# frozen_string_literal: true
module Types
module Vulnerability
class ExternalIssueLinkTypeEnum < BaseEnum
graphql_name 'VulnerabilityExternalIssueLinkType'
description 'The type of the external issue link related to a vulnerability'
::Vulnerabilities::ExternalIssueLink.link_types.keys.each do |link_type|
value link_type.to_s.upcase, value: link_type.to_s, description: "#{link_type.titleize} link type"
end
end
end
end
......@@ -42,6 +42,9 @@ module Types
description: "List of issue links related to the vulnerability",
resolver: Resolvers::Vulnerabilities::IssueLinksResolver
field :external_issue_links, ::Types::Vulnerability::ExternalIssueLinkType.connection_type, null: false,
description: 'List of external issue links related to the vulnerability'
field :location, VulnerabilityLocationType, null: true,
description: 'Location metadata for the vulnerability. Its fields depend on the type of security scan that found the vulnerability'
......
......@@ -223,6 +223,7 @@ module EE
enable :create_vulnerability_export
enable :admin_vulnerability
enable :admin_vulnerability_issue_link
enable :admin_vulnerability_external_issue_link
end
rule { issues_disabled & merge_requests_disabled }.policy do
......@@ -274,6 +275,7 @@ module EE
prevent :create_vulnerability
prevent :admin_vulnerability
prevent :admin_vulnerability_issue_link
prevent :admin_vulnerability_external_issue_link
end
rule { auditor & ~guest }.policy do
......
# frozen_string_literal: true
module Vulnerabilities
class ExternalIssueLinkPolicy < BasePolicy
delegate { @subject.vulnerability.project }
end
end
......@@ -19,6 +19,7 @@ module Jira
@sort = params[:sort] || DEFAULT_SORT
@sort_direction = params[:sort_direction] || DEFAULT_SORT_DIRECTION
@vulnerability_ids = params[:vulnerability_ids]
@issue_ids = params[:issue_ids]
end
def execute
......@@ -30,7 +31,7 @@ module Jira
private
attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids
attr_reader :jira_project_key, :sort, :sort_direction, :search, :labels, :status, :reporter, :assignee, :state, :vulnerability_ids, :issue_ids
def jql_filters
[
......@@ -41,7 +42,8 @@ module Jira
by_assignee,
by_open_and_closed,
by_summary_and_description,
by_vulnerability_ids
by_vulnerability_ids,
by_issue_ids
].compact.join(' AND ')
end
......@@ -104,6 +106,15 @@ module Jira
.then { |query| "(#{query})" }
end
def by_issue_ids
return if issue_ids.blank?
issue_ids
.map { |issue_id| %Q[id = #{escape_quotes(issue_id.to_s)}] }
.join(' OR ')
.then { |query| "(#{query})" }
end
def escape_quotes(param)
param.gsub('\\', '\\\\\\').gsub('"', '\\"')
end
......
---
title: Extend Vulnerability GraphQL API with External Issue Links
merge_request: 48616
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Integrations::Jira::ByIdsFinder do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }
let(:jira_issue_ids) { %w[10000 10001] }
let(:finder_params) { [project, issue_ids: jira_issue_ids] }
let(:by_ids_finder) { described_class.new(project, jira_issue_ids) }
describe '#execute' do
context 'when reactive_caching is still fetching data' do
it 'returns nil' do
expect(by_ids_finder.execute).to be_nil
end
end
context 'when reactive_caching has finished' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return([{ jira_issue: 1 }, { jira_issue: 2 }])
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns issues encapsulated in hash' do
expect(by_ids_finder.execute).to eq(issues: [{ jira_issue: 1 }, { jira_issue: 2 }], error: nil)
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::IntegrationError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::IntegrationError, 'project key not set')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'project key not set')
end
end
context 'when reactive_caching failed with ::Projects::Integrations::Jira::IssuesFinder::RequestError' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::IssuesFinder, *finder_params) do |issues_finder|
allow(issues_finder).to receive(:execute).and_raise(::Projects::Integrations::Jira::IssuesFinder::RequestError, 'jira instance not found')
end
synchronous_reactive_cache(by_ids_finder)
end
it 'returns empty issues list with error message' do
expect(by_ids_finder.execute).to eq(issues: [], error: 'jira instance not found')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ExternalIssueResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'when Jira issues are requested' do
let_it_be(:vulnerability_external_issue_link) { create(:vulnerabilities_external_issue_link) }
let(:jira_issue) do
double(
id: vulnerability_external_issue_link.external_issue_key,
summary: 'Issue Title',
created: Time.at(1606348800).utc,
updated: Time.at(1606348800).utc,
resolutiondate: Time.at(1606348800).utc,
status: double(name: 'To Do'),
key: 'GV-1',
labels: [],
reporter: double(displayName: 'User', accountId: '10000'),
assignee: nil,
client: double(options: { site: nil })
)
end
let(:expected_result) do
{
'project_id' => vulnerability_external_issue_link.vulnerability.project_id,
'title' => 'Issue Title',
'created_at' => '2020-11-26T00:00:00.000Z',
'updated_at' => '2020-11-26T00:00:00.000Z',
'closed_at' => '2020-11-26T00:00:00.000Z',
'status' => 'To Do',
'labels' => [],
'author' => {
'name' => 'User',
'web_url' => 'people/10000'
},
'assignees' => [],
'web_url' => 'browse/GV-1',
'references' => {
'relative' => 'GV-1'
},
'external_tracker' => 'jira'
}
end
context 'when Jira API responds with nil' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(nil)
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(nil)
end
batch_sync { resolve_external_issue({}) }
end
it 'returns nil' do
result = batch_sync { resolve_external_issue({}) }
expect(result).to be_nil
end
end
context 'when Jira API responds with found issues' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
end
it 'sends request to Jira to fetch issues' do
params = [vulnerability_external_issue_link.vulnerability.project, [vulnerability_external_issue_link.external_issue_key]]
expect_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder, *params) do |issues_finder|
expect(issues_finder).to receive(:execute).and_return(issues: [jira_issue])
end
batch_sync { resolve_external_issue({}) }
end
it 'returns serialized Jira issues' do
result = batch_sync { resolve_external_issue({}) }
expect(result.as_json).to eq(expected_result)
end
end
context 'when Jira API responds with an integration error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service not configured.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service not configured.')
end
end
context 'when Jira API responds with an request error' do
before do
allow_next_instance_of(::Projects::Integrations::Jira::ByIdsFinder) do |issues_finder|
allow(issues_finder).to receive(:execute).and_return(error: 'Jira service unavailable.')
end
end
it 'raises a GraphQL exception' do
expect { batch_sync { resolve_external_issue({}) } }.to raise_error(GraphQL::ExecutionError, 'Jira service unavailable.')
end
end
def resolve_external_issue(args)
resolve(described_class, obj: vulnerability_external_issue_link, args: args, ctx: { current_user: current_user })
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ExternalIssue'] do
let(:expected_fields) { %i[title relative_reference status external_tracker web_url created_at updated_at] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
......@@ -6,7 +6,7 @@ RSpec.describe Types::PermissionTypes::Vulnerability do
it do
expected_permissions = %i[read_vulnerability_feedback create_vulnerability_feedback destroy_vulnerability_feedback
update_vulnerability_feedback create_vulnerability create_vulnerability_export
admin_vulnerability admin_vulnerability_issue_link]
admin_vulnerability admin_vulnerability_issue_link admin_vulnerability_external_issue_link]
expected_permissions.each do |permission|
expect(described_class).to have_graphql_field(permission)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLinkType'] do
let(:expected_values) { %w[CREATED] }
subject { described_class.values.keys }
it { is_expected.to contain_exactly(*expected_values) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['VulnerabilityExternalIssueLink'] do
let(:expected_fields) { %i[id link_type external_issue] }
subject { described_class }
it { is_expected.to have_graphql_fields(expected_fields) }
end
......@@ -26,6 +26,7 @@ RSpec.describe GitlabSchema.types['Vulnerability'] do
detected_at
confirmed_at
notes
external_issue_links
discussions]
end
......
......@@ -24,7 +24,7 @@ RSpec.describe ProjectPolicy do
%i[
admin_vulnerability_feedback read_project_audit_events read_project_security_dashboard
read_vulnerability read_vulnerability_scanner create_vulnerability create_vulnerability_export admin_vulnerability
admin_vulnerability_issue_link read_merge_train
admin_vulnerability_issue_link admin_vulnerability_external_issue_link read_merge_train
]
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::ExternalIssueLinkPolicy do
let!(:user) { create(:user) }
let!(:project) { create(:project) }
let!(:vulnerability) { create(:vulnerability, project: project) }
let!(:vulnerability_external_issue_link) { build(:vulnerabilities_external_issue_link, vulnerability: vulnerability, author: user) }
subject { described_class.new(user, vulnerability_external_issue_link) }
context 'when the security_dashboard feature is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
context "when the current user has developer access to the vulnerability's project" do
before do
project.add_developer(user)
end
it { is_expected.to be_allowed(:admin_vulnerability_external_issue_link) }
end
context "when the current user does not have developer access to the vulnerability's project" do
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
context 'when the security_dashboard feature is disabled' do
before do
stub_licensed_features(security_dashboard: false)
project.add_developer(user)
end
it { is_expected.to be_disallowed(:admin_vulnerability_external_issue_link) }
end
end
......@@ -117,5 +117,13 @@ RSpec.describe Jira::JqlBuilderService do
expect(subject).to eq('project = PROJECT_KEY AND (description ~ "/-/security/vulnerabilities/1" OR description ~ "/-/security/vulnerabilities/25") order by created DESC')
end
end
context 'with issue_ids params' do
let(:params) { { issue_ids: %w[1 25] } }
it 'builds jql' do
expect(subject).to eq('project = PROJECT_KEY AND (id = 1 OR id = 25) order by created DESC')
end
end
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