1# frozen_string_literal: true
2
3require 'spec_helper'
4
5RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter do
6  include FilterSpecHelper
7
8  let_it_be(:parent_group) { create(:group, :public) }
9  let_it_be(:group) { create(:group, :public, parent: parent_group) }
10  let_it_be(:project) { create(:project, :public, group: group) }
11  let_it_be(:namespace) { create(:namespace) }
12  let_it_be(:another_project) { create(:project, :public, namespace: namespace) }
13
14  it 'requires project context' do
15    expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
16  end
17
18  shared_examples 'reference parsing' do
19    %w(pre code a style).each do |elem|
20      it "ignores valid references contained inside '#{elem}' element" do
21        exp = act = "<#{elem}>milestone #{reference}</#{elem}>"
22        expect(reference_filter(act).to_html).to eq exp
23      end
24    end
25
26    it 'includes default classes' do
27      doc = reference_filter("Milestone #{reference}")
28
29      expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-milestone has-tooltip'
30    end
31
32    it 'includes a data-project attribute' do
33      doc = reference_filter("Milestone #{reference}")
34      link = doc.css('a').first
35
36      expect(link).to have_attribute('data-project')
37      expect(link.attr('data-project')).to eq project.id.to_s
38    end
39
40    it 'includes a data-milestone attribute' do
41      doc = reference_filter("See #{reference}")
42      link = doc.css('a').first
43
44      expect(link).to have_attribute('data-milestone')
45      expect(link.attr('data-milestone')).to eq milestone.id.to_s
46    end
47
48    it 'supports an :only_path context' do
49      doc = reference_filter("Milestone #{reference}", only_path: true)
50      link = doc.css('a').first.attr('href')
51
52      expect(link).not_to match %r(https?://)
53      expect(link).to eq urls.milestone_path(milestone)
54    end
55  end
56
57  shared_examples 'Integer-based references' do
58    it 'links to a valid reference' do
59      doc = reference_filter("See #{reference}")
60
61      expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
62    end
63
64    it 'links with adjacent text' do
65      doc = reference_filter("Milestone (#{reference}.)")
66      expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
67    end
68
69    it 'ignores invalid milestone IIDs' do
70      exp = act = "Milestone #{invalidate_reference(reference)}"
71
72      expect(reference_filter(act).to_html).to eq exp
73    end
74  end
75
76  shared_examples 'String-based single-word references' do
77    let(:reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
78
79    before do
80      milestone.update!(name: 'gfm')
81    end
82
83    it 'links to a valid reference' do
84      doc = reference_filter("See #{reference}")
85
86      expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
87      expect(doc.text).to eq "See #{milestone.reference_link_text}"
88    end
89
90    it 'links with adjacent text' do
91      doc = reference_filter("Milestone (#{reference}.)")
92      expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
93    end
94
95    it 'links with adjacent html tags' do
96      doc = reference_filter("Milestone <p>#{reference}</p>.")
97      expect(doc.to_html).to match(%r(<p><a.+>#{milestone.reference_link_text}</a></p>))
98    end
99
100    it 'ignores invalid milestone names' do
101      exp = act = "Milestone #{Milestone.reference_prefix}#{milestone.name.reverse}"
102
103      expect(reference_filter(act).to_html).to eq exp
104    end
105  end
106
107  shared_examples 'String-based multi-word references in quotes' do
108    let(:reference) { milestone.to_reference(format: :name) }
109
110    before do
111      milestone.update!(name: 'gfm references')
112    end
113
114    it 'links to a valid reference' do
115      doc = reference_filter("See #{reference}")
116
117      expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
118      expect(doc.text).to eq "See #{milestone.reference_link_text}"
119    end
120
121    it 'links with adjacent text' do
122      doc = reference_filter("Milestone (#{reference}.)")
123      expect(doc.to_html).to match(%r(\(<a.+>#{milestone.reference_link_text}</a>\.\)))
124    end
125
126    it 'ignores invalid milestone names' do
127      exp = act = %(Milestone #{Milestone.reference_prefix}"#{milestone.name.reverse}")
128
129      expect(reference_filter(act).to_html).to eq exp
130    end
131  end
132
133  shared_examples 'referencing a milestone in a link href' do
134    let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
135    let(:link_reference) { %Q{<a href="#{unquoted_reference}">Milestone</a>} }
136
137    before do
138      milestone.update!(name: 'gfm')
139    end
140
141    it 'links to a valid reference' do
142      doc = reference_filter("See #{link_reference}")
143
144      expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
145    end
146
147    it 'links with adjacent text' do
148      doc = reference_filter("Milestone (#{link_reference}.)")
149      expect(doc.to_html).to match(%r(\(<a.+>Milestone</a>\.\)))
150    end
151
152    it 'includes a data-project attribute' do
153      doc = reference_filter("Milestone #{link_reference}")
154      link = doc.css('a').first
155
156      expect(link).to have_attribute('data-project')
157      expect(link.attr('data-project')).to eq project.id.to_s
158    end
159
160    it 'includes a data-milestone attribute' do
161      doc = reference_filter("See #{link_reference}")
162      link = doc.css('a').first
163
164      expect(link).to have_attribute('data-milestone')
165      expect(link.attr('data-milestone')).to eq milestone.id.to_s
166    end
167  end
168
169  shared_examples 'linking to a milestone as the entire link' do
170    let(:unquoted_reference) { "#{Milestone.reference_prefix}#{milestone.name}" }
171    let(:link) { urls.milestone_url(milestone) }
172    let(:link_reference) { %Q{<a href="#{link}">#{link}</a>} }
173
174    it 'replaces the link text with the milestone reference' do
175      doc = reference_filter("See #{link}")
176
177      expect(doc.css('a').first.text).to eq(unquoted_reference)
178    end
179
180    it 'includes a data-project attribute' do
181      doc = reference_filter("Milestone #{link_reference}")
182      link = doc.css('a').first
183
184      expect(link).to have_attribute('data-project')
185      expect(link.attr('data-project')).to eq project.id.to_s
186    end
187
188    it 'includes a data-milestone attribute' do
189      doc = reference_filter("See #{link_reference}")
190      link = doc.css('a').first
191
192      expect(link).to have_attribute('data-milestone')
193      expect(link.attr('data-milestone')).to eq milestone.id.to_s
194    end
195  end
196
197  shared_examples 'cross-project / cross-namespace complete reference' do
198    let_it_be(:milestone) { create(:milestone, project: another_project) }
199    let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
200    let!(:result) { reference_filter("See #{reference}") }
201
202    it 'points to referenced project milestone page' do
203      expect(result.css('a').first.attr('href')).to eq urls
204        .project_milestone_url(another_project, milestone)
205    end
206
207    it 'link has valid text' do
208      doc = reference_filter("See (#{reference}.)")
209
210      expect(doc.css('a').first.text)
211        .to eq("#{milestone.reference_link_text} in #{another_project.full_path}")
212    end
213
214    it 'has valid text' do
215      doc = reference_filter("See (#{reference}.)")
216
217      expect(doc.text)
218        .to eq("See (#{milestone.reference_link_text} in #{another_project.full_path}.)")
219    end
220
221    it 'escapes the name attribute' do
222      allow_next_instance_of(Milestone) do |instance|
223        allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
224      end
225
226      doc = reference_filter("See #{reference}")
227
228      expect(doc.css('a').first.text)
229        .to eq "#{milestone.reference_link_text} in #{another_project.full_path}"
230    end
231  end
232
233  shared_examples 'cross-project / same-namespace complete reference' do
234    let_it_be(:project) { create(:project, :public, namespace: namespace) }
235    let_it_be(:milestone) { create(:milestone, project: another_project) }
236    let(:reference) { "#{another_project.full_path}%#{milestone.iid}" }
237    let!(:result) { reference_filter("See #{reference}") }
238
239    it 'points to referenced project milestone page' do
240      expect(result.css('a').first.attr('href')).to eq urls
241        .project_milestone_url(another_project, milestone)
242    end
243
244    it 'link has valid text' do
245      doc = reference_filter("See (#{reference}.)")
246
247      expect(doc.css('a').first.text)
248        .to eq("#{milestone.reference_link_text} in #{another_project.path}")
249    end
250
251    it 'has valid text' do
252      doc = reference_filter("See (#{reference}.)")
253
254      expect(doc.text)
255        .to eq("See (#{milestone.reference_link_text} in #{another_project.path}.)")
256    end
257
258    it 'escapes the name attribute' do
259      allow_next_instance_of(Milestone) do |instance|
260        allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
261      end
262
263      doc = reference_filter("See #{reference}")
264
265      expect(doc.css('a').first.text)
266        .to eq "#{milestone.reference_link_text} in #{another_project.path}"
267    end
268  end
269
270  shared_examples 'cross project shorthand reference' do
271    let_it_be(:project) { create(:project, :public, namespace: namespace) }
272    let_it_be(:milestone) { create(:milestone, project: another_project) }
273    let(:reference) { "#{another_project.path}%#{milestone.iid}" }
274    let!(:result) { reference_filter("See #{reference}") }
275
276    it 'points to referenced project milestone page' do
277      expect(result.css('a').first.attr('href')).to eq urls
278        .project_milestone_url(another_project, milestone)
279    end
280
281    it 'link has valid text' do
282      doc = reference_filter("See (#{reference}.)")
283
284      expect(doc.css('a').first.text)
285        .to eq("#{milestone.reference_link_text} in #{another_project.path}")
286    end
287
288    it 'has valid text' do
289      doc = reference_filter("See (#{reference}.)")
290
291      expect(doc.text)
292        .to eq("See (#{milestone.reference_link_text} in #{another_project.path}.)")
293    end
294
295    it 'escapes the name attribute' do
296      allow_next_instance_of(Milestone) do |instance|
297        allow(instance).to receive(:title).and_return(%{"></a>whatever<a title="})
298      end
299
300      doc = reference_filter("See #{reference}")
301
302      expect(doc.css('a').first.text)
303        .to eq "#{milestone.reference_link_text} in #{another_project.path}"
304    end
305  end
306
307  shared_examples 'references with HTML entities' do
308    before do
309      milestone.update!(title: '&lt;html&gt;')
310    end
311
312    it 'links to a valid reference' do
313      doc = reference_filter('See %"&lt;html&gt;"')
314
315      expect(doc.css('a').first.attr('href')).to eq urls.milestone_url(milestone)
316      expect(doc.text).to eq 'See %<html>'
317    end
318
319    it 'ignores invalid milestone names and escapes entities' do
320      act = %(Milestone %"&lt;non valid&gt;")
321
322      expect(reference_filter(act).to_html).to eq act
323    end
324  end
325
326  shared_context 'project milestones' do
327    let(:reference) { milestone.to_reference(format: :iid) }
328
329    include_examples 'reference parsing'
330
331    it_behaves_like 'Integer-based references'
332    it_behaves_like 'String-based single-word references'
333    it_behaves_like 'String-based multi-word references in quotes'
334    it_behaves_like 'referencing a milestone in a link href'
335    it_behaves_like 'linking to a milestone as the entire link'
336    it_behaves_like 'cross-project / cross-namespace complete reference'
337    it_behaves_like 'cross-project / same-namespace complete reference'
338    it_behaves_like 'cross project shorthand reference'
339    it_behaves_like 'references with HTML entities'
340    it_behaves_like 'HTML text with references' do
341      let(:resource) { milestone }
342      let(:resource_text) { "#{resource.class.reference_prefix}#{resource.title}" }
343    end
344  end
345
346  shared_context 'group milestones' do
347    let(:reference) { milestone.to_reference(format: :name) }
348
349    include_examples 'reference parsing'
350
351    it_behaves_like 'String-based single-word references'
352    it_behaves_like 'String-based multi-word references in quotes'
353    it_behaves_like 'referencing a milestone in a link href'
354    it_behaves_like 'references with HTML entities'
355    it_behaves_like 'HTML text with references' do
356      let(:resource) { milestone }
357      let(:resource_text) { "#{resource.class.reference_prefix}#{resource.title}" }
358    end
359
360    it 'does not support references by IID' do
361      doc = reference_filter("See #{Milestone.reference_prefix}#{milestone.iid}")
362
363      expect(doc.css('a')).to be_empty
364    end
365
366    it 'does not support references by link' do
367      doc = reference_filter("See #{urls.milestone_url(milestone)}")
368
369      expect(doc.css('a').first.text).to eq(urls.milestone_url(milestone))
370    end
371
372    it 'does not support cross-project references', :aggregate_failures do
373      another_group = create(:group)
374      another_project = create(:project, :public, group: group)
375      project_reference = another_project.to_reference_base(project)
376      input_text = "See #{project_reference}#{reference}"
377
378      milestone.update!(group: another_group)
379
380      doc = reference_filter(input_text)
381
382      expect(input_text).to match(Milestone.reference_pattern)
383      expect(doc.css('a')).to be_empty
384    end
385
386    it 'supports parent group references' do
387      milestone.update!(group: parent_group)
388
389      doc = reference_filter("See #{reference}")
390      expect(doc.css('a').first.text).to eq(milestone.reference_link_text)
391    end
392  end
393
394  context 'group context' do
395    let(:group) { create(:group) }
396    let(:context) { { project: nil, group: group } }
397
398    context 'when project milestone' do
399      let(:milestone) { create(:milestone, project: project) }
400
401      it 'links to a valid reference' do
402        reference = "#{project.full_path}%#{milestone.iid}"
403
404        result = reference_filter("See #{reference}", context)
405
406        expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
407      end
408
409      it 'ignores internal references' do
410        exp = act = "See %#{milestone.iid}"
411
412        expect(reference_filter(act, context).to_html).to eq exp
413      end
414    end
415
416    context 'when group milestone' do
417      let(:group_milestone) { create(:milestone, title: 'group_milestone', group: group) }
418
419      context 'for subgroups' do
420        let(:sub_group) { create(:group, parent: group) }
421        let(:sub_group_milestone) { create(:milestone, title: 'sub_group_milestone', group: sub_group) }
422
423        it 'links to a valid reference of subgroup and group milestones' do
424          [group_milestone, sub_group_milestone].each do |milestone|
425            reference = "%#{milestone.title}"
426
427            result = reference_filter("See #{reference}", { project: nil, group: sub_group })
428
429            expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
430          end
431        end
432      end
433
434      it 'ignores internal references' do
435        exp = act = "See %#{group_milestone.iid}"
436
437        expect(reference_filter(act, context).to_html).to eq exp
438      end
439    end
440
441    context 'when referencing both project and group milestones' do
442      let(:milestone) { create(:milestone, project: project) }
443      let(:group_milestone) { create(:milestone, title: 'group_milestone', group: group) }
444
445      it 'links to valid references' do
446        links = reference_filter("See #{milestone.to_reference(full: true)} and #{group_milestone.to_reference}", context).css('a')
447
448        expect(links.length).to eq(2)
449        expect(links[0].attr('href')).to eq(urls.milestone_url(milestone))
450        expect(links[1].attr('href')).to eq(urls.milestone_url(group_milestone))
451      end
452    end
453  end
454
455  context 'when milestone is open' do
456    context 'project milestones' do
457      let_it_be_with_reload(:milestone) { create(:milestone, project: project) }
458
459      include_context 'project milestones'
460    end
461
462    context 'group milestones' do
463      let_it_be_with_reload(:milestone) { create(:milestone, group: group) }
464
465      include_context 'group milestones'
466    end
467  end
468
469  context 'when milestone is closed' do
470    context 'project milestones' do
471      let_it_be_with_reload(:milestone) { create(:milestone, :closed, project: project) }
472
473      include_context 'project milestones'
474    end
475
476    context 'group milestones' do
477      let_it_be_with_reload(:milestone) { create(:milestone, :closed, group: group) }
478
479      include_context 'group milestones'
480    end
481  end
482
483  context 'checking N+1' do
484    let_it_be(:group)              { create(:group) }
485    let_it_be(:group2)             { create(:group) }
486    let_it_be(:project)            { create(:project, :public, namespace: group) }
487    let_it_be(:project2)           { create(:project, :public, namespace: group2) }
488    let_it_be(:project3)           { create(:project, :public) }
489    let_it_be(:project_milestone)  { create(:milestone, project: project) }
490    let_it_be(:project_milestone2) { create(:milestone, project: project) }
491    let_it_be(:project2_milestone) { create(:milestone, project: project2) }
492    let_it_be(:group2_milestone)   { create(:milestone, group: group2) }
493    let_it_be(:project_reference)  { "#{project_milestone.to_reference}" }
494    let_it_be(:project_reference2) { "#{project_milestone2.to_reference}" }
495    let_it_be(:project2_reference) { "#{project2_milestone.to_reference(full: true)}" }
496    let_it_be(:group2_reference)   { "#{project2.full_path}%\"#{group2_milestone.name}\"" }
497
498    it 'does not have N+1 per multiple references per project', :use_sql_query_cache do
499      markdown = "#{project_reference}"
500      control_count = 4
501
502      expect do
503        reference_filter(markdown)
504      end.not_to exceed_all_query_limit(control_count)
505
506      markdown = "#{project_reference} %qwert %werty %ertyu %rtyui #{project_reference2}"
507
508      expect do
509        reference_filter(markdown)
510      end.not_to exceed_all_query_limit(control_count)
511    end
512
513    it 'has N+1 for multiple unique project/group references', :use_sql_query_cache do
514      markdown = "#{project_reference}"
515      control_count = 4
516
517      expect do
518        reference_filter(markdown, project: project)
519      end.not_to exceed_all_query_limit(control_count)
520
521      # Since we're not batching milestone queries across projects/groups,
522      # queries increase when a new project/group is added.
523      # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/330359
524      markdown = "#{project_reference} #{group2_reference}"
525      control_count += 5
526
527      expect do
528        reference_filter(markdown)
529      end.not_to exceed_all_query_limit(control_count)
530
531      # third reference to already queried project/namespace, nothing extra (no N+1 here)
532      markdown = "#{project_reference} #{group2_reference} #{project_reference2}"
533
534      expect do
535        reference_filter(markdown)
536      end.not_to exceed_all_query_limit(control_count)
537
538      # last reference needs additional queries
539      markdown = "#{project_reference} #{group2_reference} #{project2_reference} #{project3.full_path}%test_milestone"
540      control_count += 6
541
542      expect do
543        reference_filter(markdown)
544      end.not_to exceed_all_query_limit(control_count)
545
546      # Use an iid instead of title reference
547      markdown = "#{project_reference} #{group2_reference} #{project2.full_path}%#{project2_milestone.iid} #{project3.full_path}%test_milestone"
548
549      expect do
550        reference_filter(markdown)
551      end.not_to exceed_all_query_limit(control_count)
552    end
553  end
554end
555