Commit 8fcba2ee authored by Robert Speicher's avatar Robert Speicher

Merge branch 'add_securtiy_report_summary_214388' into 'master'

Add pipeline securityReportSummary to GraphQL

Closes #214388

See merge request gitlab-org/gitlab!31550
parents 71ead5cd 39a4076a
# 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
......
......@@ -8222,6 +8222,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
"""
......@@ -8954,6 +8959,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
"""
......@@ -10839,6 +10854,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.
"""
......
......@@ -24485,6 +24485,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",
......@@ -26393,6 +26407,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",
......@@ -31765,6 +31806,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