Commit 39a4076a authored by Craig Smith's avatar Craig Smith Committed by Robert Speicher

Add pipeline securityReportSummary to GraphQL

To enable the frontend to display the
vulnerability count and scanned resources count
for a pipeline, this change adds those values
for each security scan type to GraphQL
parent d48fc458
# frozen_string_literal: true
module Resolvers
class ProjectPipelineResolver < BaseResolver
alias_method :project, :object
argument :iid, GraphQL::ID_TYPE,
required: true,
description: 'IID of the Pipeline, e.g., "1"'
def resolve(iid:)
BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
args[:key].ci_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) }
end
end
end
end
......@@ -42,3 +42,5 @@ module Types
end
end
end
Types::Ci::PipelineType.prepend_if_ee('::EE::Types::Ci::PipelineType')
......@@ -6,7 +6,8 @@ module Types
class Pipeline < BasePermissionType
graphql_name 'PipelinePermissions'
abilities :update_pipeline, :admin_pipeline, :destroy_pipeline
abilities :admin_pipeline, :destroy_pipeline
ability_field :update_pipeline, calls_gitaly: true
end
end
end
......
......@@ -165,6 +165,12 @@ module Types
description: 'Build pipelines of the project',
resolver: Resolvers::ProjectPipelinesResolver
field :pipeline,
Types::Ci::PipelineType,
null: true,
description: 'Build pipeline of the project',
resolver: Resolvers::ProjectPipelineResolver
field :sentry_detailed_error,
Types::ErrorTracking::SentryDetailedErrorType,
null: true,
......
......@@ -254,6 +254,7 @@ module Ci
scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
scope :for_ref, -> (ref) { where(ref: ref) }
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :with_reports, -> (reports_scope) do
......
......@@ -8172,6 +8172,11 @@ type Pipeline {
"""
iid: String!
"""
Vulnerability and scanned resource counts for each security scanner of the pipeline
"""
securityReportSummary: SecurityReportSummary
"""
SHA of the pipeline's commit
"""
......@@ -8904,6 +8909,16 @@ type Project {
"""
path: String!
"""
Build pipeline of the project
"""
pipeline(
"""
IID of the Pipeline, e.g., "1"
"""
iid: ID!
): Pipeline
"""
Build pipelines of the project
"""
......@@ -10789,6 +10804,51 @@ type RunDASTScanPayload {
pipelineUrl: String
}
"""
Represents summary of a security report
"""
type SecurityReportSummary {
"""
Aggregated counts for the container_scanning scan
"""
containerScanning: SecurityReportSummarySection
"""
Aggregated counts for the dast scan
"""
dast: SecurityReportSummarySection
"""
Aggregated counts for the dependency_scanning scan
"""
dependencyScanning: SecurityReportSummarySection
"""
Aggregated counts for the sast scan
"""
sast: SecurityReportSummarySection
"""
Aggregated counts for the secret_detection scan
"""
secretDetection: SecurityReportSummarySection
}
"""
Represents a section of a summary of a security report
"""
type SecurityReportSummarySection {
"""
Total number of scanned resources
"""
scannedResourcesCount: Int
"""
Total number of vulnerabilities
"""
vulnerabilitiesCount: Int
}
"""
A Sentry error.
"""
......
......@@ -24362,6 +24362,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "securityReportSummary",
"description": "Vulnerability and scanned resource counts for each security scanner of the pipeline",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummary",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sha",
"description": "SHA of the pipeline's commit",
......@@ -26270,6 +26284,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipeline",
"description": "Build pipeline of the project",
"args": [
{
"name": "iid",
"description": "IID of the Pipeline, e.g., \"1\"",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Pipeline",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipelines",
"description": "Build pipelines of the project",
......@@ -31642,6 +31683,130 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SecurityReportSummary",
"description": "Represents summary of a security report",
"fields": [
{
"name": "containerScanning",
"description": "Aggregated counts for the container_scanning scan",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dast",
"description": "Aggregated counts for the dast scan",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dependencyScanning",
"description": "Aggregated counts for the dependency_scanning scan",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sast",
"description": "Aggregated counts for the sast scan",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "secretDetection",
"description": "Aggregated counts for the secret_detection scan",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SecurityReportSummarySection",
"description": "Represents a section of a summary of a security report",
"fields": [
{
"name": "scannedResourcesCount",
"description": "Total number of scanned resources",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "vulnerabilitiesCount",
"description": "Total number of vulnerabilities",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SentryDetailedError",
......@@ -1242,6 +1242,7 @@ Information about pagination in a connection.
| `finishedAt` | Time | Timestamp of the pipeline's completion |
| `id` | ID! | ID of the pipeline |
| `iid` | String! | Internal ID of the pipeline |
| `securityReportSummary` | SecurityReportSummary | Vulnerability and scanned resource counts for each security scanner of the pipeline |
| `sha` | String! | SHA of the pipeline's commit |
| `startedAt` | Time | Timestamp when the pipeline was started |
| `status` | PipelineStatusEnum! | Status of the pipeline (CREATED, WAITING_FOR_RESOURCE, PREPARING, PENDING, RUNNING, FAILED, SUCCESS, CANCELED, SKIPPED, MANUAL, SCHEDULED) |
......@@ -1296,6 +1297,7 @@ Information about pagination in a connection.
| `onlyAllowMergeIfPipelineSucceeds` | Boolean | Indicates if merge requests of the project can only be merged with successful jobs |
| `openIssuesCount` | Int | Number of open issues for the project |
| `path` | String! | Path of the project |
| `pipeline` | Pipeline | Build pipeline of the project |
| `printingMergeRequestLinkEnabled` | Boolean | Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line |
| `publicJobs` | Boolean | Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts |
| `release` | Release | A single release of the project. Available only when feature flag `graphql_release_data` is enabled |
......@@ -1531,6 +1533,27 @@ Autogenerated return type of RunDASTScan
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `pipelineUrl` | String | URL of the pipeline that was created. |
## SecurityReportSummary
Represents summary of a security report
| Name | Type | Description |
| --- | ---- | ---------- |
| `containerScanning` | SecurityReportSummarySection | Aggregated counts for the container_scanning scan |
| `dast` | SecurityReportSummarySection | Aggregated counts for the dast scan |
| `dependencyScanning` | SecurityReportSummarySection | Aggregated counts for the dependency_scanning scan |
| `sast` | SecurityReportSummarySection | Aggregated counts for the sast scan |
| `secretDetection` | SecurityReportSummarySection | Aggregated counts for the secret_detection scan |
## SecurityReportSummarySection
Represents a section of a summary of a security report
| Name | Type | Description |
| --- | ---- | ---------- |
| `scannedResourcesCount` | Int | Total number of scanned resources |
| `vulnerabilitiesCount` | Int | Total number of vulnerabilities |
## SentryDetailedError
A Sentry error.
......
# frozen_string_literal: true
module EE
module Types
module Ci
module PipelineType
extend ActiveSupport::Concern
prepended do
field :security_report_summary,
::Types::SecurityReportSummaryType,
null: true,
extras: [:lookahead],
description: 'Vulnerability and scanned resource counts for each security scanner of the pipeline',
resolver: ::Resolvers::SecurityReportSummaryResolver
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
class SecurityReportSummaryResolver < BaseResolver
type Types::SecurityReportSummaryType, null: true
alias_method :pipeline, :object
def resolve(lookahead:)
Security::ReportSummaryService.new(
pipeline,
selection_information(lookahead)
).execute
end
private
def selection_information(lookahead)
lookahead.selections.each_with_object({}) do |report_type, response|
response[report_type.name.to_sym] = report_type.selections.map(&:name)
end
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class SecurityReportSummarySectionType < BaseObject
graphql_name 'SecurityReportSummarySection'
description 'Represents a section of a summary of a security report'
field :vulnerabilities_count, GraphQL::INT_TYPE, null: true, description: 'Total number of vulnerabilities'
field :scanned_resources_count, GraphQL::INT_TYPE, null: true, description: 'Total number of scanned resources'
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class SecurityReportSummaryType < BaseObject
graphql_name 'SecurityReportSummary'
description 'Represents summary of a security report'
::Vulnerabilities::Occurrence::REPORT_TYPES.keys.each do |report_type|
field report_type, ::Types::SecurityReportSummarySectionType, null: true,
description: "Aggregated counts for the #{report_type} scan"
end
end
end
......@@ -34,6 +34,10 @@ module EE
.for_ref(ref)
.for_project_paths(project_path)
end
scope :security_scans_scanned_resources_count, -> (report_types) do
joins(:security_scans).where(security_scans: { scan_type: report_types }).group(:scan_type).sum(:scanned_resources_count)
end
end
def shared_runners_minutes_limit_enabled?
......
# frozen_string_literal: true
module Security
class ReportSummaryService
include Gitlab::Utils::StrongMemoize
# @param [Ci::Pipeline] pipeline
# @param [Hash[Symbol, Array[Symbol]] selection_information keys must be in the set of Vulnerabilities::Occurrence::REPORT_TYPES for example: {dast: [:scanned_resources_count, :vulnerabilities_count], container_scanning:[:vulnerabilities_count]}
def initialize(pipeline, selection_information)
@pipeline = pipeline
@selection_information = selection_information
end
def execute
@selection_information.each_with_object({}) do |(report_type, summary_types), response|
response[report_type] = summary_counts_for_report_type(report_type, summary_types)
end
end
private
def summary_counts_for_report_type(report_type, summary_types)
summary_types.each_with_object({}) do |summary_type, response|
case summary_type
when :vulnerabilities_count
response[:vulnerabilities_count] = vulnerability_counts[report_type.to_s]
when :scanned_resources_count
response[:scanned_resources_count] = scanned_resources_counts[report_type.to_s]
end
end
end
def requested_report_types(summary_type)
@report_types_for_summary_type ||= Gitlab::Utils.multiple_key_invert(@selection_information)
@report_types_for_summary_type[summary_type].map(&:to_s)
end
def vulnerability_counts
strong_memoize(:vulnerability_counts) do
::Security::VulnerabilityCountingService.new(@pipeline, requested_report_types(:vulnerabilities_count)).execute
end
end
def scanned_resources_counts
strong_memoize(:scanned_resources_counts) do
::Security::ScannedResourcesCountingService.new(@pipeline, requested_report_types(:scanned_resources_count)).execute
end
end
end
end
# frozen_string_literal: true
module Security
# Service for counting the number of scanned resources for
# an array of report types within a pipeline
#
class ScannedResourcesCountingService
# @param [Ci::Pipeline] pipeline
# @param Array[Symbol] report_types Summary report types. Valid values are members of Vulnerabilities::Occurrence::REPORT_TYPES
def initialize(pipeline, report_types)
@pipeline = pipeline
@report_types = report_types
end
def execute
@pipeline.builds
.security_scans_scanned_resources_count(@report_types)
.transform_keys { |k| Security::Scan.scan_types.key(k) }
.reverse_merge(no_counts)
end
def no_counts
@report_types.zip([0].cycle).to_h
end
end
end
# frozen_string_literal: true
module Security
# Service for counting the number of vulnerability findings for
# an array of report types within a pipeline
#
class VulnerabilityCountingService
# @param [Ci::Pipeline] pipeline
# @param Array[String] report_types Summary report types. Valid values are members of Vulnerabilities::Occurrence::REPORT_TYPES
def initialize(pipeline, report_types)
@pipeline = pipeline
@report_types = report_types
end
def execute
findings = ::Security::PipelineVulnerabilitiesFinder.new(pipeline: @pipeline, params: { report_type: @report_types }).execute
findings.occurrences.group_by(&:report_type).compact.transform_values(&:size).reverse_merge(no_counts)
end
private
def no_counts
@report_types.zip([0].cycle).to_h
end
end
end
---
title: Add Pipeline.securityReportSummary to GraphQL
merge_request: 31550
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::SecurityReportSummaryResolver do
include GraphqlHelpers
let_it_be(:pipeline) { double('project') }
describe '#resolve' do
context 'All fields are requested' do
let(:lookahead) do
build_mock_lookahead(expected_selection_info)
end
let(:expected_selection_info) do
{
dast: [:scanned_resources_count, :vulnerabilities_count],
sast: [:scanned_resources_count, :vulnerabilities_count],
container_scanning: [:scanned_resources_count, :vulnerabilities_count],
dependency_scanning: [:scanned_resources_count, :vulnerabilities_count]
}
end
it 'returns calls the ReportSummaryService' do
expect_next_instance_of(
Security::ReportSummaryService,
pipeline,
expected_selection_info
) do |summary_service|
expect(summary_service).to receive(:execute)
end
resolve(described_class, obj: pipeline, args: { lookahead: lookahead })
end
end
end
end
def build_mock_lookahead(structure)
lookahead_selections = structure.map do |report_type, count_types|
stub_count_types = count_types.map do |count_type|
double(count_type, name: count_type)
end
double(report_type, name: report_type, selections: stub_count_types)
end
double('lookahead', selections: lookahead_selections)
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Pipeline'] do
it { expect(described_class.graphql_name).to eq('Pipeline') }
it 'includes the ee specific fields' do
expected_fields = %w[
security_report_summary
]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['SecurityReportSummarySection'] do
specify { expect(described_class.graphql_name).to eq('SecurityReportSummarySection') }
it 'has specific fields' do
expected_fields = %w[vulnerabilities_count scanned_resources_count]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['SecurityReportSummary'] do
specify { expect(described_class.graphql_name).to eq('SecurityReportSummary') }
it 'has specific fields' do
expected_fields = %w[dast sast containerScanning dependencyScanning]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Security::ReportSummaryService, '#execute' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
before_all do
create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dast, job: job, project: project)
end
create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :sast, job: job, project: project)
end
create(:ci_build, :success, name: 'cs_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :container_scanning, job: job, project: project)
end
create(:ci_build, :success, name: 'ds_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dependency_scanning, job: job, project: project)
end
create_security_scan(project, pipeline, 'dast', 34)
create_security_scan(project, pipeline, 'sast', 12)
end
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, dast: true)
end
let(:result) do
described_class.new(
pipeline,
selection_information
).execute
end
context 'Some fields are requested' do
let(:selection_information) do
{
dast: [:scanned_resources_count, :vulnerabilities_count],
container_scanning: [:vulnerabilities_count]
}
end
it 'returns only the request fields' do
expect(result).to eq({
dast: { scanned_resources_count: 34, vulnerabilities_count: 20 },
container_scanning: { vulnerabilities_count: 8 }
})
end
end
context 'When some fields are not requested' do
let(:selection_information) do
{
dast: [:scanned_resources_count]
}
end
it 'does not make needless queries' do
expect(::Security::VulnerabilityCountingService).not_to receive(:new)
expect_next_instance_of(::Security::ScannedResourcesCountingService, anything, ['dast']) do |service|
expect(service).to receive(:execute).and_return({})
end
result
end
end
context 'All fields are requested' do
let(:selection_information) do
{
dast: [:scanned_resources_count, :vulnerabilities_count],
sast: [:scanned_resources_count, :vulnerabilities_count],
container_scanning: [:scanned_resources_count, :vulnerabilities_count],
dependency_scanning: [:scanned_resources_count, :vulnerabilities_count]
}
end
it 'returns the scanned_resources_count' do
expect(result).to match(a_hash_including(
dast: a_hash_including(scanned_resources_count: 34),
sast: a_hash_including(scanned_resources_count: 12),
container_scanning: a_hash_including(scanned_resources_count: 0),
dependency_scanning: a_hash_including(scanned_resources_count: 0)
))
end
it 'returns the vulnerability count' do
expect(result).to match(a_hash_including(
dast: a_hash_including(vulnerabilities_count: 20),
sast: a_hash_including(vulnerabilities_count: 33),
container_scanning: a_hash_including(vulnerabilities_count: 8),
dependency_scanning: a_hash_including(vulnerabilities_count: 4)
))
end
context 'When ran no security scans' do
let(:pipeline) { create(:ci_pipeline, :success) }
it 'returns 0 vulnerabilities' do
expect(result[:dast][:vulnerabilities_count]).to be(0)
end
it 'returns 0 scanned resources' do
expect(result[:dast][:scanned_resources_count]).to be(0)
end
end
end
end
def create_security_scan(project, pipeline, report_type, scanned_resources_count)
dast_build = create(:ee_ci_build, :artifacts, project: project, pipeline: pipeline, name: report_type)
create(:security_scan, scan_type: report_type, scanned_resources_count: scanned_resources_count, build: dast_build)
end
# frozen_string_literal: true
require 'spec_helper'
describe Security::ScannedResourcesCountingService, '#execute' do
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, dast: true)
end
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
context "The Pipeline has security builds" do
before_all do
create_security_scan(project, pipeline, 'dast', 34)
create_security_scan(project, pipeline, 'sast', 12)
end
context 'All report types are requested' do
subject { described_class.new(pipeline, %w[sast dast container_scanning dependency_scanning]).execute }
it {
is_expected.to match(a_hash_including("sast" => 12,
"dast" => 34,
"container_scanning" => 0,
"dependency_scanning" => 0))
}
end
context 'Only the report type dast is requested' do
subject { described_class.new(pipeline, %w[dast]).execute }
it {
is_expected.to eq({ "dast" => 34 })
}
end
end
context "The Pipeline has no security builds" do
let_it_be(:pipeline) { create(:ci_pipeline, :success) }
subject { described_class.new(pipeline, %w[sast dast container_scanning dependency_scanning]).execute }
it {
is_expected.to match(a_hash_including("sast" => 0,
"dast" => 0,
"container_scanning" => 0,
"dependency_scanning" => 0))
}
end
context 'performance' do
subject { described_class.new(pipeline, %w[sast dast container_scanning dependency_scanning]).execute }
it 'performs only one query' do
count = ActiveRecord::QueryRecorder.new { subject }.count
expect(count).to eq(1)
end
end
end
def create_security_scan(project, pipeline, report_type, scanned_resources_count)
dast_build = create(:ee_ci_build, :artifacts, project: project, pipeline: pipeline, name: report_type)
create(:security_scan, scan_type: report_type, scanned_resources_count: scanned_resources_count, build: dast_build)
end
# frozen_string_literal: true
require 'spec_helper'
describe Security::VulnerabilityCountingService, '#execute' do
before do
stub_licensed_features(sast: true, dependency_scanning: true, container_scanning: true, dast: true)
end
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, :success, project: project) }
context "The pipeline has security builds" do
before_all do
create(:ci_build, :success, name: 'dast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dast, job: job, project: project)
end
create(:ci_build, :success, name: 'sast_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :sast, job: job, project: project)
end
create(:ci_build, :success, name: 'cs_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :container_scanning, job: job, project: project)
end
create(:ci_build, :success, name: 'ds_job', pipeline: pipeline, project: project) do |job|
create(:ee_ci_job_artifact, :dependency_scanning, job: job, project: project)
end
end
context 'All report types are requested' do
subject { described_class.new(pipeline, %w[sast dast container_scanning dependency_scanning]).execute }
it {
is_expected.to match(a_hash_including("sast" => 33,
"dast" => 20,
"container_scanning" => 8,
"dependency_scanning" => 4))
}
end
context 'Only the report type dast is requested' do
subject { described_class.new(pipeline, %w[dast]).execute }
it {
is_expected.to eq({ "dast" => 20 })
}
end
end
context "The Pipeline has no security builds" do
let_it_be(:pipeline) { create(:ci_pipeline, :success) }
subject { described_class.new(pipeline, %w[sast dast container_scanning dependency_scanning]).execute }
it {
is_expected.to match(a_hash_including("sast" => 0,
"dast" => 0,
"container_scanning" => 0,
"dependency_scanning" => 0))
}
end
context 'performance' do
it 'performs only one query' do
count = ActiveRecord::QueryRecorder.new { described_class.new(pipeline, %w[dast]).execute }.count
expect(count).to eq(1)
count = ActiveRecord::QueryRecorder.new { described_class.new(pipeline, %w[dast sast container_scanning]).execute }.count
expect(count).to eq(1)
end
end
end
......@@ -160,5 +160,23 @@ module Gitlab
Addressable::URI.parse(uri_string)
rescue Addressable::URI::InvalidURIError, TypeError
end
# Invert a hash, collecting all keys that map to a given value in an array.
#
# Unlike `Hash#invert`, where the last encountered pair wins, and which has the
# type `Hash[k, v] => Hash[v, k]`, `multiple_key_invert` does not lose any
# information, has the type `Hash[k, v] => Hash[v, Array[k]]`, and the original
# hash can always be reconstructed.
#
# example:
#
# multiple_key_invert({ a: 1, b: 2, c: 1 })
# # => { 1 => [:a, :c], 2 => [:b] }
#
def multiple_key_invert(hash)
hash.flat_map { |k, v| Array.wrap(v).zip([k].cycle) }
.group_by(&:first)
.transform_values { |kvs| kvs.map(&:last) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::ProjectPipelineResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, iid: '1234') }
let_it_be(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
def resolve_pipeline(project, args)
resolve(described_class, obj: project, args: args, ctx: { current_user: current_user })
end
it 'resolves pipeline for the passed iid' do
result = batch_sync do
resolve_pipeline(project, { iid: '1234' })
end
expect(result).to eq(pipeline)
end
it 'does not resolve a pipeline outside the project' do
result = batch_sync do
resolve_pipeline(other_pipeline.project, { iid: '1234' })
end
expect(result).to be_nil
end
it 'errors when no iid is passed' do
expect { resolve_pipeline(project, {}) }.to raise_error(ArgumentError)
end
end
......@@ -345,4 +345,17 @@ describe Gitlab::Utils do
expect(described_class.parse_url(1)).to be nil
end
end
describe 'multiple_key_invert' do
it 'invert keys with array values' do
hash = {
dast: [:vulnerabilities_count, :scanned_resources_count],
sast: [:vulnerabilities_count]
}
expect(described_class.multiple_key_invert(hash)).to eq({
vulnerabilities_count: [:dast, :sast],
scanned_resources_count: [:dast]
})
end
end
end
......@@ -121,6 +121,17 @@ describe Ci::Pipeline, :mailer do
end
end
describe '.for_iid' do
subject { described_class.for_iid(iid) }
let(:iid) { '1234' }
let!(:pipeline) { create(:ci_pipeline, iid: '1234') }
it 'returns the pipeline' do
is_expected.to contain_exactly(pipeline)
end
end
describe '.for_sha' do
subject { described_class.for_sha(sha) }
......
# frozen_string_literal: true
require 'spec_helper'
describe 'getting pipeline information nested in a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:current_user) { create(:user) }
let(:pipeline_graphql_data) { graphql_data['project']['pipeline'] }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('pipeline', iid: pipeline.iid.to_s)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'contains pipeline information' do
post_graphql(query, current_user: current_user)
expect(pipeline_graphql_data).not_to be_nil
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