Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-04-21 02:50:22 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-04-21 02:50:22 +0300
commit9dc93a4519d9d5d7be48ff274127136236a3adb3 (patch)
tree70467ae3692a0e35e5ea56bcb803eb512a10bedb /spec/lib/banzai/filter/references
parent4b0f34b6d759d6299322b3a54453e930c6121ff0 (diff)
Add latest changes from gitlab-org/gitlab@13-11-stable-eev13.11.0-rc43
Diffstat (limited to 'spec/lib/banzai/filter/references')
-rw-r--r--spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb102
-rw-r--r--spec/lib/banzai/filter/references/alert_reference_filter_spec.rb223
-rw-r--r--spec/lib/banzai/filter/references/commit_range_reference_filter_spec.rb255
-rw-r--r--spec/lib/banzai/filter/references/commit_reference_filter_spec.rb272
-rw-r--r--spec/lib/banzai/filter/references/design_reference_filter_spec.rb287
-rw-r--r--spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb257
-rw-r--r--spec/lib/banzai/filter/references/feature_flag_reference_filter_spec.rb223
-rw-r--r--spec/lib/banzai/filter/references/issue_reference_filter_spec.rb549
-rw-r--r--spec/lib/banzai/filter/references/label_reference_filter_spec.rb705
-rw-r--r--spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb289
-rw-r--r--spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb463
-rw-r--r--spec/lib/banzai/filter/references/project_reference_filter_spec.rb100
-rw-r--r--spec/lib/banzai/filter/references/reference_filter_spec.rb224
-rw-r--r--spec/lib/banzai/filter/references/snippet_reference_filter_spec.rb222
-rw-r--r--spec/lib/banzai/filter/references/user_reference_filter_spec.rb204
15 files changed, 4375 insertions, 0 deletions
diff --git a/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb b/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb
new file mode 100644
index 00000000000..076c112ac87
--- /dev/null
+++ b/spec/lib/banzai/filter/references/abstract_reference_filter_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do
+ let_it_be(:project) { create(:project) }
+
+ let(:doc) { Nokogiri::HTML.fragment('') }
+ let(:filter) { described_class.new(doc, project: project) }
+
+ describe '#references_per_parent' do
+ let(:doc) { Nokogiri::HTML.fragment("#1 #{project.full_path}#2 #2") }
+
+ it 'returns a Hash containing references grouped per parent paths' do
+ expect(described_class).to receive(:object_class).exactly(6).times.and_return(Issue)
+
+ refs = filter.references_per_parent
+
+ expect(refs).to match(a_hash_including(project.full_path => contain_exactly(1, 2)))
+ end
+ end
+
+ describe '#data_attributes_for' do
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ it 'is not an XSS vector' do
+ allow(described_class).to receive(:object_class).and_return(Issue)
+
+ data_attributes = filter.data_attributes_for('xss &lt;img onerror=alert(1) src=x&gt;', project, issue, link_content: true)
+
+ expect(data_attributes[:original]).to eq('xss &amp;lt;img onerror=alert(1) src=x&amp;gt;')
+ end
+ end
+
+ describe '#parent_per_reference' do
+ it 'returns a Hash containing projects grouped per parent paths' do
+ expect(filter).to receive(:references_per_parent)
+ .and_return({ project.full_path => Set.new([1]) })
+
+ expect(filter.parent_per_reference)
+ .to eq({ project.full_path => project })
+ end
+ end
+
+ describe '#find_for_paths' do
+ context 'with RequestStore disabled' do
+ it 'returns a list of Projects for a list of paths' do
+ expect(filter.find_for_paths([project.full_path]))
+ .to eq([project])
+ end
+
+ it "return an empty array for paths that don't exist" do
+ expect(filter.find_for_paths(['nonexistent/project']))
+ .to eq([])
+ end
+ end
+
+ context 'with RequestStore enabled', :request_store do
+ it 'returns a list of Projects for a list of paths' do
+ expect(filter.find_for_paths([project.full_path]))
+ .to eq([project])
+ end
+
+ context "when no project with that path exists" do
+ it "returns no value" do
+ expect(filter.find_for_paths(['nonexistent/project']))
+ .to eq([])
+ end
+
+ it "adds the ref to the project refs cache" do
+ project_refs_cache = {}
+ allow(filter).to receive(:refs_cache).and_return(project_refs_cache)
+
+ filter.find_for_paths(['nonexistent/project'])
+
+ expect(project_refs_cache).to eq({ 'nonexistent/project' => nil })
+ end
+
+ context 'when the project refs cache includes nil values' do
+ before do
+ # adds { 'nonexistent/project' => nil } to cache
+ filter.from_ref_cached('nonexistent/project')
+ end
+
+ it "return an empty array for paths that don't exist" do
+ expect(filter.find_for_paths(['nonexistent/project']))
+ .to eq([])
+ end
+ end
+ end
+ end
+ end
+
+ describe '#current_parent_path' do
+ it 'returns the path of the current parent' do
+ doc = Nokogiri::HTML.fragment('')
+ filter = described_class.new(doc, project: project)
+
+ expect(filter.current_parent_path).to eq(project.full_path)
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/alert_reference_filter_spec.rb b/spec/lib/banzai/filter/references/alert_reference_filter_spec.rb
new file mode 100644
index 00000000000..7c6b0cac24b
--- /dev/null
+++ b/spec/lib/banzai/filter/references/alert_reference_filter_spec.rb
@@ -0,0 +1,223 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::AlertReferenceFilter do
+ include FilterSpecHelper
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
+ let_it_be(:reference) { alert.to_reference }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Alert #{reference}</#{elem}>"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'internal reference' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Alert (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
+ end
+
+ it 'ignores invalid alert IDs' do
+ exp = act = "Alert #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq alert.title
+ end
+
+ it 'escapes the title attribute' do
+ allow(alert).to receive(:title).and_return(%{"></a>whatever<a title="})
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.text).to eq "Alert #{reference}"
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Alert #{reference}")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-alert has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Alert #{reference}")
+ 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-alert attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-alert')
+ expect(link.attr('data-alert')).to eq alert.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Alert #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.details_project_alert_management_url(project, alert.iid, only_path: true)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.full_path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql(reference)
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{reference}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.full_path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}^alert##{alert.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}^alert##{alert.iid}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { "#{project2.path}^alert##{alert.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}^alert##{alert.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}^alert##{alert.iid}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}^alert##{alert.iid}.)")
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let_it_be(:namespace) { create(:namespace, name: 'cross-reference') }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project2) }
+ let_it_be(:reference) { alert.details_url }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq alert.details_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(alert.to_reference(project))}</a>\.\)})
+ end
+
+ it 'ignores invalid alert IDs on the referenced project' do
+ act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'group context' do
+ let_it_be(:group) { create(:group) }
+
+ it 'links to a valid reference' do
+ reference = "#{project.full_path}^alert##{alert.iid}"
+ result = reference_filter("See #{reference}", { project: nil, group: group } )
+
+ expect(result.css('a').first.attr('href')).to eq(alert.details_url)
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See ^alert##{alert.iid}"
+
+ expect(reference_filter(act, project: nil, group: group).to_html).to eq exp
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/commit_range_reference_filter_spec.rb b/spec/lib/banzai/filter/references/commit_range_reference_filter_spec.rb
new file mode 100644
index 00000000000..b235de06b30
--- /dev/null
+++ b/spec/lib/banzai/filter/references/commit_range_reference_filter_spec.rb
@@ -0,0 +1,255 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::CommitRangeReferenceFilter do
+ include FilterSpecHelper
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:commit1) { project.commit("HEAD~2") }
+ let(:commit2) { project.commit }
+
+ let(:range) { CommitRange.new("#{commit1.id}...#{commit2.id}", project) }
+ let(:range2) { CommitRange.new("#{commit1.id}..#{commit2.id}", project) }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Commit Range #{range.to_reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'internal reference' do
+ let(:reference) { range.to_reference }
+ let(:reference2) { range2.to_reference }
+
+ it 'links to a valid two-dot reference' do
+ doc = reference_filter("See #{reference2}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project, range2.to_param)
+ end
+
+ it 'links to a valid three-dot reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project, range.to_param)
+ end
+
+ it 'links to a valid short ID' do
+ reference = "#{commit1.short_id}...#{commit2.id}"
+ reference2 = "#{commit1.id}...#{commit2.short_id}"
+
+ exp = commit1.short_id + '...' + commit2.short_id
+
+ expect(reference_filter("See #{reference}").css('a').first.text).to eq exp
+ expect(reference_filter("See #{reference2}").css('a').first.text).to eq exp
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ exp = Regexp.escape(range.reference_link_text)
+ expect(doc.to_html).to match(%r{\(<a.+>#{exp}</a>\.\)})
+ end
+
+ it 'ignores invalid commit IDs' do
+ exp = act = "See #{commit1.id.reverse}...#{commit2.id}"
+
+ allow(project.repository).to receive(:commit).with(commit1.id.reverse)
+ allow(project.repository).to receive(:commit).with(commit2.id)
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes no title attribute' do
+ doc = reference_filter("See #{reference}")
+ expect(doc.css('a').first.attr('title')).to eq ""
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("See #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit_range has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("See #{reference}")
+ 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-commit-range attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-commit-range')
+ expect(link.attr('data-commit-range')).to eq range.to_s
+ end
+
+ it 'supports an :only_path option' do
+ doc = reference_filter("See #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.project_compare_url(project, from: commit1.id, to: commit2.id, only_path: true)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:project2) { create(:project, :public, :repository) }
+ let(:reference) { "#{project2.full_path}@#{commit1.id}...#{commit2.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.full_path}@#{commit1.short_id}...#{commit2.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eql("Fixed (#{project2.full_path}@#{commit1.short_id}...#{commit2.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Fixed #{project2.full_path}@#{commit1.id.reverse}...#{commit2.id}"
+ expect(reference_filter(act).to_html).to eq exp
+
+ exp = act = "Fixed #{project2.full_path}@#{commit1.id}...#{commit2.id.reverse}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, :repository, namespace: namespace) }
+ let(:project2) { create(:project, :public, :repository, path: "same-namespace", namespace: namespace) }
+ let(:reference) { "#{project2.path}@#{commit1.id}...#{commit2.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eql("Fixed (#{project2.path}@#{commit1.short_id}...#{commit2.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Fixed #{project2.path}@#{commit1.id.reverse}...#{commit2.id}"
+ expect(reference_filter(act).to_html).to eq exp
+
+ exp = act = "Fixed #{project2.path}@#{commit1.id}...#{commit2.id.reverse}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, :repository, namespace: namespace) }
+ let(:project2) { create(:project, :public, :repository, path: "same-namespace", namespace: namespace) }
+ let(:reference) { "#{project2.path}@#{commit1.id}...#{commit2.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_compare_url(project2, range.to_param)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eql("#{project2.path}@#{commit1.short_id}...#{commit2.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eql("Fixed (#{project2.path}@#{commit1.short_id}...#{commit2.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Fixed #{project2.path}@#{commit1.id.reverse}...#{commit2.id}"
+ expect(reference_filter(act).to_html).to eq exp
+
+ exp = act = "Fixed #{project2.path}@#{commit1.id}...#{commit2.id.reverse}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:range) { CommitRange.new("#{commit1.id}...master", project) }
+ let(:reference) { urls.project_compare_url(project2, from: commit1.id, to: 'master') }
+
+ before do
+ range.project = project2
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ exp = Regexp.escape(range.reference_link_text(project))
+ expect(doc.to_html).to match(%r{\(<a.+>#{exp}</a>\.\)})
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id.reverse}...#{commit2.id}"
+ expect(reference_filter(act).to_html).to eq exp
+
+ exp = act = "Fixed #{project2.to_reference_base}@#{commit1.id}...#{commit2.id.reverse}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'group context' do
+ let(:context) { { project: nil, group: create(:group) } }
+
+ it 'ignores internal references' do
+ exp = act = "See #{range.to_reference}"
+
+ expect(reference_filter(act, context).to_html).to eq exp
+ end
+
+ it 'links to a full-path reference' do
+ reference = "#{project.full_path}@#{commit1.short_id}...#{commit2.short_id}"
+
+ expect(reference_filter("See #{reference}", context).css('a').first.text).to eql(reference)
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/commit_reference_filter_spec.rb b/spec/lib/banzai/filter/references/commit_reference_filter_spec.rb
new file mode 100644
index 00000000000..bee8e42d12e
--- /dev/null
+++ b/spec/lib/banzai/filter/references/commit_reference_filter_spec.rb
@@ -0,0 +1,272 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::CommitReferenceFilter do
+ include FilterSpecHelper
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:commit) { project.commit }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Commit #{commit.id}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'internal reference' do
+ let(:reference) { commit.id }
+
+ # Let's test a variety of commit SHA sizes just to be paranoid
+ [7, 8, 12, 18, 20, 32, 40].each do |size|
+ it "links to a valid reference of #{size} characters" do
+ doc = reference_filter("See #{reference[0...size]}")
+
+ expect(doc.css('a').first.text).to eq commit.short_id
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_commit_url(project, reference)
+ end
+ end
+
+ it 'always uses the short ID as the link text' do
+ doc = reference_filter("See #{commit.id}")
+ expect(doc.text).to eq "See #{commit.short_id}"
+
+ doc = reference_filter("See #{commit.id[0...7]}")
+ expect(doc.text).to eq "See #{commit.short_id}"
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{commit.short_id}</a>\.\)})
+ end
+
+ it 'ignores invalid commit IDs' do
+ invalid = invalidate_reference(reference)
+ exp = act = "See #{invalid}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("See #{reference}")
+ expect(doc.css('a').first.attr('title')).to eq commit.title
+ end
+
+ it 'escapes the title attribute' do
+ allow_next_instance_of(Commit) do |instance|
+ allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
+ end
+
+ doc = reference_filter("See #{reference}")
+ expect(doc.text).to eq "See #{commit.short_id}"
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("See #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("See #{reference}")
+ 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-commit attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-commit')
+ expect(link.attr('data-commit')).to eq commit.id
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("See #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.project_commit_url(project, reference, only_path: true)
+ end
+
+ context "in merge request context" do
+ let(:noteable) { create(:merge_request, target_project: project, source_project: project) }
+ let(:commit) { noteable.commits.first }
+
+ it 'handles merge request contextual commit references' do
+ url = urls.diffs_project_merge_request_url(project, noteable, commit_id: commit.id)
+ doc = reference_filter("See #{reference}", noteable: noteable)
+
+ expect(doc.css('a').first[:href]).to eq(url)
+ end
+
+ context "a doc with many (29) strings that could be SHAs" do
+ let!(:oids) { noteable.commits.collect(&:id) }
+
+ it 'makes only a single request to Gitaly' do
+ expect(Gitlab::GitalyClient).to receive(:allow_n_plus_1_calls).exactly(0).times
+ expect(Gitlab::Git::Commit).to receive(:batch_by_oid).once.and_call_original
+
+ reference_filter("A big list of SHAs #{oids.join(", ")}", noteable: noteable)
+ end
+ end
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.full_path}@#{commit.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{project2.full_path}@#{commit.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Committed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}@#{commit.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}@#{commit.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Committed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:reference) { "#{project2.full_path}@#{commit.short_id}" }
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}@#{commit.short_id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}@#{commit.short_id}.)")
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ exp = act = "Committed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:reference) { urls.project_commit_url(project2, commit.id) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_commit_url(project2, commit.id)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{commit.reference_link_text(project)}</a>\.\)})
+ end
+
+ it 'ignores invalid commit IDs on the referenced project' do
+ act = "Committed #{invalidate_reference(reference)}"
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'URL reference for a commit patch' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, :repository, namespace: namespace) }
+ let(:commit) { project2.commit }
+ let(:link) { urls.project_commit_url(project2, commit.id) }
+ let(:extension) { '.patch' }
+ let(:reference) { link + extension }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.text).to eq("See #{commit.reference_link_text(project)} (patch)")
+ end
+
+ it 'does not link to patch when extension match is after the path' do
+ invalidate_commit_reference = reference_filter("#{link}/builds.patch")
+
+ doc = reference_filter("See (#{invalidate_commit_reference})")
+
+ expect(doc.css('a').first.attr('href')).to eq "#{link}/builds"
+ expect(doc.text).to eq("See (#{commit.reference_link_text(project)} (builds).patch)")
+ end
+ end
+
+ context 'group context' do
+ let(:context) { { project: nil, group: create(:group) } }
+
+ it 'ignores internal references' do
+ exp = act = "See #{commit.id}"
+
+ expect(reference_filter(act, context).to_html).to eq exp
+ end
+
+ it 'links to a valid reference' do
+ act = "See #{project.full_path}@#{commit.id}"
+
+ expect(reference_filter(act, context).css('a').first.text).to eql("#{project.full_path}@#{commit.short_id}")
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/design_reference_filter_spec.rb b/spec/lib/banzai/filter/references/design_reference_filter_spec.rb
new file mode 100644
index 00000000000..52514ad17fc
--- /dev/null
+++ b/spec/lib/banzai/filter/references/design_reference_filter_spec.rb
@@ -0,0 +1,287 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::DesignReferenceFilter do
+ include FilterSpecHelper
+ include DesignManagementTestHelpers
+
+ let_it_be(:issue) { create(:issue, iid: 10) }
+ let_it_be(:issue_proj_2) { create(:issue, iid: 20) }
+ let_it_be(:issue_b) { create(:issue, project: issue.project) }
+ let_it_be(:developer) { create(:user, developer_projects: [issue.project, issue_proj_2.project]) }
+ let_it_be(:design_a) { create(:design, :with_versions, issue: issue) }
+ let_it_be(:design_b) { create(:design, :with_versions, issue: issue_b) }
+ let_it_be(:design_proj_2) { create(:design, :with_versions, issue: issue_proj_2) }
+ let_it_be(:project_with_no_lfs) { create(:project, :public, lfs_enabled: false) }
+
+ let(:design) { design_a }
+ let(:project) { issue.project }
+ let(:project_2) { issue_proj_2.project }
+ let(:reference) { design.to_reference }
+ let(:design_url) { url_for_design(design) }
+ let(:input_text) { "Added #{design_url}" }
+ let(:doc) { process_doc(input_text) }
+ let(:current_user) { developer }
+
+ before do
+ enable_design_management
+ end
+
+ shared_examples 'a no-op filter' do
+ it 'does nothing' do
+ expect(process(input_text)).to eq(baseline(input_text).to_html)
+ end
+ end
+
+ shared_examples 'a good link reference' do
+ let(:link) { doc.css('a').first }
+ let(:href) { url_for_design(design) }
+ let(:title) { design.filename }
+
+ it 'produces a good link', :aggregate_failures do
+ expect(link.attr('href')).to eq(href)
+ expect(link.attr('title')).to eq(title)
+ expect(link.attr('class')).to eq('gfm gfm-design has-tooltip')
+ expect(link.attr('data-project')).to eq(design.project.id.to_s)
+ expect(link.attr('data-issue')).to eq(design.issue.id.to_s)
+ expect(link.attr('data-original')).to eq(href)
+ expect(link.attr('data-reference-type')).to eq('design')
+ expect(link.text).to eq(design.to_reference(project))
+ end
+ end
+
+ describe '.call' do
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+ end
+
+ it 'does not error when we add redaction to the pipeline' do
+ enable_design_management
+
+ res = reference_pipeline(redact: true).to_document(input_text)
+
+ expect(res.css('a').first).to be_present
+ end
+
+ describe '#call' do
+ describe 'feature flags' do
+ context 'design management is not enabled' do
+ before do
+ enable_design_management(false)
+ end
+
+ it_behaves_like 'a no-op filter'
+ end
+ end
+ end
+
+ %w(pre code a style).each do |elem|
+ context "wrapped in a <#{elem}/>" do
+ let(:input_text) { "<#{elem}>Design #{url_for_design(design)}</#{elem}>" }
+
+ it_behaves_like 'a no-op filter'
+ end
+ end
+
+ describe '.identifier' do
+ where(:filename) do
+ [
+ ['simple.png'],
+ ['SIMPLE.PNG'],
+ ['has spaces.png'],
+ ['has-hyphen.jpg'],
+ ['snake_case.svg'],
+ ['has "quotes".svg'],
+ ['has <special> characters [o].svg']
+ ]
+ end
+
+ with_them do
+ let(:design) { build(:design, issue: issue, filename: filename) }
+ let(:url) { url_for_design(design) }
+ let(:pattern) { described_class.object_class.link_reference_pattern }
+ let(:parsed) do
+ m = pattern.match(url)
+ described_class.identifier(m) if m
+ end
+
+ it 'can parse the reference' do
+ expect(parsed).to have_attributes(
+ filename: filename,
+ issue_iid: issue.iid
+ )
+ end
+ end
+ end
+
+ describe 'static properties' do
+ specify do
+ expect(described_class).to have_attributes(
+ object_sym: :design,
+ object_class: ::DesignManagement::Design
+ )
+ end
+ end
+
+ describe '#data_attributes_for' do
+ let(:subject) { filter_instance.data_attributes_for(input_text, project, design) }
+
+ specify do
+ is_expected.to include(issue: design.issue_id,
+ original: input_text,
+ project: project.id,
+ design: design.id)
+ end
+ end
+
+ context 'a design with a quoted filename' do
+ let(:filename) { %q{A "very" good file.png} }
+ let(:design) { create(:design, :with_versions, issue: issue, filename: filename) }
+
+ it 'links to the design' do
+ expect(doc.css('a').first.attr('href'))
+ .to eq url_for_design(design)
+ end
+ end
+
+ context 'internal reference' do
+ it_behaves_like 'a reference containing an element node'
+
+ context 'the reference is valid' do
+ it_behaves_like 'a good link reference'
+
+ context 'the filename needs to be escaped' do
+ where(:filename) do
+ [
+ ['with some spaces.png'],
+ ['with <script>console.log("pwded")<%2Fscript>.png']
+ ]
+ end
+
+ with_them do
+ let(:design) { create(:design, :with_versions, filename: filename, issue: issue) }
+ let(:link) { doc.css('a').first }
+
+ it 'replaces the content with the reference, but keeps the link', :aggregate_failures do
+ expect(doc.text).to eq(CGI.unescapeHTML("Added #{design.to_reference}"))
+ expect(link.attr('title')).to eq(design.filename)
+ expect(link.attr('href')).to eq(design_url)
+ end
+ end
+ end
+ end
+
+ context 'the reference is to a non-existant design' do
+ let(:design_url) { url_for_design(build(:design, issue: issue)) }
+
+ it_behaves_like 'a no-op filter'
+ end
+
+ context 'design management is disabled for the referenced project' do
+ let(:public_issue) { create(:issue, project: project_with_no_lfs) }
+ let(:design) { create(:design, :with_versions, issue: public_issue) }
+
+ it_behaves_like 'a no-op filter'
+ end
+ end
+
+ describe 'link pattern' do
+ let(:reference) { url_for_design(design) }
+
+ it 'matches' do
+ expect(reference).to match(DesignManagement::Design.link_reference_pattern)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:design) { design_proj_2 }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it_behaves_like 'a good link reference'
+
+ it 'links to a valid reference' do
+ expect(doc.css('a').first.attr('href')).to eq(design_url)
+ end
+
+ context 'design management is disabled for that project' do
+ let(:design) { create(:design, project: project_with_no_lfs) }
+
+ it_behaves_like 'a no-op filter'
+ end
+
+ it 'link has valid text' do
+ ref = "#{design.project.full_path}##{design.issue.iid}[#{design.filename}]"
+
+ expect(doc.css('a').first.text).to eql(ref)
+ end
+
+ it 'includes default classes' do
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-design has-tooltip'
+ end
+
+ context 'the reference is invalid' do
+ let(:design_url) { url_for_design(design).gsub(/jpg/, 'gif') }
+
+ it_behaves_like 'a no-op filter'
+ end
+ end
+
+ describe 'performance' do
+ it 'is linear in the number of projects with design management enabled each design refers to' do
+ design_c = build(:design, :with_versions, issue: issue)
+ design_d = build(:design, :with_versions, issue: issue_b)
+ design_e = build(:design, :with_versions, issue: build_stubbed(:issue, project: project_2))
+
+ one_ref_per_project = <<~MD
+ Design #{url_for_design(design_a)}, #{url_for_design(design_proj_2)}
+ MD
+
+ multiple_references = <<~MD
+ Designs that affect the count:
+ * #{url_for_design(design_a)}
+ * #{url_for_design(design_b)}
+ * #{url_for_design(design_c)}
+ * #{url_for_design(design_d)}
+ * #{url_for_design(design_proj_2)}
+ * #{url_for_design(design_e)}
+
+ Things that do not affect the count:
+ * #{url_for_design(build_stubbed(:design, project: project_with_no_lfs))}
+ * #{url_for_designs(issue)}
+ * #1[not a valid reference.gif]
+ MD
+
+ baseline = ActiveRecord::QueryRecorder.new { process(one_ref_per_project) }
+
+ # each project mentioned requires 2 queries:
+ #
+ # * SELECT "issues".* FROM "issues" WHERE "issues"."project_id" = 1 AND ...
+ # :in `parent_records'*/
+ # * SELECT "_designs".* FROM "_designs"
+ # WHERE (issue_id = ? AND filename = ?) OR ...
+ # :in `parent_records'*/
+ #
+ # In addition there is a 1 query overhead for all the projects at the
+ # start. Currently, the baseline for 2 projects is `2 * 2 + 1 = 5` queries
+ #
+ expect { process(multiple_references) }.not_to exceed_query_limit(baseline.count)
+ end
+ end
+
+ private
+
+ def process_doc(text)
+ reference_filter(text, project: project)
+ end
+
+ def baseline(text)
+ null_filter(text, project: project)
+ end
+
+ def process(text)
+ process_doc(text).to_html
+ end
+end
diff --git a/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
new file mode 100644
index 00000000000..3b274f98020
--- /dev/null
+++ b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
@@ -0,0 +1,257 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::ExternalIssueReferenceFilter do
+ include FilterSpecHelper
+
+ let_it_be_with_refind(:project) { create(:project) }
+
+ shared_examples_for "external issue tracker" do
+ it_behaves_like 'a reference containing an element node'
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Issue #{reference}</#{elem}>"
+
+ expect(filter(act).to_html).to eq exp
+ end
+ end
+
+ it 'ignores valid references when using default tracker' do
+ expect(project).to receive(:default_issues_tracker?).and_return(true)
+
+ exp = act = "Issue #{reference}"
+ expect(filter(act).to_html).to eq exp
+ end
+
+ it 'links to a valid reference' do
+ doc = filter("Issue #{reference}")
+ issue_id = doc.css('a').first.attr("data-external-issue")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq project.external_issue_tracker.issue_url(issue_id)
+ end
+
+ it 'links to the external tracker' do
+ doc = filter("Issue #{reference}")
+
+ link = doc.css('a').first.attr('href')
+ issue_id = doc.css('a').first.attr("data-external-issue")
+
+ expect(link).to eq(project.external_issue_tracker.issue_url(issue_id))
+ end
+
+ it 'links with adjacent text' do
+ doc = filter("Issue (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{reference}</a>\.\)})
+ end
+
+ it 'includes a title attribute' do
+ doc = filter("Issue #{reference}")
+ expect(doc.css('a').first.attr('title')).to include("Issue in #{project.external_issue_tracker.title}")
+ end
+
+ it 'escapes the title attribute' do
+ allow(project.external_issue_tracker).to receive(:title)
+ .and_return(%{"></a>whatever<a title="})
+
+ doc = filter("Issue #{reference}")
+ expect(doc.text).to eq "Issue #{reference}"
+ end
+
+ it 'includes default classes' do
+ doc = filter("Issue #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+
+ it 'supports an :only_path context' do
+ doc = filter("Issue #{reference}", only_path: true)
+
+ link = doc.css('a').first.attr('href')
+ issue_id = doc.css('a').first["data-external-issue"]
+
+ expect(link).to eq project.external_issue_tracker.issue_path(issue_id)
+ end
+
+ it 'has an empty link if issue_url is invalid' do
+ expect_any_instance_of(project.external_issue_tracker.class).to receive(:issue_url) { 'javascript:alert("foo");' }
+
+ doc = filter("Issue #{reference}")
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq ''
+ end
+
+ it 'has an empty link if issue_path is invalid' do
+ expect_any_instance_of(project.external_issue_tracker.class).to receive(:issue_path) { 'javascript:alert("foo");' }
+
+ doc = filter("Issue #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq ''
+ end
+
+ context 'with RequestStore enabled', :request_store do
+ let(:reference_filter) { HTML::Pipeline.new([described_class]) }
+
+ it 'queries the collection on the first call' do
+ expect_any_instance_of(Project).to receive(:default_issues_tracker?).once.and_call_original
+ expect_any_instance_of(Project).to receive(:external_issue_reference_pattern).once.and_call_original
+
+ not_cached = reference_filter.call("look for #{reference}", { project: project })
+
+ expect_any_instance_of(Project).not_to receive(:default_issues_tracker?)
+ expect_any_instance_of(Project).not_to receive(:external_issue_reference_pattern)
+
+ cached = reference_filter.call("look for #{reference}", { project: project })
+
+ # Links must be the same
+ expect(cached[:output].css('a').first[:href]).to eq(not_cached[:output].css('a').first[:href])
+ end
+ end
+ end
+
+ context "redmine project" do
+ let_it_be(:service) { create(:redmine_service, project: project) }
+
+ before do
+ project.update!(issues_enabled: false)
+ end
+
+ context "with a hash prefix" do
+ let(:issue) { ExternalIssue.new("#123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with a single-letter prefix" do
+ let(:issue) { ExternalIssue.new("T-123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+ end
+
+ context "youtrack project" do
+ let_it_be(:service) { create(:youtrack_service, project: project) }
+
+ before do
+ project.update!(issues_enabled: false)
+ end
+
+ context "with right markdown" do
+ let(:issue) { ExternalIssue.new("YT-123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with underscores in the prefix" do
+ let(:issue) { ExternalIssue.new("PRJ_1-123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with lowercase letters in the prefix" do
+ let(:issue) { ExternalIssue.new("YTkPrj-123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with a single-letter prefix" do
+ let(:issue) { ExternalIssue.new("T-123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with a lowercase prefix" do
+ let(:issue) { ExternalIssue.new("gl-030", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+ end
+
+ context "jira project" do
+ let_it_be(:service) { create(:jira_service, project: project) }
+
+ let(:reference) { issue.to_reference }
+
+ context "with right markdown" do
+ let(:issue) { ExternalIssue.new("JIRA-123", project) }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "with a single-letter prefix" do
+ let(:issue) { ExternalIssue.new("J-123", project) }
+
+ it "ignores reference" do
+ exp = act = "Issue #{reference}"
+ expect(filter(act).to_html).to eq exp
+ end
+ end
+
+ context "with wrong markdown" do
+ let(:issue) { ExternalIssue.new("#123", project) }
+
+ it "ignores reference" do
+ exp = act = "Issue #{reference}"
+ expect(filter(act).to_html).to eq exp
+ end
+ end
+ end
+
+ context "ewm project" do
+ let_it_be(:service) { create(:ewm_service, project: project) }
+
+ before do
+ project.update!(issues_enabled: false)
+ end
+
+ context "rtcwi keyword" do
+ let(:issue) { ExternalIssue.new("rtcwi 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "workitem keyword" do
+ let(:issue) { ExternalIssue.new("workitem 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "defect keyword" do
+ let(:issue) { ExternalIssue.new("defect 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "task keyword" do
+ let(:issue) { ExternalIssue.new("task 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+
+ context "bug keyword" do
+ let(:issue) { ExternalIssue.new("bug 123", project) }
+ let(:reference) { issue.to_reference }
+
+ it_behaves_like "external issue tracker"
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/feature_flag_reference_filter_spec.rb b/spec/lib/banzai/filter/references/feature_flag_reference_filter_spec.rb
new file mode 100644
index 00000000000..c64b66f746e
--- /dev/null
+++ b/spec/lib/banzai/filter/references/feature_flag_reference_filter_spec.rb
@@ -0,0 +1,223 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::FeatureFlagReferenceFilter do
+ include FilterSpecHelper
+
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let_it_be(:reference) { feature_flag.to_reference }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Feature Flag #{reference}</#{elem}>"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'with internal reference' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project, feature_flag)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Feature Flag (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
+ end
+
+ it 'ignores invalid feature flag IIDs' do
+ exp = act = "Check [feature_flag:#{non_existing_record_id}]"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Feature Flag #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq feature_flag.name
+ end
+
+ it 'escapes the title attribute' do
+ allow(feature_flag).to receive(:name).and_return(%{"></a>whatever<a title="})
+ doc = reference_filter("Feature Flag #{reference}")
+
+ expect(doc.text).to eq "Feature Flag #{reference}"
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Feature Flag #{reference}")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-feature_flag has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Feature Flag #{reference}")
+ 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-feature-flag attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-feature-flag')
+ expect(link.attr('data-feature-flag')).to eq feature_flag.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Feature Flag #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.edit_project_feature_flag_url(project, feature_flag.iid, only_path: true)
+ end
+ end
+
+ context 'with cross-project / cross-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
+ let_it_be(:reference) { "[feature_flag:#{project2.full_path}/#{feature_flag.iid}]" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
+ end
+
+ it 'produces a valid text in a link' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql(reference)
+ end
+
+ it 'produces a valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{reference}.)")
+ end
+
+ it 'ignores invalid feature flag IIDs on the referenced project' do
+ exp = act = "Check [feature_flag:#{non_existing_record_id}]"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'with cross-project / same-namespace complete reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
+ let_it_be(:reference) { "[feature_flag:#{project2.full_path}/#{feature_flag.iid}]" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
+ end
+
+ it 'produces a valid text in a link' do
+ doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+
+ expect(doc.css('a').first.text).to eql("[feature_flag:#{project2.path}/#{feature_flag.iid}]")
+ end
+
+ it 'produces a valid text' do
+ doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+
+ expect(doc.text).to eql("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+ end
+
+ it 'ignores invalid feature flag IIDs on the referenced project' do
+ exp = act = "Check [feature_flag:#{non_existing_record_id}]"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'with cross-project shorthand reference' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
+ let_it_be(:reference) { "[feature_flag:#{project2.path}/#{feature_flag.iid}]" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
+ end
+
+ it 'produces a valid text in a link' do
+ doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+
+ expect(doc.css('a').first.text).to eql("[feature_flag:#{project2.path}/#{feature_flag.iid}]")
+ end
+
+ it 'produces a valid text' do
+ doc = reference_filter("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+
+ expect(doc.text).to eql("See ([feature_flag:#{project2.path}/#{feature_flag.iid}].)")
+ end
+
+ it 'ignores invalid feature flag IDs on the referenced project' do
+ exp = act = "Check [feature_flag:#{non_existing_record_id}]"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'with cross-project URL reference' do
+ let_it_be(:namespace) { create(:namespace, name: 'cross-reference') }
+ let_it_be(:project2) { create(:project, :public, namespace: namespace) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project2) }
+ let_it_be(:reference) { urls.edit_project_feature_flag_url(project2, feature_flag) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.edit_project_feature_flag_url(project2, feature_flag)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(feature_flag.to_reference(project))}</a>\.\)})
+ end
+
+ it 'ignores invalid feature flag IIDs on the referenced project' do
+ act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'with group context' do
+ let_it_be(:group) { create(:group) }
+
+ it 'links to a valid reference' do
+ reference = "[feature_flag:#{project.full_path}/#{feature_flag.iid}]"
+ result = reference_filter("See #{reference}", { project: nil, group: group } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.edit_project_feature_flag_url(project, feature_flag))
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See [feature_flag:#{feature_flag.iid}]"
+
+ expect(reference_filter(act, project: nil, group: group).to_html).to eq exp
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
new file mode 100644
index 00000000000..b849355f6db
--- /dev/null
+++ b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb
@@ -0,0 +1,549 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
+ include FilterSpecHelper
+ include DesignManagementTestHelpers
+
+ def helper
+ IssuesHelper
+ end
+
+ let(:project) { create(:project, :public) }
+ let(:issue) { create(:issue, project: project) }
+ let(:issue_path) { "/#{issue.project.namespace.path}/#{issue.project.path}/-/issues/#{issue.iid}" }
+ let(:issue_url) { "http://#{Gitlab.config.gitlab.host}#{issue_path}" }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Issue #{issue.to_reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'performance' do
+ let(:another_issue) { create(:issue, project: project) }
+
+ it 'does not have a N+1 query problem' do
+ single_reference = "Issue #{issue.to_reference}"
+ multiple_references = "Issues #{issue.to_reference} and #{another_issue.to_reference}"
+
+ control_count = ActiveRecord::QueryRecorder.new { reference_filter(single_reference).to_html }.count
+
+ expect { reference_filter(multiple_references).to_html }.not_to exceed_query_limit(control_count)
+ end
+ end
+
+ context 'internal reference' do
+ let(:reference) { "##{issue.iid}" }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("Fixed #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+ expect(doc.text).to eql("Fixed (#{reference}.)")
+ end
+
+ it 'ignores invalid issue IDs' do
+ invalid = invalidate_reference(reference)
+ exp = act = "Fixed #{invalid}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Issue #{reference}")
+ expect(doc.css('a').first.attr('title')).to eq issue.title
+ end
+
+ it 'escapes the title attribute' do
+ issue.update_attribute(:title, %{"></a>whatever<a title="})
+
+ doc = reference_filter("Issue #{reference}")
+ expect(doc.text).to eq "Issue #{reference}"
+ end
+
+ it 'renders non-HTML tooltips' do
+ doc = reference_filter("Issue #{reference}")
+
+ expect(doc.at_css('a')).not_to have_attribute('data-html')
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Issue #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Issue #{reference}")
+ 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-issue attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-issue')
+ expect(link.attr('data-issue')).to eq issue.id.to_s
+ end
+
+ it 'includes a data-original attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-original')
+ expect(link.attr('data-original')).to eq reference
+ end
+
+ it 'does not escape the data-original attribute' do
+ inner_html = 'element <code>node</code> inside'
+ doc = reference_filter(%{<a href="#{reference}">#{inner_html}</a>})
+ expect(doc.children.first.attr('data-original')).to eq inner_html
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Issue #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq issue_path
+ end
+
+ it 'does not process links containing issue numbers followed by text' do
+ href = "#{reference}st"
+ doc = reference_filter("<a href='#{href}'></a>")
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq(href)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:reference) { "#{project2.full_path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public) }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'ignores valid references when cross-reference project uses external tracker' do
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
+
+ exp = act = "Issue #{reference}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.full_path}##{issue.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eq("Fixed (#{project2.full_path}##{issue.iid}.)")
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+
+ it 'ignores invalid issue IDs on the referenced project' do
+ exp = act = "Fixed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let(:reference) { "#{project2.full_path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace) }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'ignores valid references when cross-reference project uses external tracker' do
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
+
+ exp = act = "Issue #{reference}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}##{issue.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+
+ it 'ignores invalid issue IDs on the referenced project' do
+ exp = act = "Fixed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let(:reference) { "#{project2.path}##{issue.iid}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace) }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'ignores valid references when cross-reference project uses external tracker' do
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project2, issue.iid)
+ .and_return(nil)
+
+ exp = act = "Issue #{reference}"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}##{issue.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+
+ it 'ignores invalid issue IDs on the referenced project' do
+ exp = act = "Fixed #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let(:reference) { issue_url + "#note_123" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'link with trailing slash' do
+ doc = reference_filter("Fixed (#{issue_url + "/"}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(issue.to_reference(project))}</a>\.\)})
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)</a>\.\)})
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+ end
+
+ context 'cross-project reference in link href' do
+ let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
+ let(:reference) { issue.to_reference(project) }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference_link}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference_link}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>Reference</a>\.\)})
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference_link}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+ end
+
+ context 'cross-project URL in link href' do
+ let(:reference_link) { %{<a href="#{reference}">Reference</a>} }
+ let(:reference) { "#{issue_url + "#note_123"}" }
+ let(:issue) { create(:issue, project: project2) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference_link}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq issue_url + "#note_123"
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Fixed (#{reference_link}.)")
+
+ expect(doc.to_html).to match(%r{\(<a.+>Reference</a>\.\)})
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Fixed (#{reference_link}.)")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
+ end
+ end
+
+ context 'when processing a link to the designs tab' do
+ let(:designs_tab_url) { url_for_designs(issue) }
+ let(:input_text) { "See #{designs_tab_url}" }
+
+ subject(:link) { reference_filter(input_text).css('a').first }
+
+ before do
+ enable_design_management
+ end
+
+ it 'includes the word "designs" after the reference in the text content', :aggregate_failures do
+ expect(link.attr('title')).to eq(issue.title)
+ expect(link.attr('href')).to eq(designs_tab_url)
+ expect(link.text).to eq("#{issue.to_reference} (designs)")
+ end
+
+ context 'design management is not available' do
+ before do
+ enable_design_management(false)
+ end
+
+ it 'links to the issue, but not to the designs tab' do
+ expect(link.text).to eq(issue.to_reference)
+ end
+ end
+ end
+
+ context 'group context' do
+ let(:group) { create(:group) }
+ let(:context) { { project: nil, group: group } }
+
+ it 'ignores shorthanded issue reference' do
+ reference = "##{issue.iid}"
+ text = "Fixed #{reference}"
+
+ expect(reference_filter(text, context).to_html).to eq(text)
+ end
+
+ it 'ignores valid references when cross-reference project uses external tracker' do
+ expect_any_instance_of(described_class).to receive(:find_object)
+ .with(project, issue.iid)
+ .and_return(nil)
+
+ reference = "#{project.full_path}##{issue.iid}"
+ text = "Issue #{reference}"
+
+ expect(reference_filter(text, context).to_html).to eq(text)
+ end
+
+ it 'links to a valid reference for complete cross-reference' do
+ reference = "#{project.full_path}##{issue.iid}"
+ doc = reference_filter("See #{reference}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq(issue_url)
+ expect(link.text).to include("#{project.full_path}##{issue.iid}")
+ end
+
+ it 'ignores reference for shorthand cross-reference' do
+ reference = "#{project.path}##{issue.iid}"
+ text = "See #{reference}"
+
+ expect(reference_filter(text, context).to_html).to eq(text)
+ end
+
+ it 'links to a valid reference for url cross-reference' do
+ reference = issue_url + "#note_123"
+
+ doc = reference_filter("See #{reference}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq(issue_url + "#note_123")
+ expect(link.text).to include("#{project.full_path}##{issue.iid}")
+ end
+
+ it 'links to a valid reference for cross-reference in link href' do
+ reference = "#{issue_url + "#note_123"}"
+ reference_link = %{<a href="#{reference}">Reference</a>}
+
+ doc = reference_filter("See #{reference_link}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq(issue_url + "#note_123")
+ expect(link.text).to include('Reference')
+ end
+
+ it 'links to a valid reference for issue reference in the link href' do
+ reference = issue.to_reference(group)
+ reference_link = %{<a href="#{reference}">Reference</a>}
+ doc = reference_filter("See #{reference_link}", context)
+
+ link = doc.css('a').first
+ expect(link.attr('href')).to eq(issue_url)
+ expect(link.text).to include('Reference')
+ end
+ end
+
+ describe '#records_per_parent' do
+ context 'using an internal issue tracker' do
+ it 'returns a Hash containing the issues per project' do
+ doc = Nokogiri::HTML.fragment('')
+ filter = described_class.new(doc, project: project)
+
+ expect(filter).to receive(:parent_per_reference)
+ .and_return({ project.full_path => project })
+
+ expect(filter).to receive(:references_per_parent)
+ .and_return({ project.full_path => Set.new([issue.iid]) })
+
+ expect(filter.records_per_parent)
+ .to eq({ project => { issue.iid => issue } })
+ end
+ end
+ end
+
+ describe '.references_in' do
+ let(:merge_request) { create(:merge_request) }
+
+ it 'yields valid references' do
+ expect do |b|
+ described_class.references_in(issue.to_reference, &b)
+ end.to yield_with_args(issue.to_reference, issue.iid, nil, nil, MatchData)
+ end
+
+ it "doesn't yield invalid references" do
+ expect do |b|
+ described_class.references_in('#0', &b)
+ end.not_to yield_control
+ end
+
+ it "doesn't yield unsupported references" do
+ expect do |b|
+ described_class.references_in(merge_request.to_reference, &b)
+ end.not_to yield_control
+ end
+ end
+
+ describe '#object_link_text_extras' do
+ before do
+ enable_design_management(enabled)
+ end
+
+ let(:current_user) { project.owner }
+ let(:enabled) { true }
+ let(:matches) { Issue.link_reference_pattern.match(input_text) }
+ let(:extras) { subject.object_link_text_extras(issue, matches) }
+
+ subject { filter_instance }
+
+ context 'the link does not go to the designs tab' do
+ let(:input_text) { Gitlab::Routing.url_helpers.project_issue_url(issue.project, issue) }
+
+ it 'does not include designs' do
+ expect(extras).not_to include('designs')
+ end
+ end
+
+ context 'the link goes to the designs tab' do
+ let(:input_text) { url_for_designs(issue) }
+
+ it 'includes designs' do
+ expect(extras).to include('designs')
+ end
+
+ context 'design management is disabled' do
+ let(:enabled) { false }
+
+ it 'does not include designs in the extras' do
+ expect(extras).not_to include('designs')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/label_reference_filter_spec.rb b/spec/lib/banzai/filter/references/label_reference_filter_spec.rb
new file mode 100644
index 00000000000..db7dda96cad
--- /dev/null
+++ b/spec/lib/banzai/filter/references/label_reference_filter_spec.rb
@@ -0,0 +1,705 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'html/pipeline'
+
+RSpec.describe Banzai::Filter::References::LabelReferenceFilter do
+ include FilterSpecHelper
+
+ let(:project) { create(:project, :public, name: 'sample-project') }
+ let(:label) { create(:label, project: project) }
+ let(:reference) { label.to_reference }
+
+ it_behaves_like 'HTML text with references' do
+ let(:resource) { label }
+ let(:resource_text) { resource.title }
+ end
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Label #{reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Label #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label has-tooltip gl-link gl-label-link'
+ end
+
+ it 'avoids N+1 cached queries', :use_sql_query_cache, :request_store do
+ # Run this once to establish a baseline
+ reference_filter("Label #{reference}")
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ reference_filter("Label #{reference}")
+ end
+
+ labels_markdown = Array.new(10, "Label #{reference}").join('\n')
+
+ expect { reference_filter(labels_markdown) }.not_to exceed_all_query_limit(control_count.count)
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Label #{reference}")
+ 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-label attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-label')
+ expect(link.attr('data-label')).to eq label.id.to_s
+ end
+
+ it 'includes protocol when :only_path not present' do
+ doc = reference_filter("Label #{reference}")
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to match %r(https?://)
+ end
+
+ it 'does not include protocol when :only_path true' do
+ doc = reference_filter("Label #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ end
+
+ it 'links to issue list when :label_url_method is not present' do
+ doc = reference_filter("Label #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq urls.project_issues_path(project, label_name: label.name)
+ end
+
+ it 'links to merge request list when `label_url_method: :project_merge_requests_url`' do
+ doc = reference_filter("Label #{reference}", { only_path: true, label_url_method: "project_merge_requests_url" })
+ link = doc.css('a').first.attr('href')
+
+ expect(link).to eq urls.project_merge_requests_path(project, label_name: label.name)
+ end
+
+ context 'project that does not exist referenced' do
+ let(:result) { reference_filter('aaa/bbb~ccc') }
+
+ it 'does not link reference' do
+ expect(result.to_html).to eq 'aaa/bbb~ccc'
+ end
+ end
+
+ describe 'label span element' do
+ it 'includes default classes' do
+ doc = reference_filter("Label #{reference}")
+ expect(doc.css('a span').first.attr('class')).to include 'gl-label-text'
+ end
+
+ it 'includes a style attribute' do
+ doc = reference_filter("Label #{reference}")
+ expect(doc.css('a span').first.attr('style')).to match(/\Abackground-color: #\h{6}\z/)
+ end
+ end
+
+ context 'Integer-based references' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label IDs' do
+ exp = act = "Label #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based single-word references' do
+ let(:label) { create(:label, name: 'gfm', project: project) }
+ let(:reference) { "#{Label.reference_prefix}#{label.name}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See gfm'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}).")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\)\.))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = "Label #{Label.reference_prefix}#{label.name.reverse}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based single-word references that begin with a digit' do
+ let(:label) { create(:label, name: '2fa', project: project) }
+ let(:reference) { "#{Label.reference_prefix}#{label.name}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See 2fa'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}).")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\)\.))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = "Label #{Label.reference_prefix}#{label.id}#{label.name.reverse}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based single-word references with special characters' do
+ let(:label) { create(:label, name: '?g.fm&', project: project) }
+ let(:reference) { "#{Label.reference_prefix}#{label.name}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See ?g.fm&'
+ end
+
+ it 'does not include trailing punctuation', :aggregate_failures do
+ ['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation|
+ doc = filter("Label #{reference}#{trailing_punctuation}")
+ expect(doc.to_html).to match(%r(<span.+><a.+><span.+>\?g\.fm&amp;</span></a></span>#{Regexp.escape(trailing_punctuation)}))
+ end
+ end
+
+ it 'ignores invalid label names' do
+ act = "Label #{Label.reference_prefix}#{label.name.reverse}"
+ exp = "Label #{Label.reference_prefix}&amp;mf.g?"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based multi-word references in quotes' do
+ let(:label) { create(:label, name: 'gfm references', project: project) }
+ let(:reference) { label.to_reference(format: :name) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See gfm references'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = %(Label #{Label.reference_prefix}"#{label.name.reverse}")
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based multi-word references that begin with a digit' do
+ let(:label) { create(:label, name: '2 factor authentication', project: project) }
+ let(:reference) { label.to_reference(format: :name) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See 2 factor authentication'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{label.name}</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = "Label #{Label.reference_prefix}#{label.id}#{label.name.reverse}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'String-based multi-word references with special characters in quotes' do
+ let(:label) { create(:label, name: 'g.fm & references?', project: project) }
+ let(:reference) { label.to_reference(format: :name) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See g.fm & references?'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>g\.fm &amp; references\?</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label names' do
+ act = %(Label #{Label.reference_prefix}"#{label.name.reverse}")
+ exp = %(Label #{Label.reference_prefix}"?secnerefer &amp; mf.g\")
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'References with html entities' do
+ let!(:label) { create(:label, name: '&lt;html&gt;', project: project) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter('See ~"&lt;html&gt;"')
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ expect(doc.text).to eq 'See <html>'
+ end
+
+ it 'ignores invalid label names and escapes entities' do
+ act = %(Label #{Label.reference_prefix}"&lt;non valid&gt;")
+
+ expect(reference_filter(act).to_html).to eq act
+ end
+ end
+
+ describe 'consecutive references' do
+ let(:bug) { create(:label, name: 'bug', project: project) }
+ let(:feature_proposal) { create(:label, name: 'feature proposal', project: project) }
+ let(:technical_debt) { create(:label, name: 'technical debt', project: project) }
+
+ let(:bug_reference) { "#{Label.reference_prefix}#{bug.name}" }
+ let(:feature_proposal_reference) { feature_proposal.to_reference(format: :name) }
+ let(:technical_debt_reference) { technical_debt.to_reference(format: :name) }
+
+ context 'separated with a comma' do
+ let(:references) { "#{bug_reference}, #{feature_proposal_reference}, #{technical_debt_reference}" }
+
+ it 'links to valid references' do
+ doc = reference_filter("See #{references}")
+
+ expect(doc.css('a').map { |a| a.attr('href') }).to match_array([
+ urls.project_issues_url(project, label_name: bug.name),
+ urls.project_issues_url(project, label_name: feature_proposal.name),
+ urls.project_issues_url(project, label_name: technical_debt.name)
+ ])
+ expect(doc.text).to eq 'See bug, feature proposal, technical debt'
+ end
+ end
+
+ context 'separated with a space' do
+ let(:references) { "#{bug_reference} #{feature_proposal_reference} #{technical_debt_reference}" }
+
+ it 'links to valid references' do
+ doc = reference_filter("See #{references}")
+
+ expect(doc.css('a').map { |a| a.attr('href') }).to match_array([
+ urls.project_issues_url(project, label_name: bug.name),
+ urls.project_issues_url(project, label_name: feature_proposal.name),
+ urls.project_issues_url(project, label_name: technical_debt.name)
+ ])
+ expect(doc.text).to eq 'See bug feature proposal technical debt'
+ end
+ end
+ end
+
+ describe 'edge cases' do
+ it 'gracefully handles non-references matching the pattern' do
+ exp = act = '(format nil "~0f" 3.0) ; 3.0'
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'referencing a label in a link href' do
+ let(:reference) { %Q{<a href="#{label.to_reference}">Label</a>} }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: label.name)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+>Label</a></span>\.\)))
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Label #{reference}")
+ 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-label attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-label')
+ expect(link.attr('data-label')).to eq label.id.to_s
+ end
+ end
+
+ describe 'group label references' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:group_label) { create(:group_label, name: 'gfm references', group: group) }
+
+ context 'without project reference' do
+ let(:reference) { group_label.to_reference(format: :name) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}", project: project)
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: group_label.name)
+ expect(doc.text).to eq 'See gfm references'
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{group_label.name}</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = %(Label #{Label.reference_prefix}"#{group_label.name.reverse}")
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'with project reference' do
+ let(:reference) { "#{project.to_reference_base}#{group_label.to_reference(format: :name)}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}", project: project)
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_issues_url(project, label_name: group_label.name)
+ expect(doc.text).to eq "See gfm references"
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Label (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<span.+><a.+><span.+>#{group_label.name}</span></a></span>\.\)))
+ end
+
+ it 'ignores invalid label names' do
+ exp = act = %(Label #{project.to_reference_base}#{Label.reference_prefix}"#{group_label.name.reverse}")
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+ end
+
+ describe 'cross-project / cross-namespace complete reference' do
+ let(:project2) { create(:project) }
+ let(:label) { create(:label, project: project2, color: '#00ff00') }
+ let(:reference) { "#{project2.full_path}~#{label.name}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'links to a valid reference' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(project2, label_name: label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style')).to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project2.full_name}"
+ end
+
+ it 'has valid text' do
+ expect(result.text).to eq "See #{label.name} in #{project2.full_name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'cross-project / same-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, namespace: namespace) }
+ let(:label) { create(:label, project: project2, color: '#00ff00') }
+ let(:reference) { "#{project2.full_path}~#{label.name}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'links to a valid reference' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(project2, label_name: label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style')).to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project2.name}"
+ end
+
+ it 'has valid text' do
+ expect(result.text).to eq "See #{label.name} in #{project2.name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'cross-project shorthand reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, namespace: namespace) }
+ let(:project2) { create(:project, namespace: namespace) }
+ let(:label) { create(:label, project: project2, color: '#00ff00') }
+ let(:reference) { "#{project2.path}~#{label.name}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'links to a valid reference' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(project2, label_name: label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project2.name}"
+ end
+
+ it 'has valid text' do
+ expect(result.text).to eq "See #{label.name} in #{project2.name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'cross group label references' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:another_group) { create(:group) }
+ let(:another_project) { create(:project, :public, namespace: another_group) }
+ let(:group_label) { create(:group_label, group: another_group, color: '#00ff00') }
+ let(:reference) { "#{another_project.full_path}~#{group_label.name}" }
+ let!(:result) { reference_filter("See #{reference}", project: project) }
+
+ it 'points to referenced project issues page' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(another_project, label_name: group_label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text)
+ .to eq "#{group_label.name} in #{another_project.full_name}"
+ end
+
+ it 'has valid text' do
+ expect(result.text)
+ .to eq "See #{group_label.name} in #{another_project.full_name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ context 'when group name has HTML entities' do
+ let(:another_group) { create(:group, name: 'random', path: 'another_group') }
+
+ before do
+ another_group.name = "<img src=x onerror=alert(1)>"
+ another_group.save!(validate: false)
+ end
+
+ it 'escapes the HTML entities' do
+ expect(result.text)
+ .to eq "See #{group_label.name} in #{another_project.full_name}"
+ end
+ end
+ end
+
+ describe 'cross-project / same-group_label complete reference' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:another_project) { create(:project, :public, namespace: group) }
+ let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
+ let(:reference) { "#{another_project.full_path}~#{group_label.name}" }
+ let!(:result) { reference_filter("See #{reference}", project: project) }
+
+ it 'points to referenced project issues page' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(another_project, label_name: group_label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text)
+ .to eq "#{group_label.name} in #{another_project.name}"
+ end
+
+ it 'has valid text' do
+ expect(result.text)
+ .to eq "See #{group_label.name} in #{another_project.name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'same project / same group_label complete reference' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
+ let(:reference) { "#{project.full_path}~#{group_label.name}" }
+ let!(:result) { reference_filter("See #{reference}", project: project) }
+
+ it 'points to referenced project issues page' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(project, label_name: group_label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text).to eq group_label.name
+ end
+
+ it 'has valid text' do
+ expect(result.text).to eq "See #{group_label.name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'same project / same group_label shorthand reference' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :public, namespace: group) }
+ let(:group_label) { create(:group_label, group: group, color: '#00ff00') }
+ let(:reference) { "#{project.path}~#{group_label.name}" }
+ let!(:result) { reference_filter("See #{reference}", project: project) }
+
+ it 'points to referenced project issues page' do
+ expect(result.css('a').first.attr('href'))
+ .to eq urls.project_issues_url(project, label_name: group_label.name)
+ end
+
+ it 'has valid color' do
+ expect(result.css('a span').first.attr('style'))
+ .to match /background-color: #00ff00/
+ end
+
+ it 'has valid link text' do
+ expect(result.css('a').first.text).to eq group_label.name
+ end
+
+ it 'has valid text' do
+ expect(result.text).to eq "See #{group_label.name}"
+ end
+
+ it 'ignores invalid IDs on the referenced label' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'group context' do
+ it 'points to the page defined in label_url_method' do
+ group = create(:group)
+ label = create(:group_label, group: group)
+ reference = "~#{label.name}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: group, label_url_method: :group_url } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.group_url(group, label_name: label.name))
+ end
+
+ it 'finds labels also in ancestor groups' do
+ group = create(:group)
+ label = create(:group_label, group: group)
+ subgroup = create(:group, parent: group)
+ reference = "~#{label.name}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: subgroup, label_url_method: :group_url } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.group_url(subgroup, label_name: label.name))
+ end
+
+ it 'points to referenced project issues page' do
+ project = create(:project)
+ label = create(:label, project: project)
+ reference = "#{project.full_path}~#{label.name}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.project_issues_url(project, label_name: label.name))
+ expect(result.css('a').first.text).to eq "#{label.name} in #{project.full_name}"
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb
new file mode 100644
index 00000000000..7a634b0b513
--- /dev/null
+++ b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb
@@ -0,0 +1,289 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter do
+ include FilterSpecHelper
+
+ let(:project) { create(:project, :public) }
+ let(:merge) { create(:merge_request, source_project: project) }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Merge #{merge.to_reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ describe 'performance' do
+ let(:another_merge) { create(:merge_request, source_project: project, source_branch: 'fix') }
+
+ it 'does not have a N+1 query problem' do
+ single_reference = "Merge request #{merge.to_reference}"
+ multiple_references = "Merge requests #{merge.to_reference} and #{another_merge.to_reference}"
+
+ control_count = ActiveRecord::QueryRecorder.new { reference_filter(single_reference).to_html }.count
+
+ expect { reference_filter(multiple_references).to_html }.not_to exceed_query_limit(control_count)
+ end
+ end
+
+ describe 'all references' do
+ let(:doc) { reference_filter(merge.to_reference) }
+ let(:tag_el) { doc.css('a').first }
+
+ it 'adds merge request iid' do
+ expect(tag_el["data-iid"]).to eq(merge.iid.to_s)
+ end
+
+ it 'adds project data attribute with project id' do
+ expect(tag_el["data-project-path"]).to eq(project.full_path)
+ end
+
+ it 'does not add `has-tooltip` class' do
+ expect(tag_el["class"]).not_to include('has-tooltip')
+ end
+ end
+
+ context 'internal reference' do
+ let(:reference) { merge.to_reference }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_merge_request_url(project, merge)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Merge (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
+ end
+
+ it 'ignores invalid merge IDs' do
+ exp = act = "Merge #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'ignores out-of-bounds merge request IDs on the referenced project' do
+ exp = act = "Merge !#{Gitlab::Database::MAX_INT_VALUE + 1}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'has no title' do
+ doc = reference_filter("Merge #{reference}")
+ expect(doc.css('a').first.attr('title')).to eq ""
+ end
+
+ it 'escapes the title attribute' do
+ merge.update_attribute(:title, %{"></a>whatever<a title="})
+
+ doc = reference_filter("Merge #{reference}")
+ expect(doc.text).to eq "Merge #{reference}"
+ end
+
+ it 'includes default classes, without tooltip' do
+ doc = reference_filter("Merge #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-merge_request'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Merge #{reference}")
+ 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-merge-request attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-merge-request')
+ expect(link.attr('data-merge-request')).to eq merge.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Merge #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.project_merge_request_url(project, merge, only_path: true)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:project2) { create(:project, :public) }
+ let(:merge) { create(:merge_request, source_project: project2) }
+ let(:reference) { "#{project2.full_path}!#{merge.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eq(reference)
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.text).to eq("Merge (#{reference}.)")
+ end
+
+ it 'ignores invalid merge IDs on the referenced project' do
+ exp = act = "Merge #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let!(:merge) { create(:merge_request, source_project: project2) }
+ let(:reference) { "#{project2.full_path}!#{merge.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eq("#{project2.path}!#{merge.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.text).to eq("Merge (#{project2.path}!#{merge.iid}.)")
+ end
+
+ it 'ignores invalid merge IDs on the referenced project' do
+ exp = act = "Merge #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let!(:merge) { create(:merge_request, source_project: project2) }
+ let(:reference) { "#{project2.path}!#{merge.iid}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_merge_request_url(project2, merge)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eq("#{project2.path}!#{merge.iid}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("Merge (#{reference}.)")
+
+ expect(doc.text).to eq("Merge (#{project2.path}!#{merge.iid}.)")
+ end
+
+ it 'ignores invalid merge IDs on the referenced project' do
+ exp = act = "Merge #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'URL reference for a commit' do
+ let(:mr) { create(:merge_request) }
+ let(:reference) do
+ urls.project_merge_request_url(mr.project, mr) + "/diffs?commit_id=#{mr.diff_head_sha}"
+ end
+
+ let(:commit) { mr.commits.find { |commit| commit.sha == mr.diff_head_sha } }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'commit ref tag is valid' do
+ doc = reference_filter("See #{reference}")
+ commit_ref_tag = doc.css('a').first.css('span.gfm.gfm-commit')
+
+ expect(commit_ref_tag.text).to eq(commit.short_id)
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.text).to eq("See #{mr.to_reference(full: true)} (#{commit.short_id})")
+ end
+
+ it 'has valid title attribute' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq(commit.title)
+ end
+
+ it 'ignores invalid commit short_ids on link text' do
+ invalidate_commit_reference =
+ urls.project_merge_request_url(mr.project, mr) + "/diffs?commit_id=12345678"
+ doc = reference_filter("See #{invalidate_commit_reference}")
+
+ expect(doc.text).to eq("See #{mr.to_reference(full: true)} (diffs)")
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:merge) { create(:merge_request, source_project: project2, target_project: project2) }
+ let(:reference) { urls.project_merge_request_url(project2, merge) + '/diffs#note_123' }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq reference
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Merge (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(merge.to_reference(project))} \(diffs, comment 123\)</a>\.\)})
+ end
+ end
+
+ context 'group context' do
+ it 'links to a valid reference' do
+ reference = "#{project.full_path}!#{merge.iid}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.project_merge_request_url(project, merge))
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb
new file mode 100644
index 00000000000..dafdc71ce64
--- /dev/null
+++ b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb
@@ -0,0 +1,463 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter do
+ include FilterSpecHelper
+
+ let_it_be(:parent_group) { create(:group, :public) }
+ let_it_be(:group) { create(:group, :public, parent: parent_group) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:another_project) { create(:project, :public, namespace: namespace) }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ shared_examples 'reference parsing' do
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>milestone #{reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Milestone #{reference}")
+
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Milestone #{reference}")
+ 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-milestone attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-milestone')
+ expect(link.attr('data-milestone')).to eq milestone.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Milestone #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.milestone_path(milestone)
+ end
+ end
+
+ shared_examples 'Integer-based references' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Milestone (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
+ end
+
+ it 'ignores invalid milestone IIDs' do
+ exp = act = "Milestone #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ shared_examples 'String-based single-word references' do
+ let(:reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+
+ before do
+ milestone.update!(name: 'gfm')
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
+ expect(doc.text).to eq "See #{milestone.reference_link_text}"
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Milestone (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
+ end
+
+ it 'ignores invalid milestone names' do
+ exp = act = "Milestone #{Milestone.reference_prefix}#{milestone.name.reverse}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ shared_examples 'String-based multi-word references in quotes' do
+ let(:reference) { milestone.to_reference(format: :name) }
+
+ before do
+ milestone.update!(name: 'gfm references')
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
+ expect(doc.text).to eq "See #{milestone.reference_link_text}"
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Milestone (#{reference}.)")
+ expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
+ end
+
+ it 'ignores invalid milestone names' do
+ exp = act = %(Milestone #{Milestone.reference_prefix}"#{milestone.name.reverse}")
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ shared_examples 'referencing a milestone in a link href' do
+ let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+ let(:link_reference) { %Q{<a href="#{unquoted_reference}">Milestone</a>} }
+
+ before do
+ milestone.update!(name: 'gfm')
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{link_reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Milestone (#{link_reference}.)")
+ expect(doc.to_html).to match(%r(\(<a.+>Milestone</a>\.\)))
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Milestone #{link_reference}")
+ 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-milestone attribute' do
+ doc = reference_filter("See #{link_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-milestone')
+ expect(link.attr('data-milestone')).to eq milestone.id.to_s
+ end
+ end
+
+ shared_examples 'linking to a milestone as the entire link' do
+ let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
+ let(:link) { urls.milestone_url(milestone) }
+ let(:link_reference) { %Q{<a href="#{link}">#{link}</a>} }
+
+ it 'replaces the link text with the milestone reference' do
+ doc = reference_filter("See #{link}")
+
+ expect(doc.css('a').first.text).to eq(unquoted_reference)
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Milestone #{link_reference}")
+ 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-milestone attribute' do
+ doc = reference_filter("See #{link_reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-milestone')
+ expect(link.attr('data-milestone')).to eq milestone.id.to_s
+ end
+ end
+
+ shared_examples 'cross-project / cross-namespace complete reference' do
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'points to referenced project milestone page' do
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.reference_link_text} in #{another_project.full_path}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text)
+ .to eq("See (#{milestone.reference_link_text} in #{another_project.full_path}.)")
+ end
+
+ it 'escapes the name attribute' do
+ allow_next_instance_of(Milestone) do |instance|
+ allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
+ end
+
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.reference_link_text} in #{another_project.full_path}"
+ end
+ end
+
+ shared_examples 'cross-project / same-namespace complete reference' do
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'points to referenced project milestone page' do
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.reference_link_text} in #{another_project.path}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text)
+ .to eq("See (#{milestone.reference_link_text} in #{another_project.path}.)")
+ end
+
+ it 'escapes the name attribute' do
+ allow_next_instance_of(Milestone) do |instance|
+ allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
+ end
+
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.reference_link_text} in #{another_project.path}"
+ end
+ end
+
+ shared_examples 'cross project shorthand reference' do
+ let_it_be(:project) { create(:project, :public, namespace: namespace) }
+ let_it_be(:milestone) { create(:milestone, project: another_project) }
+ let(:reference) { "#{another_project.path}%#{milestone.iid}" }
+ let!(:result) { reference_filter("See #{reference}") }
+
+ it 'points to referenced project milestone page' do
+ expect(result.css('a').first.attr('href')).to eq urls
+ .project_milestone_url(another_project, milestone)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text)
+ .to eq("#{milestone.reference_link_text} in #{another_project.path}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text)
+ .to eq("See (#{milestone.reference_link_text} in #{another_project.path}.)")
+ end
+
+ it 'escapes the name attribute' do
+ allow_next_instance_of(Milestone) do |instance|
+ allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
+ end
+
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.text)
+ .to eq "#{milestone.reference_link_text} in #{another_project.path}"
+ end
+ end
+
+ shared_examples 'references with HTML entities' do
+ before do
+ milestone.update!(title: '&lt;html&gt;')
+ end
+
+ it 'links to a valid reference' do
+ doc = reference_filter('See %"&lt;html&gt;"')
+
+ expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
+ expect(doc.text).to eq 'See %<html>'
+ end
+
+ it 'ignores invalid milestone names and escapes entities' do
+ act = %(Milestone %"&lt;non valid&gt;")
+
+ expect(reference_filter(act).to_html).to eq act
+ end
+ end
+
+ shared_context 'project milestones' do
+ let(:reference) { milestone.to_reference(format: :iid) }
+
+ include_examples 'reference parsing'
+
+ it_behaves_like 'Integer-based references'
+ it_behaves_like 'String-based single-word references'
+ it_behaves_like 'String-based multi-word references in quotes'
+ it_behaves_like 'referencing a milestone in a link href'
+ it_behaves_like 'cross-project / cross-namespace complete reference'
+ it_behaves_like 'cross-project / same-namespace complete reference'
+ it_behaves_like 'cross project shorthand reference'
+ it_behaves_like 'references with HTML entities'
+ it_behaves_like 'HTML text with references' do
+ let(:resource) { milestone }
+ let(:resource_text) { "#{resource.class.reference_prefix}#{resource.title}" }
+ end
+ end
+
+ shared_context 'group milestones' do
+ let(:reference) { milestone.to_reference(format: :name) }
+
+ include_examples 'reference parsing'
+
+ it_behaves_like 'String-based single-word references'
+ it_behaves_like 'String-based multi-word references in quotes'
+ it_behaves_like 'referencing a milestone in a link href'
+ it_behaves_like 'references with HTML entities'
+ it_behaves_like 'HTML text with references' do
+ let(:resource) { milestone }
+ let(:resource_text) { "#{resource.class.reference_prefix}#{resource.title}" }
+ end
+
+ it 'does not support references by IID' do
+ doc = reference_filter("See #{Milestone.reference_prefix}#{milestone.iid}")
+
+ expect(doc.css('a')).to be_empty
+ end
+
+ it 'does not support references by link' do
+ doc = reference_filter("See #{urls.milestone_url(milestone)}")
+
+ expect(doc.css('a').first.text).to eq(urls.milestone_url(milestone))
+ end
+
+ it 'does not support cross-project references', :aggregate_failures do
+ another_group = create(:group)
+ another_project = create(:project, :public, group: group)
+ project_reference = another_project.to_reference_base(project)
+ input_text = "See #{project_reference}#{reference}"
+
+ milestone.update!(group: another_group)
+
+ doc = reference_filter(input_text)
+
+ expect(input_text).to match(Milestone.reference_pattern)
+ expect(doc.css('a')).to be_empty
+ end
+
+ it 'supports parent group references' do
+ milestone.update!(group: parent_group)
+
+ doc = reference_filter("See #{reference}")
+ expect(doc.css('a').first.text).to eq(milestone.reference_link_text)
+ end
+ end
+
+ context 'group context' do
+ let(:group) { create(:group) }
+ let(:context) { { project: nil, group: group } }
+
+ context 'when project milestone' do
+ let(:milestone) { create(:milestone, project: project) }
+
+ it 'links to a valid reference' do
+ reference = "#{project.full_path}%#{milestone.iid}"
+
+ result = reference_filter("See #{reference}", context)
+
+ expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See %#{milestone.iid}"
+
+ expect(reference_filter(act, context).to_html).to eq exp
+ end
+ end
+
+ context 'when group milestone' do
+ let(:group_milestone) { create(:milestone, title: 'group_milestone', group: group) }
+
+ context 'for subgroups' do
+ let(:sub_group) { create(:group, parent: group) }
+ let(:sub_group_milestone) { create(:milestone, title: 'sub_group_milestone', group: sub_group) }
+
+ it 'links to a valid reference of subgroup and group milestones' do
+ [group_milestone, sub_group_milestone].each do |milestone|
+ reference = "%#{milestone.title}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: sub_group })
+
+ expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
+ end
+ end
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See %#{group_milestone.iid}"
+
+ expect(reference_filter(act, context).to_html).to eq exp
+ end
+ end
+ end
+
+ context 'when milestone is open' do
+ context 'project milestones' do
+ let_it_be_with_reload(:milestone) { create(:milestone, project: project) }
+
+ include_context 'project milestones'
+ end
+
+ context 'group milestones' do
+ let_it_be_with_reload(:milestone) { create(:milestone, group: group) }
+
+ include_context 'group milestones'
+ end
+ end
+
+ context 'when milestone is closed' do
+ context 'project milestones' do
+ let_it_be_with_reload(:milestone) { create(:milestone, :closed, project: project) }
+
+ include_context 'project milestones'
+ end
+
+ context 'group milestones' do
+ let_it_be_with_reload(:milestone) { create(:milestone, :closed, group: group) }
+
+ include_context 'group milestones'
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/project_reference_filter_spec.rb b/spec/lib/banzai/filter/references/project_reference_filter_spec.rb
new file mode 100644
index 00000000000..7a77d57cd42
--- /dev/null
+++ b/spec/lib/banzai/filter/references/project_reference_filter_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::ProjectReferenceFilter do
+ include FilterSpecHelper
+
+ def invalidate_reference(reference)
+ "#{reference.reverse}"
+ end
+
+ def get_reference(project)
+ project.to_reference
+ end
+
+ let(:project) { create(:project, :public) }
+ subject { project }
+
+ let(:subject_name) { "project" }
+ let(:reference) { get_reference(project) }
+
+ it_behaves_like 'user reference or project reference'
+
+ it 'ignores invalid projects' do
+ exp = act = "Hey #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq(CGI.escapeHTML(exp))
+ end
+
+ context 'when invalid reference strings are very long' do
+ shared_examples_for 'fails fast' do |ref_string|
+ it 'fails fast for long strings' do
+ # took well under 1 second in CI https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/3267#note_172824
+ expect do
+ Timeout.timeout(3.seconds) { reference_filter(ref_string).to_html }
+ end.not_to raise_error
+ end
+ end
+
+ it_behaves_like 'fails fast', 'A' * 50000
+ it_behaves_like 'fails fast', '/a' * 50000
+ end
+
+ it 'allows references with text after the > character' do
+ doc = reference_filter("Hey #{reference}foo")
+ expect(doc.css('a').first.attr('href')).to eq urls.project_url(subject)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Hey #{CGI.escapeHTML(reference)}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project has-tooltip'
+ end
+
+ context 'in group context' do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, group: group) }
+
+ let(:nested_group) { create(:group, :nested) }
+ let(:nested_project) { create(:project, group: nested_group) }
+
+ it 'supports mentioning a project' do
+ reference = get_reference(project)
+ doc = reference_filter("Hey #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.project_url(project)
+ end
+
+ it 'supports mentioning a project in a nested group' do
+ reference = get_reference(nested_project)
+ doc = reference_filter("Hey #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.project_url(nested_project)
+ end
+ end
+
+ describe '#projects_hash' do
+ it 'returns a Hash containing all Projects' do
+ document = Nokogiri::HTML.fragment("<p>#{get_reference(project)}</p>")
+ filter = described_class.new(document, project: project)
+
+ expect(filter.projects_hash).to eq({ project.full_path => project })
+ end
+ end
+
+ describe '#projects' do
+ it 'returns the projects mentioned in a document' do
+ document = Nokogiri::HTML.fragment("<p>#{get_reference(project)}</p>")
+ filter = described_class.new(document, project: project)
+
+ expect(filter.projects).to eq([project.full_path])
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/reference_filter_spec.rb b/spec/lib/banzai/filter/references/reference_filter_spec.rb
new file mode 100644
index 00000000000..4bcb41ef2a9
--- /dev/null
+++ b/spec/lib/banzai/filter/references/reference_filter_spec.rb
@@ -0,0 +1,224 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::ReferenceFilter do
+ let(:project) { build_stubbed(:project) }
+
+ describe '#each_node' do
+ it 'iterates over the nodes in a document' do
+ document = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
+ filter = described_class.new(document, project: project)
+
+ expect { |b| filter.each_node(&b) }
+ .to yield_with_args(an_instance_of(Nokogiri::XML::Element))
+ end
+
+ it 'returns an Enumerator when no block is given' do
+ document = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
+ filter = described_class.new(document, project: project)
+
+ expect(filter.each_node).to be_an_instance_of(Enumerator)
+ end
+
+ it 'skips links with a "gfm" class' do
+ document = Nokogiri::HTML.fragment('<a href="foo" class="gfm">foo</a>')
+ filter = described_class.new(document, project: project)
+
+ expect { |b| filter.each_node(&b) }.not_to yield_control
+ end
+
+ it 'skips text nodes in pre elements' do
+ document = Nokogiri::HTML.fragment('<pre>foo</pre>')
+ filter = described_class.new(document, project: project)
+
+ expect { |b| filter.each_node(&b) }.not_to yield_control
+ end
+ end
+
+ describe '#nodes' do
+ it 'returns an Array of the HTML nodes' do
+ document = Nokogiri::HTML.fragment('<a href="foo">foo</a>')
+ filter = described_class.new(document, project: project)
+
+ expect(filter.nodes).to eq([document.children[0]])
+ end
+ end
+
+ RSpec.shared_context 'document nodes' do
+ let(:document) { Nokogiri::HTML.fragment('<p data-sourcepos="1:1-1:18"></p>') }
+ let(:nodes) { [] }
+ let(:filter) { described_class.new(document, project: project) }
+ let(:ref_pattern) { nil }
+ let(:href_link) { nil }
+
+ before do
+ nodes.each do |node|
+ document.children.first.add_child(node)
+ end
+ end
+ end
+
+ RSpec.shared_context 'new nodes' do
+ let(:nodes) { [{ value: "1" }, { value: "2" }, { value: "3" }] }
+ let(:expected_nodes) { [{ value: "1.1" }, { value: "1.2" }, { value: "1.3" }, { value: "2.1" }, { value: "2.2" }, { value: "2.3" }, { value: "3.1" }, { value: "3.2" }, { value: "3.3" }] }
+ let(:new_nodes) do
+ {
+ 0 => [{ value: "1.1" }, { value: "1.2" }, { value: "1.3" }],
+ 2 => [{ value: "3.1" }, { value: "3.2" }, { value: "3.3" }],
+ 1 => [{ value: "2.1" }, { value: "2.2" }, { value: "2.3" }]
+ }
+ end
+ end
+
+ RSpec.shared_examples 'replaces text' do |method_name, index|
+ let(:args) { [filter.nodes[index], index, ref_pattern || href_link].compact }
+
+ context 'when content didnt change' do
+ it 'does not replace link node with html' do
+ filter.send(method_name, *args) do
+ existing_content
+ end
+
+ expect(filter).not_to receive(:replace_text_with_html)
+ end
+ end
+
+ context 'when link node has changed' do
+ let(:html) { %(text <a href="reference_url" class="gfm gfm-user" title="reference">Reference</a>) }
+
+ it 'replaces reference node' do
+ filter.send(method_name, *args) do
+ html
+ end
+
+ expect(document.css('a').length).to eq 1
+ end
+
+ it 'calls replace_and_update_new_nodes' do
+ expect(filter).to receive(:replace_and_update_new_nodes).with(filter.nodes[index], index, html)
+
+ filter.send(method_name, *args) do
+ html
+ end
+ end
+
+ it 'stores filtered new nodes' do
+ filter.send(method_name, *args) do
+ html
+ end
+
+ expect(filter.instance_variable_get(:@new_nodes)).to eq({ index => [filter.each_node.to_a[index]] })
+ end
+ end
+ end
+
+ RSpec.shared_examples 'replaces document node' do |method_name|
+ context 'when parent has only one node' do
+ let(:nodes) { [node] }
+
+ it_behaves_like 'replaces text', method_name, 0
+ end
+
+ context 'when parent has multiple nodes' do
+ let(:node1) { Nokogiri::HTML.fragment('<span>span text</span>') }
+ let(:node2) { Nokogiri::HTML.fragment('<span>text</span>') }
+
+ context 'when pattern matches in the first node' do
+ let(:nodes) { [node, node1, node2] }
+
+ it_behaves_like 'replaces text', method_name, 0
+ end
+
+ context 'when pattern matches in the middle node' do
+ let(:nodes) { [node1, node, node2] }
+
+ it_behaves_like 'replaces text', method_name, 1
+ end
+
+ context 'when pattern matches in the last node' do
+ let(:nodes) { [node1, node2, node] }
+
+ it_behaves_like 'replaces text', method_name, 2
+ end
+ end
+ end
+
+ describe '#replace_text_when_pattern_matches' do
+ include_context 'document nodes'
+ let(:node) { Nokogiri::HTML.fragment('text @reference') }
+
+ let(:ref_pattern) { %r{(?<!\w)@(?<user>[a-zA-Z0-9_\-\.]*)}x }
+
+ context 'when node has no reference pattern' do
+ let(:node) { Nokogiri::HTML.fragment('random text') }
+ let(:nodes) { [node] }
+
+ it 'skips node' do
+ expect { |b| filter.replace_text_when_pattern_matches(filter.nodes[0], 0, ref_pattern, &b) }.not_to yield_control
+ end
+ end
+
+ it_behaves_like 'replaces document node', :replace_text_when_pattern_matches do
+ let(:existing_content) { node.to_html }
+ end
+ end
+
+ describe '#replace_link_node_with_text' do
+ include_context 'document nodes'
+ let(:node) { Nokogiri::HTML.fragment('<a>end text</a>') }
+
+ it_behaves_like 'replaces document node', :replace_link_node_with_text do
+ let(:existing_content) { node.text }
+ end
+ end
+
+ describe '#replace_link_node_with_href' do
+ include_context 'document nodes'
+ let(:node) { Nokogiri::HTML.fragment('<a href="link">end text</a>') }
+ let(:href_link) { CGI.unescape(node.attr('href').to_s) }
+
+ it_behaves_like 'replaces document node', :replace_link_node_with_href do
+ let(:existing_content) { href_link }
+ end
+ end
+
+ describe "#call_and_update_nodes" do
+ include_context 'new nodes'
+ let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
+ let(:filter) { described_class.new(document, project: project) }
+
+ it "updates all new nodes", :aggregate_failures do
+ filter.instance_variable_set('@nodes', nodes)
+
+ expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
+ expect(filter).to receive(:with_update_nodes).and_call_original
+ expect(filter).to receive(:update_nodes!).and_call_original
+
+ filter.call_and_update_nodes
+
+ expect(filter.result[:reference_filter_nodes]).to eq(expected_nodes)
+ end
+ end
+
+ describe ".call" do
+ include_context 'new nodes'
+
+ let(:document) { Nokogiri::HTML.fragment('<a href="foo">foo</a>') }
+
+ let(:result) { { reference_filter_nodes: nodes } }
+
+ it "updates all nodes", :aggregate_failures do
+ expect_next_instance_of(described_class) do |filter|
+ expect(filter).to receive(:call_and_update_nodes).and_call_original
+ expect(filter).to receive(:with_update_nodes).and_call_original
+ expect(filter).to receive(:call) { filter.instance_variable_set('@new_nodes', new_nodes) }
+ expect(filter).to receive(:update_nodes!).and_call_original
+ end
+
+ described_class.call(document, { project: project }, result)
+
+ expect(result[:reference_filter_nodes]).to eq(expected_nodes)
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/snippet_reference_filter_spec.rb b/spec/lib/banzai/filter/references/snippet_reference_filter_spec.rb
new file mode 100644
index 00000000000..32a706925ba
--- /dev/null
+++ b/spec/lib/banzai/filter/references/snippet_reference_filter_spec.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::SnippetReferenceFilter do
+ include FilterSpecHelper
+
+ let(:project) { create(:project, :public) }
+ let(:snippet) { create(:project_snippet, project: project) }
+ let(:reference) { snippet.to_reference }
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Snippet #{reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'internal reference' do
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls
+ .project_snippet_url(project, snippet)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Snippet (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(reference)}</a>\.\)})
+ end
+
+ it 'ignores invalid snippet IDs' do
+ exp = act = "Snippet #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+
+ it 'includes a title attribute' do
+ doc = reference_filter("Snippet #{reference}")
+ expect(doc.css('a').first.attr('title')).to eq snippet.title
+ end
+
+ it 'escapes the title attribute' do
+ snippet.update_attribute(:title, %{"></a>whatever<a title="})
+
+ doc = reference_filter("Snippet #{reference}")
+ expect(doc.text).to eq "Snippet #{reference}"
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Snippet #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-snippet has-tooltip'
+ end
+
+ it 'includes a data-project attribute' do
+ doc = reference_filter("Snippet #{reference}")
+ 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-snippet attribute' do
+ doc = reference_filter("See #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-snippet')
+ expect(link.attr('data-snippet')).to eq snippet.id.to_s
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Snippet #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r(https?://)
+ expect(link).to eq urls.project_snippet_url(project, snippet, only_path: true)
+ end
+ end
+
+ context 'cross-project / cross-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let!(:snippet) { create(:project_snippet, project: project2) }
+ let(:reference) { "#{project2.full_path}$#{snippet.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.css('a').first.text).to eql(reference)
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{reference}.)")
+
+ expect(doc.text).to eql("See (#{reference}.)")
+ end
+
+ it 'ignores invalid snippet IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project / same-namespace complete reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let!(:snippet) { create(:project_snippet, project: project2) }
+ let(:reference) { "#{project2.full_path}$#{snippet.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}$#{snippet.id}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}$#{snippet.id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}$#{snippet.id}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}$#{snippet.id}.)")
+ end
+
+ it 'ignores invalid snippet IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project shorthand reference' do
+ let(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :public, namespace: namespace) }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let!(:snippet) { create(:project_snippet, project: project2) }
+ let(:reference) { "#{project2.path}$#{snippet.id}" }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
+ end
+
+ it 'link has valid text' do
+ doc = reference_filter("See (#{project2.path}$#{snippet.id}.)")
+
+ expect(doc.css('a').first.text).to eql("#{project2.path}$#{snippet.id}")
+ end
+
+ it 'has valid text' do
+ doc = reference_filter("See (#{project2.path}$#{snippet.id}.)")
+
+ expect(doc.text).to eql("See (#{project2.path}$#{snippet.id}.)")
+ end
+
+ it 'ignores invalid snippet IDs on the referenced project' do
+ exp = act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'cross-project URL reference' do
+ let(:namespace) { create(:namespace, name: 'cross-reference') }
+ let(:project2) { create(:project, :public, namespace: namespace) }
+ let(:snippet) { create(:project_snippet, project: project2) }
+ let(:reference) { urls.project_snippet_url(project2, snippet) }
+
+ it 'links to a valid reference' do
+ doc = reference_filter("See #{reference}")
+
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_snippet_url(project2, snippet)
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("See (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>#{Regexp.escape(snippet.to_reference(project))}</a>\.\)})
+ end
+
+ it 'ignores invalid snippet IDs on the referenced project' do
+ act = "See #{invalidate_reference(reference)}"
+
+ expect(reference_filter(act).to_html).to match(%r{<a.+>#{Regexp.escape(invalidate_reference(reference))}</a>})
+ end
+ end
+
+ context 'group context' do
+ it 'links to a valid reference' do
+ reference = "#{project.full_path}$#{snippet.id}"
+
+ result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
+
+ expect(result.css('a').first.attr('href')).to eq(urls.project_snippet_url(project, snippet))
+ end
+
+ it 'ignores internal references' do
+ exp = act = "See $#{snippet.id}"
+
+ expect(reference_filter(act, project: nil, group: create(:group)).to_html).to eq exp
+ end
+ end
+end
diff --git a/spec/lib/banzai/filter/references/user_reference_filter_spec.rb b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb
new file mode 100644
index 00000000000..e4703606b47
--- /dev/null
+++ b/spec/lib/banzai/filter/references/user_reference_filter_spec.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::References::UserReferenceFilter do
+ include FilterSpecHelper
+
+ def get_reference(user)
+ user.to_reference
+ end
+
+ let(:project) { create(:project, :public) }
+ let(:user) { create(:user) }
+ subject { user }
+
+ let(:subject_name) { "user" }
+ let(:reference) { get_reference(user) }
+
+ it_behaves_like 'user reference or project reference'
+
+ it 'requires project context' do
+ expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
+ end
+
+ it 'ignores invalid users' do
+ exp = act = "Hey #{invalidate_reference(reference)}"
+ expect(reference_filter(act).to_html).to eq(exp)
+ end
+
+ it 'ignores references with text before the @ sign' do
+ exp = act = "Hey foo#{reference}"
+ expect(reference_filter(act).to_html).to eq(exp)
+ end
+
+ %w(pre code a style).each do |elem|
+ it "ignores valid references contained inside '#{elem}' element" do
+ exp = act = "<#{elem}>Hey #{reference}</#{elem}>"
+ expect(reference_filter(act).to_html).to eq exp
+ end
+ end
+
+ context 'mentioning @all' do
+ let(:reference) { User.reference_prefix + 'all' }
+
+ it_behaves_like 'a reference containing an element node'
+
+ before do
+ project.add_developer(project.creator)
+ end
+
+ it 'supports a special @all mention' do
+ project.add_developer(user)
+ doc = reference_filter("Hey #{reference}", author: user)
+
+ expect(doc.css('a').length).to eq 1
+ expect(doc.css('a').first.attr('href'))
+ .to eq urls.project_url(project)
+ end
+
+ it 'includes a data-author attribute when there is an author' do
+ project.add_developer(user)
+ doc = reference_filter(reference, author: user)
+
+ expect(doc.css('a').first.attr('data-author')).to eq(user.id.to_s)
+ end
+
+ it 'does not include a data-author attribute when there is no author' do
+ doc = reference_filter(reference)
+
+ expect(doc.css('a').first.has_attribute?('data-author')).to eq(false)
+ end
+
+ it 'ignores reference to all when the user is not a project member' do
+ doc = reference_filter("Hey #{reference}", author: user)
+
+ expect(doc.css('a').length).to eq 0
+ end
+ end
+
+ context 'mentioning a group' do
+ let(:reference) { group.to_reference }
+ let(:group) { create(:group) }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to the Group' do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('href')).to eq urls.group_url(group)
+ end
+
+ it 'includes a data-group attribute' do
+ doc = reference_filter("Hey #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute('data-group')
+ expect(link.attr('data-group')).to eq group.id.to_s
+ end
+ end
+
+ context 'mentioning a nested group' do
+ let(:reference) { group.to_reference }
+ let(:group) { create(:group, :nested) }
+
+ it_behaves_like 'a reference containing an element node'
+
+ it 'links to the nested group' do
+ doc = reference_filter("Hey #{reference}")
+
+ expect(doc.css('a').first.attr('href')).to eq urls.group_url(group)
+ end
+
+ it 'has the full group name as a title' do
+ doc = reference_filter("Hey #{reference}")
+
+ expect(doc.css('a').first.attr('title')).to eq group.full_name
+ end
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Mention me (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(<a.+>#{reference}</a>\.\)})
+ end
+
+ it 'includes default classes' do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member js-user-link'
+ end
+
+ context 'when a project is not specified' do
+ let(:project) { nil }
+
+ it 'does not link a User' do
+ doc = reference_filter("Hey #{reference}")
+
+ expect(doc).not_to include('a')
+ end
+
+ context 'when skip_project_check set to true' do
+ it 'links to a User' do
+ doc = reference_filter("Hey #{reference}", skip_project_check: true)
+
+ expect(doc.css('a').first.attr('href')).to eq urls.user_url(user)
+ end
+
+ it 'does not link users using @all reference' do
+ doc = reference_filter("Hey #{User.reference_prefix}all", skip_project_check: true)
+
+ expect(doc).not_to include('a')
+ end
+ end
+ end
+
+ context 'in group context' do
+ let(:group) { create(:group) }
+ let(:group_member) { create(:user) }
+
+ before do
+ group.add_developer(group_member)
+ end
+
+ let(:context) { { author: group_member, project: nil, group: group } }
+
+ it 'supports a special @all mention' do
+ reference = User.reference_prefix + 'all'
+ doc = reference_filter("Hey #{reference}", context)
+
+ expect(doc.css('a').length).to eq(1)
+ expect(doc.css('a').first.attr('href')).to eq urls.group_url(group)
+ end
+
+ it 'supports mentioning a single user' do
+ reference = get_reference(group_member)
+ doc = reference_filter("Hey #{reference}", context)
+
+ expect(doc.css('a').first.attr('href')).to eq urls.user_url(group_member)
+ end
+
+ it 'supports mentioning a group' do
+ reference = group.to_reference
+ doc = reference_filter("Hey #{reference}", context)
+
+ expect(doc.css('a').first.attr('href')).to eq urls.user_url(group)
+ end
+ end
+
+ describe '#namespaces' do
+ it 'returns a Hash containing all Namespaces' do
+ document = Nokogiri::HTML.fragment("<p>#{get_reference(user)}</p>")
+ filter = described_class.new(document, project: project)
+ ns = user.namespace
+
+ expect(filter.namespaces).to eq({ ns.path => ns })
+ end
+ end
+
+ describe '#usernames' do
+ it 'returns the usernames mentioned in a document' do
+ document = Nokogiri::HTML.fragment("<p>#{get_reference(user)}</p>")
+ filter = described_class.new(document, project: project)
+
+ expect(filter.usernames).to eq([user.username])
+ end
+ end
+end