Commit 0854b021 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents b84cd406 b37098e3
......@@ -688,17 +688,20 @@ export const searchBy = (query = '', searchSpace = {}) => {
*/
export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER);
const scopedLabelRegex = new RegExp(`(.*)${SCOPED_LABEL_DELIMITER}.*`);
/**
* Returns the base value of the scoped label
*
* Expected Label to be an Object with `title` as a key:
* { title: 'LabelTitle', ...otherProperties };
* Returns the key of a scoped label.
* For example:
* - returns `scoped` if the label is `scoped::value`.
* - returns `scoped::label` if the label is `scoped::label::value`.
*
* @param {Object} label
* @returns String
* @param {Object} label object containing `title` property
* @returns String scoped label key, or full label if it is not a scoped label
*/
export const scopedLabelKey = ({ title = '' }) =>
isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0];
export const scopedLabelKey = ({ title = '' }) => {
return title.replace(scopedLabelRegex, '$1');
};
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
......
import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
......@@ -67,9 +66,11 @@ export default {
}
if (isScopedLabel(candidateLabel)) {
const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`;
const currentActiveScopedLabel = state.labels.find(
({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title,
({ set, title }) =>
set &&
title !== candidateLabel.title &&
scopedLabelKey({ title }) === scopedLabelKey(candidateLabel),
);
if (currentActiveScopedLabel) {
......
......@@ -37,6 +37,15 @@ module Enums
security_audit: 4
}.with_indifferent_access.freeze
# keep the order of the values in the state enum, it is used in state_order method to properly order vulnerabilities based on state
# remember to recreate index_vulnerabilities_on_state_case_id index when you update or extend this enum
VULNERABILITY_STATES = {
detected: 1,
confirmed: 4,
resolved: 3,
dismissed: 2
}.with_indifferent_access.freeze
def self.confidence_levels
CONFIDENCE_LEVELS
end
......@@ -52,6 +61,10 @@ module Enums
def self.detection_methods
DETECTION_METHODS
end
def self.vulnerability_states
VULNERABILITY_STATES
end
end
end
......
......@@ -131,17 +131,27 @@ module ApplicationWorker
end
end
def log_bulk_perform_async?
@log_bulk_perform_async
end
def log_bulk_perform_async!
@log_bulk_perform_async = true
end
def queue_size
Sidekiq::Queue.new(queue).size
end
def bulk_perform_async(args_list)
if Feature.enabled?(:sidekiq_push_bulk_in_batches)
in_safe_limit_batches(args_list) do |args_batch, _|
Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch)
if log_bulk_perform_async?
Sidekiq.logger.info('class' => self, 'args_list' => args_list, 'args_list_count' => args_list.length, 'message' => 'Inserting multiple jobs')
end
do_push_bulk(args_list).tap do |job_ids|
if log_bulk_perform_async?
Sidekiq.logger.info('class' => self, 'jid_list' => job_ids, 'jid_list_count' => job_ids.length, 'message' => 'Completed JID insertion')
end
else
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list)
end
end
......@@ -188,6 +198,16 @@ module ApplicationWorker
private
def do_push_bulk(args_list)
if Feature.enabled?(:sidekiq_push_bulk_in_batches)
in_safe_limit_batches(args_list) do |args_batch, _|
Sidekiq::Client.push_bulk('class' => self, 'args' => args_batch)
end
else
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list)
end
end
def in_safe_limit_batches(args_list, schedule_at = nil, safe_limit = SAFE_PUSH_BULK_LIMIT)
# `schedule_at` could be one of
# - nil.
......
# frozen_string_literal: true
class AddStatesIntoApprovalProjectRules < Gitlab::Database::Migration[1.0]
def up
add_column :approval_project_rules, :vulnerability_states, :text, array: true, null: false, default: ['newly_detected']
end
def down
remove_column :approval_project_rules, :vulnerability_states
end
end
eeda27c42a80d23851bb58b00cee79feeffbe9ae1fef76b3034f92c8610a8aaf
\ No newline at end of file
......@@ -10579,7 +10579,8 @@ CREATE TABLE approval_project_rules (
scanners text[],
vulnerabilities_allowed smallint DEFAULT 0 NOT NULL,
severity_levels text[] DEFAULT '{}'::text[] NOT NULL,
report_type smallint
report_type smallint,
vulnerability_states text[] DEFAULT '{newly_detected}'::text[] NOT NULL
);
CREATE TABLE approval_project_rules_groups (
......@@ -163,6 +163,35 @@ We can then loop over the `versions` array with something like:
Note that the data file must have the `yaml` extension (not `yml`) and that
we reference the array with a symbol (`:versions`).
## Archived documentation banner
A banner is displayed on archived documentation pages with the text `This is archived documentation for
GitLab. Go to the latest.` when either:
- The version of the documentation displayed is not the first version entry in `online` in
`content/_data/versions.yaml`.
- The documentation was built from the default branch (`main`).
For example, if the `online` entries for `content/_data/versions.yaml` are:
```yaml
online:
- "14.4"
- "14.3"
- "14.2"
```
In this case, the archived documentation banner isn't displayed:
- For 14.4, the docs built from the `14.4` branch. The branch name is the first entry in `online`.
- For 14.5-pre, the docs built from the default project branch (`main`).
The archived documentation banner is displayed:
- For 14.3.
- For 14.2.
- For any other version.
## Bumping versions of CSS and JavaScript
Whenever the custom CSS and JavaScript files under `content/assets/` change,
......
......@@ -14,6 +14,7 @@ import {
VULNERABILITY_CHECK_NAME,
COVERAGE_CHECK_NAME,
APPROVAL_DIALOG_I18N,
APPROVAL_VULNERABILITY_STATES,
} from '../constants';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
......@@ -72,6 +73,10 @@ export default {
serverValidationErrors: [],
scanners: [],
severityLevels: [],
vulnerabilityStates: [],
approvalVulnerabilityStatesKeys: Object.keys(APPROVAL_VULNERABILITY_STATES),
reportTypesKeys: Object.keys(REPORT_TYPES),
severityLevelsKeys: Object.keys(SEVERITY_LEVELS),
...this.getInitialData(),
};
},
......@@ -144,11 +149,7 @@ export default {
return '';
},
invalidScanners() {
if (this.scanners.length <= 0) {
return APPROVAL_DIALOG_I18N.validations.scannersRequired;
}
return '';
return this.scanners.length <= 0;
},
invalidVulnerabilitiesAllowedError() {
if (!isNumber(this.vulnerabilitiesAllowed)) {
......@@ -161,11 +162,10 @@ export default {
return '';
},
invalidSeverityLevels() {
if (this.severityLevels.length === 0) {
return APPROVAL_DIALOG_I18N.validations.severityLevelsRequired;
}
return '';
return this.severityLevels.length === 0;
},
invalidVulnerabilityStates() {
return this.vulnerabilityStates.length === 0;
},
isValid() {
return (
......@@ -175,7 +175,8 @@ export default {
this.isValidApprovers &&
this.areValidScanners &&
this.isValidVulnerabilitiesAllowed &&
this.areValidSeverityLevels
this.areValidSeverityLevels &&
this.areValidVulnerabilityStates
);
},
isValidName() {
......@@ -203,6 +204,9 @@ export default {
areValidSeverityLevels() {
return !this.showValidation || !this.isVulnerabilityCheck || !this.invalidSeverityLevels;
},
areValidVulnerabilityStates() {
return !this.showValidation || !this.isVulnerabilityCheck || !this.invalidVulnerabilityStates;
},
isMultiSubmission() {
return this.settings.allowMultiRule && !this.isFallbackSubmission;
},
......@@ -242,6 +246,7 @@ export default {
protectedBranchIds: this.branches.map((x) => x.id),
scanners: this.scanners,
severityLevels: this.severityLevels,
vulnerabilityStates: this.vulnerabilityStates,
};
},
isEditing() {
......@@ -251,11 +256,11 @@ export default {
return VULNERABILITY_CHECK_NAME === this.name;
},
areAllScannersSelected() {
return this.scanners.length === Object.values(this.$options.REPORT_TYPES).length;
return this.scanners.length === this.reportTypesKeys.length;
},
scannersText() {
switch (this.scanners.length) {
case Object.values(this.$options.REPORT_TYPES).length:
case this.reportTypesKeys.length:
return APPROVAL_DIALOG_I18N.form.allScannersSelectedLabel;
case 0:
return APPROVAL_DIALOG_I18N.form.scannersSelectLabel;
......@@ -269,11 +274,11 @@ export default {
}
},
areAllSeverityLevelsSelected() {
return this.severityLevels.length === Object.values(this.$options.SEVERITY_LEVELS).length;
return this.severityLevels.length === this.severityLevelsKeys.length;
},
severityLevelsText() {
switch (this.severityLevels.length) {
case Object.keys(this.$options.SEVERITY_LEVELS).length:
case this.severityLevelsKeys.length:
return APPROVAL_DIALOG_I18N.form.allSeverityLevelsSelectedLabel;
case 0:
return APPROVAL_DIALOG_I18N.form.severityLevelsSelectLabel;
......@@ -286,6 +291,24 @@ export default {
});
}
},
vulnerabilityStatesText() {
switch (this.vulnerabilityStates.length) {
case this.approvalVulnerabilityStatesKeys.length:
return APPROVAL_DIALOG_I18N.form.allVulnerabilityStatesSelectedLabel;
case 0:
return APPROVAL_DIALOG_I18N.form.vulnerabilityStatesSelectLabel;
case 1:
return APPROVAL_VULNERABILITY_STATES[this.vulnerabilityStates[0]];
default:
return sprintf(APPROVAL_DIALOG_I18N.form.multipleSelectedLabel, {
firstLabel: APPROVAL_VULNERABILITY_STATES[this.vulnerabilityStates[0]],
numberOfAdditionalLabels: this.vulnerabilityStates.length - 1,
});
}
},
areAllVulnerabilityStatesSelected() {
return this.vulnerabilityStates.length === this.approvalVulnerabilityStatesKeys.length;
},
},
watch: {
approversToAdd(value) {
......@@ -404,10 +427,11 @@ export default {
scanners: this.initRule.scanners || [],
vulnerabilitiesAllowed: this.initRule.vulnerabilitiesAllowed || 0,
severityLevels: this.initRule.severityLevels || [],
vulnerabilityStates: this.initRule.vulnerabilityStates || [],
};
},
setAllSelectedScanners() {
this.scanners = this.areAllScannersSelected ? [] : Object.keys(this.$options.REPORT_TYPES);
this.scanners = this.areAllScannersSelected ? [] : this.reportTypesKeys;
},
isScannerSelected(scanner) {
return this.scanners.includes(scanner);
......@@ -421,9 +445,7 @@ export default {
}
},
setAllSelectedSeverityLevels() {
this.severityLevels = this.areAllSeverityLevelsSelected
? []
: Object.keys(this.$options.SEVERITY_LEVELS);
this.severityLevels = this.areAllSeverityLevelsSelected ? [] : this.severityLevelsKeys;
},
isSeveritySelected(severity) {
return this.severityLevels.includes(severity);
......@@ -436,10 +458,27 @@ export default {
this.severityLevels.splice(pos, 1);
}
},
setAllSelectedVulnerabilityStates() {
this.vulnerabilityStates = this.areAllVulnerabilityStatesSelected
? []
: this.approvalVulnerabilityStatesKeys;
},
isVulnerabilityStateSelected(vulnerability) {
return this.vulnerabilityStates.includes(vulnerability);
},
setVulnerabilityState(vulnerability) {
const pos = this.vulnerabilityStates.indexOf(vulnerability);
if (pos === -1) {
this.vulnerabilityStates.push(vulnerability);
} else {
this.vulnerabilityStates.splice(pos, 1);
}
},
},
APPROVAL_DIALOG_I18N,
REPORT_TYPES: omit(REPORT_TYPES, EXCLUDED_REPORT_TYPE),
SEVERITY_LEVELS,
APPROVAL_VULNERABILITY_STATES,
};
</script>
......@@ -466,7 +505,7 @@ export default {
:label="$options.APPROVAL_DIALOG_I18N.form.scannersLabel"
:description="$options.APPROVAL_DIALOG_I18N.form.scannersDescription"
:state="areValidScanners"
:invalid-feedback="invalidScanners"
:invalid-feedback="$options.APPROVAL_DIALOG_I18N.validations.scannersRequired"
data-testid="scanners-group"
>
<gl-dropdown :text="scannersText">
......@@ -504,6 +543,34 @@ export default {
:selected-branches="branches"
/>
</gl-form-group>
<gl-form-group
v-if="isVulnerabilityCheck"
:label="$options.APPROVAL_DIALOG_I18N.form.vulnerabilityStatesLabel"
:description="$options.APPROVAL_DIALOG_I18N.form.vulnerabilityStatesDescription"
:state="areValidVulnerabilityStates"
:invalid-feedback="$options.APPROVAL_DIALOG_I18N.validations.vulnerabilityStatesRequired"
data-testid="vulnerability-states-group"
>
<gl-dropdown :text="vulnerabilityStatesText">
<gl-dropdown-item
key="all"
is-check-item
:is-checked="areAllVulnerabilityStatesSelected"
@click.native.capture.stop="setAllSelectedVulnerabilityStates"
>
<gl-truncate :text="$options.APPROVAL_DIALOG_I18N.form.selectAllLabel" />
</gl-dropdown-item>
<gl-dropdown-item
v-for="(value, key) in $options.APPROVAL_VULNERABILITY_STATES"
:key="key"
is-check-item
:is-checked="isVulnerabilityStateSelected(key)"
@click.native.capture.stop="setVulnerabilityState(key)"
>
<gl-truncate :text="value" />
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
v-if="isVulnerabilityCheck"
:label="$options.APPROVAL_DIALOG_I18N.form.vulnerabilitiesAllowedLabel"
......@@ -526,7 +593,7 @@ export default {
:label="$options.APPROVAL_DIALOG_I18N.form.severityLevelsLabel"
:description="$options.APPROVAL_DIALOG_I18N.form.severityLevelsDescription"
:state="areValidSeverityLevels"
:invalid-feedback="invalidSeverityLevels"
:invalid-feedback="$options.APPROVAL_DIALOG_I18N.validations.severityLevelsRequired"
data-testid="severity-levels-group"
>
<gl-dropdown :text="severityLevelsText">
......
......@@ -118,6 +118,12 @@ export const APPROVAL_DIALOG_I18N = {
vulnerabilitiesAllowedDescription: s__(
'ApprovalRule|Number of vulnerabilities allowed before approval rule is triggered.',
),
vulnerabilityStatesLabel: s__('ApprovalRule|Vulnerability states'),
vulnerabilityStatesDescription: s__(
'ApprovalRule|Apply this approval rule to consider only the selected vulnerability states.',
),
vulnerabilityStatesSelectLabel: s__('ApprovalRule|Select vulnerability states'),
allVulnerabilityStatesSelectedLabel: s__('ApprovalRule|All vulnerability states'),
severityLevelsLabel: s__('ApprovalRule|Severity levels'),
severityLevelsDescription: s__(
'ApprovalRule|Apply this approval rule to consider only the selected severity levels.',
......@@ -140,5 +146,14 @@ export const APPROVAL_DIALOG_I18N = {
'ApprovalRule|Please enter a number equal or greater than zero',
),
severityLevelsRequired: s__('ApprovalRule|Please select at least one severity level'),
vulnerabilityStatesRequired: s__('ApprovalRule|Please select at least one vulnerability state'),
},
};
export const APPROVAL_VULNERABILITY_STATES = {
newly_detected: s__('ApprovalRule|Newly detected'),
detected: s__('ApprovalRule|Previously detected'),
confirmed: s__('ApprovalRule|Confirmed'),
dismissed: s__('ApprovalRule|Dismissed'),
resolved: s__('ApprovalRule|Resolved'),
};
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import {
RULE_TYPE_REGULAR,
RULE_TYPE_ANY_APPROVER,
......@@ -39,15 +42,7 @@ function reportTypeFromName(ruleName) {
}
export const mapApprovalRuleRequest = (req) => ({
name: req.name,
approvals_required: req.approvalsRequired,
users: req.users,
groups: req.groups,
remove_hidden_groups: req.removeHiddenGroups,
protected_branch_ids: req.protectedBranchIds,
scanners: req.scanners,
vulnerabilities_allowed: req.vulnerabilitiesAllowed,
severity_levels: req.severityLevels,
...convertObjectPropsToSnakeCase(req),
report_type: reportTypeFromName(req.name),
rule_type: ruleTypeFromName(req.name),
});
......@@ -57,21 +52,9 @@ export const mapApprovalFallbackRuleRequest = (req) => ({
});
export const mapApprovalRuleResponse = (res) => ({
id: res.id,
...convertObjectPropsToCamelCase(res),
hasSource: Boolean(res.source_rule),
name: res.name,
approvalsRequired: res.approvals_required,
minApprovalsRequired: 0,
approvers: res.approvers,
containsHiddenGroups: res.contains_hidden_groups,
users: res.users,
groups: res.groups,
ruleType: res.rule_type,
protectedBranches: res.protected_branches,
overridden: res.overridden,
scanners: res.scanners,
vulnerabilitiesAllowed: res.vulnerabilities_allowed,
severityLevels: res.severity_levels,
});
export const mapApprovalSettingsResponse = (res) => ({
......
......@@ -7,6 +7,9 @@ class ApprovalProjectRule < ApplicationRecord
UNSUPPORTED_SCANNER = 'cluster_image_scanning'
SUPPORTED_SCANNERS = (::Ci::JobArtifact::SECURITY_REPORT_FILE_TYPES - [UNSUPPORTED_SCANNER]).freeze
DEFAULT_SEVERITIES = %w[unknown high critical].freeze
NEWLY_DETECTED = 'newly_detected'
NEWLY_DETECTED_STATE = { NEWLY_DETECTED.to_sym => 0 }.freeze
APPROVAL_VULNERABILITY_STATES = ::Enums::Vulnerability.vulnerability_states.merge(NEWLY_DETECTED_STATE).freeze
belongs_to :project
has_and_belongs_to_many :protected_branches
......@@ -37,6 +40,8 @@ class ApprovalProjectRule < ApplicationRecord
validates :severity_levels, inclusion: { in: ::Enums::Vulnerability.severity_levels.keys }
default_value_for :severity_levels, allows_nil: false, value: DEFAULT_SEVERITIES
validates :vulnerability_states, inclusion: { in: APPROVAL_VULNERABILITY_STATES.keys }
def applies_to_branch?(branch)
return true if protected_branches.empty?
......@@ -65,6 +70,14 @@ class ApprovalProjectRule < ApplicationRecord
push_audit_event("Removed #{model.class.name} #{model.name} from approval group on #{self.name} rule")
end
def vulnerability_states_for_branch(branch = project.default_branch)
if applies_to_branch?(branch)
self.vulnerability_states
else
self.vulnerability_states.select { |state| NEWLY_DETECTED == state }
end
end
private
def report_approver_attributes
......
......@@ -27,13 +27,14 @@ module Ci
current_month.safe_find_or_create_by(namespace_id: namespace_id)
end
def self.increase_usage(usage, amount)
return unless amount > 0
def self.increase_usage(usage, increments)
increment_params = increments.select { |_attribute, value| value > 0 }
return if increment_params.empty?
# The use of `update_counters` ensures we do a SQL update rather than
# incrementing the counter for the object in memory and then save it.
# This is better for concurrent updates.
update_counters(usage, amount_used: amount)
update_counters(usage, increment_params)
end
def self.reset_current_usage(namespace)
......
......@@ -30,13 +30,14 @@ module Ci
current_month.safe_find_or_create_by(project_id: project_id)
end
def self.increase_usage(usage, amount)
return unless amount > 0
def self.increase_usage(usage, increments)
increment_params = increments.select { |_attribute, value| value > 0 }
return if increment_params.empty?
# The use of `update_counters` ensures we do a SQL update rather than
# incrementing the counter for the object in memory and then save it.
# This is better for concurrent updates.
update_counters(usage, amount_used: amount)
update_counters(usage, increment_params)
end
end
end
......
......@@ -57,9 +57,7 @@ module EE
has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: 'VulnerabilityUserMention'
# keep the order of the values in the state enum, it is used in state_order method to properly order vulnerabilities based on state
# remember to recreate index_vulnerabilities_on_state_case_id index when you update or extend this enum
enum state: { detected: 1, confirmed: 4, resolved: 3, dismissed: 2 }
enum state: ::Enums::Vulnerability.vulnerability_states
enum severity: ::Enums::Vulnerability.severity_levels, _prefix: :severity
enum confidence: ::Enums::Vulnerability.confidence_levels, _prefix: :confidence
enum report_type: ::Enums::Vulnerability.report_types
......@@ -74,6 +72,7 @@ module EE
scope :with_author_and_project, -> { includes(:author, :project) }
scope :with_findings, -> { includes(:findings) }
scope :with_findings_by_uuid_and_state, -> (uuid, state) { with_findings.where(findings: { uuid: uuid }, state: state) }
scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
scope :with_created_issue_links_and_issues, -> { includes(created_issue_links: :issue) }
......
......@@ -16,10 +16,10 @@ module Ci
end
# Updates the project and namespace usage based on the passed consumption amount
def execute(consumption)
def execute(consumption, duration = nil)
legacy_track_usage_of_monthly_minutes(consumption)
ensure_idempotency { track_usage_of_monthly_minutes(consumption) }
ensure_idempotency { track_monthly_usage(consumption, duration.to_i) }
send_minutes_email_notification
end
......@@ -53,14 +53,23 @@ module Ci
update_legacy_namespace_minutes(consumption_in_seconds)
end
def track_usage_of_monthly_minutes(consumption)
def track_monthly_usage(consumption, duration)
# preload minutes usage data outside of transaction
project_usage
namespace_usage
::Ci::Minutes::NamespaceMonthlyUsage.transaction do
::Ci::Minutes::NamespaceMonthlyUsage.increase_usage(namespace_usage, consumption) if namespace_usage
::Ci::Minutes::ProjectMonthlyUsage.increase_usage(project_usage, consumption) if project_usage
if namespace_usage
::Ci::Minutes::NamespaceMonthlyUsage.increase_usage(namespace_usage,
amount_used: consumption,
shared_runners_duration: duration)
end
if project_usage
::Ci::Minutes::ProjectMonthlyUsage.increase_usage(project_usage,
amount_used: consumption,
shared_runners_duration: duration)
end
end
end
......
......@@ -23,7 +23,7 @@ module Ci
log_error(payload)
error("Failed to update approval rules")
ensure
[:project_rule_vulnerabilities_allowed, :project_rule_scanners, :project_rule_severity_levels, :project_vulnerability_report, :reports].each do |memoization|
[:project_rule_vulnerabilities_allowed, :project_rule_scanners, :project_rule_severity_levels, :project_vulnerability_report, :reports, :project_rule_vulnerability_states].each do |memoization|
clear_memoization(memoization)
end
end
......@@ -86,7 +86,7 @@ module Ci
def merge_requests_approved_security_reports
pipeline.merge_requests_as_head_pipeline.reject do |merge_request|
reports.present? && reports.violates_default_policy_against?(merge_request.base_pipeline&.security_reports, project_rule_vulnerabilities_allowed, project_rule_severity_levels)
reports.present? && reports.violates_default_policy_against?(merge_request.base_pipeline&.security_reports, project_rule_vulnerabilities_allowed, project_rule_severity_levels, project_rule_vulnerability_states)
end
end
......@@ -102,6 +102,12 @@ module Ci
end
end
def project_rule_vulnerability_states
strong_memoize(:project_rule_vulnerability_states) do
project_vulnerability_report&.vulnerability_states_for_branch
end
end
def project_vulnerability_report
strong_memoize(:project_vulnerability_report) do
pipeline.project.vulnerability_report_rule
......
......@@ -13,10 +13,10 @@ module Ci
# used by the service object.
sidekiq_options retry: 3
def perform(consumption, project_id, namespace_id, build_id)
def perform(consumption, project_id, namespace_id, build_id, params = {})
::Ci::Minutes::UpdateProjectAndNamespaceUsageService
.new(project_id, namespace_id, build_id)
.execute(consumption)
.execute(consumption, params[:duration].to_i)
end
end
end
......
......@@ -12,6 +12,7 @@ class ProjectImportScheduleWorker
feature_category :source_code_management
sidekiq_options retry: false
loggable_arguments 1 # For the job waiter key
log_bulk_perform_async!
# UpdateAllMirrorsWorker depends on the queue size of this worker:
# https://gitlab.com/gitlab-org/gitlab/-/issues/340630
......
......@@ -9,13 +9,15 @@ value_type: string
status: active
time_frame: none
data_source: license
instrumentation_class: LicenseMdFiveMetric
instrumentation_class: LicenseMetric
options:
attribute: md5
data_category: standard
distribution:
- ee
- ee
tier:
- premium
- ultimate
- premium
- ultimate
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/557
performance_indicator_type: []
milestone: "<13.9"
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: license
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: id
distribution:
- ee
tier:
......
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: license
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: user_count
distribution:
- ee
tier:
......
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: license
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: starts_at
distribution:
- ee
tier:
......
......@@ -10,6 +10,9 @@ status: active
data_category: subscription
time_frame: none
data_source: license
instrumentation_class: LicenseMetric
options:
attribute: expires_at
distribution:
- ee
tier:
......
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: license
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: plan
distribution:
- ee
tier:
......
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: license
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: trial
distribution:
- ee
tier:
......
......@@ -9,7 +9,9 @@ value_type: string
status: active
time_frame: none
data_source: license
instrumentation_class: ZuoraSubscriptionIdMetric
instrumentation_class: LicenseMetric
options:
attribute: subscription_id
data_category: standard
distribution:
- ee
......
......@@ -10,6 +10,9 @@ status: active
time_frame: none
data_source: database
data_category: subscription
instrumentation_class: LicenseMetric
options:
attribute: trial_ends_on
distribution:
- ee
tier:
......
......@@ -16,6 +16,7 @@ module API
optional :vulnerabilities_allowed, type: Integer, desc: 'The number of vulnerabilities allowed for this rule'
optional :severity_levels, type: Array[String], desc: 'The security levels to be considered by the approval rule'
optional :report_type, type: String, desc: 'The type of the report required when rule type equals to report_approver'
optional :vulnerability_states, type: Array[String], desc: 'The vulnerability states to be considered by the approval rule'
end
params :update_project_approval_rule do
......@@ -29,6 +30,7 @@ module API
optional :scanners, type: Array[String], desc: 'The security scanners to be considered by the approval rule'
optional :vulnerabilities_allowed, type: Integer, desc: 'The number of vulnerabilities allowed for this rule'
optional :severity_levels, type: Array[String], desc: 'The security levels to be considered by the approval rule'
optional :vulnerability_states, type: Array[String], desc: 'The vulnerability states to be considered by the approval rule'
end
params :delete_project_approval_rule do
......
......@@ -12,6 +12,7 @@ module EE
expose :scanners, override: true
expose :vulnerabilities_allowed, override: true
expose :severity_levels, override: true
expose :vulnerability_states, override: true
end
end
end
......
# frozen_string_literal: true
module EE
module Gitlab
module Ci
module Reports
module Security
module Reports
extend ::Gitlab::Utils::Override
private
override :unsafe_findings_count
def unsafe_findings_count(target_reports, severity_levels, vulnerability_states)
pipeline_uuids = unsafe_findings_uuids(severity_levels)
pipeline_count = count_by_uuid(pipeline_uuids, vulnerability_states)
new_uuids = pipeline_uuids - target_reports&.unsafe_findings_uuids(severity_levels).to_a
if vulnerability_states.include?(ApprovalProjectRule::NEWLY_DETECTED)
pipeline_count += new_uuids.count
end
pipeline_count
end
def count_by_uuid(uuids, states)
pipeline.project.vulnerabilities.with_findings_by_uuid_and_state(uuids, states.reject { |state| ApprovalProjectRule::NEWLY_DETECTED == state }).count
end
end
end
end
end
end
end
......@@ -72,7 +72,7 @@ module EE
def features_usage_data_ee
{
elasticsearch_enabled: alt_usage_data(fallback: nil) { ::Gitlab::CurrentSettings.elasticsearch_search? },
license_trial_ends_on: alt_usage_data(fallback: nil) { License.trial_ends_on },
license_trial_ends_on: add_metric("LicenseMetric", options: { attribute: "trial_ends_on" }),
geo_enabled: alt_usage_data(fallback: nil) { ::Gitlab::Geo.enabled? },
user_cap_feature_enabled: add_metric('UserCapSettingEnabledMetric', time_frame: 'none')
}
......@@ -90,7 +90,7 @@ module EE
end
if license
usage_data[:license_md5] = add_metric("LicenseMdFiveMetric")
usage_data[:license_md5] = add_metric("LicenseMetric", options: { attribute: 'md5' })
usage_data[:license_id] = license.license_id
# rubocop: disable UsageData/LargeTable
usage_data[:historical_max_users] = add_metric("HistoricalMaxUsersMetric")
......@@ -103,7 +103,7 @@ module EE
usage_data[:license_plan] = license.plan
usage_data[:license_add_ons] = license.add_ons
usage_data[:license_trial] = license.trial?
usage_data[:license_subscription_id] = alt_usage_data(fallback: nil) { license.subscription_id }
usage_data[:license_subscription_id] = license.subscription_id
end
usage_data
......
# frozen_string_literal: true
module Gitlab
module Usage
module Metrics
module Instrumentations
class LicenseMdFiveMetric < ::Gitlab::Usage::Metrics::Instrumentations::GenericMetric
value do
::License.current.md5
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Usage
module Metrics
module Instrumentations
class LicenseMetric < GenericMetric
# Usage example
#
# In metric YAML defintion
# instrumentation_class: LicenseMetric
# options:
# attribute: md5
# end
ALLOWED_ATTRIBUTES = %w(md5
id
plan
trial
starts_at
expires_at
user_count
trial_ends_on
subscription_id).freeze
def initialize(time_frame:, options: {})
super
raise ArgumentError, "License options attribute are required" unless license_attribute.present?
raise ArgumentError, "Attribute: #{license_attribute} it not allowed" unless license_attribute.in?(ALLOWED_ATTRIBUTES)
end
def value
return ::License.trial_ends_on if license_attribute == "trial_ends_on"
alt_usage_data(fallback: nil) do
# license_attribute is checked in the constructor, so it's safe
::License.current.send(license_attribute) # rubocop: disable GitlabSecurity/PublicSend
end
end
private
def license_attribute
options[:attribute]
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Usage
module Metrics
module Instrumentations
class ZuoraSubscriptionIdMetric < ::Gitlab::Usage::Metrics::Instrumentations::GenericMetric
value do
::License.current.subscription_id
end
end
end
end
end
end
......@@ -46,6 +46,12 @@
"items": {
"type": "string"
}
},
"vulnerability_states":{
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
......
import { GlFormGroup, GlFormInput, GlTruncate } from '@gitlab/ui';
import { GlDropdown, GlFormGroup, GlFormInput, GlTruncate } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
......@@ -13,6 +13,8 @@ import {
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
VULNERABILITY_CHECK_NAME,
APPROVAL_VULNERABILITY_STATES,
APPROVAL_DIALOG_I18N,
} from 'ee/approvals/constants';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
......@@ -21,28 +23,14 @@ import ProtectedBranchesSelector from 'ee/vue_shared/components/branches_selecto
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
TEST_RULE,
TEST_RULE_VULNERABILITY_CHECK,
TEST_PROTECTED_BRANCHES,
TEST_RULE_WITH_PROTECTED_BRANCHES,
} from '../mocks';
const TEST_PROJECT_ID = '7';
const TEST_RULE = {
id: 10,
name: 'QA',
approvalsRequired: 2,
users: [{ id: 1 }, { id: 2 }, { id: 3 }],
groups: [{ id: 1 }, { id: 2 }],
};
const TEST_PROTECTED_BRANCHES = [{ id: 2 }, { id: 3 }, { id: 4 }];
const TEST_RULE_WITH_PROTECTED_BRANCHES = {
...TEST_RULE,
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_RULE_VULNERABILITY_CHECK = {
...TEST_RULE,
id: null,
name: VULNERABILITY_CHECK_NAME,
scanners: ['sast', 'dast'],
vulnerabilitiesAllowed: 0,
severityLevels: ['high'],
};
const TEST_APPROVERS = [{ id: 7, type: TYPE_USER }];
const TEST_APPROVALS_REQUIRED = 3;
const TEST_FALLBACK_RULE = {
......@@ -101,6 +89,9 @@ describe('EE Approvals RuleForm', () => {
const findScannersGroup = () => wrapper.findByTestId('scanners-group');
const findVulnerabilityFormGroup = () => wrapper.findByTestId('vulnerability-amount-group');
const findSeverityLevelsGroup = () => wrapper.findByTestId('severity-levels-group');
const findVulnerabilityStatesGroup = () => wrapper.findByTestId('vulnerability-states-group');
const findVulnerabilityStatesDropdown = () =>
findVulnerabilityStatesGroup().findComponent(GlDropdown);
const inputsAreValid = (inputs) => inputs.every((x) => x.props('state'));
......@@ -207,6 +198,7 @@ describe('EE Approvals RuleForm', () => {
scanners: [],
severityLevels: [],
protectedBranchIds: branches.map((x) => x.id),
vulnerabilityStates: [],
};
await findNameInput().vm.$emit('input', expected.name);
......@@ -287,6 +279,7 @@ describe('EE Approvals RuleForm', () => {
scanners: [],
severityLevels: [],
protectedBranchIds: branches.map((x) => x.id),
vulnerabilityStates: [],
};
beforeEach(async () => {
......@@ -368,6 +361,7 @@ describe('EE Approvals RuleForm', () => {
scanners: [],
severityLevels: [],
protectedBranchIds: [],
vulnerabilityStates: [],
};
it('on submit, puts rule', async () => {
......@@ -720,6 +714,95 @@ describe('EE Approvals RuleForm', () => {
);
});
});
describe('without any vulnerability state selected', () => {
beforeEach(() => {
createComponent({
initRule: {
...TEST_RULE_VULNERABILITY_CHECK,
vulnerabilityStates: [],
},
});
findForm().trigger('submit');
});
it('does not dispatch the action on submit', () => {
expect(actions.postRule).not.toHaveBeenCalled();
});
it('changes vulnerability states dropdown text to select vulnerability states', () => {
expect(findVulnerabilityStatesDropdown().props('text')).toBe(
APPROVAL_DIALOG_I18N.form.vulnerabilityStatesSelectLabel,
);
});
it('shows error message in regards to vulnerability states selection', () => {
expect(findVulnerabilityStatesGroup().props('invalidFeedback')).toBe(
APPROVAL_DIALOG_I18N.validations.vulnerabilityStatesRequired,
);
});
});
describe('with one vulnerability state selected', () => {
beforeEach(() => {
createComponent({
initRule: {
...TEST_RULE_VULNERABILITY_CHECK,
vulnerabilityStates: ['newly_detected'],
},
});
findForm().trigger('submit');
});
it('dispatches the action on submit', () => {
expect(actions.postRule).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
vulnerabilityStates: TEST_RULE_VULNERABILITY_CHECK.vulnerabilityStates,
}),
);
});
it('changes vulnerability states dropdown text to its name', () => {
expect(findVulnerabilityStatesDropdown().props('text')).toBe(
APPROVAL_VULNERABILITY_STATES.newly_detected,
);
});
});
describe('with all vulnerability states selected', () => {
beforeEach(() => {
createComponent({
initRule: {
...TEST_RULE_VULNERABILITY_CHECK,
vulnerabilityStates: Object.keys(APPROVAL_VULNERABILITY_STATES),
},
});
});
it('changes vulnerability states dropdown text to all selected', () => {
expect(findVulnerabilityStatesDropdown().props('text')).toBe(
APPROVAL_DIALOG_I18N.form.allVulnerabilityStatesSelectedLabel,
);
});
});
describe('with all but one vulnerability state selected', () => {
beforeEach(() => {
createComponent({
initRule: {
...TEST_RULE_VULNERABILITY_CHECK,
vulnerabilityStates: Object.keys(APPROVAL_VULNERABILITY_STATES).splice(1),
},
});
});
it('changes vulnerability states dropdown text to all selected', () => {
expect(findVulnerabilityStatesDropdown().props('text')).toBe(
'Previously detected +3 more',
);
});
});
});
});
......
......@@ -93,3 +93,28 @@ export const createGroupApprovalsState = (locked = null) => ({
},
},
});
export const TEST_PROTECTED_BRANCHES = [{ id: 2 }, { id: 3 }, { id: 4 }];
export const TEST_RULE = {
id: 10,
name: 'QA',
approvalsRequired: 2,
users: [{ id: 1 }, { id: 2 }, { id: 3 }],
groups: [{ id: 1 }, { id: 2 }],
};
export const TEST_RULE_VULNERABILITY_CHECK = {
...TEST_RULE,
id: null,
name: 'Vulnerability-Check',
scanners: ['sast', 'dast'],
vulnerabilitiesAllowed: 0,
severityLevels: ['high'],
vulnerabilityStates: ['newly_detected'],
};
export const TEST_RULE_WITH_PROTECTED_BRANCHES = {
...TEST_RULE,
protectedBranches: TEST_PROTECTED_BRANCHES,
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Security::Reports do
let_it_be(:pipeline) { create(:ci_pipeline) }
let_it_be(:artifact) { create(:ci_job_artifact, :sast) }
let(:security_reports) { described_class.new(pipeline) }
describe "#violates_default_policy_against?" do
let(:high_severity_dast) { build(:ci_reports_security_finding, severity: 'high', report_type: :dast) }
let(:vulnerabilities_allowed) { 0 }
let(:severity_levels) { %w(critical high) }
let(:vulnerability_states) { %w(newly_detected)}
subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states) }
before do
security_reports.get_report('sast', artifact).add_finding(high_severity_dast)
end
context 'when the target_reports is `nil`' do
let(:target_reports) { nil }
it { is_expected.to be(true) }
context 'with existing vulnerabilities' do
let!(:finding) { create(:vulnerabilities_finding, :detected, report_type: :sast, project: pipeline.project, uuid: high_severity_dast.uuid) }
it { is_expected.to be(true) }
context 'with vulnerability states matching existing vulnerabilities' do
let(:vulnerability_states) { %w(detected)}
it { is_expected.to be(true) }
end
context 'with vulnerability states not matching existing vulnerabilities' do
let(:vulnerability_states) { %w(resolved)}
it { is_expected.to be(false) }
end
end
end
context 'when the target_reports is not `nil`' do
let(:target_reports) { described_class.new(pipeline) }
it { is_expected.to be(true) }
context "when none of the reports have a new unsafe vulnerability" do
before do
target_reports.get_report('sast', artifact).add_finding(high_severity_dast)
end
it { is_expected.to be(false) }
context 'with existing vulnerabilities' do
let!(:finding) { create(:vulnerabilities_finding, :detected, report_type: :sast, project: pipeline.project, uuid: high_severity_dast.uuid) }
it { is_expected.to be(false) }
context 'with vulnerability states matching existing vulnerability' do
let(:vulnerability_states) { %w(detected)}
it { is_expected.to be(true) }
end
context 'with vulnerability states not matching existing vulnerabilities' do
let(:vulnerability_states) { %w(resolved)}
it { is_expected.to be(false) }
end
end
end
end
end
end
......@@ -187,6 +187,10 @@ RSpec.describe Gitlab::UsageData do
describe '.features_usage_data_ee' do
subject { described_class.features_usage_data_ee }
before do
stub_feature_flags(usage_data_instrumentation: false)
end
it 'gathers feature usage data of EE' do
expect(subject[:elasticsearch_enabled]).to eq(Gitlab::CurrentSettings.elasticsearch_search?)
expect(subject[:geo_enabled]).to eq(Gitlab::Geo.enabled?)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Usage::Metrics::Instrumentations::LicenseMdFiveMetric do
it_behaves_like 'a correct instrumented metric value', { time_frame: 'none', data_source: 'ruby' } do
let(:expected_value) { Digest::MD5.hexdigest(::License.current.data) }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Usage::Metrics::Instrumentations::ZuoraSubscriptionIdMetric do
it_behaves_like 'a correct instrumented metric value', { time_frame: 'none', data_source: 'ruby' } do
let(:expected_value) { ::License.current.subscription_id }
end
end
......@@ -15,6 +15,12 @@ RSpec.describe ApprovalProjectRule do
expect(::Enums::Vulnerability.severity_levels.keys).to include(*described_class::DEFAULT_SEVERITIES)
end
end
context 'APPROVAL_VULNERABILITY_STATES' do
it 'contains all vulnerability states' do
expect(described_class::APPROVAL_VULNERABILITY_STATES).to include(*::Enums::Vulnerability.vulnerability_states.keys)
end
end
end
describe 'associations' do
......@@ -177,20 +183,21 @@ RSpec.describe ApprovalProjectRule do
context "with a `Vulnerability-Check` rule" do
using RSpec::Parameterized::TableSyntax
where(:is_valid, :scanners, :vulnerabilities_allowed, :severity_levels) do
true | [] | 0 | []
true | %w(dast) | 1 | %w(critical high medium)
true | %w(dast sast) | 10 | %w(critical high)
true | %w(dast dast) | 100 | %w(critical)
false | %w(dast dast) | 100 | %w(unknown_severity)
false | %w(dast unknown_scanner) | 100 | %w(critical)
false | [described_class::UNSUPPORTED_SCANNER] | 100 | %w(critical)
false | %w(dast sast) | 1.1 | %w(critical)
false | %w(dast sast) | 'one' | %w(critical)
where(:is_valid, :scanners, :vulnerabilities_allowed, :severity_levels, :vulnerability_states) do
true | [] | 0 | [] | %w(newly_detected)
true | %w(dast) | 1 | %w(critical high medium) | %w(newly_detected resolved)
true | %w(dast sast) | 10 | %w(critical high) | %w(resolved detected)
true | %w(dast dast) | 100 | %w(critical) | %w(detected dismissed)
false | %w(dast dast) | 100 | %w(critical) | %w(dismissed unknown)
false | %w(dast dast) | 100 | %w(unknown_severity) | %w(detected dismissed)
false | %w(dast unknown_scanner) | 100 | %w(critical) | %w(detected dismissed)
false | [described_class::UNSUPPORTED_SCANNER] | 100 | %w(critical) | %w(detected dismissed)
false | %w(dast sast) | 1.1 | %w(critical) | %w(detected dismissed)
false | %w(dast sast) | 'one' | %w(critical) | %w(detected dismissed)
end
with_them do
let(:vulnerability_check_rule) { build(:approval_project_rule, :vulnerability, scanners: scanners, vulnerabilities_allowed: vulnerabilities_allowed, severity_levels: severity_levels) }
let(:vulnerability_check_rule) { build(:approval_project_rule, :vulnerability, scanners: scanners, vulnerabilities_allowed: vulnerabilities_allowed, severity_levels: severity_levels, vulnerability_states: vulnerability_states) }
specify { expect(vulnerability_check_rule.valid?).to be(is_valid) }
end
......@@ -273,5 +280,27 @@ RSpec.describe ApprovalProjectRule do
it_behaves_like 'auditable'
end
describe '#vulnerability_states_for_branch' do
let(:project) { create(:project, :repository) }
let(:branch_name) { project.default_branch }
let!(:rule) { build(:approval_project_rule, project: project, protected_branches: protected_branches, vulnerability_states: %w(newly_detected resolved)) }
context 'with protected branch set to any' do
let(:protected_branches) { [] }
it 'returns all content of vulnerability states' do
expect(rule.vulnerability_states_for_branch).to contain_exactly('newly_detected', 'resolved')
end
end
context 'with protected branch set to a custom branch' do
let(:protected_branches) { [create(:protected_branch, project: project, name: 'custom_branch')] }
it 'returns only the content of vulnerability states' do
expect(rule.vulnerability_states_for_branch).to contain_exactly('newly_detected')
end
end
end
end
end
......@@ -71,27 +71,7 @@ RSpec.describe Ci::Minutes::NamespaceMonthlyUsage do
end
describe '.increase_usage' do
subject { described_class.increase_usage(current_usage, amount) }
context 'when amount is greater than 0' do
let(:amount) { 10.5 }
it 'updates the current month usage' do
subject
expect(current_usage.reload.amount_used).to eq(110.5)
end
end
context 'when amount is less or equal to 0' do
let(:amount) { -2.0 }
it 'does not update the current month usage' do
subject
expect(current_usage.reload.amount_used).to eq(100.0)
end
end
it_behaves_like 'CI minutes increase usage'
end
describe '.for_namespace' do
......
......@@ -66,29 +66,13 @@ RSpec.describe Ci::Minutes::ProjectMonthlyUsage do
end
describe '.increase_usage' do
subject { described_class.increase_usage(usage, amount) }
let(:usage) { create(:ci_project_monthly_usage, project: project, amount_used: 100.0) }
context 'when amount is greater than 0' do
let(:amount) { 10.5 }
it 'updates the current month usage' do
subject
expect(usage.reload.amount_used).to eq(110.5)
end
let_it_be_with_refind(:current_usage) do
create(:ci_project_monthly_usage,
project: project,
amount_used: 100)
end
context 'when amount is less or equal to 0' do
let(:amount) { -2.0 }
it 'does not update the current month usage' do
subject
expect(usage.reload.amount_used).to eq(100.0)
end
end
it_behaves_like 'CI minutes increase usage'
end
describe '.for_namespace_monthly_usage' do
......
......@@ -826,4 +826,26 @@ RSpec.describe Vulnerability do
)
end
end
describe '.with_findings_by_uuid_and_state scope' do
let_it_be(:vulnerability) { create(:vulnerability, state: :detected) }
let(:uuid) { [SecureRandom.uuid] }
subject { described_class.with_findings_by_uuid_and_state(uuid, ["detected"]) }
it { is_expected.to be_empty }
context 'with findings' do
let_it_be(:finding) { create(:vulnerabilities_finding, vulnerability: vulnerability) }
it { is_expected.to be_empty }
context 'with matching uuid' do
let(:uuid) { [finding.uuid] }
it { is_expected.to contain_exactly(vulnerability) }
end
end
end
end
......@@ -9,13 +9,14 @@ RSpec.describe Ci::Minutes::UpdateProjectAndNamespaceUsageService do
let(:namespace) { project.namespace }
let(:build) { create(:ci_build) }
let(:consumption_minutes) { 120 }
let(:duration) { 1_000 }
let(:consumption_seconds) { consumption_minutes * 60 }
let(:namespace_amount_used) { Ci::Minutes::NamespaceMonthlyUsage.find_or_create_current(namespace_id: namespace.id).amount_used }
let(:project_amount_used) { Ci::Minutes::ProjectMonthlyUsage.find_or_create_current(project_id: project.id).amount_used }
let(:service) { described_class.new(project.id, namespace.id, build.id) }
describe '#execute', :clean_gitlab_redis_shared_state do
subject { service.execute(consumption_minutes) }
subject { service.execute(consumption_minutes, duration) }
shared_examples 'updates legacy consumption' do
it 'updates legacy statistics with consumption seconds' do
......@@ -26,10 +27,20 @@ RSpec.describe Ci::Minutes::UpdateProjectAndNamespaceUsageService do
end
shared_examples 'updates monthly consumption' do
it 'updates monthly usage with consumption minutes' do
it 'updates monthly usage for namespace' do
current_usage = Ci::Minutes::NamespaceMonthlyUsage.find_or_create_current(namespace_id: namespace.id)
expect { subject }
.to change { current_usage.reload.amount_used }.by(consumption_minutes)
.and change { current_usage.reload.shared_runners_duration }.by(duration)
end
it 'updates monthly usage for project' do
current_usage = Ci::Minutes::ProjectMonthlyUsage.find_or_create_current(project_id: project.id)
expect { subject }
.to change { Ci::Minutes::NamespaceMonthlyUsage.find_or_create_current(namespace_id: namespace.id).amount_used }.by(consumption_minutes)
.and change { Ci::Minutes::ProjectMonthlyUsage.find_or_create_current(project_id: project.id).amount_used }.by(consumption_minutes)
.to change { current_usage.reload.amount_used }.by(consumption_minutes)
.and change { current_usage.reload.shared_runners_duration }.by(duration)
end
end
......
......@@ -22,9 +22,10 @@ RSpec.describe Ci::SyncReportsToApprovalRulesService, '#execute' do
let(:scanners) { %w[dependency_scanning] }
let(:vulnerabilities_allowed) { 0 }
let(:severity_levels) { %w[high unknown] }
let(:vulnerability_states) { %w(newly_detected) }
before do
create(:approval_project_rule, :vulnerability, project: project, approvals_required: 2, scanners: scanners, vulnerabilities_allowed: vulnerabilities_allowed, severity_levels: severity_levels)
create(:approval_project_rule, :vulnerability, project: project, approvals_required: 2, scanners: scanners, vulnerabilities_allowed: vulnerabilities_allowed, severity_levels: severity_levels, vulnerability_states: vulnerability_states)
end
context 'when there are security reports' do
......@@ -78,6 +79,15 @@ RSpec.describe Ci::SyncReportsToApprovalRulesService, '#execute' do
.to change { report_approver_rule.reload.approvals_required }.from(2).to(0)
end
end
context 'without any vulnerability state related to the security reports' do
let(:vulnerability_states) { %w(resolved) }
it 'lowers approvals_required count to zero' do
expect { subject }
.to change { report_approver_rule.reload.approvals_required }.from(2).to(0)
end
end
end
context 'when only low-severity vulnerabilities are present' do
......
# frozen_string_literal: true
RSpec.shared_examples_for 'CI minutes increase usage' do
subject { described_class.increase_usage(current_usage, increments) }
let(:increments) { { amount_used: amount } }
context 'when amount is greater than 0' do
let(:amount) { 10.5 }
it 'updates the current month usage' do
subject
expect(current_usage.reload.amount_used).to eq(110.5)
end
end
context 'when amount is less or equal to 0' do
let(:amount) { -2.0 }
it 'does not update the current month usage' do
subject
expect(current_usage.reload.amount_used).to eq(100.0)
end
end
context 'when shared_runners_duration is incremented' do
let(:increments) { { amount_used: amount, shared_runners_duration: duration } }
let(:amount) { 10.5 }
context 'when duration is positive' do
let(:duration) { 10 }
it 'updates the duration and amount used' do
subject
expect(current_usage.reload.amount_used).to eq(110.5)
expect(current_usage.shared_runners_duration).to eq(10)
end
context 'when amount_used is zero' do
let(:amount) { 0 }
it 'updates only the duration' do
subject
expect(current_usage.reload.amount_used).to eq(100.0)
expect(current_usage.shared_runners_duration).to eq(10)
end
end
end
context 'when duration is zero' do
let(:duration) { 0 }
it 'updates only the amount used' do
subject
expect(current_usage.reload.amount_used).to eq(110.5)
expect(current_usage.shared_runners_duration).to eq(0)
end
context 'when amount_used is zero' do
let(:amount) { 0 }
it 'does not perform updates' do
subject
expect(current_usage.reload.amount_used).to eq(100.0)
expect(current_usage.shared_runners_duration).to eq(0)
end
end
end
end
end
......@@ -9,35 +9,78 @@ RSpec.describe Ci::Minutes::UpdateProjectAndNamespaceUsageWorker do
let(:consumption) { 100 }
let(:consumption_seconds) { consumption * 60 }
let(:duration) { 60_000 }
let(:worker) { described_class.new }
describe '#perform', :clean_gitlab_redis_shared_state do
subject { perform_multiple([consumption, project.id, namespace.id, build.id]) }
context 'when duration param is not passed in' do
subject { perform_multiple([consumption, project.id, namespace.id, build.id]) }
context 'behaves idempotently for monthly usage update' do
it 'executes UpdateProjectAndNamespaceUsageService' do
service_instance = double
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).at_least(:once).and_return(service_instance)
expect(service_instance).to receive(:execute).at_least(:once).with(consumption)
context 'behaves idempotently for monthly usage update' do
it 'executes UpdateProjectAndNamespaceUsageService' do
service_instance = double
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).at_least(:once).and_return(service_instance)
expect(service_instance).to receive(:execute).at_least(:once).with(consumption, 0)
subject
subject
end
it 'updates monthly usage but not shared_runners_duration' do
subject
namespace_usage = Ci::Minutes::NamespaceMonthlyUsage.find_by(namespace: namespace)
expect(namespace_usage.amount_used).to eq(consumption)
expect(namespace_usage.shared_runners_duration).to eq(0)
project_usage = Ci::Minutes::ProjectMonthlyUsage.find_by(project: project)
expect(project_usage.amount_used).to eq(consumption)
expect(project_usage.shared_runners_duration).to eq(0)
end
end
it 'updates monthly usage' do
it 'does not behave idempotently for legacy statistics update' do
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).twice.and_call_original
subject
expect(Ci::Minutes::NamespaceMonthlyUsage.find_by(namespace: namespace).amount_used).to eq(consumption)
expect(Ci::Minutes::ProjectMonthlyUsage.find_by(project: project).amount_used).to eq(consumption)
expect(project.statistics.reload.shared_runners_seconds).to eq(2 * consumption_seconds)
expect(namespace.reload.namespace_statistics.shared_runners_seconds).to eq(2 * consumption_seconds)
end
end
it 'does not behave idempotently for legacy statistics update' do
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).twice.and_call_original
context 'when duration param is passed in' do
subject { perform_multiple([consumption, project.id, namespace.id, build.id, { duration: duration }]) }
context 'behaves idempotently for monthly usage update' do
it 'executes UpdateProjectAndNamespaceUsageService' do
service_instance = double
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).at_least(:once).and_return(service_instance)
expect(service_instance).to receive(:execute).at_least(:once).with(consumption, duration)
subject
end
subject
it 'updates monthly usage and shared_runners_duration' do
subject
expect(project.statistics.reload.shared_runners_seconds).to eq(2 * consumption_seconds)
expect(namespace.reload.namespace_statistics.shared_runners_seconds).to eq(2 * consumption_seconds)
namespace_usage = Ci::Minutes::NamespaceMonthlyUsage.find_by(namespace: namespace)
expect(namespace_usage.amount_used).to eq(consumption)
expect(namespace_usage.shared_runners_duration).to eq(duration)
project_usage = Ci::Minutes::ProjectMonthlyUsage.find_by(project: project)
expect(project_usage.amount_used).to eq(consumption)
expect(project_usage.shared_runners_duration).to eq(duration)
end
end
it 'does not behave idempotently for legacy statistics update' do
expect(::Ci::Minutes::UpdateProjectAndNamespaceUsageService).to receive(:new).twice.and_call_original
subject
expect(project.statistics.reload.shared_runners_seconds).to eq(2 * consumption_seconds)
expect(namespace.reload.namespace_statistics.shared_runners_seconds).to eq(2 * consumption_seconds)
end
end
end
end
......@@ -22,21 +22,24 @@ module Gitlab
reports.values.flat_map(&:findings)
end
def violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels)
unsafe_findings_count(target_reports, severity_levels) > vulnerabilities_allowed
def violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states)
unsafe_findings_count(target_reports, severity_levels, vulnerability_states) > vulnerabilities_allowed
end
private
def findings_diff(target_reports)
findings - target_reports&.findings.to_a
def unsafe_findings_uuids(severity_levels)
findings.select { |finding| finding.unsafe?(severity_levels) }.map(&:uuid)
end
def unsafe_findings_count(target_reports, severity_levels)
findings_diff(target_reports).count {|finding| finding.unsafe?(severity_levels)}
private
def unsafe_findings_count(target_reports, severity_levels, vulnerability_states)
new_uuids = unsafe_findings_uuids(severity_levels) - target_reports&.unsafe_findings_uuids(severity_levels).to_a
new_uuids.count
end
end
end
end
end
end
Gitlab::Ci::Reports::Security::Reports.prepend_mod_with('Gitlab::Ci::Reports::Security::Reports')
......@@ -10,10 +10,10 @@ module Gitlab
uncached_data.deep_stringify_keys.dig(*key_path.split('.'))
end
def add_metric(metric, time_frame: 'none')
def add_metric(metric, time_frame: 'none', options: {})
metric_class = "Gitlab::Usage::Metrics::Instrumentations::#{metric}".constantize
metric_class.new(time_frame: time_frame).suggested_name
metric_class.new(time_frame: time_frame, options: options).suggested_name
end
private
......
......@@ -12,10 +12,10 @@ module Gitlab
super.with_indifferent_access.deep_merge(instrumentation_metrics.with_indifferent_access)
end
def add_metric(metric, time_frame: 'none')
def add_metric(metric, time_frame: 'none', options: {})
metric_class = "Gitlab::Usage::Metrics::Instrumentations::#{metric}".constantize
metric_class.new(time_frame: time_frame).instrumentation
metric_class.new(time_frame: time_frame, options: options).instrumentation
end
def count(relation, column = nil, batch: true, batch_size: nil, start: nil, finish: nil)
......
......@@ -12,10 +12,10 @@ module Gitlab
super.with_indifferent_access.deep_merge(instrumentation_metrics.with_indifferent_access)
end
def add_metric(metric, time_frame: 'none')
def add_metric(metric, time_frame: 'none', options: {})
metric_class = "Gitlab::Usage::Metrics::Instrumentations::#{metric}".constantize
metric_class.new(time_frame: time_frame).instrumentation
metric_class.new(time_frame: time_frame, options: options).instrumentation
end
def count(relation, column = nil, *args, **kwargs)
......
......@@ -45,14 +45,14 @@ module Gitlab
MAX_BUCKET_SIZE = 100
INSTRUMENTATION_CLASS_FALLBACK = -100
def add_metric(metric, time_frame: 'none')
def add_metric(metric, time_frame: 'none', options: {})
# Results of this method should be overwritten by instrumentation class values
# -100 indicates the metric was not properly merged.
return INSTRUMENTATION_CLASS_FALLBACK if Feature.enabled?(:usage_data_instrumentation)
metric_class = "Gitlab::Usage::Metrics::Instrumentations::#{metric}".constantize
metric_class.new(time_frame: time_frame).value
metric_class.new(time_frame: time_frame, options: options).value
end
def count(relation, column = nil, batch: true, batch_size: nil, start: nil, finish: nil)
......
......@@ -4223,12 +4223,18 @@ msgstr ""
msgid "ApprovalRule|All severity levels"
msgstr ""
msgid "ApprovalRule|All vulnerability states"
msgstr ""
msgid "ApprovalRule|Apply this approval rule to consider only the selected security scanners."
msgstr ""
msgid "ApprovalRule|Apply this approval rule to consider only the selected severity levels."
msgstr ""
msgid "ApprovalRule|Apply this approval rule to consider only the selected vulnerability states."
msgstr ""
msgid "ApprovalRule|Approval rules"
msgstr ""
......@@ -4241,12 +4247,21 @@ msgstr ""
msgid "ApprovalRule|Approvers"
msgstr ""
msgid "ApprovalRule|Confirmed"
msgstr ""
msgid "ApprovalRule|Dismissed"
msgstr ""
msgid "ApprovalRule|Examples: QA, Security."
msgstr ""
msgid "ApprovalRule|Name"
msgstr ""
msgid "ApprovalRule|Newly detected"
msgstr ""
msgid "ApprovalRule|Number of vulnerabilities allowed before approval rule is triggered."
msgstr ""
......@@ -4259,6 +4274,15 @@ msgstr ""
msgid "ApprovalRule|Please select at least one severity level"
msgstr ""
msgid "ApprovalRule|Please select at least one vulnerability state"
msgstr ""
msgid "ApprovalRule|Previously detected"
msgstr ""
msgid "ApprovalRule|Resolved"
msgstr ""
msgid "ApprovalRule|Rule name"
msgstr ""
......@@ -4274,6 +4298,9 @@ msgstr ""
msgid "ApprovalRule|Select severity levels"
msgstr ""
msgid "ApprovalRule|Select vulnerability states"
msgstr ""
msgid "ApprovalRule|Severity levels"
msgstr ""
......@@ -4283,6 +4310,9 @@ msgstr ""
msgid "ApprovalRule|Vulnerabilities allowed"
msgstr ""
msgid "ApprovalRule|Vulnerability states"
msgstr ""
msgid "ApprovalSettings|Merge request approval settings have been updated."
msgstr ""
......
......@@ -1008,6 +1008,21 @@ describe('common_utils', () => {
});
});
describe('scopedLabelKey', () => {
it.each`
label | expectedLabelKey
${undefined} | ${''}
${''} | ${''}
${'title'} | ${'title'}
${'scoped::value'} | ${'scoped'}
${'scoped::label::value'} | ${'scoped::label'}
${'scoped::label-some::value'} | ${'scoped::label-some'}
${'scoped::label::some::value'} | ${'scoped::label::some'}
`('returns "$expectedLabelKey" when label is "$label"', ({ label, expectedLabelKey }) => {
expect(commonUtils.scopedLabelKey({ title: label })).toBe(expectedLabelKey);
});
});
describe('getDashPath', () => {
it('returns the path following /-/', () => {
expect(commonUtils.getDashPath('/some/-/url-with-dashes-/')).toEqual('url-with-dashes-/');
......
import { cloneDeep } from 'lodash';
import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types';
import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations';
......@@ -153,47 +154,40 @@ describe('LabelsSelect Mutations', () => {
});
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
let labels;
beforeEach(() => {
labels = [
{ id: 1, title: 'scoped' },
{ id: 2, title: 'scoped::one', set: false },
{ id: 3, title: 'scoped::test', set: true },
{ id: 4, title: '' },
];
});
it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
const updatedLabelIds = [2];
const state = {
labels,
};
mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
state.labels.forEach((label) => {
if (updatedLabelIds.includes(label.id)) {
expect(label.touched).toBe(true);
expect(label.set).toBe(true);
}
const labels = [
{ id: 1, title: 'scoped' },
{ id: 2, title: 'scoped::label::one', set: false },
{ id: 3, title: 'scoped::label::two', set: false },
{ id: 4, title: 'scoped::label::three', set: true },
{ id: 5, title: 'scoped::one', set: false },
{ id: 6, title: 'scoped::two', set: false },
{ id: 7, title: 'scoped::three', set: true },
{ id: 8, title: '' },
];
it.each`
label | labelGroupIds
${labels[0]} | ${[]}
${labels[1]} | ${[labels[2], labels[3]]}
${labels[2]} | ${[labels[1], labels[3]]}
${labels[3]} | ${[labels[1], labels[2]]}
${labels[4]} | ${[labels[5], labels[6]]}
${labels[5]} | ${[labels[4], labels[6]]}
${labels[6]} | ${[labels[4], labels[5]]}
${labels[7]} | ${[]}
`('updates `touched` and `set` props for $label.title', ({ label, labelGroupIds }) => {
const state = { labels: cloneDeep(labels) };
mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: label.id }] });
expect(state.labels[label.id - 1]).toMatchObject({
touched: true,
set: !labels[label.id - 1].set,
});
});
describe('when label is scoped', () => {
it('unsets the currently selected scoped label and sets the current label', () => {
const state = {
labels,
};
mutations[types.UPDATE_SELECTED_LABELS](state, {
labels: [{ id: 2, title: 'scoped::one' }],
});
expect(state.labels).toEqual([
{ id: 1, title: 'scoped' },
{ id: 2, title: 'scoped::one', set: true, touched: true },
{ id: 3, title: 'scoped::test', set: false },
{ id: 4, title: '' },
]);
labelGroupIds.forEach((l) => {
expect(state.labels[l.id - 1].touched).toBeFalsy();
expect(state.labels[l.id - 1].set).toBe(false);
});
});
});
......
......@@ -57,8 +57,9 @@ RSpec.describe Gitlab::Ci::Reports::Security::Reports do
let(:high_severity_dast) { build(:ci_reports_security_finding, severity: 'high', report_type: :dast) }
let(:vulnerabilities_allowed) { 0 }
let(:severity_levels) { %w(critical high) }
let(:vulnerability_states) { %w(newly_detected)}
subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels) }
subject { security_reports.violates_default_policy_against?(target_reports, vulnerabilities_allowed, severity_levels, vulnerability_states) }
before do
security_reports.get_report('sast', artifact).add_finding(high_severity_dast)
......
......@@ -341,6 +341,7 @@ RSpec.describe ApplicationWorker do
it 'enqueues jobs in one go' do
expect(Sidekiq::Client).to(
receive(:push_bulk).with(hash_including('args' => args)).once.and_call_original)
expect(Sidekiq.logger).not_to receive(:info)
perform_action
......@@ -349,6 +350,19 @@ RSpec.describe ApplicationWorker do
end
end
shared_examples_for 'logs bulk insertions' do
it 'logs arguments and job IDs' do
worker.log_bulk_perform_async!
expect(Sidekiq.logger).to(
receive(:info).with(hash_including('args_list' => args)).once.and_call_original)
expect(Sidekiq.logger).to(
receive(:info).with(hash_including('jid_list' => anything)).once.and_call_original)
perform_action
end
end
before do
stub_const(worker.name, worker)
end
......@@ -381,6 +395,7 @@ RSpec.describe ApplicationWorker do
include_context 'set safe limit beyond the number of jobs to be enqueued'
it_behaves_like 'enqueues jobs in one go'
it_behaves_like 'logs bulk insertions'
it_behaves_like 'returns job_id of all enqueued jobs'
it_behaves_like 'does not schedule the jobs for any specific time'
end
......@@ -400,6 +415,7 @@ RSpec.describe ApplicationWorker do
include_context 'set safe limit beyond the number of jobs to be enqueued'
it_behaves_like 'enqueues jobs in one go'
it_behaves_like 'logs bulk insertions'
it_behaves_like 'returns job_id of all enqueued jobs'
it_behaves_like 'does not schedule the jobs for any specific time'
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