diff --git a/.rubocop_todo/performance/string_identifier_argument.yml b/.rubocop_todo/performance/string_identifier_argument.yml index 047a32c35f726cfa2409fd24206766ee501cf8d6..d067d03315a966bc65495a84bc0a35f374a22e03 100644 --- a/.rubocop_todo/performance/string_identifier_argument.yml +++ b/.rubocop_todo/performance/string_identifier_argument.yml @@ -184,12 +184,12 @@ Performance/StringIdentifierArgument: - 'spec/support/helpers/redis_helpers.rb' - 'spec/support/redis.rb' - 'spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb' - - 'spec/support/shared_examples/banzai/filters/reference_filter_shared_examples.rb' - 'spec/support/shared_examples/ci/jwt_shared_examples.rb' - 'spec/support/shared_examples/controllers/application_settings_shared_examples.rb' - 'spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb' - 'spec/support/shared_examples/database_health_status_indicators/prometheus_alert_based_shared_examples.rb' - 'spec/support/shared_examples/graphql/container_expiration_policy_shared_examples.rb' + - 'spec/support/shared_examples/lib/banzai/filters/reference_filter_shared_examples.rb' - 'spec/support/shared_examples/models/active_record_enum_shared_examples.rb' - 'spec/support/shared_examples/models/application_setting_shared_examples.rb' - 'spec/support/shared_examples/models/concerns/cascading_namespace_setting_shared_examples.rb' diff --git a/ee/lib/banzai/filter/references/iteration_reference_filter.rb b/ee/lib/banzai/filter/references/iteration_reference_filter.rb index 91a29bc31bdc48656cf2c28869b56332cb83de96..cb0c4b9c5b5891d0a553ecc0bf848aa74b6949ca 100644 --- a/ee/lib/banzai/filter/references/iteration_reference_filter.rb +++ b/ee/lib/banzai/filter/references/iteration_reference_filter.rb @@ -51,7 +51,10 @@ def parse_symbol(symbol, match_data) # holds the id { iteration_id: symbol.to_i, iteration_name: nil } else - { iteration_id: match_data[:iteration_id]&.to_i, iteration_name: match_data[:iteration_name]&.tr('"', '') } + { + iteration_id: match_data[:iteration_id]&.to_i, + iteration_name: match_data[:iteration_name]&.tr('"', '') + } end end @@ -80,23 +83,10 @@ def references_in(text, pattern = ::Iteration.reference_pattern) # default implementation. return super(text, pattern) if pattern != ::Iteration.reference_pattern - iterations = {} - - unescaped_html = unescape_html_entities(text).gsub(pattern).with_index do |match, index| - ident = identifier($~) - iteration = yield match, ident, $~[:project], $~[:namespace], $~ - - if iteration != match - iterations[index] = iteration - "#{::Banzai::Filter::References::AbstractReferenceFilter::REFERENCE_PLACEHOLDER}#{index}" - else - match - end + replace_references_in_text_with_html(text.gsub(pattern)) do |match_data| + ident = identifier(match_data) + yield match_data[0], ident, match_data[:project], match_data[:namespace], match_data end - - return text if iterations.empty? - - escape_with_placeholders(unescaped_html, iterations) end def find_iterations(parent, ids: nil, names: nil) diff --git a/ee/lib/banzai/filter/references/iterations_cadence_reference_filter.rb b/ee/lib/banzai/filter/references/iterations_cadence_reference_filter.rb index 50ccf109e82de62e17bdce86aa7b5d7addb44378..789988e90f07032866032da98e2e749c50b145ab 100644 --- a/ee/lib/banzai/filter/references/iterations_cadence_reference_filter.rb +++ b/ee/lib/banzai/filter/references/iterations_cadence_reference_filter.rb @@ -46,7 +46,10 @@ def parent_records(parent, ids) def parse_symbol(symbol, match_data) return { cadence_id: symbol.to_i, cadence_title: nil } if symbol - { cadence_id: match_data[:cadence_id]&.to_i, cadence_title: match_data[:cadence_title]&.tr('"', '') } + { + cadence_id: match_data[:cadence_id]&.to_i, + cadence_title: match_data[:cadence_title]&.tr('"', '') + } end # This method has the contract that if a string `ref` refers to a @@ -57,22 +60,10 @@ def record_identifier(record) end def references_in(text, pattern = object_class.reference_pattern) - cadences = {} - - unescaped_html = unescape_html_entities(text).gsub(pattern).with_index do |match, index| - ident = identifier($~) - cadence = yield match, ident, nil, nil, $~ - - next match if cadence == match - - cadences[index] = cadence - - "#{::Banzai::Filter::References::AbstractReferenceFilter::REFERENCE_PLACEHOLDER}#{index}" + replace_references_in_text_with_html(text.gsub(pattern)) do |match_data| + ident = identifier(match_data) + yield match_data[0], ident, nil, nil, match_data end - - return text if cadences.empty? - - escape_with_placeholders(unescaped_html, cadences) end def url_for_object(cadence, group) diff --git a/ee/lib/ee/banzai/filter/references/epic_reference_filter.rb b/ee/lib/ee/banzai/filter/references/epic_reference_filter.rb index 80ad20809a91b6597414f93ff246b01012f07b6d..a235456503a7f59f9ab70b0d2217e32298b816e4 100644 --- a/ee/lib/ee/banzai/filter/references/epic_reference_filter.rb +++ b/ee/lib/ee/banzai/filter/references/epic_reference_filter.rb @@ -12,13 +12,11 @@ module EpicReferenceFilter extend ActiveSupport::Concern def references_in(text, pattern = object_class.reference_pattern) - ::Gitlab::Utils::Gsub - .gsub_with_limit(text, pattern, limit: ::Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| + replace_references_in_text_with_html(::Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, + limit: ::Banzai::Filter::FILTER_ITEM_LIMIT)) do |match_data| symbol = match_data[object_sym] if object_class.reference_valid?(symbol) yield match_data[0], symbol.to_i, nil, match_data[:group], match_data - else - match_data[0] end end end @@ -32,9 +30,9 @@ def reference_class(object_sym, tooltip: false) super end - def data_attributes_for(text, group, object, link_content: false, link_reference: false) + def data_attributes_for(original, group, object, link_content: false, link_reference: false) { - original: text, + original: original, link: link_content, link_reference: link_reference, group: group.id, diff --git a/ee/lib/ee/banzai/filter/references/label_reference_filter.rb b/ee/lib/ee/banzai/filter/references/label_reference_filter.rb index eee57295f27cb1cc1498cd4a007e9722ea1777f2..6cdab6d09621e090253d4ac4553d97ea0da5ee05 100644 --- a/ee/lib/ee/banzai/filter/references/label_reference_filter.rb +++ b/ee/lib/ee/banzai/filter/references/label_reference_filter.rb @@ -8,7 +8,7 @@ module LabelReferenceFilter extend ::Gitlab::Utils::Override override :data_attributes_for - def data_attributes_for(text, parent, object, link_content: false, link_reference: false) + def data_attributes_for(original, parent, object, link_content: false, link_reference: false) return super unless object.scoped_label? # Enabling HTML tooltips for scoped labels here. diff --git a/ee/lib/ee/banzai/filter/references/vulnerability_reference_filter.rb b/ee/lib/ee/banzai/filter/references/vulnerability_reference_filter.rb index 85611f630856ca93ec39e3e20a6f67b2b58afab4..5b893e7172a59f393ab7e838c19f7265a0e45d6b 100644 --- a/ee/lib/ee/banzai/filter/references/vulnerability_reference_filter.rb +++ b/ee/lib/ee/banzai/filter/references/vulnerability_reference_filter.rb @@ -12,30 +12,22 @@ module VulnerabilityReferenceFilter extend ActiveSupport::Concern def references_in(text, pattern = object_class.reference_pattern) - text.gsub(pattern) do |match| - symbol = $~[object_sym] + replace_references_in_text_with_html(text.gsub(pattern)) do |match_data| + symbol = match_data[object_sym] if object_class.reference_valid?(symbol) - yield match, symbol.to_i, $~[:project], $~[:namespace], $~ - else - match + yield match_data[0], symbol.to_i, match_data[:project], match_data[:namespace], match_data end end end - def unescape_link(href) - return href if object_class.reference_pattern.match?(href) - - super - end - def url_for_object(vulnerability, project) urls = ::Gitlab::Routing.url_helpers urls.project_security_vulnerability_url(project, vulnerability, only_path: context[:only_path]) end - def data_attributes_for(text, project, object, link_content: false, link_reference: false) + def data_attributes_for(original, project, object, link_content: false, link_reference: false) { - original: text, + original: original, link: link_content, link_reference: link_reference, project: project.id, diff --git a/ee/spec/lib/banzai/filter/references/epic_reference_filter_spec.rb b/ee/spec/lib/banzai/filter/references/epic_reference_filter_spec.rb index 81324772abf176154aefe2b3929e8a04c5284b56..549cffe43e9fddfdc1425a57d0ddfe96e70da0ea 100644 --- a/ee/spec/lib/banzai/filter/references/epic_reference_filter_spec.rb +++ b/ee/spec/lib/banzai/filter/references/epic_reference_filter_spec.rb @@ -74,7 +74,7 @@ def doc(reference = nil) link = doc(reference).css('a').first expect(link).to have_attribute('data-original') - expect(link.attr('data-original')).to eq(CGI.escapeHTML(reference)) + expect(link.attr('data-original')).to eq_html(reference) end it 'includes a data-reference-format attribute' do @@ -102,13 +102,13 @@ def doc(reference = nil) it 'ignores invalid epic IIDs' do text = "Check {non_existing_record_iid}" - expect(doc(text).to_s).to include(ERB::Util.html_escape_once(text)) + expect(doc(text).to_s).to include_html(text) end it 'ignores out of range epic IDs' do text = "Check &1161452270761535925900804973910297" - expect(doc(text).to_s).to include(ERB::Util.html_escape_once(text)) + expect(doc(text).to_s).to include_html(text) end it 'does not process links containing epic numbers followed by text' do @@ -139,7 +139,7 @@ def doc(reference = nil) it 'ignores invalid epic IIDs' do text = "Check &#{non_existing_record_iid}" - expect(doc(text).to_s).to include(ERB::Util.html_escape_once(text)) + expect(doc(text).to_s).to include(text) end end @@ -203,13 +203,13 @@ def doc(reference = nil) it 'ignores a shorthand reference from another group' do text = "Check {epic.iid}" - expect(doc(text).to_s).to include(ERB::Util.html_escape_once(text)) + expect(doc(text).to_s).to include_html(text) end it 'ignores reference with incomplete group path' do text = "Check @#{epic.group.path}{epic.iid}" - expect(doc(text).to_s).to include(ERB::Util.html_escape_once(text)) + expect(doc(text).to_s).to include_html(text) end it 'links to a valid reference for full reference' do @@ -354,4 +354,9 @@ def doc(reference = nil) let(:filter_result) { reference_filter(text, project: nil, group: group) } let(:ends_with) { " #{CGI.escapeHTML(epic.to_reference(group))}
" } end + + it_behaves_like 'ReferenceFilter#references_in' do + let(:reference) { epic.to_reference(group) } + let(:filter_instance) { described_class.new(nil, { project: nil, group: group }) } + end end diff --git a/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb b/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb index 8d2307e51c54f33b93106e09822d055da57b8772..27c338e245d40c0d6451fbacc916641a2de1274f 100644 --- a/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb +++ b/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Banzai::Filter::References::IterationReferenceFilter do +RSpec.describe Banzai::Filter::References::IterationReferenceFilter, feature_category: :markdown do include FilterSpecHelper let(:parent_group) { create(:group, :public) } @@ -331,4 +331,23 @@ end.not_to exceed_all_query_limit(max_count) end end + + it_behaves_like 'a reference which does not unescape its content in data-original' do + let(:context) { { project: nil, group: group } } + let(:iteration_title) { "x<script>alert('xss');</script>" } + let(:resource) { create(:iteration, title: iteration_title, group: group) } + let(:reference) { %(#{resource.class.reference_prefix}"#{iteration_title}") } + + # This is probably bad. + let(:expected_resource_title) { "x" } + + let(:expected_href) { urls.iteration_url(resource) } + let(:expected_replacement_text) { resource.display_text } + end + + it_behaves_like 'ReferenceFilter#references_in' do + let(:iteration) { create(:iteration, :with_title, group: group) } + let(:reference) { %(#{Iteration.reference_prefix}"#{iteration.name}") } + let(:filter_instance) { described_class.new(nil, { project: nil }) } + end end diff --git a/ee/spec/lib/banzai/filter/references/iterations_cadence_reference_filter_spec.rb b/ee/spec/lib/banzai/filter/references/iterations_cadence_reference_filter_spec.rb index e7686c03cda94d146418f6bc97776b919bfd8e66..f0715d8a86b412c6be964c6269d2889d44f8d64f 100644 --- a/ee/spec/lib/banzai/filter/references/iterations_cadence_reference_filter_spec.rb +++ b/ee/spec/lib/banzai/filter/references/iterations_cadence_reference_filter_spec.rb @@ -39,7 +39,7 @@ link = doc(text).css('a').first expect(link).to have_attribute('data-original') - expect(link.attr('data-original')).to eq(reference) + expect(link.attr('data-original')).to eq_html(reference) end end @@ -119,4 +119,21 @@ def doc(text) expect { reference_filter(markdown, context) }.not_to exceed_all_query_limit(1) end end + + it_behaves_like 'a reference which does not unescape its content in data-original' do + let(:context) { { project: nil, group: group } } + let(:cadence_title) { "x" } + let(:cadence_title_escaped) { "x<script>alert('xss');</script>" } + let(:resource) { create(:iterations_cadence, title: cadence_title, group: group) } + let(:reference) { %(#{resource.class.reference_prefix}"#{cadence_title_escaped}"#{resource.class.reference_postfix}) } + + let(:expected_resource_title) { cadence_title } + let(:expected_href) { urls.group_iteration_cadences_url(group, resource) } + let(:expected_replacement_text) { "#{resource.class.reference_prefix}#{resource.id}#{resource.class.reference_postfix}" } + end + + it_behaves_like 'ReferenceFilter#references_in' do + let(:reference) { cadence.to_reference } + let(:filter_instance) { described_class.new(nil, { project: nil }) } + end end diff --git a/ee/spec/lib/banzai/filter/references/vulnerability_reference_filters_spec.rb b/ee/spec/lib/banzai/filter/references/vulnerability_reference_filters_spec.rb index d5fcf17b5e59cf6c7f68021860fd798eef51700d..d67a2971f8825c57970c1be07f890daeb5b594b1 100644 --- a/ee/spec/lib/banzai/filter/references/vulnerability_reference_filters_spec.rb +++ b/ee/spec/lib/banzai/filter/references/vulnerability_reference_filters_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Banzai::Filter::References::VulnerabilityReferenceFilter do +RSpec.describe Banzai::Filter::References::VulnerabilityReferenceFilter, feature_category: :markdown do include FilterSpecHelper let(:urls) { Gitlab::Routing.url_helpers } @@ -198,4 +198,9 @@ def doc(reference = nil) expect { reference_filter(multiple_references).to_html }.not_to exceed_query_limit(control) end end + + it_behaves_like 'ReferenceFilter#references_in' do + let(:reference) { "[vulnerability:#{vulnerability.id}]" } + let(:filter_instance) { described_class.new(nil, { project: }) } + end end diff --git a/lib/banzai/filter/playable_link_filter.rb b/lib/banzai/filter/playable_link_filter.rb index a56961a0c26cdd3fdc9a5697c81213a8af133fa4..77d64965ef2b11592d263a717c9cb64f7c1ace1c 100644 --- a/lib/banzai/filter/playable_link_filter.rb +++ b/lib/banzai/filter/playable_link_filter.rb @@ -55,14 +55,14 @@ def media_element(doc, element) end def download_link(doc, element) - link_content = element['title'] || element['alt'] + link_text = element['title'] || element['alt'] link_element_attrs = { class: 'gl-text-sm gl-text-subtle gl-mb-1', href: element['src'], target: '_blank', rel: 'noopener noreferrer', - title: "Download '#{link_content}'" + title: "Download '#{link_text}'" } # make sure the original non-proxied src carries over @@ -70,7 +70,7 @@ def download_link(doc, element) link_element_attrs['data-canonical-src'] = element['data-canonical-src'] end - doc.document.create_element('a', link_content, link_element_attrs) + doc.document.create_element('a', link_text, link_element_attrs) end def media_node(doc, element) diff --git a/lib/banzai/filter/references/abstract_reference_filter.rb b/lib/banzai/filter/references/abstract_reference_filter.rb index dc21b224b1d3a767297cbcc3eff2c6ba699374cb..b5a0a0d7dfe9c3d67bc195c55c0cb5836ae43268 100644 --- a/lib/banzai/filter/references/abstract_reference_filter.rb +++ b/lib/banzai/filter/references/abstract_reference_filter.rb @@ -16,47 +16,31 @@ def initialize(doc, context = nil, result = nil) @reference_cache = ReferenceCache.new(self, context, result) end - # REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found - # reference (which we replace with placeholder during re-scaping). The - # random number helps ensure it's pretty close to unique. Since it's a - # transitory value (it never gets saved) we can initialize once, and it - # doesn't matter if it changes on a restart. - REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_" - REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)} - - # Public: Find references in text (like `!123` for merge requests) + # See ReferenceFilter#references_in for requirements on the input, block, + # and return value of this function. # - # references_in(text) do |match, id, project_ref, matches| - # object = find_object(project_ref, id) - # "#{object.to_reference}" - # end - # - # text - String text to search. - # - # Yields the String match, the Integer referenced object ID, an optional String - # of the external project reference, and all of the matchdata. - # - # Returns a String replaced with the return of the block. + # This function yields the String match, the Integer referenced object ID, an optional String + # of the external project reference, an optional String of the namespace reference, + # and the full MatchData. def references_in(text, pattern = object_class.reference_pattern) - Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| + replace_references_in_text_with_html(Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, + limit: Banzai::Filter::FILTER_ITEM_LIMIT)) do |match_data| if ident = identifier(match_data) - yield match_data[0], ident, match_data.named_captures['project'], match_data.named_captures['namespace'], - match_data - else - match_data[0] + yield match_data[0], ident, match_data.named_captures['project'], + match_data.named_captures['namespace'], match_data end end end def identifier(match_data) - symbol = symbol_from_match(match_data) + symbol = symbol_from_match_data(match_data) parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol) end - def symbol_from_match(match) + def symbol_from_match_data(match_data) key = object_sym - match[key] if match.names.include?(key.to_s) + match_data[key] if match_data.names.include?(key.to_s) end # Transform a symbol extracted from the text to a meaningful value @@ -133,34 +117,32 @@ def call nodes.each_with_index do |node, index| if text_node?(node) && ref_pattern - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| + replace_node_when_text_matches(node, index, ref_pattern) do |content| object_link_filter(content, ref_pattern) end elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| + yield_valid_link(node) do |link, text, inner_html| if ref_pattern && link =~ ref_pattern_anchor - replace_link_node_with_href(node, index, link) do - object_link_filter(link, ref_pattern_anchor, link_content: inner_html) - end + html = object_link_filter(link, ref_pattern_anchor, link_content_html: inner_html) + replace_node_with_html(node, index, html) if html next end next unless link_pattern - if link == inner_html && inner_html =~ link_pattern_start - replace_link_node_with_text(node, index) do - object_link_filter(inner_html, link_pattern_start, link_reference: true) - end + if link == text && text =~ link_pattern_start + html = object_link_filter(text, link_pattern_start, link_reference: true) + replace_node_with_html(node, index, html) if html next end if link_pattern_anchor.match?(link) - replace_link_node_with_href(node, index, link) do - object_link_filter(link, link_pattern_anchor, link_content: inner_html, link_reference: true) - end + html = object_link_filter(link, link_pattern_anchor, link_content_html: inner_html, + link_reference: true) + replace_node_with_html(node, index, html) if html next end @@ -176,14 +158,21 @@ def call # # text - String text to replace references in. # pattern - Reference pattern to match against. - # link_content - Original content of the link being replaced. + # link_content_html - Original HTML content of the link being replaced. # link_reference - True if this was using the link reference pattern, # false otherwise. # - # Returns a String with references replaced with links. All links - # have `gfm` and `gfm-OBJECT_NAME` class names attached for styling. - def object_link_filter(text, pattern, link_content: nil, link_reference: false) - references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches| + # Returns String HTML with references replaced with links, or nil if no + # replacements were made. All links have `gfm` and `gfm-OBJECT_NAME` + # class names attached for styling. + # + # Note carefully: + # + # * `text` is text, not HTML. + # * `link_content_html`, if provided, is HTML. + # * The return value is HTML (or nil). + def object_link_filter(text, pattern, link_content_html: nil, link_reference: false) + references_in(text, pattern) do |match_text, id, project_ref, namespace_ref, matches| parent_path = if parent_type == :group reference_cache.full_group_path(namespace_ref) elsif parent_type == :namespace @@ -193,54 +182,51 @@ def object_link_filter(text, pattern, link_content: nil, link_reference: false) end parent = from_ref_cached(parent_path) + next unless parent - if parent - object = - if link_reference - find_object_from_link_cached(parent, id) - else - find_object_cached(parent, id) - end - end + object = + if link_reference + find_object_from_link_cached(parent, id) + else + find_object_cached(parent, id) + end - if object - title = object_link_title(object, matches) - klass = reference_class(object_sym) - - data_attributes = data_attributes_for( - link_content || match, - parent, - object, - link_content: !!link_content, - link_reference: link_reference - ) - data_attributes[:reference_format] = matches[:format] if matches.names.include?("format") - data_attributes.merge!(additional_object_attributes(object)) - - data = data_attribute(data_attributes) - - url = - if matches.names.include?("url") && matches[:url] - matches[:url] - else - url_for_object_cached(object, parent) - end + next unless object - url.chomp!(matches[:format]) if matches.names.include?("format") + title = object_link_title(object, matches) + klass = reference_class(object_sym) - content = context[:link_text] || link_content || object_link_text(object, matches) + data_attributes = data_attributes_for( + link_content_html || CGI.escapeHTML(match_text), + parent, + object, + link_content: !!link_content_html, + link_reference: link_reference + ) + data_attributes[:reference_format] = matches[:format] if matches.names.include?("format") + data_attributes.merge!(additional_object_attributes(object)) - link = write_opening_tag("a", { - "href" => url, - "title" => title, - "class" => klass, - **data - }) << content.to_s << "" + data = data_attribute(data_attributes) - wrap_link(link, object) - else - match - end + url = + if matches.names.include?("url") && matches[:url] + matches[:url] + else + url_for_object_cached(object, parent) + end + + url.chomp!(matches[:format]) if matches.names.include?("format") + + content = context[:link_text] || link_content_html || object_link_text(object, matches) + + link = write_opening_tag("a", { + "href" => url, + "title" => title, + "class" => klass, + **data + }) << content.to_s << "" + + wrap_link(link, object) end end @@ -248,7 +234,9 @@ def wrap_link(link, object) link end - def data_attributes_for(text, parent, object, link_content: false, link_reference: false) + # "link_content" is true when "original" is the inner HTML content, or false when "original" + # is HTML-escaped plain text representing the link as written. + def data_attributes_for(original, parent, object, link_content: false, link_reference: false) parent_id = case parent when Group { group: parent.id, namespace: parent.id } @@ -259,7 +247,7 @@ def data_attributes_for(text, parent, object, link_content: false, link_referenc end { - original: text, + original: original, link: link_content, link_reference: link_reference, object_sym => object.id @@ -306,14 +294,6 @@ def parent attr_accessor :reference_cache - def escape_with_placeholders(text, placeholder_data) - escaped = escape_html_entities(text) - - escaped.gsub(REFERENCE_PLACEHOLDER_PATTERN) do |match| - placeholder_data[Regexp.last_match(1).to_i] - end - end - def additional_object_attributes(object) {} end diff --git a/lib/banzai/filter/references/commit_range_reference_filter.rb b/lib/banzai/filter/references/commit_range_reference_filter.rb index 34ba4f7d4edb03361e85f0c1e7e063c026bc1720..38506972b34dba8049dda9c26ba2bd2a6710546d 100644 --- a/lib/banzai/filter/references/commit_range_reference_filter.rb +++ b/lib/banzai/filter/references/commit_range_reference_filter.rb @@ -11,7 +11,9 @@ class CommitRangeReferenceFilter < AbstractReferenceFilter self.object_class = CommitRange def references_in(text, pattern = object_reference_pattern) - Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| + replace_references_in_text_with_html( + Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, + limit: Banzai::Filter::FILTER_ITEM_LIMIT)) do |match_data| yield match_data[0], match_data[:commit_range], match_data[:project], match_data[:namespace], match_data end diff --git a/lib/banzai/filter/references/commit_reference_filter.rb b/lib/banzai/filter/references/commit_reference_filter.rb index 27d1fb52a3bf5683da20d27df233c9c8c315b9cc..685c40c280daaa7e1fa8d89fd8c2f8312b11b65c 100644 --- a/lib/banzai/filter/references/commit_reference_filter.rb +++ b/lib/banzai/filter/references/commit_reference_filter.rb @@ -11,7 +11,8 @@ class CommitReferenceFilter < AbstractReferenceFilter self.object_class = Commit def references_in(text, pattern = object_reference_pattern) - Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| + replace_references_in_text_with_html(Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, + limit: Banzai::Filter::FILTER_ITEM_LIMIT)) do |match_data| yield match_data[0], match_data[:commit], match_data[:project], match_data[:namespace], match_data end diff --git a/lib/banzai/filter/references/design_reference_filter.rb b/lib/banzai/filter/references/design_reference_filter.rb index db736b7328a20b59a472b350b1163dd101fa032f..edc84fe0a800cfa8057129bccf339634e27d495e 100644 --- a/lib/banzai/filter/references/design_reference_filter.rb +++ b/lib/banzai/filter/references/design_reference_filter.rb @@ -66,7 +66,7 @@ def url_for_object(design, project) Gitlab::Routing.url_helpers.designs_project_issue_path(project, design.issue, path_options) end - def data_attributes_for(_text, _project, design, **_kwargs) + def data_attributes_for(_original, _project, design, **_kwargs) super.merge(issue: design.issue_id) end diff --git a/lib/banzai/filter/references/external_issue_reference_filter.rb b/lib/banzai/filter/references/external_issue_reference_filter.rb index 6a4ee2e6a06174491fb01685736d3a2df91d10b5..d376356a05dabf09f0b0a3bae8a643af271a24a4 100644 --- a/lib/banzai/filter/references/external_issue_reference_filter.rb +++ b/lib/banzai/filter/references/external_issue_reference_filter.rb @@ -24,25 +24,30 @@ def self.default_issues_tracker?(project) # Public: Find `JIRA-123` issue references in text # - # references_in(text, pattern) do |match, issue| + # references_in(text, pattern) do |match_text, issue| # "##{issue}" # end # # text - String text to search. # - # Yields the String match and the String issue reference. + # Yields the String text match and the String issue reference. # - # Returns a String replaced with the return of the block. + # Returns a HTML String replaced with the return of the block. + # + # See ReferenceFilter#references_in for a detailed discussion. def references_in(text, pattern = object_reference_pattern) - case pattern - when Regexp - Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| - yield match_data[0], match_data[:issue] - end - when Gitlab::UntrustedRegexp - pattern.replace_gsub(text, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match| - yield match, match[:issue] + enumerator = + case pattern + when Regexp + Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) + when Gitlab::UntrustedRegexp + pattern.replace_gsub(text, limit: Banzai::Filter::FILTER_ITEM_LIMIT) + else + raise ArgumentError, "#{self.class.name} given #{pattern.class.name} pattern; should be Regexp or Gitlab::UntrustedRegexp" end + + replace_references_in_text_with_html(enumerator) do |match_data| + yield match_data[0], match_data[:issue] end end @@ -59,15 +64,17 @@ def call # issue's details page. # # text - String text to replace references in. - # link_content - Original content of the link being replaced. + # link_content_html - Original HTML content of the link being replaced. # - # Returns a String with `JIRA-123` references replaced with links. All + # Returns a HTML String with `JIRA-123` references replaced with links. All # links have `gfm` and `gfm-issue` class names attached for styling. - def object_link_filter(text, pattern, link_content: nil, link_reference: false) - references_in(text) do |match, id| + # + # Returns nil if no replacements are made. + def object_link_filter(text, pattern, link_content_html: nil, link_reference: false) + references_in(text) do |match_text, id| klass = reference_class(:issue) data = data_attribute(project: project.id, external_issue: id) - content = link_content || match + content = link_content_html || CGI.escapeHTML(match_text) write_opening_tag("a", { "title" => issue_title, diff --git a/lib/banzai/filter/references/issue_reference_filter.rb b/lib/banzai/filter/references/issue_reference_filter.rb index 3222f4eea9181b1b7071a97f7a7bbb2ef7f6e610..bb7b48cd79e8ce7e7c04960e26a4b4393ae0e5ae 100644 --- a/lib/banzai/filter/references/issue_reference_filter.rb +++ b/lib/banzai/filter/references/issue_reference_filter.rb @@ -38,7 +38,7 @@ def reference_class(object_sym, tooltip: false) super end - def data_attributes_for(text, parent, object, **data) + def data_attributes_for(original, parent, object, **data) additional_attributes = { iid: object.iid, namespace_path: parent.full_path } if parent.is_a?(Namespaces::ProjectNamespace) || parent.is_a?(Project) additional_attributes[:project_path] = parent.full_path diff --git a/lib/banzai/filter/references/label_reference_filter.rb b/lib/banzai/filter/references/label_reference_filter.rb index 10399f05631430c84def37f3ee495dad77e94a12..07ad15247528d04daeabcbe1c13db0f1b8b6ff86 100644 --- a/lib/banzai/filter/references/label_reference_filter.rb +++ b/lib/banzai/filter/references/label_reference_filter.rb @@ -70,23 +70,10 @@ def record_identifier(record) end def references_in(text, pattern = Label.reference_pattern) - labels = {} - - unescaped_html = unescape_html_entities(text).gsub(pattern).with_index do |match, index| - ident = identifier($~) - label = yield match, ident, $~[:project], $~[:namespace], $~ - - if label != match - labels[index] = label - "#{REFERENCE_PLACEHOLDER}#{index}" - else - match - end + replace_references_in_text_with_html(text.gsub(pattern)) do |match_data| + ident = identifier(match_data) + yield match_data[0], ident, match_data[:project], match_data[:namespace], match_data end - - return text if labels.empty? - - escape_with_placeholders(unescaped_html, labels) end def find_labels(parent, absolute_path: false) diff --git a/lib/banzai/filter/references/merge_request_reference_filter.rb b/lib/banzai/filter/references/merge_request_reference_filter.rb index 271c34fe4067481ea9a4c0734a62877b4ce7d0bb..9f11c5134ca8959e46034fec2a958f713a5347d2 100644 --- a/lib/banzai/filter/references/merge_request_reference_filter.rb +++ b/lib/banzai/filter/references/merge_request_reference_filter.rb @@ -52,7 +52,7 @@ def reference_class(object_sym, tooltip: false) super end - def data_attributes_for(text, parent, object, **data) + def data_attributes_for(original, parent, object, **data) super.merge(project_path: parent.full_path, iid: object.iid) end diff --git a/lib/banzai/filter/references/milestone_reference_filter.rb b/lib/banzai/filter/references/milestone_reference_filter.rb index fa1fd9baaf916ac011108011c157c0120a213686..df1d4595ce5530452218e4170ceee7f7a3c00488 100644 --- a/lib/banzai/filter/references/milestone_reference_filter.rb +++ b/lib/banzai/filter/references/milestone_reference_filter.rb @@ -58,7 +58,11 @@ def parse_symbol(symbol, match_data) # holds the iid { milestone_iid: symbol.to_i, milestone_name: nil, absolute_path: absolute_path } else - { milestone_iid: match_data[:milestone_iid]&.to_i, milestone_name: match_data[:milestone_name]&.tr('"', ''), absolute_path: absolute_path } + { + milestone_iid: match_data[:milestone_iid]&.to_i, + milestone_name: match_data[:milestone_name]&.tr('"', ''), + absolute_path: absolute_path + } end end @@ -87,23 +91,10 @@ def references_in(text, pattern = Milestone.reference_pattern) # default implementation. return super(text, pattern) if pattern != Milestone.reference_pattern - milestones = {} - - unescaped_html = unescape_html_entities(text).gsub(pattern).with_index do |match, index| - ident = identifier($~) - milestone = yield match, ident, $~[:project], $~[:namespace], $~ - - if milestone != match - milestones[index] = milestone - "#{REFERENCE_PLACEHOLDER}#{index}" - else - match - end + replace_references_in_text_with_html(text.gsub(pattern)) do |match_data| + ident = identifier(match_data) + yield match_data[0], ident, match_data[:project], match_data[:namespace], match_data end - - return text if milestones.empty? - - escape_with_placeholders(unescaped_html, milestones) end def find_milestones(parent, find_by_iid = false, absolute_path: false) @@ -159,7 +150,7 @@ def requires_unescaping? true end - def data_attributes_for(text, parent, object, link_content: false, link_reference: false) + def data_attributes_for(original, parent, object, link_content: false, link_reference: false) object_parent = object.resource_parent return super unless object_parent.is_a?(Group) diff --git a/lib/banzai/filter/references/project_reference_filter.rb b/lib/banzai/filter/references/project_reference_filter.rb index 52688a15245b75d3babd7496dbce8d39fc38680c..3805ac20ee0a8889dede3de5c7ad3079e30fd435 100644 --- a/lib/banzai/filter/references/project_reference_filter.rb +++ b/lib/banzai/filter/references/project_reference_filter.rb @@ -10,17 +10,20 @@ class ProjectReferenceFilter < ReferenceFilter # Public: Find `namespace/project>` project references in text # - # references_in(text) do |match, project| + # references_in(text) do |match_text, project| # "#{project}>" # end # # text - String text to search. # - # Yields the String match, and the String project name. + # Yields the String text match, and the String project name. # - # Returns a String replaced with the return of the block. + # Returns a HTML String with replacements made, or nil if no replacements were made. + # + # See ReferenceFilter#references_in for a detailed discussion. def references_in(text, pattern = object_reference_pattern) - Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) do |match_data| + replace_references_in_text_with_html(Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, + limit: Banzai::Filter::FILTER_ITEM_LIMIT)) do |match_data| yield match_data[0], "#{match_data[:namespace]}/#{match_data[:project]}" end end @@ -35,17 +38,15 @@ def object_reference_pattern # project page. # # text - String text to replace references in. - # link_content - Original content of the link being replaced. + # link_content_html - Original HTML content of the link being replaced. # - # Returns a String with `namespace/project>` references replaced with links. All links - # have `gfm` and `gfm-project` class names attached for styling. - def object_link_filter(text, pattern, link_content: nil, link_reference: false) - references_in(text) do |match, project_path| - cached_call(:banzai_url_for_object, match, path: [Project, project_path.downcase]) do + # Returns a String HTML with `namespace/project>` references replaced with links, or nil + # if no replacements were made. All links have `gfm` and `gfm-project` class names attached for styling. + def object_link_filter(text, pattern, link_content_html: nil, link_reference: false) + references_in(text) do |match_text, project_path| + cached_call(:banzai_url_for_object, match_text, path: [Project, project_path.downcase]) do if project = projects_hash[project_path.downcase] - link_to_project(project, link_content: link_content) || match - else - match + link_to_project(project, link_content_html: link_content_html) end end end @@ -85,10 +86,10 @@ def link_class reference_class(:project) end - def link_to_project(project, link_content: nil) + def link_to_project(project, link_content_html: nil) url = urls.project_url(project, only_path: context[:only_path]) data = data_attribute(project: project.id) - content = link_content || project.to_reference + content = link_content_html || CGI.escapeHTML(project.to_reference) write_opening_tag("a", { "href" => url, diff --git a/lib/banzai/filter/references/reference_cache.rb b/lib/banzai/filter/references/reference_cache.rb index 97bb24376c00fa6e21342a832aed56445e483000..82525a978f1ca2d49e9ab80706eebc3482ec0fcb 100644 --- a/lib/banzai/filter/references/reference_cache.rb +++ b/lib/banzai/filter/references/reference_cache.rb @@ -203,16 +203,12 @@ def refs_cache end def prepare_doc_for_scan - filter.requires_unescaping? ? unescape_html_entities(html_content) : html_content + filter.requires_unescaping? ? CGI.unescapeHTML(html_content) : html_content end def html_content result[:rendered_html] ||= filter.doc.to_html end - - def unescape_html_entities(text) - CGI.unescapeHTML(text.to_s) - end end end end diff --git a/lib/banzai/filter/references/reference_filter.rb b/lib/banzai/filter/references/reference_filter.rb index 07916f69e60f01a16d9b59fa598d7bcd10871a99..41d2220498df59ca646fb87b5b0a511b48dfe5f0 100644 --- a/lib/banzai/filter/references/reference_filter.rb +++ b/lib/banzai/filter/references/reference_filter.rb @@ -20,6 +20,13 @@ class ReferenceFilter < HTML::Pipeline::Filter REFERENCE_TYPE_ATTRIBUTE = :reference_type REFERENCE_TYPE_DATA_ATTRIBUTE_NAME = "data-#{REFERENCE_TYPE_ATTRIBUTE.to_s.dasherize}".freeze + # REFERENCE_PLACEHOLDER is used for replacement HTML to be inserted during + # reference matching. The random string helps ensure it's pretty close to unique. + # Since it's a transitory value (it never leaves the filter) we can initialize once, + # and it doesn't matter if it changes on a restart. + REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_".freeze + REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)} + class << self # Implement in child class # Example: self.reference_type = :merge_request @@ -50,15 +57,14 @@ def call nodes.each_with_index do |node, index| if text_node?(node) - replace_text_when_pattern_matches(node, index, object_reference_pattern) do |content| + replace_node_when_text_matches(node, index, object_reference_pattern) do |content| object_link_filter(content, object_reference_pattern) end elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| + yield_valid_link(node) do |link, _text, inner_html| if ref_pattern_start.match?(link) - replace_link_node_with_href(node, index, link) do - object_link_filter(link, ref_pattern_start, link_content: inner_html) - end + html = object_link_filter(link, ref_pattern_start, link_content_html: inner_html) + replace_node_with_html(node, index, html) if html end end end @@ -67,23 +73,107 @@ def call doc end - # Public: Find references in text (like `!123` for merge requests) + # Public: Find references in text (like `!123` for merge requests). + # + # Simplified usage (see AbstractReferenceFilter#object_link_filter for a full example): # - # references_in(text) do |match, id, project_ref, matches| - # object = find_object(project_ref, id) - # "#{object.to_reference}" + # references_in(text) do |match_text, id, project_ref, namespace_ref, matches| + # object = find_object(namespace_ref, project_ref, id) + # if object + # write_opening_tag("a", { + # "href" => "...", + # # ... other attributes ... + # }) << CGI.escapeHTML(object.to_reference) << "" + # else + # CGI.escapeHTML(match_text) + # end # end # - # text - String text to search. + # text - String text to make replacements in. This is the content of a DOM text + # node, and *cannot* be returned without modification. + # + # Yields each String text match as the first argument; the remaining arguments + # depend on the particular subclass of ReferenceFilter being used. The block + # must return HTML, and not text. + # + # Returns a HTML String replaced with replacements made, or nil if no replacements + # were made. + # + # Notes: + # + # * The input is text -- we expect to match e.g. &1 or %"Git 2.5" without having to + # deal with HTML entities, as our object reference patterns are not compatible + # with entities. # - # Yields the String match, the Integer referenced object ID, an optional String - # of the external project reference, and all of the matchdata. + # * Likewise, any strings yielded to the block are text, but the block must return + # HTML, whether it's a successful resolution or not. Any text input used in the + # block must be escaped for return. # - # Returns a String replaced with the return of the block. + # * The return value is HTML, as the whole point is to create links (and possibly other + # elements). Care must be taken that any input text is escaped before reaching + # the output. This must be respected in all subclasses of ReferenceFilter. + # + # See AbstractReferenceFilter#references_in for a reference implementation that safely + # handles user input. def references_in(text, pattern = object_reference_pattern) raise NotImplementedError, "#{self.class} must implement method: #{__callee__}" end + # A helper to make it easier to implement #references_in correctly. + # + # Pass in the replacement Enumerator of choice, on *plain text* input. + # This will often look like this: + # + # Gitlab::Utils::Gsub.gsub_with_limit(text, pattern, limit: Banzai::Filter::FILTER_ITEM_LIMIT) + # + # Or the simpler: + # + # text.gsub(pattern) + # + # In both cases, the subject we're performing replacements on is text, not HTML. + # + # This function will yield MatchData from the underlying enumerator -- $~ if it's available, + # otherwise the yield from the enumerator itself is asserted to be MatchData or RE2::MatchData. + # The given block should return HTML (!) to substitute in place, or nil if no replacement + # is to be made. + # + # The function returns HTML, safely substituting the block results in, while escaping all + # other text. The supplied block MUST escape any input text returned within it, if any. + # + # If no replacements were made --- that is, the enumerator didn't yield anything, or the block + # given to this function only returned nil --- nil is returned. + def replace_references_in_text_with_html(enumerator) + replacements = {} + + # We operate on text, yield text, and substitute back in only text. + replaced_text = enumerator.with_index do |enumerator_yielded, index| + match_data = $~ || enumerator_yielded + unless match_data.is_a?(MatchData) || match_data.is_a?(RE2::MatchData) + raise ArgumentError, "enumerator didn't yield MatchData (is #{match_data.inspect}) and $~ is unavailable" + end + + replacement = yield match_data + if replacement + # The yield returns HTML to us, but we can't substitute it back in yet --- there + # remains unescaped, textual content in unmatched parts of the string which we + # need to escape without affecting the block yields. Instead, store the result, + # and substitute back into the text a placeholder we can replace after escaping. + replacements[index] = replacement + "#{REFERENCE_PLACEHOLDER}#{index}" + else + match_data.to_s + end + end + + return unless replacements.any? + + # Escape the replaced_text --- which doesn't change the placeholders --- and only then + # replace the placeholders with the yielded HTML. + CGI.escapeHTML(replaced_text).gsub(REFERENCE_PLACEHOLDER_PATTERN) do + replacements[Regexp.last_match(1).to_i] + end + end + # Iterates over all and text() nodes in a document. # # Nodes are skipped whenever their ancestor is one of the nodes returned @@ -184,50 +274,44 @@ def reference_class(type, tooltip: true) "#{gfm_klass} has-tooltip" end - # Yields the link's URL and inner HTML whenever the node is a valid tag. + # Yields the link's URL, inner text, and inner HTML whenever the node is a valid tag. + # + # We expect that the URL and inner *text* will be equal when the link was a result of + # an autolink in Markdown. For example: + # + # ```markdown + #foo
'
+
it 'does not replace redacted reference with original content if href is given' do
html = "Marge"
doc = Nokogiri::HTML.fragment(html)
diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb
index e12a3b61001a77f95c2314a6550d89531cd66452..c967e8657a4295c87d2ca6a725b8beceb21ef904 100644
--- a/spec/mailers/emails/service_desk_spec.rb
+++ b/spec/mailers/emails/service_desk_spec.rb
@@ -342,12 +342,11 @@
context 'with template' do
context 'with all-user reference in a an external author comment' do
let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "Hey @all, just a ping", author: Users::Internal.support_bot) }
+ let(:expected_template_html) { 'Hey @all, just a ping' }
let(:template_content) { 'some text %{ NOTE_TEXT }' }
context 'when `disable_all_mention` is disabled' do
- let(:expected_template_html) { 'Hey , just a ping' }
-
before do
stub_feature_flags(disable_all_mention: false)
end
@@ -356,8 +355,6 @@
end
context 'when `disable_all_mention` is enabled' do
- let(:expected_template_html) { 'Hey @all, just a ping' }
-
before do
stub_feature_flags(disable_all_mention: true)
end
diff --git a/spec/requests/projects/incident_management/timeline_events_spec.rb b/spec/requests/projects/incident_management/timeline_events_spec.rb
index 8b866dcea5dda73f8e8844bc7ef502d049f9f301..587eb558d7959c42028517874df6d2f086959826 100644
--- a/spec/requests/projects/incident_management/timeline_events_spec.rb
+++ b/spec/requests/projects/incident_management/timeline_events_spec.rb
@@ -38,8 +38,9 @@
params: { text: timeline_text }
expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response["body"]).to eq_html(expected_body)
expect(json_response).to eq({
- body: expected_body,
+ body: json_response["body"],
references: {
commands: '',
suggestions: [],
diff --git a/spec/support/matchers/eq_html.rb b/spec/support/matchers/eq_html.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ab1c9dcf846cc2c106a52d5e334a8154c440e8c1
--- /dev/null
+++ b/spec/support/matchers/eq_html.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+# Assert that the two values are the same in HTML context, modulo escaping operations that
+# do not change the meaning of the value --- i.e. do not change how a web browser or HTML parser
+# would treat them.
+#
+# This means, e.g. '"' and '"' are the same when encountered outside an HTML tag,
+# but '<b>' and '' are not.
+#
+# Likewise, '' and
+# "" are considered the same.
+#
+# This matcher helps tests be less brittle or flakey in the face of changing attribute orders,
+# and less repetitive in the face of comparing essentially unchanged values that nonetheless
+# are represented differently.
+#
+# Note that it uses a real HTML parser internally so as not to introduce security issues
+# through accidental de-escaping or any of the other myriad ways HTML can be messed with.
+#
+# ---
+#
+# The option "trim_text_nodes: true" can be given. This trims whitespace at the start and
+# end of the text content of text nodes on each side. This should be used with care, but can
+# aid in spec clarity by allowing expected DOMs to be written "prettified".
+RSpec::Matchers.define :eq_html do |expected, **normalize_opts|
+ include EqHtmlMatcher
+
+ match do |actual|
+ raise ArgumentError, "expected should be a String, not #{expected.class}" unless expected.is_a?(String)
+ raise ArgumentError, "actual should be a String, not #{actual.class}" unless actual.is_a?(String)
+
+ normalize_html(actual, **normalize_opts) == normalize_html(expected, **normalize_opts)
+ end
+
+ failure_message do |actual|
+ "Expected that\n\n #{actual}\n\nwould normalize to the same HTML as\n\n #{expected}\n\nbut it didn't.\n\n " \
+ "#{normalize_html(actual, **normalize_opts).inspect}\n!=\n " \
+ "#{normalize_html(expected, **normalize_opts).inspect}"
+ end
+
+ failure_message_when_negated do |actual|
+ "Expected that\n\n #{actual}\n\nwould NOT normalize to the same HTML as\n\n #{expected}\n\nbut it did!\n\n " \
+ "#{normalize_html(actual, **normalize_opts).inspect}\n==\n " \
+ "#{normalize_html(expected, **normalize_opts).inspect}"
+ end
+end
+
+# Assert the right-hand value is contained in the left-hand value after normalising HTML on both sides per eq_html.
+# See eq_html's docstring for details on what this means.
+#
+# Note this implies you can only check for inclusion of bare text (outside tags) and whole tags; a
+# partial tag will normalise to text (""
+# includes the HTML "node inside' }
- let(:reference_with_element) { %(#{inner_html}) }
-
- it 'does not escape inner html' do
- doc = reference_filter(reference_with_element, try(:context) || {})
-
- expect(doc.children.first.children.first.inner_html).to eq(inner_html)
- end
-end
-
-# Requires a reference, subject and subject_name:
-# subject { create(:user) }
-# let(:reference) { subject.to_reference }
-# let(:subject_name) { 'user' }
-RSpec.shared_examples 'user reference or project reference' do
- shared_examples 'it contains a data- attribute' do
- it 'includes a data- attribute' do
- doc = reference_filter("Hey #{reference}")
- link = doc.css('a').first
-
- expect(link).to have_attribute("data-#{subject_name}")
- expect(link.attr("data-#{subject_name}")).to eq subject.id.to_s
- end
- end
-
- context 'when mentioning a resource' do
- it_behaves_like 'a reference containing an element node'
- it_behaves_like 'it contains a data- attribute'
-
- it "links to a resource" do
- doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.send("#{subject_name}_url", subject)
- end
-
- it 'links to a resource with a period' do
- subject = create(subject_name.to_sym, name: 'alphA.Beta')
-
- doc = reference_filter("Hey #{get_reference(subject)}")
- expect(doc.css('a').length).to eq 1
- end
-
- it 'links to a resource with an underscore' do
- subject = create(subject_name.to_sym, name: 'ping_pong_king')
-
- doc = reference_filter("Hey #{get_reference(subject)}")
- expect(doc.css('a').length).to eq 1
- end
-
- it 'links to a resource with different case-sensitivity' do
- subject = create(subject_name.to_sym, name: 'RescueRanger')
- reference = get_reference(subject)
-
- doc = reference_filter("Hey #{reference.upcase}")
- expect(doc.css('a').length).to eq 1
- expect(doc.css('a').text).to eq(reference)
- end
- end
-
- it 'supports an :only_path context' do
- doc = reference_filter("Hey #{reference}", only_path: true)
- link = doc.css('a').first.attr('href')
-
- expect(link).not_to match %r{https?://}
- expect(link).to eq urls.send "#{subject_name}_path", subject
- end
-
- describe 'referencing a resource in a link href' do
- let(:reference) { %(Some text) }
-
- it_behaves_like 'it contains a data- attribute'
-
- it 'links to the resource' do
- doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('href')).to eq urls.send "#{subject_name}_url", subject
- end
-
- it 'links with adjacent text' do
- doc = reference_filter("Mention me (#{reference}.)")
- expect(doc.to_html).to match(%r{\(node
inside' }
+ let(:reference_with_element) { %(#{inner_html}) }
+
+ it 'does not escape inner html' do
+ doc = reference_filter(reference_with_element, try(:context) || {})
+
+ expect(doc.children.first.children.first.inner_html).to eq(inner_html)
+ end
+end
+
+# Requires a reference, subject and subject_name:
+# subject { create(:user) }
+# let(:reference) { subject.to_reference }
+# let(:subject_name) { 'user' }
+RSpec.shared_examples 'user reference or project reference' do
+ shared_examples 'it contains a data- attribute' do
+ it 'includes a data- attribute' do
+ doc = reference_filter("Hey #{reference}")
+ link = doc.css('a').first
+
+ expect(link).to have_attribute("data-#{subject_name}")
+ expect(link.attr("data-#{subject_name}")).to eq subject.id.to_s
+ end
+ end
+
+ context 'when mentioning a resource' do
+ it_behaves_like 'a reference containing an element node'
+ it_behaves_like 'it contains a data- attribute'
+
+ it "links to a resource" do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('href')).to eq urls.send(:"#{subject_name}_url", subject)
+ end
+
+ it 'links to a resource with a period' do
+ subject = create(subject_name.to_sym, name: 'alphA.Beta')
+
+ doc = reference_filter("Hey #{get_reference(subject)}")
+ expect(doc.css('a').length).to eq 1
+ end
+
+ it 'links to a resource with an underscore' do
+ subject = create(subject_name.to_sym, name: 'ping_pong_king')
+
+ doc = reference_filter("Hey #{get_reference(subject)}")
+ expect(doc.css('a').length).to eq 1
+ end
+
+ it 'links to a resource with different case-sensitivity' do
+ subject = create(subject_name.to_sym, name: 'RescueRanger')
+ reference = get_reference(subject)
+
+ doc = reference_filter("Hey #{reference.upcase}")
+ expect(doc.css('a').length).to eq 1
+ expect(doc.css('a').text).to eq(reference)
+ end
+ end
+
+ it 'supports an :only_path context' do
+ doc = reference_filter("Hey #{reference}", only_path: true)
+ link = doc.css('a').first.attr('href')
+
+ expect(link).not_to match %r{https?://}
+ expect(link).to eq urls.send :"#{subject_name}_path", subject
+ end
+
+ describe 'referencing a resource in a link href' do
+ let(:reference) { %(Some text) }
+
+ it_behaves_like 'it contains a data- attribute'
+
+ it 'links to the resource' do
+ doc = reference_filter("Hey #{reference}")
+ expect(doc.css('a').first.attr('href')).to eq urls.send :"#{subject_name}_url", subject
+ end
+
+ it 'links with adjacent text' do
+ doc = reference_filter("Mention me (#{reference}.)")
+ expect(doc.to_html).to match(%r{\(