Commit c4b058a5 authored by Michael Kozono's avatar Michael Kozono

Merge branch '217614-add-reference-filter-for-vulnerabilities' into 'master'

Add extensible reference filter for Vulnerability

See merge request gitlab-org/gitlab!47292
parents e72e55c6 2db7c999
...@@ -14,6 +14,7 @@ export default function initGFMInput($els) { ...@@ -14,6 +14,7 @@ export default function initGFMInput($els) {
milestones: enableGFM, milestones: enableGFM,
mergeRequests: enableGFM, mergeRequests: enableGFM,
labels: enableGFM, labels: enableGFM,
vulnerabilities: enableGFM,
}); });
}); });
} }
...@@ -6,6 +6,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator'; ...@@ -6,6 +6,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import { isUserBusy } from '~/set_status_modal/utils'; import { isUserBusy } from '~/set_status_modal/utils';
import glRegexp from './lib/utils/regexp'; import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache'; import AjaxCache from './lib/utils/ajax_cache';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils'; import { spriteIcon } from './lib/utils/common_utils';
import * as Emoji from '~/emoji'; import * as Emoji from '~/emoji';
...@@ -55,6 +56,7 @@ export const defaultAutocompleteConfig = { ...@@ -55,6 +56,7 @@ export const defaultAutocompleteConfig = {
milestones: true, milestones: true,
labels: true, labels: true,
snippets: true, snippets: true,
vulnerabilities: true,
}; };
class GfmAutoComplete { class GfmAutoComplete {
...@@ -62,6 +64,7 @@ class GfmAutoComplete { ...@@ -62,6 +64,7 @@ class GfmAutoComplete {
this.dataSources = dataSources; this.dataSources = dataSources;
this.cachedData = {}; this.cachedData = {};
this.isLoadingData = {}; this.isLoadingData = {};
this.previousQuery = '';
} }
setup(input, enableMap = defaultAutocompleteConfig) { setup(input, enableMap = defaultAutocompleteConfig) {
...@@ -561,7 +564,7 @@ class GfmAutoComplete { ...@@ -561,7 +564,7 @@ class GfmAutoComplete {
} }
getDefaultCallbacks() { getDefaultCallbacks() {
const fetchData = this.fetchData.bind(this); const self = this;
return { return {
sorter(query, items, searchKey) { sorter(query, items, searchKey) {
...@@ -574,7 +577,14 @@ class GfmAutoComplete { ...@@ -574,7 +577,14 @@ class GfmAutoComplete {
}, },
filter(query, data, searchKey) { filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) { if (GfmAutoComplete.isLoading(data)) {
fetchData(this.$inputor, this.at); self.fetchData(this.$inputor, this.at);
return data;
} else if (
GfmAutoComplete.isTypeWithBackendFiltering(this.at) &&
self.previousQuery !== query
) {
self.fetchData(this.$inputor, this.at, query);
self.previousQuery = query;
return data; return data;
} }
return $.fn.atwho.default.callbacks.filter(query, data, searchKey); return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
...@@ -622,13 +632,22 @@ class GfmAutoComplete { ...@@ -622,13 +632,22 @@ class GfmAutoComplete {
}; };
} }
fetchData($input, at) { fetchData($input, at, search) {
if (this.isLoadingData[at]) return; if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true; this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
if (this.cachedData[at]) { if (GfmAutoComplete.isTypeWithBackendFiltering(at)) {
axios
.get(dataSource, { params: { search } })
.then(({ data }) => {
this.loadData($input, at, data);
})
.catch(() => {
this.isLoadingData[at] = false;
});
} else if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]); this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadEmojiData($input, at).catch(() => {}); this.loadEmojiData($input, at).catch(() => {});
...@@ -714,7 +733,9 @@ class GfmAutoComplete { ...@@ -714,7 +733,9 @@ class GfmAutoComplete {
// https://github.com/ichord/At.js // https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers) const atSymbolsWithBar = Object.keys(controllers)
.join('|') .join('|')
.replace(/[$]/, '\\$&'); .replace(/[$]/, '\\$&')
.replace(/([[\]:])/g, '\\$1');
const atSymbolsWithoutBar = Object.keys(controllers).join(''); const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
...@@ -745,9 +766,14 @@ GfmAutoComplete.atTypeMap = { ...@@ -745,9 +766,14 @@ GfmAutoComplete.atTypeMap = {
'~': 'labels', '~': 'labels',
'%': 'milestones', '%': 'milestones',
'/': 'commands', '/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets', $: 'snippets',
}; };
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
GfmAutoComplete.isTypeWithBackendFiltering = type =>
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
function findEmoji(name) { function findEmoji(name) {
return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => { return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
if (a.index !== b.index) { if (a.index !== b.index) {
......
...@@ -16,5 +16,6 @@ export default (initGFM = true) => { ...@@ -16,5 +16,6 @@ export default (initGFM = true) => {
milestones: initGFM, milestones: initGFM,
labels: initGFM, labels: initGFM,
snippets: initGFM, snippets: initGFM,
vulnerabilities: initGFM,
}); });
}; };
...@@ -177,6 +177,7 @@ export default { ...@@ -177,6 +177,7 @@ export default {
milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
vulnerabilities: this.enableAutocomplete,
}, },
true, true,
); );
......
...@@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
private private
def autocomplete_service def autocomplete_service
@autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user) @autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user, params)
end end
def target def target
......
...@@ -159,6 +159,7 @@ module NotesHelper ...@@ -159,6 +159,7 @@ module NotesHelper
members: autocomplete, members: autocomplete,
issues: autocomplete, issues: autocomplete,
mergeRequests: autocomplete, mergeRequests: autocomplete,
vulnerabilities: autocomplete,
epics: autocomplete, epics: autocomplete,
milestones: autocomplete, milestones: autocomplete,
labels: autocomplete labels: autocomplete
......
...@@ -22,7 +22,7 @@ module Mentionable ...@@ -22,7 +22,7 @@ module Mentionable
def self.default_pattern def self.default_pattern
strong_memoize(:default_pattern) do strong_memoize(:default_pattern) do
issue_pattern = Issue.reference_pattern issue_pattern = Issue.reference_pattern
link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic].map(&:link_reference_pattern).compact) link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact)
reference_pattern(link_patterns, issue_pattern) reference_pattern(link_patterns, issue_pattern)
end end
end end
......
...@@ -2,6 +2,27 @@ ...@@ -2,6 +2,27 @@
# Placeholder class for model that is implemented in EE # Placeholder class for model that is implemented in EE
class Vulnerability < ApplicationRecord class Vulnerability < ApplicationRecord
include IgnorableColumns
def self.link_reference_pattern
nil
end
def self.reference_prefix
'[vulnerability:'
end
def self.reference_prefix_escaped
'[vulnerability&lbrack;'
end
def self.reference_postfix
']'
end
def self.reference_postfix_escaped
'&rbrack;'
end
end end
Vulnerability.prepend_if_ee('EE::Vulnerability') Vulnerability.prepend_if_ee('EE::Vulnerability')
...@@ -425,6 +425,7 @@ GFM recognizes the following: ...@@ -425,6 +425,7 @@ GFM recognizes the following:
| merge request | `!123` | `namespace/project!123` | `project!123` | | merge request | `!123` | `namespace/project!123` | `project!123` |
| snippet | `$123` | `namespace/project$123` | `project$123` | | snippet | `$123` | `namespace/project$123` | `project$123` |
| epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | | | epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | |
| vulnerability **(ULTIMATE)** *(1)* | `[vulnerability:123]` | `[vulnerability:namespace/project/123]` | `[vulnerability:project/123]` |
| label by ID | `~123` | `namespace/project~123` | `project~123` | | label by ID | `~123` | `namespace/project~123` | `project~123` |
| one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` | | one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` |
| multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` | | multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |
...@@ -438,6 +439,26 @@ GFM recognizes the following: ...@@ -438,6 +439,26 @@ GFM recognizes the following:
| repository file line references | `[README](doc/README#L13)` | | | | repository file line references | `[README](doc/README#L13)` | | |
| [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` | | [alert](../operations/incident_management/alerts.md) | `^alert#123` | `namespace/project^alert#123` | `project^alert#123` |
1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/281035) in GitLab 13.6.
The Vulnerability special references feature is under development but ready for production use.
It is deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
can opt to enable it for your instance.
It's disabled on GitLab.com.
To disable it:
```ruby
Feature.disable(:vulnerability_special_references)
```
To enable it:
```ruby
Feature.enable(:vulnerability_special_references)
```
For example, referencing an issue by using `#123` will format the output as a link For example, referencing an issue by using `#123` will format the output as a link
to issue number 123 with text `#123`. Likewise, a link to issue number 123 will be to issue number 123 with text `#123`. Likewise, a link to issue number 123 will be
recognized and formatted with text `#123`. recognized and formatted with text `#123`.
......
...@@ -16,6 +16,10 @@ class GfmAutoCompleteEE extends GfmAutoComplete { ...@@ -16,6 +16,10 @@ class GfmAutoCompleteEE extends GfmAutoComplete {
this.setupAutoCompleteEpics($input, this.getDefaultCallbacks()); this.setupAutoCompleteEpics($input, this.getDefaultCallbacks());
} }
if (this.enableMap.vulnerabilities) {
this.setupAutoCompleteVulnerabilities($input, this.getDefaultCallbacks());
}
super.setupAtWho($input); super.setupAtWho($input);
} }
...@@ -51,6 +55,41 @@ class GfmAutoCompleteEE extends GfmAutoComplete { ...@@ -51,6 +55,41 @@ class GfmAutoCompleteEE extends GfmAutoComplete {
}, },
}); });
}; };
setupAutoCompleteVulnerabilities = ($input, defaultCallbacks) => {
$input.atwho({
at: '[vulnerability:',
suffix: ']',
alias: 'vulnerabilities',
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
skipSpecialCharacterTest: true,
callbacks: {
...defaultCallbacks,
beforeSave(merges) {
return merges.map(m => {
if (m.title == null) {
return m;
}
return {
id: m.id,
title: m.title.replace(/<(?:.|\n)*?>/gm, ''),
reference: m.reference,
search: `${m.id} ${m.title}`,
};
});
},
},
});
};
} }
export default GfmAutoCompleteEE; export default GfmAutoCompleteEE;
...@@ -7,6 +7,7 @@ module EE ...@@ -7,6 +7,7 @@ module EE
prepended do prepended do
feature_category :epics, [:epics] feature_category :epics, [:epics]
feature_category :vulnerability_management, [:vulnerabilities]
end end
def epics def epics
...@@ -14,6 +15,12 @@ module EE ...@@ -14,6 +15,12 @@ module EE
render json: autocomplete_service.epics render json: autocomplete_service.epics
end end
def vulnerabilities
return render_404 unless project.feature_available?(:security_dashboard)
render json: autocomplete_service.vulnerabilities
end
end end
end end
end end
...@@ -7,6 +7,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController ...@@ -7,6 +7,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
feature_category :issue_tracking, [:issues, :labels, :milestones, :commands] feature_category :issue_tracking, [:issues, :labels, :milestones, :commands]
feature_category :code_review, [:merge_requests] feature_category :code_review, [:merge_requests]
feature_category :epics, [:epics] feature_category :epics, [:epics]
feature_category :vulnerability_management, [:vulnerabilities]
def members def members
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
...@@ -31,6 +32,10 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController ...@@ -31,6 +32,10 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
render json: @autocomplete_service.epics(confidential_only: params[:confidential_only]) render json: @autocomplete_service.epics(confidential_only: params[:confidential_only])
end end
def vulnerabilities
render json: vulnerability_serializer.represent(@autocomplete_service.vulnerabilities, parent_group: @group)
end
def commands def commands
render json: @autocomplete_service.commands(target) render json: @autocomplete_service.commands(target)
end end
...@@ -42,13 +47,17 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController ...@@ -42,13 +47,17 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
private private
def load_autocomplete_service def load_autocomplete_service
@autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user) @autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user, params)
end end
def issuable_serializer def issuable_serializer
GroupIssuableAutocompleteSerializer.new GroupIssuableAutocompleteSerializer.new
end end
def vulnerability_serializer
GroupVulnerabilityAutocompleteSerializer.new
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def target def target
QuickActions::TargetService QuickActions::TargetService
......
...@@ -20,10 +20,10 @@ module Autocomplete ...@@ -20,10 +20,10 @@ module Autocomplete
DEFAULT_AUTOCOMPLETE_LIMIT = 5 DEFAULT_AUTOCOMPLETE_LIMIT = 5
def execute def execute
return [] unless vulnerable.feature_available?(:security_dashboard) return ::Vulnerability.none unless vulnerable.feature_available?(:security_dashboard)
::Security::VulnerabilitiesFinder # rubocop: disable CodeReuse/Finder ::Security::VulnerabilitiesFinder # rubocop: disable CodeReuse/Finder
.new(vulnerable, params) .new(vulnerable)
.execute .execute
.autocomplete_search(params[:search].to_s) .autocomplete_search(params[:search].to_s)
.with_limit(DEFAULT_AUTOCOMPLETE_LIMIT) .with_limit(DEFAULT_AUTOCOMPLETE_LIMIT)
......
...@@ -78,6 +78,8 @@ module EE ...@@ -78,6 +78,8 @@ module EE
def autocomplete_data_sources(object, noteable_type) def autocomplete_data_sources(object, noteable_type)
return {} unless object && noteable_type return {} unless object && noteable_type
enabled_for_vulnerabilities = object.feature_available?(:security_dashboard) && ::Feature.enabled?(:vulnerability_special_references, object)
if object.is_a?(Group) if object.is_a?(Group)
{ {
members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
...@@ -85,13 +87,15 @@ module EE ...@@ -85,13 +87,15 @@ module EE
issues: issues_group_autocomplete_sources_path(object), issues: issues_group_autocomplete_sources_path(object),
mergeRequests: merge_requests_group_autocomplete_sources_path(object), mergeRequests: merge_requests_group_autocomplete_sources_path(object),
epics: epics_group_autocomplete_sources_path(object), epics: epics_group_autocomplete_sources_path(object),
vulnerabilities: enabled_for_vulnerabilities ? vulnerabilities_group_autocomplete_sources_path(object) : nil,
commands: commands_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), commands: commands_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_group_autocomplete_sources_path(object) milestones: milestones_group_autocomplete_sources_path(object)
} }.compact
elsif object.group&.feature_available?(:epics)
{ epics: epics_project_autocomplete_sources_path(object) }.merge(super)
else else
super {
epics: object.group&.feature_available?(:epics) ? epics_project_autocomplete_sources_path(object) : nil,
vulnerabilities: enabled_for_vulnerabilities ? vulnerabilities_project_autocomplete_sources_path(object) : nil
}.compact.merge(super)
end end
end end
......
...@@ -11,7 +11,8 @@ module EE ...@@ -11,7 +11,8 @@ module EE
override :other_patterns override :other_patterns
def other_patterns def other_patterns
super.unshift( super.unshift(
::Epic.reference_pattern ::Epic.reference_pattern,
::Vulnerability.reference_pattern
) )
end end
end end
......
...@@ -70,6 +70,7 @@ module EE ...@@ -70,6 +70,7 @@ module EE
validates :description, length: { maximum: ::Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true validates :description, length: { maximum: ::Issuable::DESCRIPTION_LENGTH_MAX }, allow_blank: true
validates :description_html, length: { maximum: ::Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true validates :description_html, length: { maximum: ::Issuable::DESCRIPTION_HTML_LENGTH_MAX }, allow_blank: true
scope :with_author_and_project, -> { includes(:author, :project) }
scope :with_findings, -> { includes(:findings) } scope :with_findings, -> { includes(:findings) }
scope :with_findings_and_scanner, -> { includes(findings: :scanner) } scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) } scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
...@@ -126,9 +127,10 @@ module EE ...@@ -126,9 +127,10 @@ module EE
to: :finding, allow_nil: true to: :finding, allow_nil: true
def to_reference(from = nil, full: false) def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}" project
.to_reference_base(from, full: full)
"#{project.to_reference_base(from, full: full)}#{reference}" .then { |reference_base| reference_base.present? ? "#{reference_base}/" : nil }
.then { |reference_base| "#{self.class.reference_prefix}#{reference_base}#{id}#{self.class.reference_postfix}" }
end end
# There will only be one finding associated with a vulnerability for the foreseeable future # There will only be one finding associated with a vulnerability for the foreseeable future
...@@ -188,6 +190,28 @@ module EE ...@@ -188,6 +190,28 @@ module EE
end end
class_methods do class_methods do
def reference_pattern
@reference_pattern ||= %r{
#{Regexp.escape(reference_prefix)}(#{::Project.reference_pattern}\/)?(?<vulnerability>\d+)#{Regexp.escape(reference_postfix)}
}x
end
def link_reference_pattern
%r{
(?<url>
#{Regexp.escape(::Gitlab.config.gitlab.url)}
\/#{::Project.reference_pattern}
(?:\/\-)
\/security\/vulnerabilities
\/(?<vulnerability>\d+)
(?<path>
(\/[a-z0-9_=-]+)*\/*
)?
(?<anchor>\#[a-z0-9_-]+)?
)
}x
end
def parent_class def parent_class
::Project ::Project
end end
......
# frozen_string_literal: true
class GroupVulnerabilityAutocompleteEntity < Grape::Entity
expose :id
expose :title
expose :reference do |vulnerability, options|
vulnerability.to_reference(options[:parent_group])
end
end
# frozen_string_literal: true
class GroupVulnerabilityAutocompleteSerializer < BaseSerializer
entity GroupVulnerabilityAutocompleteEntity
end
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
module EE module EE
module PreviewMarkdownService module PreviewMarkdownService
def quick_action_types def quick_action_types
super.push('Epic') super
.push('Epic')
.push('Vulnerability')
end end
end end
end end
...@@ -7,6 +7,13 @@ module EE ...@@ -7,6 +7,13 @@ module EE
.new(current_user, group_id: project.group&.id, state: 'opened') .new(current_user, group_id: project.group&.id, state: 'opened')
.execute.select([:iid, :title]) .execute.select([:iid, :title])
end end
def vulnerabilities
::Autocomplete::VulnerabilitiesAutocompleteFinder
.new(current_user, project, params)
.execute
.select([:id, :title])
end
end end
end end
end end
...@@ -37,6 +37,13 @@ module Groups ...@@ -37,6 +37,13 @@ module Groups
.select(:iid, :title) .select(:iid, :title)
end end
def vulnerabilities
::Autocomplete::VulnerabilitiesAutocompleteFinder
.new(current_user, group, params)
.execute
.select([:id, :title, :project_id])
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def milestones def milestones
group_ids = group.self_and_ancestors.public_or_visible_to_user(current_user).pluck(:id) group_ids = group.self_and_ancestors.public_or_visible_to_user(current_user).pluck(:id)
......
---
title: Enable Special References for Vulnerabilities
merge_request: 47292
author:
type: added
---
name: vulnerability_special_references
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47292
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/281035
milestone: '13.6'
type: development
group: group::threat insights
default_enabled: false
...@@ -86,6 +86,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -86,6 +86,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get 'merge_requests' get 'merge_requests'
get 'labels' get 'labels'
get 'epics' get 'epics'
get 'vulnerabilities'
get 'commands' get 'commands'
get 'milestones' get 'milestones'
end end
......
...@@ -22,6 +22,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -22,6 +22,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :autocomplete_sources, only: [] do resources :autocomplete_sources, only: [] do
collection do collection do
get 'epics' get 'epics'
get 'vulnerabilities'
end end
end end
......
# frozen_string_literal: true
module EE
module Banzai
module Filter
# HTML filter that replaces vulnerability references with links. References to
# vulnerabilities that do not exist are ignored.
#
# This filter supports cross-project/group references.
module VulnerabilityReferenceFilter
extend ActiveSupport::Concern
class_methods do
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, $~[:project], $~[:namespace], $~
else
match
end
end
end
end
def unescape_link(href)
return href if href =~ object_class.reference_pattern
super
end
def url_for_object(vulnerability, project)
urls = ::Gitlab::Routing.url_helpers
urls.project_security_vulnerability_url(project, vulnerability, only_path: context[:only_path])
end
def data_attributes_for(text, project, object, link_content: false, link_reference: false)
{
original: escape_html_entities(text),
link: link_content,
link_reference: link_reference,
project: project.id,
object_sym => object.id
}
end
def parent_records(parent, ids)
return ::Vulnerability.none if ids.blank? || parent.nil? || ::Feature.disabled?(:vulnerability_special_references, parent)
parent.vulnerabilities.id_in(ids.to_a)
end
def record_identifier(record)
record.id.to_i
end
private
def parent_type
:project
end
end
end
end
end
...@@ -4,15 +4,20 @@ module EE ...@@ -4,15 +4,20 @@ module EE
module Banzai module Banzai
module IssuableExtractor module IssuableExtractor
EPIC_REFERENCE_TYPE = '@data-reference-type="epic"'.freeze EPIC_REFERENCE_TYPE = '@data-reference-type="epic"'.freeze
VULNERABILITY_REFERENCE_TYPE = '@data-reference-type="vulnerability"'.freeze
private private
def reference_types def reference_types
super.push(EPIC_REFERENCE_TYPE) super
.push(EPIC_REFERENCE_TYPE)
.push(VULNERABILITY_REFERENCE_TYPE)
end end
def parsers def parsers
super.push(::Banzai::ReferenceParser::EpicParser.new(context)) super
.push(::Banzai::ReferenceParser::EpicParser.new(context))
.push(::Banzai::ReferenceParser::VulnerabilityParser.new(context))
end end
end end
end end
......
...@@ -18,6 +18,7 @@ module EE ...@@ -18,6 +18,7 @@ module EE
[ [
::Banzai::Filter::EpicReferenceFilter, ::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter, ::Banzai::Filter::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter,
*super *super
] ]
end end
......
...@@ -11,6 +11,7 @@ module EE ...@@ -11,6 +11,7 @@ module EE
[ [
::Banzai::Filter::EpicReferenceFilter, ::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter, ::Banzai::Filter::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter,
*super *super
] ]
end end
......
# frozen_string_literal: true
module EE
module Banzai
module ReferenceParser
module VulnerabilityParser
def references_relation
Vulnerability
end
def records_for_nodes(nodes)
@vulnerabilities_for_nodes ||= grouped_objects_for_nodes(
nodes,
::Vulnerability.with_author_and_project,
self.class.data_attribute
)
end
def can_read_reference?(user, vulnerability)
can?(user, :read_vulnerability, vulnerability)
end
end
end
end
end
...@@ -8,34 +8,67 @@ RSpec.describe Projects::AutocompleteSourcesController do ...@@ -8,34 +8,67 @@ RSpec.describe Projects::AutocompleteSourcesController do
let_it_be(:project) { create(:project, :public, group: group) } let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:epic) { create(:epic, group: group) } let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group2) } let_it_be(:epic2) { create(:epic, group: group2) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
before do before do
sign_in(user) sign_in(user)
end end
context 'when epics feature is disabled' do describe '#epics' do
it 'returns 404 status' do context 'when epics feature is disabled' do
get :epics, params: { namespace_id: project.namespace, project_id: project } it 'returns 404 status' do
get :epics, params: { namespace_id: project.namespace, project_id: project }
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
describe '#epics' do
it 'returns the correct response' do
get :epics, params: { namespace_id: project.namespace, project_id: project }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.count).to eq(1)
expect(json_response.first).to include(
'iid' => epic.iid, 'title' => epic.title
)
end
end
end end
end end
context 'when epics feature is enabled' do describe '#vulnerabilities' do
before do context 'when vulnerabilities feature is disabled' do
stub_licensed_features(epics: true) it 'returns 404 status' do
get :vulnerabilities, params: { namespace_id: project.namespace, project_id: project }
expect(response).to have_gitlab_http_status(:not_found)
end
end end
describe '#epics' do context 'when vulnerabilities feature is enabled' do
it 'returns the correct response' do before do
get :epics, params: { namespace_id: project.namespace, project_id: project } stub_licensed_features(security_dashboard: true)
project.add_developer(user)
end
describe '#vulnerabilities' do
it 'returns the correct response', :aggregate_failures do
get :vulnerabilities, params: { namespace_id: project.namespace, project_id: project }
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an(Array) expect(json_response).to be_an(Array)
expect(json_response.count).to eq(1) expect(json_response.count).to eq(1)
expect(json_response.first).to include( expect(json_response.first).to include(
'iid' => epic.iid, 'title' => epic.title 'id' => vulnerability.id, 'title' => vulnerability.title
) )
end
end end
end end
end end
......
...@@ -33,6 +33,31 @@ RSpec.describe Groups::AutocompleteSourcesController do ...@@ -33,6 +33,31 @@ RSpec.describe Groups::AutocompleteSourcesController do
end end
end end
describe '#vulnerabilities' do
let_it_be_with_reload(:project) { create(:project, :private, group: group) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
before do
project.add_developer(user)
stub_licensed_features(security_dashboard: true)
end
it 'returns 200 status' do
get :vulnerabilities, params: { group_id: group }
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns the correct response', :aggregate_failures do
get :vulnerabilities, params: { group_id: group }
expect(json_response).to be_an(Array)
expect(json_response.first).to include(
'id' => vulnerability.id, 'title' => vulnerability.title
)
end
end
describe '#issues' do describe '#issues' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
...@@ -105,29 +105,39 @@ RSpec.describe ApplicationHelper do ...@@ -105,29 +105,39 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :epics, :commands, :milestones]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :epics, :commands, :milestones])
end end
context 'when vulnerabilities are enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
it 'returns paths for autocomplete_sources_controller with vulnerabilities' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :epics, :vulnerabilities, :commands, :milestones])
end
end
end end
context 'project' do context 'project' do
let(:object) { create(:project) } let(:object) { create(:project) }
let(:noteable_type) { Issue } let(:noteable_type) { Issue }
context 'when epics are enabled' do context 'when epics and vulnerabilities are enabled' do
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true, security_dashboard: true)
end end
it 'returns paths for autocomplete_sources_controller for personal projects' do it 'returns paths for autocomplete_sources_controller for personal projects' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :vulnerabilities])
end end
it 'returns paths for autocomplete_sources_controller including epics for group projects' do it 'returns paths for autocomplete_sources_controller including epics and vulnerabilities for group projects' do
object.update!(group: create(:group)) object.update!(group: create(:group))
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :epics]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets, :epics, :vulnerabilities])
end end
end end
context 'when epics are disabled' do context 'when epics and vulnerabilities are disabled' do
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets])
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::Filter::VulnerabilityReferenceFilter do
include FilterSpecHelper
let(:urls) { Gitlab::Routing.url_helpers }
let(:project) { create(:project) }
let(:another_project) { create(:project) }
let(:vulnerability) { create(:vulnerability, project: project) }
let(:full_ref_text) { "Check [vulnerability:#{vulnerability.project.full_path}/#{vulnerability.id}]" }
def doc(reference = nil)
reference ||= "Check [vulnerability:#{vulnerability.id}]"
context = { project: project, group: nil }
reference_filter(reference, context)
end
context 'when vulnerability_special_references feature is disabled' do
let(:reference) { "[vulnerability:#{vulnerability.id}]" }
before do
stub_feature_flags(vulnerability_special_references: false)
end
it 'does not link to a reference' do
expect(doc.css('a')).to be_empty
end
it 'leaves the adjacent text' do
expect(doc.text).to eq("Check #{reference}")
end
end
context 'when vulnerability_special_references feature is enabled' do
before do
stub_feature_flags(vulnerability_special_references: true)
end
context 'internal reference' do
let(:reference) { "[vulnerability:#{vulnerability.id}]" }
it 'links to a valid reference' do
expect(doc.css('a').first.attr('href')).to eq(urls.project_security_vulnerability_url(project, vulnerability))
end
it 'links with adjacent text' do
expect(doc.text).to eq("Check #{reference}")
end
it 'includes a title attribute' do
expect(doc.css('a').first.attr('title')).to eq(vulnerability.title)
end
it 'escapes the title attribute' do
vulnerability.update_column(:title, %{"></a>whatever<a title="})
expect(doc.text).to eq("Check #{reference}")
end
it 'includes default classes' do
expect(doc.css('a').first.attr('class')).to eq('gfm gfm-vulnerability has-tooltip')
end
it 'includes a data-project attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-project')
expect(link.attr('data-project')).to eq(project.id.to_s)
end
it 'includes a data-vulnerability attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-vulnerability')
expect(link.attr('data-vulnerability')).to eq(vulnerability.id.to_s)
end
it 'includes a data-original attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-original')
expect(link.attr('data-original')).to eq(CGI.escapeHTML(reference))
end
it 'ignores invalid vulnerability IDs' do
text = "Check [vulnerability:#{non_existing_record_id}]"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'ignores out of range vulnerability IDs' do
text = "Check &1161452270761535925900804973910297"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'does not process links containing vulnerability numbers followed by text' do
href = "#{reference}st"
link = doc("<a href='#{href}'></a>").css('a').first.attr('href')
expect(link).to eq(href)
end
end
context 'cross-reference' do
before do
vulnerability.update_column(:project_id, another_project.id)
end
it 'ignores a shorthand reference from another group' do
text = "Check [vulnerability:#{vulnerability.id}]"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'links to a valid reference for full reference' do
expect(doc(full_ref_text).css('a').first.attr('href')).to eq(urls.project_security_vulnerability_url(another_project, vulnerability))
end
it 'link has valid text' do
expect(doc(full_ref_text).css('a').first.text).to eq("[vulnerability:#{vulnerability.project.full_path}/#{vulnerability.id}]")
end
it 'includes default classes' do
expect(doc(full_ref_text).css('a').first.attr('class')).to eq('gfm gfm-vulnerability has-tooltip')
end
end
context 'escaped cross-reference' do
before do
vulnerability.update_column(:project_id, another_project.id)
end
it 'ignores a shorthand reference from another group' do
text = "Check [vulnerability:#{vulnerability.id}]"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'links to a valid reference for full reference' do
expect(doc(full_ref_text).css('a').first.attr('href')).to eq(urls.project_security_vulnerability_url(another_project, vulnerability))
end
it 'link has valid text' do
expect(doc(full_ref_text).css('a').first.text).to eq("[vulnerability:#{vulnerability.project.full_path}/#{vulnerability.id}]")
end
it 'includes default classes' do
expect(doc(full_ref_text).css('a').first.attr('class')).to eq('gfm gfm-vulnerability has-tooltip')
end
end
context 'url reference' do
let(:link) { urls.project_security_vulnerability_url(vulnerability.project, vulnerability) }
let(:text) { "Check #{link}" }
let(:project) { create(:project) }
before do
vulnerability.update_column(:project_id, another_project.id)
end
it 'links to a valid reference' do
expect(doc(text).css('a').first.attr('href')).to eq(urls.project_security_vulnerability_url(another_project, vulnerability))
end
it 'link has valid text' do
expect(doc(text).css('a').first.text).to eq(vulnerability.to_reference(project))
end
it 'includes default classes' do
expect(doc(text).css('a').first.attr('class')).to eq('gfm gfm-vulnerability has-tooltip')
end
it 'matches link reference with trailing slash' do
doc2 = reference_filter("Fixed (#{link}/.)")
expect(doc2).to match(%r{\(#{Regexp.escape(vulnerability.to_reference(project))}\.\)})
end
end
context 'url in a link href' do
let(:link) { urls.project_security_vulnerability_url(vulnerability.project, vulnerability) }
let(:text) do
ref = %{<a href="#{link}">Reference</a>}
"Check #{ref}"
end
before do
vulnerability.update_column(:project_id, another_project.id)
end
it 'links to a valid reference for link href' do
expect(doc(text).css('a').first.attr('href')).to eq(urls.project_security_vulnerability_url(another_project, vulnerability))
end
it 'link has valid text' do
expect(doc(text).css('a').first.text).to eq('Reference')
end
it 'includes default classes' do
expect(doc(text).css('a').first.attr('class')).to eq('gfm gfm-vulnerability has-tooltip')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Banzai::ReferenceParser::VulnerabilityParser do
include ReferenceParserHelpers
def link(vulnerability_id)
link = empty_html_link
link['data-vulnerability'] = vulnerability_id.to_s
link
end
let(:user) { create(:user) }
let(:public_project) { create(:project, :public) }
let(:private_project1) { create(:project, :private) }
let(:private_project2) { create(:project, :private) }
let(:vulnerability) { create(:vulnerability, project: public_project) }
let(:vulnerability1) { create(:vulnerability, project: private_project1) }
let(:vulnerability2) { create(:vulnerability, project: private_project2) }
let(:nodes) do
[link(vulnerability.id), link(vulnerability1.id), link(vulnerability2.id)]
end
subject { described_class.new(Banzai::RenderContext.new(nil, user)) }
describe '#nodes_visible_to_user' do
before do
private_project1.add_developer(user)
end
context 'when the vulnerabilities feature is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
it 'returns the nodes the user can read for valid vulnerability nodes' do
expected_result = [nodes[1]]
expect(subject.nodes_visible_to_user(user, nodes)).to match_array(expected_result)
end
it 'returns an empty array for nodes without required data-attributes' do
expect(subject.nodes_visible_to_user(user, [empty_html_link])).to be_empty
end
end
context 'when the vulnerabilities feature is disabled' do
it 'returns an empty array' do
expect(subject.nodes_visible_to_user(user, nodes)).to be_empty
end
end
end
describe '#referenced_by' do
context 'when using an existing vulnerabilities IDs' do
it 'returns an Array of vulnerabilities' do
expected_result = [vulnerability, vulnerability1, vulnerability2]
expect(subject.referenced_by(nodes)).to match_array(expected_result)
end
it 'returns an empty Array for empty list of nodes' do
expect(subject.referenced_by([])).to be_empty
end
end
context 'when vulnerability with given ID does not exist' do
it 'returns an empty Array' do
expect(subject.referenced_by([link(non_existing_record_id)])).to be_empty
end
end
end
describe '#records_for_nodes' do
it 'returns a Hash containing the vulnerabilities for a list of nodes' do
expected_hash = {
nodes[0] => vulnerability,
nodes[1] => vulnerability1,
nodes[2] => vulnerability2
}
expect(subject.records_for_nodes(nodes)).to eq(expected_hash)
end
end
end
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Gitlab::ReferenceExtractor do RSpec.describe Gitlab::ReferenceExtractor do
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
before do before do
group.add_developer(project.creator) group.add_developer(project.creator)
...@@ -25,4 +25,22 @@ RSpec.describe Gitlab::ReferenceExtractor do ...@@ -25,4 +25,22 @@ RSpec.describe Gitlab::ReferenceExtractor do
expect(subject.epics).to match_array([@e0, @e1]) expect(subject.epics).to match_array([@e0, @e1])
end end
context 'for vulnerabilities' do
let_it_be(:vulnerability_0) { create(:vulnerability, project: project) }
let_it_be(:vulnerability_1) { create(:vulnerability, project: project) }
let_it_be(:vulnerability_2) { create(:vulnerability, project: create(:project, :private)) }
let(:text) { "#{vulnerability_0.to_reference(project, full: true)}, [vulnerability:#{non_existing_record_id}], #{vulnerability_1.to_reference(project, full: true)}, #{vulnerability_2.to_reference(project, full: true)}" }
before do
stub_licensed_features(security_dashboard: true)
end
it 'accesses valid vulnerabilities' do
subject.analyze(text, { project: project })
expect(subject.vulnerabilities).to match_array([vulnerability_0, vulnerability_1])
end
end
end end
...@@ -502,6 +502,114 @@ RSpec.describe Vulnerability do ...@@ -502,6 +502,114 @@ RSpec.describe Vulnerability do
it { is_expected.to match_array(expected_vulnerabilities) } it { is_expected.to match_array(expected_vulnerabilities) }
end end
describe '.reference_prefix' do
subject { described_class.reference_prefix }
it { is_expected.to eq('[vulnerability:') }
end
describe '.reference_postfix' do
subject { described_class.reference_postfix }
it { is_expected.to eq(']') }
end
describe '.reference_pattern' do
subject { described_class.reference_pattern }
it { is_expected.to match('[vulnerability:123]') }
it { is_expected.to match('[vulnerability:gitlab-foss/123]') }
it { is_expected.to match('[vulnerability:gitlab-org/gitlab-foss/123]') }
end
describe '.link_reference_pattern' do
subject { described_class.link_reference_pattern }
it { is_expected.to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/-/security/vulnerabilities/123") }
it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/security/vulnerabilities/123") }
it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/issues/123") }
it { is_expected.not_to match("gitlab-org/gitlab-foss/milestones/123") }
end
describe '#to_reference' do
let(:namespace) { build(:namespace, path: 'sample-namespace') }
let(:project) { build(:project, name: 'sample-project', namespace: namespace) }
let(:vulnerability) { build(:vulnerability, id: 1, project: project) }
context 'when nil argument' do
it 'returns vulnerability id' do
expect(vulnerability.to_reference).to eq '[vulnerability:1]'
end
it 'returns complete path to the vulnerability with full: true' do
expect(vulnerability.to_reference(full: true)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
context 'when argument is a project' do
context 'when same project' do
it 'returns vulnerability id' do
expect(vulnerability.to_reference(project)).to eq('[vulnerability:1]')
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(project, full: true)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
context 'when cross-project in same namespace' do
let(:another_project) do
build(:project, name: 'another-project', namespace: project.namespace)
end
it 'returns a cross-project reference' do
expect(vulnerability.to_reference(another_project)).to eq '[vulnerability:sample-project/1]'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(another_project, full: true)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
context 'when cross-project in different namespace' do
let(:another_namespace) { build(:namespace, path: 'another-namespace') }
let(:another_namespace_project) { build(:project, path: 'another-project', namespace: another_namespace) }
it 'returns complete path to the vulnerability' do
expect(vulnerability.to_reference(another_namespace_project)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(another_namespace_project, full: true)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
end
context 'when argument is a namespace' do
context 'when same as vulnerability' do
it 'returns path to the vulnerability with the project name' do
expect(vulnerability.to_reference(namespace)).to eq '[vulnerability:sample-project/1]'
end
it 'returns full reference with full: true' do
expect(vulnerability.to_reference(namespace, full: true)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
context 'when different from vulnerability namespace' do
let(:group) { build(:group, name: 'Group', path: 'sample-group') }
it 'returns full path to the vulnerability with full: true' do
expect(vulnerability.to_reference(group)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
it 'returns full path to the vulnerability with full: false' do
expect(vulnerability.to_reference(group, full: false)).to eq '[vulnerability:sample-namespace/sample-project/1]'
end
end
end
end
describe '#finding' do describe '#finding' do
let_it_be(:project) { create(:project, :with_vulnerability) } let_it_be(:project) { create(:project, :with_vulnerability) }
let_it_be(:vulnerability) { project.vulnerabilities.first } let_it_be(:vulnerability) { project.vulnerabilities.first }
......
...@@ -7,9 +7,9 @@ RSpec.describe GroupIssuableAutocompleteEntity do ...@@ -7,9 +7,9 @@ RSpec.describe GroupIssuableAutocompleteEntity do
let(:project) { build_stubbed(:project, group: group) } let(:project) { build_stubbed(:project, group: group) }
let(:issue) { build_stubbed(:issue, project: project) } let(:issue) { build_stubbed(:issue, project: project) }
subject { described_class.new(issue, parent_group: group).as_json }
describe '#represent' do describe '#represent' do
subject { described_class.new(issue, parent_group: group).as_json }
it 'includes the iid, title, and reference' do it 'includes the iid, title, and reference' do
expect(subject).to include(:iid, :title, :reference) expect(subject).to include(:iid, :title, :reference)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GroupVulnerabilityAutocompleteEntity do
let(:group) { build_stubbed(:group) }
let(:project) { build_stubbed(:project, group: group) }
let(:vulnerability) { build_stubbed(:vulnerability, project: project) }
describe '#represent' do
subject { described_class.new(vulnerability, parent_group: project).as_json }
it 'includes the id, title, and reference' do
expect(subject).to include(:id, :title, :reference)
end
end
end
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Groups::AutocompleteService do RSpec.describe Groups::AutocompleteService do
let!(:group) { create(:group, :nested, :private, avatar: fixture_file_upload('spec/fixtures/dk.png')) } let_it_be(:group, refind: true) { create(:group, :nested, :private, avatar: fixture_file_upload('spec/fixtures/dk.png')) }
let!(:sub_group) { create(:group, :private, parent: group) } let_it_be(:sub_group) { create(:group, :private, parent: group) }
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:epic) { create(:epic, group: group, author: user) } let!(:epic) { create(:epic, group: group, author: user) }
...@@ -118,6 +118,50 @@ RSpec.describe Groups::AutocompleteService do ...@@ -118,6 +118,50 @@ RSpec.describe Groups::AutocompleteService do
end end
end end
describe '#vulnerability' do
let_it_be_with_refind(:project) { create(:project, group: group) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
let_it_be(:guest) { create(:user) }
let(:autocomplete_user) { user }
subject { described_class.new(group, autocomplete_user).vulnerabilities.map(&:id) }
context 'when the feature is not available' do
context 'when the user is not allowed' do
it { is_expected.to be_empty }
end
context 'when the user is allowed' do
before do
project.add_developer(user)
end
it { is_expected.to be_empty }
end
end
context 'when the feature is available' do
before do
stub_licensed_features(security_dashboard: true)
end
context 'when the user is not allowed' do
let(:autocomplete_user) { guest }
it { is_expected.to be_empty }
end
context 'when the user is allowed' do
before do
project.add_developer(user)
end
it { is_expected.to contain_exactly(vulnerability.id) }
end
end
end
describe '#commands' do describe '#commands' do
context 'when target is an epic' do context 'when target is an epic' do
let(:parent_epic) { create(:epic, group: group, author: user) } let(:parent_epic) { create(:epic, group: group, author: user) }
......
...@@ -119,7 +119,7 @@ module Banzai ...@@ -119,7 +119,7 @@ module Banzai
# Yields the link's URL and inner HTML whenever the node is a valid <a> tag. # Yields the link's URL and inner HTML whenever the node is a valid <a> tag.
def yield_valid_link(node) def yield_valid_link(node)
link = CGI.unescape(node.attr('href').to_s) link = unescape_link(node.attr('href').to_s)
inner_html = node.inner_html inner_html = node.inner_html
return unless link.force_encoding('UTF-8').valid_encoding? return unless link.force_encoding('UTF-8').valid_encoding?
...@@ -127,6 +127,10 @@ module Banzai ...@@ -127,6 +127,10 @@ module Banzai
yield link, inner_html yield link, inner_html
end end
def unescape_link(href)
CGI.unescape(href)
end
def replace_text_when_pattern_matches(node, index, pattern) def replace_text_when_pattern_matches(node, index, pattern)
return unless node.text =~ pattern return unless node.text =~ pattern
......
# frozen_string_literal: true
module Banzai
module Filter
# The actual filter is implemented in the EE mixin
class VulnerabilityReferenceFilter < IssuableReferenceFilter
self.reference_type = :vulnerability
def self.object_class
Vulnerability
end
private
def project
context[:project]
end
end
end
end
Banzai::Filter::VulnerabilityReferenceFilter.prepend_if_ee('EE::Banzai::Filter::VulnerabilityReferenceFilter')
# frozen_string_literal: true
module Banzai
module ReferenceParser
# The actual parser is implemented in the EE mixin
class VulnerabilityParser < IssuableParser
self.reference_type = :vulnerability
def records_for_nodes(_nodes)
{}
end
end
end
end
Banzai::ReferenceParser::VulnerabilityParser.prepend_if_ee('::EE::Banzai::ReferenceParser::VulnerabilityParser')
...@@ -4,7 +4,7 @@ module Gitlab ...@@ -4,7 +4,7 @@ module Gitlab
# Extract possible GFM references from an arbitrary String for further processing. # Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor class ReferenceExtractor < Banzai::ReferenceExtractor
REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project
merge_request snippet commit commit_range directly_addressed_user epic iteration).freeze merge_request snippet commit commit_range directly_addressed_user epic iteration vulnerability).freeze
attr_accessor :project, :current_user, :author attr_accessor :project, :current_user, :author
# This counter is increased by a number of references filtered out by # This counter is increased by a number of references filtered out by
# banzai reference exctractor. Note that this counter is stateful and # banzai reference exctractor. Note that this counter is stateful and
...@@ -38,7 +38,7 @@ module Gitlab ...@@ -38,7 +38,7 @@ module Gitlab
end end
REFERABLES.each do |type| REFERABLES.each do |type|
define_method("#{type}s") do define_method(type.to_s.pluralize) do
@references[type] ||= references(type) @references[type] ||= references(type)
end end
end end
......
...@@ -8,15 +8,226 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete ...@@ -8,15 +8,226 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures'; import { getJSONFixture } from 'helpers/fixtures';
import waitForPromises from 'jest/helpers/wait_for_promises';
import MockAdapter from 'axios-mock-adapter';
import AjaxCache from '~/lib/utils/ajax_cache';
import axios from '~/lib/utils/axios_utils';
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
describe('GfmAutoComplete', () => { describe('GfmAutoComplete', () => {
const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ const fetchDataMock = { fetchData: jest.fn() };
fetchData: () => {}, let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
});
let atwhoInstance; let atwhoInstance;
let sorterValue; let sorterValue;
let filterValue;
describe('DefaultOptions.filter', () => {
let items;
beforeEach(() => {
jest.spyOn(fetchDataMock, 'fetchData');
jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {});
});
describe('assets loading', () => {
beforeEach(() => {
atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' };
items = ['loading'];
filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items);
});
it('should call the fetchData function without query', () => {
expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:');
});
it('should not call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
});
it('should return the passed unfiltered items', () => {
expect(filterValue).toEqual(items);
});
});
describe('backend filtering', () => {
beforeEach(() => {
atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' };
items = [];
});
describe('when previous query is different from current one', () => {
beforeEach(() => {
gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
previousQuery: 'oldquery',
...fetchDataMock,
});
filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items);
});
it('should call the fetchData function with query', () => {
expect(fetchDataMock.fetchData).toHaveBeenCalledWith(
'inputor',
'[vulnerability:',
'newquery',
);
});
it('should not call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
});
it('should return the passed unfiltered items', () => {
expect(filterValue).toEqual(items);
});
});
describe('when previous query is not different from current one', () => {
beforeEach(() => {
gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
previousQuery: 'oldquery',
...fetchDataMock,
});
filterValue = gfmAutoCompleteCallbacks.filter.call(
atwhoInstance,
'oldquery',
items,
'searchKey',
);
});
it('should not call the fetchData function', () => {
expect(fetchDataMock.fetchData).not.toHaveBeenCalled();
});
it('should call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith(
'oldquery',
items,
'searchKey',
);
});
});
});
});
describe('fetchData', () => {
const { fetchData } = GfmAutoComplete.prototype;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(axios, 'get');
jest.spyOn(AjaxCache, 'retrieve');
});
afterEach(() => {
mock.restore();
});
describe('already loading data', () => {
beforeEach(() => {
const context = {
isLoadingData: { '[vulnerability:': true },
dataSources: {},
cachedData: {},
};
fetchData.call(context, {}, '[vulnerability:', '');
});
it('should not call either axios nor AjaxCache', () => {
expect(axios.get).not.toHaveBeenCalled();
expect(AjaxCache.retrieve).not.toHaveBeenCalled();
});
});
describe('backend filtering', () => {
describe('data is not in cache', () => {
let context;
beforeEach(() => {
context = {
isLoadingData: { '[vulnerability:': false },
dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
cachedData: {},
};
});
it('should call axios with query', () => {
fetchData.call(context, {}, '[vulnerability:', 'query');
expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
params: { search: 'query' },
});
});
it.each([200, 500])('should set the loading state', async responseStatus => {
mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus);
fetchData.call(context, {}, '[vulnerability:', 'query');
expect(context.isLoadingData['[vulnerability:']).toBe(true);
await waitForPromises();
expect(context.isLoadingData['[vulnerability:']).toBe(false);
});
});
describe('data is in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '[vulnerability:': false },
dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
cachedData: { '[vulnerability:': [{}] },
};
fetchData.call(context, {}, '[vulnerability:', 'query');
});
it('should anyway call axios with query ignoring cache', () => {
expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
params: { search: 'query' },
});
});
});
});
describe('frontend filtering', () => {
describe('data is not in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '#': false },
dataSources: { issues: 'issues_autocomplete_url' },
cachedData: {},
};
fetchData.call(context, {}, '#', 'query');
});
it('should call AjaxCache', () => {
expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true);
});
});
describe('data is in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '#': false },
dataSources: { issues: 'issues_autocomplete_url' },
cachedData: { '#': [{}] },
loadData: () => {},
};
fetchData.call(context, {}, '#', 'query');
});
it('should not call AjaxCache', () => {
expect(AjaxCache.retrieve).not.toHaveBeenCalled();
});
});
});
});
describe('DefaultOptions.sorter', () => { describe('DefaultOptions.sorter', () => {
describe('assets loading', () => { describe('assets loading', () => {
...@@ -154,7 +365,6 @@ describe('GfmAutoComplete', () => { ...@@ -154,7 +365,6 @@ describe('GfmAutoComplete', () => {
'я', 'я',
'.', '.',
"'", "'",
'+',
'-', '-',
'_', '_',
]; ];
......
...@@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do ...@@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end end
it 'returns all supported prefixes' do it 'returns all supported prefixes' do
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & *iteration:)) expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & [vulnerability: *iteration:))
end end
it 'does not allow one prefix for multiple referables if not allowed specificly' do it 'does not allow one prefix for multiple referables if not allowed specificly' do
......
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