diff options
Diffstat (limited to 'spec/lib/banzai/filter/references')
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 <img onerror=alert(1) src=x>', project, issue, link_content: true) + + expect(data_attributes[:original]).to eq('xss &lt;img onerror=alert(1) src=x&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&</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}&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 & 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 & mf.g\") + + expect(reference_filter(act).to_html).to eq exp + end + end + + context 'References with html entities' do + let!(:label) { create(:label, name: '<html>', project: project) } + + it 'links to a valid reference' do + doc = reference_filter('See ~"<html>"') + + 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}"<non valid>") + + 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: '<html>') + end + + it 'links to a valid reference' do + doc = reference_filter('See %"<html>"') + + 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 %"<non valid>") + + 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 |