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: '<html>') 310 end 311 312 it 'links to a valid reference' do 313 doc = reference_filter('See %"<html>"') 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 %"<non valid>") 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