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) {
milestones: enableGFM,
mergeRequests: enableGFM,
labels: enableGFM,
vulnerabilities: enableGFM,
});
});
}
......@@ -6,6 +6,7 @@ import SidebarMediator from '~/sidebar/sidebar_mediator';
import { isUserBusy } from '~/set_status_modal/utils';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils';
import * as Emoji from '~/emoji';
......@@ -55,6 +56,7 @@ export const defaultAutocompleteConfig = {
milestones: true,
labels: true,
snippets: true,
vulnerabilities: true,
};
class GfmAutoComplete {
......@@ -62,6 +64,7 @@ class GfmAutoComplete {
this.dataSources = dataSources;
this.cachedData = {};
this.isLoadingData = {};
this.previousQuery = '';
}
setup(input, enableMap = defaultAutocompleteConfig) {
......@@ -561,7 +564,7 @@ class GfmAutoComplete {
}
getDefaultCallbacks() {
const fetchData = this.fetchData.bind(this);
const self = this;
return {
sorter(query, items, searchKey) {
......@@ -574,7 +577,14 @@ class GfmAutoComplete {
},
filter(query, data, searchKey) {
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 $.fn.atwho.default.callbacks.filter(query, data, searchKey);
......@@ -622,13 +632,22 @@ class GfmAutoComplete {
};
}
fetchData($input, at) {
fetchData($input, at, search) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
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]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadEmojiData($input, at).catch(() => {});
......@@ -714,7 +733,9 @@ class GfmAutoComplete {
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
.replace(/[$]/, '\\$&');
.replace(/[$]/, '\\$&')
.replace(/([[\]:])/g, '\\$1');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
......@@ -745,9 +766,14 @@ GfmAutoComplete.atTypeMap = {
'~': 'labels',
'%': 'milestones',
'/': 'commands',
'[vulnerability:': 'vulnerabilities',
$: 'snippets',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
GfmAutoComplete.isTypeWithBackendFiltering = type =>
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
function findEmoji(name) {
return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
if (a.index !== b.index) {
......
......@@ -16,5 +16,6 @@ export default (initGFM = true) => {
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
vulnerabilities: initGFM,
});
};
......@@ -177,6 +177,7 @@ export default {
milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
vulnerabilities: this.enableAutocomplete,
},
true,
);
......
......@@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
private
def autocomplete_service
@autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user)
@autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user, params)
end
def target
......
......@@ -159,6 +159,7 @@ module NotesHelper
members: autocomplete,
issues: autocomplete,
mergeRequests: autocomplete,
vulnerabilities: autocomplete,
epics: autocomplete,
milestones: autocomplete,
labels: autocomplete
......
......@@ -22,7 +22,7 @@ module Mentionable
def self.default_pattern
strong_memoize(:default_pattern) do
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)
end
end
......
......@@ -2,6 +2,27 @@
# Placeholder class for model that is implemented in EE
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
Vulnerability.prepend_if_ee('EE::Vulnerability')
......@@ -425,6 +425,7 @@ GFM recognizes the following:
| merge request | `!123` | `namespace/project!123` | `project!123` |
| snippet | `$123` | `namespace/project$123` | `project$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` |
| 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"` |
......@@ -438,6 +439,26 @@ GFM recognizes the following:
| repository file line references | `[README](doc/README#L13)` | | |
| [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
to issue number 123 with text `#123`. Likewise, a link to issue number 123 will be
recognized and formatted with text `#123`.
......
......@@ -16,6 +16,10 @@ class GfmAutoCompleteEE extends GfmAutoComplete {
this.setupAutoCompleteEpics($input, this.getDefaultCallbacks());
}
if (this.enableMap.vulnerabilities) {
this.setupAutoCompleteVulnerabilities($input, this.getDefaultCallbacks());
}
super.setupAtWho($input);
}
......@@ -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;
......@@ -7,6 +7,7 @@ module EE
prepended do
feature_category :epics, [:epics]
feature_category :vulnerability_management, [:vulnerabilities]
end
def epics
......@@ -14,6 +15,12 @@ module EE
render json: autocomplete_service.epics
end
def vulnerabilities
return render_404 unless project.feature_available?(:security_dashboard)
render json: autocomplete_service.vulnerabilities
end
end
end
end
......@@ -7,6 +7,7 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
feature_category :issue_tracking, [:issues, :labels, :milestones, :commands]
feature_category :code_review, [:merge_requests]
feature_category :epics, [:epics]
feature_category :vulnerability_management, [:vulnerabilities]
def members
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
......@@ -31,6 +32,10 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
render json: @autocomplete_service.epics(confidential_only: params[:confidential_only])
end
def vulnerabilities
render json: vulnerability_serializer.represent(@autocomplete_service.vulnerabilities, parent_group: @group)
end
def commands
render json: @autocomplete_service.commands(target)
end
......@@ -42,13 +47,17 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
private
def load_autocomplete_service
@autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user)
@autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user, params)
end
def issuable_serializer
GroupIssuableAutocompleteSerializer.new
end
def vulnerability_serializer
GroupVulnerabilityAutocompleteSerializer.new
end
# rubocop: disable CodeReuse/ActiveRecord
def target
QuickActions::TargetService
......
......@@ -20,10 +20,10 @@ module Autocomplete
DEFAULT_AUTOCOMPLETE_LIMIT = 5
def execute
return [] unless vulnerable.feature_available?(:security_dashboard)
return ::Vulnerability.none unless vulnerable.feature_available?(:security_dashboard)
::Security::VulnerabilitiesFinder # rubocop: disable CodeReuse/Finder
.new(vulnerable, params)
.new(vulnerable)
.execute
.autocomplete_search(params[:search].to_s)
.with_limit(DEFAULT_AUTOCOMPLETE_LIMIT)
......
......@@ -78,6 +78,8 @@ module EE
def autocomplete_data_sources(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)
{
members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
......@@ -85,13 +87,15 @@ module EE
issues: issues_group_autocomplete_sources_path(object),
mergeRequests: merge_requests_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]),
milestones: milestones_group_autocomplete_sources_path(object)
}
elsif object.group&.feature_available?(:epics)
{ epics: epics_project_autocomplete_sources_path(object) }.merge(super)
}.compact
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
......
......@@ -11,7 +11,8 @@ module EE
override :other_patterns
def other_patterns
super.unshift(
::Epic.reference_pattern
::Epic.reference_pattern,
::Vulnerability.reference_pattern
)
end
end
......
......@@ -70,6 +70,7 @@ module EE
validates :description, length: { maximum: ::Issuable::DESCRIPTION_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_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
......@@ -126,9 +127,10 @@ module EE
to: :finding, allow_nil: true
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
"#{project.to_reference_base(from, full: full)}#{reference}"
project
.to_reference_base(from, full: full)
.then { |reference_base| reference_base.present? ? "#{reference_base}/" : nil }
.then { |reference_base| "#{self.class.reference_prefix}#{reference_base}#{id}#{self.class.reference_postfix}" }
end
# There will only be one finding associated with a vulnerability for the foreseeable future
......@@ -188,6 +190,28 @@ module EE
end
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
::Project
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 @@
module EE
module PreviewMarkdownService
def quick_action_types
super.push('Epic')
super
.push('Epic')
.push('Vulnerability')
end
end
end
......@@ -7,6 +7,13 @@ module EE
.new(current_user, group_id: project.group&.id, state: 'opened')
.execute.select([:iid, :title])
end
def vulnerabilities
::Autocomplete::VulnerabilitiesAutocompleteFinder
.new(current_user, project, params)
.execute
.select([:id, :title])
end
end
end
end
......@@ -37,6 +37,13 @@ module Groups
.select(:iid, :title)
end
def vulnerabilities
::Autocomplete::VulnerabilitiesAutocompleteFinder
.new(current_user, group, params)
.execute
.select([:id, :title, :project_id])
end
# rubocop: disable CodeReuse/ActiveRecord
def milestones
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
get 'merge_requests'
get 'labels'
get 'epics'
get 'vulnerabilities'
get 'commands'
get 'milestones'
end
......
......@@ -22,6 +22,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :autocomplete_sources, only: [] do
collection do
get 'epics'
get 'vulnerabilities'
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
module Banzai
module IssuableExtractor
EPIC_REFERENCE_TYPE = '@data-reference-type="epic"'.freeze
VULNERABILITY_REFERENCE_TYPE = '@data-reference-type="vulnerability"'.freeze
private
def reference_types
super.push(EPIC_REFERENCE_TYPE)
super
.push(EPIC_REFERENCE_TYPE)
.push(VULNERABILITY_REFERENCE_TYPE)
end
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
......
......@@ -18,6 +18,7 @@ module EE
[
::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter,
*super
]
end
......
......@@ -11,6 +11,7 @@ module EE
[
::Banzai::Filter::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter,
*super
]
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,11 +8,13 @@ RSpec.describe Projects::AutocompleteSourcesController do
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group2) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
before do
sign_in(user)
end
describe '#epics' do
context 'when epics feature is disabled' do
it 'returns 404 status' do
get :epics, params: { namespace_id: project.namespace, project_id: project }
......@@ -39,4 +41,35 @@ RSpec.describe Projects::AutocompleteSourcesController do
end
end
end
end
describe '#vulnerabilities' do
context 'when vulnerabilities feature is disabled' do
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
context 'when vulnerabilities feature is enabled' do
before do
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(json_response).to be_an(Array)
expect(json_response.count).to eq(1)
expect(json_response.first).to include(
'id' => vulnerability.id, 'title' => vulnerability.title
)
end
end
end
end
end
......@@ -33,6 +33,31 @@ RSpec.describe Groups::AutocompleteSourcesController do
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
using RSpec::Parameterized::TableSyntax
......
......@@ -105,29 +105,39 @@ RSpec.describe ApplicationHelper do
it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :epics, :commands, :milestones])
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
context 'project' do
let(:object) { create(:project) }
let(:noteable_type) { Issue }
context 'when epics are enabled' do
context 'when epics and vulnerabilities are enabled' do
before do
stub_licensed_features(epics: true)
stub_licensed_features(epics: true, security_dashboard: true)
end
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
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))
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
context 'when epics are disabled' do
context 'when epics and vulnerabilities are disabled' do
it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands, :snippets])
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 @@
require 'spec_helper'
RSpec.describe Gitlab::ReferenceExtractor do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
before do
group.add_developer(project.creator)
......@@ -25,4 +25,22 @@ RSpec.describe Gitlab::ReferenceExtractor do
expect(subject.epics).to match_array([@e0, @e1])
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
......@@ -502,6 +502,114 @@ RSpec.describe Vulnerability do
it { is_expected.to match_array(expected_vulnerabilities) }
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
let_it_be(:project) { create(:project, :with_vulnerability) }
let_it_be(:vulnerability) { project.vulnerabilities.first }
......
......@@ -7,9 +7,9 @@ RSpec.describe GroupIssuableAutocompleteEntity do
let(:project) { build_stubbed(:project, group: group) }
let(:issue) { build_stubbed(:issue, project: project) }
describe '#represent' do
subject { described_class.new(issue, parent_group: group).as_json }
describe '#represent' do
it 'includes the iid, title, and reference' do
expect(subject).to include(:iid, :title, :reference)
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 @@
require 'spec_helper'
RSpec.describe Groups::AutocompleteService do
let!(:group) { create(:group, :nested, :private, avatar: fixture_file_upload('spec/fixtures/dk.png')) }
let!(:sub_group) { create(:group, :private, parent: group) }
let_it_be(:group, refind: true) { create(:group, :nested, :private, avatar: fixture_file_upload('spec/fixtures/dk.png')) }
let_it_be(:sub_group) { create(:group, :private, parent: group) }
let(:user) { create(:user) }
let!(:epic) { create(:epic, group: group, author: user) }
......@@ -118,6 +118,50 @@ RSpec.describe Groups::AutocompleteService do
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
context 'when target is an epic' do
let(:parent_epic) { create(:epic, group: group, author: user) }
......
......@@ -119,7 +119,7 @@ module Banzai
# Yields the link's URL and inner HTML whenever the node is a valid <a> tag.
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
return unless link.force_encoding('UTF-8').valid_encoding?
......@@ -127,6 +127,10 @@ module Banzai
yield link, inner_html
end
def unescape_link(href)
CGI.unescape(href)
end
def replace_text_when_pattern_matches(node, index, 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
# Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor
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
# This counter is increased by a number of references filtered out by
# banzai reference exctractor. Note that this counter is stateful and
......@@ -38,7 +38,7 @@ module Gitlab
end
REFERABLES.each do |type|
define_method("#{type}s") do
define_method(type.to_s.pluralize) do
@references[type] ||= references(type)
end
end
......
......@@ -8,15 +8,226 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants';
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');
describe('GfmAutoComplete', () => {
const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
fetchData: () => {},
});
const fetchDataMock = { fetchData: jest.fn() };
let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
let atwhoInstance;
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('assets loading', () => {
......@@ -154,7 +365,6 @@ describe('GfmAutoComplete', () => {
'я',
'.',
"'",
'+',
'-',
'_',
];
......
......@@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end
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
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