diff options
30 files changed, 2124 insertions, 870 deletions
diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index aa1de2f50ef..aff7011edd0 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -74,6 +74,7 @@ module GitlabMarkdownHelper end end + # TODO (rspeicher): This should be its own filter def create_relative_links(text) paths = extract_paths(text) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index ad4a7612724..c3b4731dff3 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -108,4 +108,7 @@ module IssuesHelper xml.summary issue.title end end + + # Required for Gitlab::Markdown::IssueReferenceFilter + module_function :url_for_issue, :title_for_issue end diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 32ef2e7ca84..8272c177d59 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -1,4 +1,6 @@ module LabelsHelper + include ActionView::Helpers::TagHelper + def project_label_names @project.labels.pluck(:title) end @@ -7,9 +9,13 @@ module LabelsHelper label_color = label.color || Label::DEFAULT_COLOR text_color = text_color_for_bg(label_color) - content_tag :span, class: 'label color-label', style: "background-color:#{label_color};color:#{text_color}" do - label.name - end + # Intentionally not using content_tag here so that this method can be called + # by LabelReferenceFilter + span = %(<span class="label color-label") + + %( style="background-color: #{label_color}; color: #{text_color}">) + + escape_once(label.name) + '</span>' + + span.html_safe end def suggested_colors @@ -42,13 +48,16 @@ module LabelsHelper r, g, b = bg_color.slice(1,7).scan(/.{2}/).map(&:hex) if (r + g + b) > 500 - "#333" + '#333333' else - "#FFF" + '#FFFFFF' end end def project_labels_options(project) options_from_collection_for_select(project.labels, 'name', 'name', params[:label_name]) end + + # Required for Gitlab::Markdown::LabelReferenceFilter + module_function :render_colored_label, :text_color_for_bg, :escape_once end diff --git a/app/models/label.rb b/app/models/label.rb index 1f22ed23d42..eee28acefc1 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -27,7 +27,7 @@ class Label < ActiveRecord::Base # Don't allow '?', '&', and ',' for label titles validates :title, presence: true, - format: { with: /\A[^&\?,&]+\z/ }, + format: { with: /\A[^&\?,]+\z/ }, uniqueness: { scope: :project_id } default_scope { order(title: :asc) } diff --git a/doc/markdown/markdown.md b/doc/markdown/markdown.md index 1d5fd4c8b0d..8ec5a20035f 100644 --- a/doc/markdown/markdown.md +++ b/doc/markdown/markdown.md @@ -163,7 +163,7 @@ Consult the [Emoji Cheat Sheet](http://emoji.codes) for a list of all supported ## Special GitLab References -GFM recognized special references. +GFM recognizes special references. You can easily reference e.g. an issue, a commit, a team member or even the whole team within a project. @@ -171,19 +171,30 @@ GFM will turn that reference into a link so you can navigate between them easily GFM will recognize the following: -- @foo : for specific team members or groups -- @all : for the whole team -- #123 : for issues -- !123 : for merge requests -- $123 : for snippets -- 1234567 : for commits -- \[file\](path/to/file) : for file references - -GFM also recognizes references to commits, issues, and merge requests in other projects: - -- namespace/project#123 : for issues -- namespace/project!123 : for merge requests -- namespace/project@1234567 : for commits +| input | references | +|-----------------------:|:---------------------------| +| `@user_name` | specific user | +| `@group_name` | specific group | +| `@all` | entire team | +| `#123` | issue | +| `!123` | merge request | +| `$123` | snippet | +| `~123` | label by ID | +| `~bug` | one-word label by name | +| `~"feature request"` | multi-word label by name | +| `9ba12248` | specific commit | +| `9ba12248...b19a04f5` | commit range comparison | +| `[README](doc/README)` | repository file references | + +GFM also recognizes certain cross-project references: + +| input | references | +|----------------------------------------:|:------------------------| +| `namespace/project#123` | issue | +| `namespace/project!123` | merge request | +| `namespace/project$123` | snippet | +| `namespace/project@9ba12248` | specific commit | +| `namespace/project@9ba12248...b19a04f5` | commit range comparison | ## Task Lists diff --git a/lib/gitlab/markdown.rb b/lib/gitlab/markdown.rb index 47c456d8dc7..f2302015437 100644 --- a/lib/gitlab/markdown.rb +++ b/lib/gitlab/markdown.rb @@ -10,11 +10,11 @@ module Gitlab # Supported reference formats are: # * @foo for team members # * #123 for issues - # * #JIRA-123 for Jira issues + # * JIRA-123 for Jira issues # * !123 for merge requests # * $123 for snippets - # * 123456 for commits - # * 123456...7890123 for commit ranges (comparisons) + # * 1c002d for specific commit + # * 1c002d...35cfb2 for commit ranges (comparisons) # # It also parses Emoji codes to insert images. See # http://www.emoji-cheat-sheet.com/ for a list of the supported icons. @@ -30,10 +30,6 @@ module Gitlab # >> gfm(":trollface:") # => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" /> module Markdown - include IssuesHelper - - attr_reader :options, :html_options - # Public: Parse the provided text with GitLab-Flavored Markdown # # text - the source text @@ -65,42 +61,14 @@ module Gitlab reference_only_path: true ) - @options = options - @html_options = html_options - - # TODO: add popups with additional information - - # Used markdown pipelines in GitLab: - # GitlabEmojiFilter - performs emoji replacement. - # SanitizationFilter - remove unsafe HTML tags and attributes - # - # see https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters - filters = [ - HTML::Pipeline::Gitlab::GitlabEmojiFilter, - HTML::Pipeline::SanitizationFilter - ] - - whitelist = HTML::Pipeline::SanitizationFilter::WHITELIST - whitelist[:attributes][:all].push('class', 'id') - whitelist[:elements].push('span') - - # Remove the rel attribute that the sanitize gem adds, and remove the - # href attribute if it contains inline javascript - fix_anchors = lambda do |env| - name, node = env[:node_name], env[:node] - if name == 'a' - node.remove_attribute('rel') - if node['href'] && node['href'].match('javascript:') - node.remove_attribute('href') - end - end - end - whitelist[:transformers].push(fix_anchors) - markdown_context = { - asset_root: Gitlab.config.gitlab.url, - asset_host: Gitlab::Application.config.asset_host, - whitelist: whitelist + asset_root: Gitlab.config.gitlab.url, + asset_host: Gitlab::Application.config.asset_host, + whitelist: sanitization_whitelist, + reference_class: html_options[:class], + only_path: options[:reference_only_path], + current_user: current_user, + project: project } markdown_pipeline = HTML::Pipeline::Gitlab.new(filters).pipeline @@ -114,21 +82,6 @@ module Gitlab text = result[:output].to_html(save_with: save_options) - # Extract pre blocks so they are not altered - # from http://github.github.com/github-flavored-markdown/ - text.gsub!(%r{<pre>.*?</pre>|<code>.*?</code>}m) { |match| extract_piece(match) } - # Extract links with probably parsable hrefs - text.gsub!(%r{<a.*?>.*?</a>}m) { |match| extract_piece(match) } - # Extract images with probably parsable src - text.gsub!(%r{<img.*?>}m) { |match| extract_piece(match) } - - text = parse(text, project) - - # Insert pre block extractions - text.gsub!(/\{gfm-extraction-(\h{32})\}/) do - insert_piece($1) - end - if options[:parse_tasks] text = parse_tasks(text) end @@ -138,242 +91,53 @@ module Gitlab private - def extract_piece(text) - @extractions ||= {} - - md5 = Digest::MD5.hexdigest(text) - @extractions[md5] = text - "{gfm-extraction-#{md5}}" - end - - def insert_piece(id) - @extractions[id] - end - - # Private: Parses text for references + # Custom filters for html-pipeline: # - # text - Text to parse + # SanitizationFilter should come first so that all generated reference HTML + # goes through untouched. # - # Returns parsed text - def parse(text, project = @project) - parse_references(text, project) if project - - text - end - - NAME_STR = Gitlab::Regex::NAMESPACE_REGEX_STR - PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})" - - REFERENCE_PATTERN = %r{ - (?<prefix>\W)? # Prefix - ( # Reference - @(?<user>#{NAME_STR}) # User name - |~(?<label>\d+) # Label ID - |(?<issue>([A-Z\-]+-)\d+) # JIRA Issue ID - |#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID - |#{PROJ_STR}?!(?<merge_request>\d+) # MR ID - |\$(?<snippet>\d+) # Snippet ID - |(#{PROJ_STR}@)?(?<commit_range>[\h]{6,40}\.{2,3}[\h]{6,40}) # Commit range - |(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID - |(?<skip>gfm-extraction-[\h]{6,40}) # Skip gfm extractions. Otherwise will be parsed as commit - ) - (?<suffix>\W)? # Suffix - }x.freeze - - TYPES = [:user, :issue, :label, :merge_request, :snippet, :commit, :commit_range].freeze - - def parse_references(text, project = @project) - # parse reference links - text.gsub!(REFERENCE_PATTERN) do |match| - type = TYPES.select{|t| !$~[t].nil?}.first - - actual_project = project - project_prefix = nil - project_path = $LAST_MATCH_INFO[:project] - if project_path - actual_project = ::Project.find_with_namespace(project_path) - actual_project = nil unless can?(current_user, :read_project, actual_project) - project_prefix = project_path - end - - parse_result($LAST_MATCH_INFO, type, - actual_project, project_prefix) || match - end - end - - # Called from #parse_references. Attempts to build a gitlab reference - # link. Returns nil if +type+ is nil, if the match string is an HTML - # entity, if the reference is invalid, or if the matched text includes an - # invalid project path. - def parse_result(match_info, type, project, project_prefix) - prefix = match_info[:prefix] - suffix = match_info[:suffix] - - return nil if html_entity?(prefix, suffix) || type.nil? - return nil if project.nil? && !project_prefix.nil? - - identifier = match_info[type] - ref_link = reference_link(type, identifier, project, project_prefix) - - if ref_link - "#{prefix}#{ref_link}#{suffix}" - else - nil - end - end - - # Return true if the +prefix+ and +suffix+ indicate that the matched string - # is an HTML entity like & - def html_entity?(prefix, suffix) - prefix && suffix && prefix[0] == '&' && suffix[-1] == ';' + # See https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters + def filters + [ + HTML::Pipeline::SanitizationFilter, + + Gitlab::Markdown::UserReferenceFilter, + Gitlab::Markdown::IssueReferenceFilter, + Gitlab::Markdown::ExternalIssueReferenceFilter, + Gitlab::Markdown::MergeRequestReferenceFilter, + Gitlab::Markdown::SnippetReferenceFilter, + Gitlab::Markdown::CommitRangeReferenceFilter, + Gitlab::Markdown::CommitReferenceFilter, + Gitlab::Markdown::LabelReferenceFilter, + + HTML::Pipeline::Gitlab::GitlabEmojiFilter + ] end - # Private: Dispatches to a dedicated processing method based on reference - # - # reference - Object reference ("@1234", "!567", etc.) - # identifier - Object identifier (Issue ID, SHA hash, etc.) + # Customize the SanitizationFilter whitelist # - # Returns string rendered by the processing method - def reference_link(type, identifier, project = @project, prefix_text = nil) - send("reference_#{type}", identifier, project, prefix_text) - end - - def reference_user(identifier, project = @project, _ = nil) - link_options = html_options.merge( - class: "gfm gfm-project_member #{html_options[:class]}" - ) + # - Allow `class` and `id` attributes on all elements + # - Allow `span` elements + # - Remove `rel` attributes from `a` elements + # - Remove `a` nodes with `javascript:` in the `href` attribute + def sanitization_whitelist + whitelist = HTML::Pipeline::SanitizationFilter::WHITELIST + whitelist[:attributes][:all].push('class', 'id') + whitelist[:elements].push('span') - if identifier == "all" - link_to( - "@all", - namespace_project_url(project.namespace, project, only_path: options[:reference_only_path]), - link_options - ) - elsif namespace = Namespace.find_by(path: identifier) - url = - if namespace.is_a?(Group) - return nil unless can?(current_user, :read_group, namespace) - group_url(identifier, only_path: options[:reference_only_path]) - else - user_url(identifier, only_path: options[:reference_only_path]) + fix_anchors = lambda do |env| + name, node = env[:node_name], env[:node] + if name == 'a' + node.remove_attribute('rel') + if node['href'] && node['href'].match('javascript:') + node.remove_attribute('href') end - - link_to("@#{identifier}", url, link_options) - end - end - - def reference_label(identifier, project = @project, _ = nil) - if label = project.labels.find_by(id: identifier) - link_options = html_options.merge( - class: "gfm gfm-label #{html_options[:class]}" - ) - link_to( - render_colored_label(label), - namespace_project_issues_path(project.namespace, project, label_name: label.name), - link_options - ) - end - end - - def reference_issue(identifier, project = @project, prefix_text = nil) - if project.default_issues_tracker? - if project.issue_exists? identifier - url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) - title = title_for_issue(identifier, project) - link_options = html_options.merge( - title: "Issue: #{title}", - class: "gfm gfm-issue #{html_options[:class]}" - ) - - link_to("#{prefix_text}##{identifier}", url, link_options) - end - else - if project.external_issue_tracker.present? - reference_external_issue(identifier, project, - prefix_text) end end - end - - def reference_merge_request(identifier, project = @project, prefix_text = nil) - if merge_request = project.merge_requests.find_by(iid: identifier) - link_options = html_options.merge( - title: "Merge Request: #{merge_request.title}", - class: "gfm gfm-merge_request #{html_options[:class]}" - ) - url = namespace_project_merge_request_url(project.namespace, project, - merge_request, - only_path: options[:reference_only_path]) - link_to("#{prefix_text}!#{identifier}", url, link_options) - end - end - - def reference_snippet(identifier, project = @project, _ = nil) - if snippet = project.snippets.find_by(id: identifier) - link_options = html_options.merge( - title: "Snippet: #{snippet.title}", - class: "gfm gfm-snippet #{html_options[:class]}" - ) - link_to( - "$#{identifier}", - namespace_project_snippet_url(project.namespace, project, snippet, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_commit(identifier, project = @project, prefix_text = nil) - if project.valid_repo? && commit = project.repository.commit(identifier) - link_options = html_options.merge( - title: commit.link_title, - class: "gfm gfm-commit #{html_options[:class]}" - ) - prefix_text = "#{prefix_text}@" if prefix_text - link_to( - "#{prefix_text}#{identifier}", - namespace_project_commit_url( project.namespace, project, commit, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_commit_range(identifier, project = @project, prefix_text = nil) - from_id, to_id = identifier.split(/\.{2,3}/, 2) - - inclusive = identifier !~ /\.{3}/ - from_id << "^" if inclusive - - if project.valid_repo? && - from = project.repository.commit(from_id) && - to = project.repository.commit(to_id) - - link_options = html_options.merge( - title: "Commits #{from_id} through #{to_id}", - class: "gfm gfm-commit_range #{html_options[:class]}" - ) - prefix_text = "#{prefix_text}@" if prefix_text - link_to( - "#{prefix_text}#{identifier}", - namespace_project_compare_url(project.namespace, project, - from: from_id, to: to_id, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_external_issue(identifier, project = @project, prefix_text = nil) - url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) - title = project.external_issue_tracker.title + whitelist[:transformers].push(fix_anchors) - link_options = html_options.merge( - title: "Issue in #{title}", - class: "gfm gfm-issue #{html_options[:class]}" - ) - link_to("#{prefix_text}##{identifier}", url, link_options) + whitelist end # Turn list items that start with "[ ]" into HTML checkbox inputs. diff --git a/lib/gitlab/markdown/commit_range_reference_filter.rb b/lib/gitlab/markdown/commit_range_reference_filter.rb new file mode 100644 index 00000000000..1128c1bed7a --- /dev/null +++ b/lib/gitlab/markdown/commit_range_reference_filter.rb @@ -0,0 +1,105 @@ +module Gitlab + module Markdown + # HTML filter that replaces commit range references with links. + # + # This filter supports cross-project references. + class CommitRangeReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find commit range references in text + # + # CommitRangeReferenceFilter.references_in(text) do |match, commit_range, project_ref| + # "<a href=...>#{commit_range}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the String commit range, and an optional String + # of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(COMMIT_RANGE_PATTERN) do |match| + yield match, $~[:commit_range], $~[:project] + end + end + + def initialize(*args) + super + + @commit_map = {} + end + + # Pattern used to extract commit range references from text + # + # The beginning and ending SHA1 sums can be between 6 and 40 hex + # characters, and the range selection can be double- or triple-dot. + # + # This pattern supports cross-project references. + COMMIT_RANGE_PATTERN = /(#{PROJECT_PATTERN}@)?(?<commit_range>\h{6,40}\.{2,3}\h{6,40})/ + + def call + replace_text_nodes_matching(COMMIT_RANGE_PATTERN) do |content| + commit_range_link_filter(content) + end + end + + # Replace commit range references in text with links to compare the commit + # ranges. + # + # text - String text to replace references in. + # + # Returns a String with commit range references replaced with links. All + # links have `gfm` and `gfm-commit_range` class names attached for + # styling. + def commit_range_link_filter(text) + self.class.references_in(text) do |match, commit_range, project_ref| + project = self.project_from_ref(project_ref) + + from_id, to_id = split_commit_range(commit_range) + + if valid_range?(project, from_id, to_id) + url = url_for_commit_range(project, from_id, to_id) + + title = "Commits #{from_id} through #{to_id}" + klass = reference_class(:commit_range) + + project_ref += '@' if project_ref + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}#{commit_range}</a>) + else + match + end + end + end + + def split_commit_range(range) + from_id, to_id = range.split(/\.{2,3}/, 2) + from_id << "^" if range !~ /\.{3}/ + + [from_id, to_id] + end + + def commit(id) + unless @commit_map[id] + @commit_map[id] = project.repository.commit(id) + end + + @commit_map[id] + end + + def valid_range?(project, from_id, to_id) + project && project.valid_repo? && commit(from_id) && commit(to_id) + end + + def url_for_commit_range(project, from_id, to_id) + h = Rails.application.routes.url_helpers + h.namespace_project_compare_url(project.namespace, project, + from: from_id, to: to_id, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/commit_reference_filter.rb b/lib/gitlab/markdown/commit_reference_filter.rb new file mode 100644 index 00000000000..745de6402cf --- /dev/null +++ b/lib/gitlab/markdown/commit_reference_filter.rb @@ -0,0 +1,80 @@ +module Gitlab + module Markdown + # HTML filter that replaces commit references with links. + # + # This filter supports cross-project references. + class CommitReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find commit references in text + # + # CommitReferenceFilter.references_in(text) do |match, commit, project_ref| + # "<a href=...>#{commit}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the String commit identifier, and an optional + # String of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(COMMIT_PATTERN) do |match| + yield match, $~[:commit], $~[:project] + end + end + + # Pattern used to extract commit references from text + # + # The SHA1 sum can be between 6 and 40 hex characters. + # + # This pattern supports cross-project references. + COMMIT_PATTERN = /(#{PROJECT_PATTERN}@)?(?<commit>\h{6,40})/ + + def call + replace_text_nodes_matching(COMMIT_PATTERN) do |content| + commit_link_filter(content) + end + end + + # Replace commit references in text with links to the commit specified. + # + # text - String text to replace references in. + # + # Returns a String with commit references replaced with links. All links + # have `gfm` and `gfm-commit` class names attached for styling. + def commit_link_filter(text) + self.class.references_in(text) do |match, commit_ref, project_ref| + project = self.project_from_ref(project_ref) + + if commit = commit_from_ref(project, commit_ref) + url = url_for_commit(project, commit) + + title = escape_once(commit.link_title) + klass = reference_class(:commit) + + project_ref += '@' if project_ref + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}#{commit_ref}</a>) + else + match + end + end + end + + def commit_from_ref(project, commit_ref) + if project && project.valid_repo? + project.repository.commit(commit_ref) + end + end + + def url_for_commit(project, commit) + h = Rails.application.routes.url_helpers + h.namespace_project_commit_url(project.namespace, project, commit, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/cross_project_reference.rb b/lib/gitlab/markdown/cross_project_reference.rb new file mode 100644 index 00000000000..887c205cdc9 --- /dev/null +++ b/lib/gitlab/markdown/cross_project_reference.rb @@ -0,0 +1,32 @@ +module Gitlab + module Markdown + # Common methods for ReferenceFilters that support an optional cross-project + # reference. + module CrossProjectReference + NAMING_PATTERN = Gitlab::Regex::NAMESPACE_REGEX_STR + PROJECT_PATTERN = "(?<project>#{NAMING_PATTERN}/#{NAMING_PATTERN})" + + # Given a cross-project reference string, get the Project record + # + # Defaults to value of `context[:project]` if: + # * No reference is given OR + # * Reference given doesn't exist + # + # ref - String reference. + # + # Returns a Project, or nil if the reference can't be accessed + def project_from_ref(ref) + return context[:project] unless ref + + other = Project.find_with_namespace(ref) + return nil unless other && user_can_reference_project?(other) + + other + end + + def user_can_reference_project?(project, user = context[:current_user]) + Ability.abilities.allowed?(user, :read_project, project) + end + end + end +end diff --git a/lib/gitlab/markdown/external_issue_reference_filter.rb b/lib/gitlab/markdown/external_issue_reference_filter.rb new file mode 100644 index 00000000000..0fc3f4cca06 --- /dev/null +++ b/lib/gitlab/markdown/external_issue_reference_filter.rb @@ -0,0 +1,63 @@ +module Gitlab + module Markdown + # HTML filter that replaces external issue tracker references with links. + # References are ignored if the project doesn't use an external issue + # tracker. + class ExternalIssueReferenceFilter < ReferenceFilter + # Public: Find `JIRA-123` issue references in text + # + # ExternalIssueReferenceFilter.references_in(text) do |match, issue| + # "<a href=...>##{issue}</a>" + # end + # + # text - String text to search. + # + # Yields the String match and the String issue reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(ISSUE_PATTERN) do |match| + yield match, $~[:issue] + end + end + + # Pattern used to extract `JIRA-123` issue references from text + ISSUE_PATTERN = /(?<issue>([A-Z\-]+-)\d+)/ + + def call + # Early return if the project isn't using an external tracker + return doc if project.nil? || project.default_issues_tracker? + + replace_text_nodes_matching(ISSUE_PATTERN) do |content| + issue_link_filter(content) + end + end + + # Replace `JIRA-123` issue references in text with links to the referenced + # issue's details page. + # + # text - String text to replace references in. + # + # Returns a String with `JIRA-123` references replaced with links. All + # links have `gfm` and `gfm-issue` class names attached for styling. + def issue_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, issue| + url = url_for_issue(issue, project, only_path: context[:only_path]) + + title = escape_once("Issue in #{project.external_issue_tracker.title}") + klass = reference_class(:issue) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{issue}</a>) + end + end + + def url_for_issue(*args) + IssuesHelper.url_for_issue(*args) + end + end + end +end diff --git a/lib/gitlab/markdown/issue_reference_filter.rb b/lib/gitlab/markdown/issue_reference_filter.rb new file mode 100644 index 00000000000..c9267cc3e9d --- /dev/null +++ b/lib/gitlab/markdown/issue_reference_filter.rb @@ -0,0 +1,74 @@ +module Gitlab + module Markdown + # HTML filter that replaces issue references with links. References to + # issues that do not exist are ignored. + # + # This filter supports cross-project references. + class IssueReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `#123` issue references in text + # + # IssueReferenceFilter.references_in(text) do |match, issue, project_ref| + # "<a href=...>##{issue}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer issue ID, and an optional String of + # the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(ISSUE_PATTERN) do |match| + yield match, $~[:issue].to_i, $~[:project] + end + end + + # Pattern used to extract `#123` issue references from text + # + # This pattern supports cross-project references. + ISSUE_PATTERN = /#{PROJECT_PATTERN}?\#(?<issue>([a-zA-Z\-]+-)?\d+)/ + + def call + replace_text_nodes_matching(ISSUE_PATTERN) do |content| + issue_link_filter(content) + end + end + + # Replace `#123` issue references in text with links to the referenced + # issue's details page. + # + # text - String text to replace references in. + # + # Returns a String with `#123` references replaced with links. All links + # have `gfm` and `gfm-issue` class names attached for styling. + def issue_link_filter(text) + self.class.references_in(text) do |match, issue, project_ref| + project = self.project_from_ref(project_ref) + + if project && project.issue_exists?(issue) + url = url_for_issue(issue, project, only_path: context[:only_path]) + + title = escape_once("Issue: #{title_for_issue(issue, project)}") + klass = reference_class(:issue) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}##{issue}</a>) + else + match + end + end + end + + def url_for_issue(*args) + IssuesHelper.url_for_issue(*args) + end + + def title_for_issue(*args) + IssuesHelper.title_for_issue(*args) + end + end + end +end diff --git a/lib/gitlab/markdown/label_reference_filter.rb b/lib/gitlab/markdown/label_reference_filter.rb new file mode 100644 index 00000000000..4c21192c0d3 --- /dev/null +++ b/lib/gitlab/markdown/label_reference_filter.rb @@ -0,0 +1,94 @@ +module Gitlab + module Markdown + # HTML filter that replaces label references with links. + class LabelReferenceFilter < ReferenceFilter + # Public: Find label references in text + # + # LabelReferenceFilter.references_in(text) do |match, id, name| + # "<a href=...>#{Label.find(id)}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, an optional Integer label ID, and an optional + # String label name. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(LABEL_PATTERN) do |match| + yield match, $~[:label_id].to_i, $~[:label_name] + end + end + + # Pattern used to extract label references from text + # + # TODO (rspeicher): Limit to double quotes (meh) or disallow single quotes in label names (bad). + LABEL_PATTERN = %r{ + ~( + (?<label_id>\d+) | # Integer-based label ID, or + (?<label_name> + [A-Za-z0-9_-]+ | # String-based single-word label title + ['"][^&\?,]+['"] # String-based multi-word label surrounded in quotes + ) + ) + }x + + def call + replace_text_nodes_matching(LABEL_PATTERN) do |content| + label_link_filter(content) + end + end + + # Replace label references in text with links to the label specified. + # + # text - String text to replace references in. + # + # Returns a String with label references replaced with links. All links + # have `gfm` and `gfm-label` class names attached for styling. + def label_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, id, name| + params = label_params(id, name) + + if label = project.labels.find_by(params) + url = url_for_label(project, label) + + klass = reference_class(:label) + + %(<a href="#{url}" class="#{klass}">#{render_colored_label(label)}</a>) + else + match + end + end + end + + def url_for_label(project, label) + h = Rails.application.routes.url_helpers + h.namespace_project_issues_path(project.namespace, project, + label_name: label.name, + only_path: context[:only_path]) + end + + def render_colored_label(label) + LabelsHelper.render_colored_label(label) + end + + # Parameters to pass to `Label.find_by` based on the given arguments + # + # id - Integer ID to pass. If present, returns {id: id} + # name - String name to pass. If `id` is absent, finds by name without + # surrounding quotes. + # + # Returns a Hash. + def label_params(id, name) + if id > 0 + { id: id } + else + # TODO (rspeicher): Don't strip single quotes if we decide to only use double quotes for surrounding. + { name: name.tr('\'"', '') } + end + end + end + end +end diff --git a/lib/gitlab/markdown/merge_request_reference_filter.rb b/lib/gitlab/markdown/merge_request_reference_filter.rb new file mode 100644 index 00000000000..40239523cda --- /dev/null +++ b/lib/gitlab/markdown/merge_request_reference_filter.rb @@ -0,0 +1,73 @@ +module Gitlab + module Markdown + # HTML filter that replaces merge request references with links. References + # to merge requests that do not exist are ignored. + # + # This filter supports cross-project references. + class MergeRequestReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `!123` merge request references in text + # + # MergeRequestReferenceFilter.references_in(text) do |match, merge_request, project_ref| + # "<a href=...>##{merge_request}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer merge request ID, and an optional + # String of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(MERGE_REQUEST_PATTERN) do |match| + yield match, $~[:merge_request].to_i, $~[:project] + end + end + + # Pattern used to extract `!123` merge request references from text + # + # This pattern supports cross-project references. + MERGE_REQUEST_PATTERN = /#{PROJECT_PATTERN}?!(?<merge_request>\d+)/ + + def call + replace_text_nodes_matching(MERGE_REQUEST_PATTERN) do |content| + merge_request_link_filter(content) + end + end + + # Replace `!123` merge request references in text with links to the + # referenced merge request's details page. + # + # text - String text to replace references in. + # + # Returns a String with `!123` references replaced with links. All links + # have `gfm` and `gfm-merge_request` class names attached for styling. + def merge_request_link_filter(text) + self.class.references_in(text) do |match, id, project_ref| + project = self.project_from_ref(project_ref) + + if project && merge_request = project.merge_requests.find_by(iid: id) + title = escape_once("Merge Request: #{merge_request.title}") + klass = reference_class(:merge_request) + + url = url_for_merge_request(merge_request, project) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}!#{id}</a>) + else + match + end + end + end + + # TODO (rspeicher): Cleanup + def url_for_merge_request(mr, project) + h = Rails.application.routes.url_helpers + h.namespace_project_merge_request_url(project.namespace, project, mr, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/reference_filter.rb b/lib/gitlab/markdown/reference_filter.rb new file mode 100644 index 00000000000..26663c8d990 --- /dev/null +++ b/lib/gitlab/markdown/reference_filter.rb @@ -0,0 +1,76 @@ +require 'active_support/core_ext/string/output_safety' +require 'html/pipeline' + +module Gitlab + module Markdown + # Base class for GitLab Flavored Markdown reference filters. + # + # References within <pre>, <code>, <a>, and <style> elements are ignored. + # + # Context options: + # :project (required) - Current project, ignored if reference is cross-project. + # :reference_class - Custom CSS class added to reference links. + # :only_path - Generate path-only links. + # + class ReferenceFilter < HTML::Pipeline::Filter + def escape_once(html) + ERB::Util.html_escape_once(html) + end + + # Don't look for references in text nodes that are children of these + # elements. + IGNORE_PARENTS = %w(pre code a style).to_set + + def ignored_ancestry?(node) + has_ancestor?(node, IGNORE_PARENTS) + end + + def project + context[:project] + end + + def reference_class(type) + "gfm gfm-#{type} #{context[:reference_class]}".strip + end + + # Iterate through the document's text nodes, yielding the current node's + # content if: + # + # * The `project` context value is present AND + # * The node's content matches `pattern` AND + # * The node is not an ancestor of an ignored node type + # + # pattern - Regex pattern against which to match the node's content + # + # Yields the current node's String contents. The result of the block will + # replace the node's existing content and update the current document. + # + # Returns the updated Nokogiri::XML::Document object. + def replace_text_nodes_matching(pattern) + return doc if project.nil? + + doc.search('text()').each do |node| + content = node.to_html + + next unless content.match(pattern) + next if ignored_ancestry?(node) + + html = yield content + + next if html == content + + node.replace(html) + end + + doc + end + + # Ensure that a :project key exists in context + # + # Note that while the key might exist, its value could be nil! + def validate + needs :project + end + end + end +end diff --git a/lib/gitlab/markdown/snippet_reference_filter.rb b/lib/gitlab/markdown/snippet_reference_filter.rb new file mode 100644 index 00000000000..ada67de992b --- /dev/null +++ b/lib/gitlab/markdown/snippet_reference_filter.rb @@ -0,0 +1,72 @@ +module Gitlab + module Markdown + # HTML filter that replaces snippet references with links. References to + # snippets that do not exist are ignored. + # + # This filter supports cross-project references. + class SnippetReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `$123` snippet references in text + # + # SnippetReferenceFilter.references_in(text) do |match, snippet| + # "<a href=...>$#{snippet}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer snippet ID, and an optional String + # of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(SNIPPET_PATTERN) do |match| + yield match, $~[:snippet].to_i, $~[:project] + end + end + + # Pattern used to extract `$123` snippet references from text + # + # This pattern supports cross-project references. + SNIPPET_PATTERN = /#{PROJECT_PATTERN}?\$(?<snippet>\d+)/ + + def call + replace_text_nodes_matching(SNIPPET_PATTERN) do |content| + snippet_link_filter(content) + end + end + + # Replace `$123` snippet references in text with links to the referenced + # snippets's details page. + # + # text - String text to replace references in. + # + # Returns a String with `$123` references replaced with links. All links + # have `gfm` and `gfm-snippet` class names attached for styling. + def snippet_link_filter(text) + self.class.references_in(text) do |match, id, project_ref| + project = self.project_from_ref(project_ref) + + if project && snippet = project.snippets.find_by(id: id) + title = escape_once("Snippet: #{snippet.title}") + klass = reference_class(:snippet) + + url = url_for_snippet(snippet, project) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}$#{id}</a>) + else + match + end + end + end + + def url_for_snippet(snippet, project) + h = Rails.application.routes.url_helpers + h.namespace_project_snippet_url(project.namespace, project, snippet, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/user_reference_filter.rb b/lib/gitlab/markdown/user_reference_filter.rb new file mode 100644 index 00000000000..5fc8ed55fe2 --- /dev/null +++ b/lib/gitlab/markdown/user_reference_filter.rb @@ -0,0 +1,92 @@ +module Gitlab + module Markdown + # HTML filter that replaces user or group references with links. + # + # A special `@all` reference is also supported. + class UserReferenceFilter < ReferenceFilter + # Public: Find `@user` user references in text + # + # UserReferenceFilter.references_in(text) do |match, username| + # "<a href=...>@#{user}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, and the String user name. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(USER_PATTERN) do |match| + yield match, $~[:user] + end + end + + # Pattern used to extract `@user` user references from text + USER_PATTERN = /@(?<user>#{Gitlab::Regex::NAMESPACE_REGEX_STR})/ + + def call + replace_text_nodes_matching(USER_PATTERN) do |content| + user_link_filter(content) + end + end + + # Replace `@user` user references in text with links to the referenced + # user's profile page. + # + # text - String text to replace references in. + # + # Returns a String with `@user` references replaced with links. All links + # have `gfm` and `gfm-project_member` class names attached for styling. + def user_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, user| + klass = reference_class(:project_member) + + if user == 'all' + url = link_to_all(project) + + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + elsif namespace = Namespace.find_by(path: user) + if namespace.is_a?(Group) + if user_can_reference_group?(namespace) + url = group_url(user, only_path: context[:only_path]) + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + else + match + end + else + url = user_url(user, only_path: context[:only_path]) + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + end + else + match + end + end + end + + private + + def urls + Rails.application.routes.url_helpers + end + + def group_url(*args) + urls.group_url(*args) + end + + def user_url(*args) + urls.user_url(*args) + end + + def link_to_all(project) + urls.namespace_project_url(project.namespace, project, + only_path: context[:only_path]) + end + + def user_can_reference_group?(group) + Ability.abilities.allowed?(context[:current_user], :read_group, group) + end + end + end +end diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index a502a8fe9cd..34aae384355 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -3,8 +3,6 @@ module Gitlab class ReferenceExtractor attr_accessor :project, :current_user, :references - include ::Gitlab::Markdown - def initialize(project, current_user = nil) @project = project @current_user = current_user @@ -34,7 +32,7 @@ module Gitlab project.team.members.flatten elsif namespace = Namespace.find_by(path: identifier) if namespace.is_a?(Group) - namespace.users + namespace.users if can?(current_user, :read_group, namespace) else namespace.owner end @@ -87,6 +85,72 @@ module Gitlab private + NAME_STR = Gitlab::Regex::NAMESPACE_REGEX_STR + PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})" + + REFERENCE_PATTERN = %r{ + (?<prefix>\W)? # Prefix + ( # Reference + @(?<user>#{NAME_STR}) # User name + |~(?<label>\d+) # Label ID + |(?<issue>([A-Z\-]+-)\d+) # JIRA Issue ID + |#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID + |#{PROJ_STR}?!(?<merge_request>\d+) # MR ID + |\$(?<snippet>\d+) # Snippet ID + |(#{PROJ_STR}@)?(?<commit_range>[\h]{6,40}\.{2,3}[\h]{6,40}) # Commit range + |(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID + ) + (?<suffix>\W)? # Suffix + }x.freeze + + TYPES = %i(user issue label merge_request snippet commit commit_range).freeze + + def parse_references(text, project = @project) + # parse reference links + text.gsub!(REFERENCE_PATTERN) do |match| + type = TYPES.detect { |t| $~[t].present? } + + actual_project = project + project_prefix = nil + project_path = $LAST_MATCH_INFO[:project] + if project_path + actual_project = ::Project.find_with_namespace(project_path) + actual_project = nil unless can?(current_user, :read_project, actual_project) + project_prefix = project_path + end + + parse_result($LAST_MATCH_INFO, type, + actual_project, project_prefix) || match + end + end + + # Called from #parse_references. Attempts to build a gitlab reference + # link. Returns nil if +type+ is nil, if the match string is an HTML + # entity, if the reference is invalid, or if the matched text includes an + # invalid project path. + def parse_result(match_info, type, project, project_prefix) + prefix = match_info[:prefix] + suffix = match_info[:suffix] + + return nil if html_entity?(prefix, suffix) || type.nil? + return nil if project.nil? && !project_prefix.nil? + + identifier = match_info[type] + ref_link = reference_link(type, identifier, project, project_prefix) + + if ref_link + "#{prefix}#{ref_link}#{suffix}" + else + nil + end + end + + # Return true if the +prefix+ and +suffix+ indicate that the matched string + # is an HTML entity like & + def html_entity?(prefix, suffix) + prefix && suffix && prefix[0] == '&' && suffix[-1] == ';' + end + def reference_link(type, identifier, project, _) references[type] << [project, identifier] end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index 944e743675c..315e91d4f35 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -36,417 +36,11 @@ describe GitlabMarkdownHelper do end describe "#gfm" do - it "should return unaltered text if project is nil" do - actual = "Testing references: ##{issue.iid}" - - expect(gfm(actual)).not_to eq(actual) - - @project = nil - expect(gfm(actual)).to eq(actual) - end - - it "should not alter non-references" do - actual = expected = "_Please_ *stop* 'helping' and all the other b*$#%' you do." - expect(gfm(actual)).to eq(expected) - end - - it "should not touch HTML entities" do - allow(@project.issues).to receive(:where). - with(id: '39').and_return([issue]) - actual = 'We'll accept good pull requests.' - expect(gfm(actual)).to eq("We'll accept good pull requests.") - end - it "should forward HTML options to links" do expect(gfm("Fixed in #{commit.id}", @project, class: 'foo')). to have_selector('a.gfm.foo') end - describe "referencing a commit range" do - let(:expected) { namespace_project_compare_path(project.namespace, project, from: earlier_commit.id, to: commit.id) } - - it "should link using a full id" do - actual = "What happened in #{earlier_commit.id}...#{commit.id}" - expect(gfm(actual)).to match(expected) - end - - it "should link using a short id" do - actual = "What happened in #{earlier_commit.short_id}...#{commit.short_id}" - expected = namespace_project_compare_path(project.namespace, project, from: earlier_commit.short_id, to: commit.short_id) - expect(gfm(actual)).to match(expected) - end - - it "should link inclusively" do - actual = "What happened in #{earlier_commit.id}..#{commit.id}" - expected = namespace_project_compare_path(project.namespace, project, from: "#{earlier_commit.id}^", to: commit.id) - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - actual = "(see #{earlier_commit.id}...#{commit.id})" - expect(gfm(actual)).to match(expected) - end - - it "should keep whitespace intact" do - actual = "Changes #{earlier_commit.id}...#{commit.id} dramatically" - expected = /Changes <a.+>#{earlier_commit.id}...#{commit.id}<\/a> dramatically/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - actual = expected = "What happened in #{earlier_commit.id.reverse}...#{commit.id.reverse}" - expect(gfm(actual)).to eq(expected) - end - - it "should include a title attribute" do - actual = "What happened in #{earlier_commit.id}...#{commit.id}" - expect(gfm(actual)).to match(/title="Commits #{earlier_commit.id} through #{commit.id}"/) - end - - it "should include standard gfm classes" do - actual = "What happened in #{earlier_commit.id}...#{commit.id}" - expect(gfm(actual)).to match(/class="\s?gfm gfm-commit_range\s?"/) - end - end - - describe "referencing a commit" do - let(:expected) { namespace_project_commit_path(project.namespace, project, commit) } - - it "should link using a full id" do - actual = "Reverts #{commit.id}" - expect(gfm(actual)).to match(expected) - end - - it "should link using a short id" do - actual = "Backported from #{commit.short_id}" - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - actual = "Reverted (see #{commit.id})" - expect(gfm(actual)).to match(expected) - end - - it "should keep whitespace intact" do - actual = "Changes #{commit.id} dramatically" - expected = /Changes <a.+>#{commit.id}<\/a> dramatically/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - actual = expected = "What happened in #{commit.id.reverse}" - expect(gfm(actual)).to eq(expected) - end - - it "should include a title attribute" do - actual = "Reverts #{commit.id}" - expect(gfm(actual)).to match(/title="#{commit.link_title}"/) - end - - it "should include standard gfm classes" do - actual = "Reverts #{commit.id}" - expect(gfm(actual)).to match(/class="\s?gfm gfm-commit\s?"/) - end - end - - describe "referencing a team member" do - let(:actual) { "@#{user.username} you are right." } - let(:expected) { user_path(user) } - - before do - project.team << [user, :master] - end - - it "should link using a simple name" do - expect(gfm(actual)).to match(expected) - end - - it "should link using a name with dots" do - user.update_attributes(name: "alphA.Beta") - expect(gfm(actual)).to match(expected) - end - - it "should link using name with underscores" do - user.update_attributes(name: "ping_pong_king") - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - actual = "Mail the admin (@#{user.username})" - expect(gfm(actual)).to match(expected) - end - - it "should keep whitespace intact" do - actual = "Yes, @#{user.username} is right." - expected = /Yes, <a.+>@#{user.username}<\/a> is right/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - actual = expected = "@#{user.username.reverse} you are right." - expect(gfm(actual)).to eq(expected) - end - - it "should include standard gfm classes" do - expect(gfm(actual)).to match(/class="\s?gfm gfm-project_member\s?"/) - end - end - - # Shared examples for referencing an object - # - # Expects the following attributes to be available in the example group: - # - # - object - The object itself - # - reference - The object reference string (e.g., #1234, $1234, !1234) - # - # Currently limited to Snippets, Issues and MergeRequests - shared_examples 'referenced object' do - let(:actual) { "Reference to #{reference}" } - let(:expected) { polymorphic_path([project.namespace, project, object]) } - - it "should link using a valid id" do - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - # Wrap the reference in parenthesis - expect(gfm(actual.gsub(reference, "(#{reference})"))).to match(expected) - - # Append some text to the end of the reference - expect(gfm(actual.gsub(reference, "#{reference}, right?"))). - to match(expected) - end - - it "should keep whitespace intact" do - actual = "Referenced #{reference} already." - expected = /Referenced <a.+>[^\s]+<\/a> already/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - # Modify the reference string so it's still parsed, but is invalid - reference.gsub!(/^(.)(\d+)$/, '\1' + ('\2' * 2)) - expect(gfm(actual)).to eq(actual) - end - - it "should include a title attribute" do - title = "#{object.class.to_s.titlecase}: #{object.title}" - expect(gfm(actual)).to match(/title="#{title}"/) - end - - it "should include standard gfm classes" do - css = object.class.to_s.underscore - expect(gfm(actual)).to match(/class="\s?gfm gfm-#{css}\s?"/) - end - end - - # Shared examples for referencing an object in a different project - # - # Expects the following attributes to be available in the example group: - # - # - object - The object itself - # - reference - The object reference string (e.g., #1234, $1234, !1234) - # - other_project - The project that owns the target object - # - # Currently limited to Snippets, Issues and MergeRequests - shared_examples 'cross-project referenced object' do - let(:project_path) { @other_project.path_with_namespace } - let(:full_reference) { "#{project_path}#{reference}" } - let(:actual) { "Reference to #{full_reference}" } - let(:expected) do - if object.is_a?(Commit) - namespace_project_commit_path(@other_project.namespace, @other_project, object) - else - polymorphic_path([@other_project.namespace, @other_project, object]) - end - end - - it 'should link using a valid id' do - expect(gfm(actual)).to match( - /#{expected}.*#{Regexp.escape(full_reference)}/ - ) - end - - it 'should link with adjacent text' do - # Wrap the reference in parenthesis - expect(gfm(actual.gsub(full_reference, "(#{full_reference})"))).to( - match(expected) - ) - - # Append some text to the end of the reference - expect(gfm(actual.gsub(full_reference, "#{full_reference}, right?"))). - to(match(expected)) - end - - it 'should keep whitespace intact' do - actual = "Referenced #{full_reference} already." - expected = /Referenced <a.+>[^\s]+<\/a> already/ - expect(gfm(actual)).to match(expected) - end - - it 'should not link with an invalid id' do - # Modify the reference string so it's still parsed, but is invalid - if object.is_a?(Commit) - reference.gsub!(/^(.).+$/, '\1' + '12345abcd') - else - reference.gsub!(/^(.)(\d+)$/, '\1' + ('\2' * 2)) - end - expect(gfm(actual)).to eq(actual) - end - - it 'should include a title attribute' do - if object.is_a?(Commit) - title = object.link_title - else - title = "#{object.class.to_s.titlecase}: #{object.title}" - end - expect(gfm(actual)).to match(/title="#{title}"/) - end - - it 'should include standard gfm classes' do - css = object.class.to_s.underscore - expect(gfm(actual)).to match(/class="\s?gfm gfm-#{css}\s?"/) - end - end - - describe "referencing an issue" do - let(:object) { issue } - let(:reference) { "##{issue.iid}" } - - include_examples 'referenced object' - end - - context 'cross-repo references' do - before(:all) do - @other_project = create(:project, :public) - @commit2 = @other_project.repository.commit - @issue2 = create(:issue, project: @other_project) - @merge_request2 = create(:merge_request, - source_project: @other_project, - target_project: @other_project) - end - - describe 'referencing an issue in another project' do - let(:object) { @issue2 } - let(:reference) { "##{@issue2.iid}" } - - include_examples 'cross-project referenced object' - end - - describe 'referencing an merge request in another project' do - let(:object) { @merge_request2 } - let(:reference) { "!#{@merge_request2.iid}" } - - include_examples 'cross-project referenced object' - end - - describe 'referencing a commit in another project' do - let(:object) { @commit2 } - let(:reference) { "@#{@commit2.id}" } - - include_examples 'cross-project referenced object' - end - end - - describe "referencing a Jira issue" do - let(:actual) { "Reference to JIRA-#{issue.iid}" } - let(:expected) { "http://jira.example/browse/JIRA-#{issue.iid}" } - let(:reference) { "JIRA-#{issue.iid}" } - - before do - jira = @project.create_jira_service if @project.jira_service.nil? - properties = {"title"=>"JIRA tracker", "project_url"=>"http://jira.example/issues/?jql=project=A", "issues_url"=>"http://jira.example/browse/:id", "new_issue_url"=>"http://jira.example/secure/CreateIssue.jspa"} - jira.update_attributes(properties: properties, active: true) - end - - after do - @project.jira_service.destroy! unless @project.jira_service.nil? - end - - it "should link using a valid id" do - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - # Wrap the reference in parenthesis - expect(gfm(actual.gsub(reference, "(#{reference})"))).to match(expected) - - # Append some text to the end of the reference - expect(gfm(actual.gsub(reference, "#{reference}, right?"))). - to match(expected) - end - - it "should keep whitespace intact" do - actual = "Referenced #{reference} already." - expected = /Referenced <a.+>[^\s]+<\/a> already/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - # Modify the reference string so it's still parsed, but is invalid - invalid_reference = actual.gsub(/(\d+)$/, "r45") - expect(gfm(invalid_reference)).to eq(invalid_reference) - end - - it "should include a title attribute" do - title = "Issue in JIRA tracker" - expect(gfm(actual)).to match(/title="#{title}"/) - end - - it "should include standard gfm classes" do - expect(gfm(actual)).to match(/class="\s?gfm gfm-issue\s?"/) - end - end - - describe "referencing a merge request" do - let(:object) { merge_request } - let(:reference) { "!#{merge_request.iid}" } - - include_examples 'referenced object' - end - - describe "referencing a snippet" do - let(:object) { snippet } - let(:reference) { "$#{snippet.id}" } - let(:actual) { "Reference to #{reference}" } - let(:expected) { namespace_project_snippet_path(project.namespace, project, object) } - - it "should link using a valid id" do - expect(gfm(actual)).to match(expected) - end - - it "should link with adjacent text" do - # Wrap the reference in parenthesis - expect(gfm(actual.gsub(reference, "(#{reference})"))).to match(expected) - - # Append some text to the end of the reference - expect(gfm(actual.gsub(reference, "#{reference}, right?"))).to match(expected) - end - - it "should keep whitespace intact" do - actual = "Referenced #{reference} already." - expected = /Referenced <a.+>[^\s]+<\/a> already/ - expect(gfm(actual)).to match(expected) - end - - it "should not link with an invalid id" do - # Modify the reference string so it's still parsed, but is invalid - reference.gsub!(/^(.)(\d+)$/, '\1' + ('\2' * 2)) - expect(gfm(actual)).to eq(actual) - end - - it "should include a title attribute" do - title = "Snippet: #{object.title}" - expect(gfm(actual)).to match(/title="#{title}"/) - end - - it "should include standard gfm classes" do - css = object.class.to_s.underscore - expect(gfm(actual)).to match(/class="\s?gfm gfm-snippet\s?"/) - end - - end - describe "referencing multiple objects" do let(:actual) { "!#{merge_request.iid} -> #{commit.id} -> ##{issue.iid}" } @@ -466,6 +60,7 @@ describe GitlabMarkdownHelper do end end + # TODO (rspeicher): These tests belong in the emoji filter spec describe "emoji" do it "matches at the start of a string" do expect(gfm(":+1:")).to match(/<img/) @@ -511,6 +106,116 @@ describe GitlabMarkdownHelper do expect(gfm(":+1:")).to match(/<img/) end end + + context 'parse_tasks: true' do + before(:all) do + @source_text_asterisk = <<-EOT.strip_heredoc + * [ ] valid unchecked task + * [x] valid lowercase checked task + * [X] valid uppercase checked task + * [ ] valid unchecked nested task + * [x] valid checked nested task + + [ ] not an unchecked task - no list item + [x] not a checked task - no list item + + * [ ] not an unchecked task - too many spaces + * [x ] not a checked task - too many spaces + * [] not an unchecked task - no spaces + * Not a task [ ] - not at beginning + EOT + + @source_text_dash = <<-EOT.strip_heredoc + - [ ] valid unchecked task + - [x] valid lowercase checked task + - [X] valid uppercase checked task + - [ ] valid unchecked nested task + - [x] valid checked nested task + EOT + end + + it 'should render checkboxes at beginning of asterisk list items' do + rendered_text = markdown(@source_text_asterisk, parse_tasks: true) + + expect(rendered_text).to match(/<input.*checkbox.*valid unchecked task/) + expect(rendered_text).to match( + /<input.*checkbox.*valid lowercase checked task/ + ) + expect(rendered_text).to match( + /<input.*checkbox.*valid uppercase checked task/ + ) + end + + it 'should render checkboxes at beginning of dash list items' do + rendered_text = markdown(@source_text_dash, parse_tasks: true) + + expect(rendered_text).to match(/<input.*checkbox.*valid unchecked task/) + expect(rendered_text).to match( + /<input.*checkbox.*valid lowercase checked task/ + ) + expect(rendered_text).to match( + /<input.*checkbox.*valid uppercase checked task/ + ) + end + + it 'should render checkboxes for nested tasks' do + rendered_text = markdown(@source_text_asterisk, parse_tasks: true) + + expect(rendered_text).to match( + /<input.*checkbox.*valid unchecked nested task/ + ) + expect(rendered_text).to match( + /<input.*checkbox.*valid checked nested task/ + ) + end + + it 'should not be confused by whitespace before bullets' do + rendered_text_asterisk = markdown(@source_text_asterisk, + parse_tasks: true) + rendered_text_dash = markdown(@source_text_dash, parse_tasks: true) + + expect(rendered_text_asterisk).to match( + /<input.*checkbox.*valid unchecked nested task/ + ) + expect(rendered_text_asterisk).to match( + /<input.*checkbox.*valid checked nested task/ + ) + expect(rendered_text_dash).to match( + /<input.*checkbox.*valid unchecked nested task/ + ) + expect(rendered_text_dash).to match( + /<input.*checkbox.*valid checked nested task/ + ) + end + + it 'should not render checkboxes outside of list items' do + rendered_text = markdown(@source_text_asterisk, parse_tasks: true) + + expect(rendered_text).not_to match( + /<input.*checkbox.*not an unchecked task - no list item/ + ) + expect(rendered_text).not_to match( + /<input.*checkbox.*not a checked task - no list item/ + ) + end + + it 'should not render checkboxes with invalid formatting' do + rendered_text = markdown(@source_text_asterisk, parse_tasks: true) + + expect(rendered_text).not_to match( + /<input.*checkbox.*not an unchecked task - too many spaces/ + ) + expect(rendered_text).not_to match( + /<input.*checkbox.*not a checked task - too many spaces/ + ) + expect(rendered_text).not_to match( + /<input.*checkbox.*not an unchecked task - no spaces/ + ) + expect(rendered_text).not_to match( + /Not a task.*<input.*checkbox.*not at beginning/ + ) + end + end end describe "#link_to_gfm" do @@ -560,11 +265,9 @@ describe GitlabMarkdownHelper do end describe "#markdown" do - it "should handle references in paragraphs" do - actual = "\n\nLorem ipsum dolor sit amet. #{commit.id} Nam pulvinar sapien eget.\n" - expected = namespace_project_commit_path(project.namespace, project, commit) - expect(markdown(actual)).to match(expected) - end + # TODO (rspeicher) - This block tests multiple different contexts. Break this up! + + # REFERENCES (PART TWO: THE REVENGE) --------------------------------------- it "should handle references in headers" do actual = "\n# Working around ##{issue.iid}\n## Apply !#{merge_request.iid}" @@ -590,37 +293,6 @@ describe GitlabMarkdownHelper do ) end - it "should handle references in lists" do - project.team << [user, :master] - - actual = "\n* dark: ##{issue.iid}\n* light by @#{member.user.username}" - - expect(markdown(actual)). - to match(%r{<li>dark: <a.+>##{issue.iid}</a></li>}) - expect(markdown(actual)). - to match(%r{<li>light by <a.+>@#{member.user.username}</a></li>}) - end - - it "should not link the apostrophe to issue 39" do - project.team << [user, :master] - allow(project.issues). - to receive(:where).with(iid: '39').and_return([issue]) - - actual = "Yes, it is @#{member.user.username}'s task." - expected = /Yes, it is <a.+>@#{member.user.username}<\/a>'s task/ - expect(markdown(actual)).to match(expected) - end - - it "should not link the apostrophe to issue 39 in code blocks" do - project.team << [user, :master] - allow(project.issues). - to receive(:where).with(iid: '39').and_return([issue]) - - actual = "Yes, `it is @#{member.user.username}'s task.`" - expected = /Yes, <code>it is @gfm\'s task.<\/code>/ - expect(markdown(actual)).to match(expected) - end - it "should handle references in <em>" do actual = "Apply _!#{merge_request.iid}_ ASAP" @@ -628,16 +300,10 @@ describe GitlabMarkdownHelper do to match(%r{Apply <em><a.+>!#{merge_request.iid}</a></em>}) end - it "should handle tables" do - actual = %Q{| header 1 | header 2 | -| -------- | -------- | -| cell 1 | cell 2 | -| cell 3 | cell 4 |} - - expect(markdown(actual)).to match(/\A<table/) - end + # CODE BLOCKS ------------------------------------------------------------- it "should leave code blocks untouched" do + allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:user_color_scheme_class).and_return(:white) target_html = "<pre class=\"code highlight white plaintext\"><code>some code from $#{snippet.id}\nhere too\n</code></pre>\n" @@ -654,12 +320,15 @@ describe GitlabMarkdownHelper do ) end + # REF-LIKE AUTOLINKS? ----------------------------------------------------- + # Basically: Don't parse references inside `<a>` tags. + it "should leave ref-like autolinks untouched" do expect(markdown("look at http://example.tld/#!#{merge_request.iid}")).to eq("<p>look at <a href=\"http://example.tld/#!#{merge_request.iid}\">http://example.tld/#!#{merge_request.iid}</a></p>\n") end it "should leave ref-like href of 'manual' links untouched" do - expect(markdown("why not [inspect !#{merge_request.iid}](http://example.tld/#!#{merge_request.iid})")).to eq("<p>why not <a href=\"http://example.tld/#!#{merge_request.iid}\">inspect </a><a class=\"gfm gfm-merge_request \" href=\"#{namespace_project_merge_request_path(project.namespace, project, merge_request)}\" title=\"Merge Request: #{merge_request.title}\">!#{merge_request.iid}</a><a href=\"http://example.tld/#!#{merge_request.iid}\"></a></p>\n") + expect(markdown("why not [inspect !#{merge_request.iid}](http://example.tld/#!#{merge_request.iid})")).to eq("<p>why not <a href=\"http://example.tld/#!#{merge_request.iid}\">inspect </a><a href=\"#{namespace_project_merge_request_path(project.namespace, project, merge_request)}\" title=\"Merge Request: #{merge_request.title}\" class=\"gfm gfm-merge_request\">!#{merge_request.iid}</a><a href=\"http://example.tld/#!#{merge_request.iid}\"></a></p>\n") end it "should leave ref-like src of images untouched" do @@ -670,23 +339,30 @@ describe GitlabMarkdownHelper do expect(markdown("##{issue.iid}")).to include(namespace_project_issue_path(project.namespace, project, issue)) end + # EMOJI ------------------------------------------------------------------- + it "should generate absolute urls for emoji" do + # TODO (rspeicher): Why isn't this with the emoji tests? expect(markdown(':smile:')).to( include(%(src="#{Gitlab.config.gitlab.url}/assets/emoji/#{Emoji.emoji_filename('smile')}.png)) ) end it "should generate absolute urls for emoji if relative url is present" do + # TODO (rspeicher): Why isn't this with the emoji tests? allow(Gitlab.config.gitlab).to receive(:url).and_return('http://localhost/gitlab/root') expect(markdown(":smile:")).to include("src=\"http://localhost/gitlab/root/assets/emoji/#{Emoji.emoji_filename('smile')}.png") end it "should generate absolute urls for emoji if asset_host is present" do + # TODO (rspeicher): Why isn't this with the emoji tests? allow(Gitlab::Application.config).to receive(:asset_host).and_return("https://cdn.example.com") ActionView::Base.any_instance.stub_chain(:config, :asset_host).and_return("https://cdn.example.com") expect(markdown(":smile:")).to include("src=\"https://cdn.example.com/assets/emoji/#{Emoji.emoji_filename('smile')}.png") end + # RELATIVE URLS ----------------------------------------------------------- + # TODO (rspeicher): These belong in a relative link filter spec it "should handle relative urls for a file in master" do actual = "[GitLab API doc](doc/api/README.md)\n" @@ -741,6 +417,8 @@ describe GitlabMarkdownHelper do expect(markdown(actual)).to match(actual) end + # SANITIZATION ------------------------------------------------------------ + it 'should sanitize tags that are not whitelisted' do actual = '<textarea>no inputs allowed</textarea> <blink>no blinks</blink>' expected = 'no inputs allowed no blinks' @@ -767,6 +445,7 @@ describe GitlabMarkdownHelper do end end + # TODO (rspeicher): This should be a context of relative link specs, not its own thing describe 'markdown for empty repository' do before do @project = empty_project @@ -780,7 +459,7 @@ describe GitlabMarkdownHelper do end end - describe "#render_wiki_content" do + describe '#render_wiki_content' do before do @wiki = double('WikiPage') allow(@wiki).to receive(:content).and_return('wiki content') @@ -803,114 +482,4 @@ describe GitlabMarkdownHelper do helper.render_wiki_content(@wiki) end end - - describe '#gfm_with_tasks' do - before(:all) do - @source_text_asterisk = <<EOT.gsub(/^\s{8}/, '') - * [ ] valid unchecked task - * [x] valid lowercase checked task - * [X] valid uppercase checked task - * [ ] valid unchecked nested task - * [x] valid checked nested task - - [ ] not an unchecked task - no list item - [x] not a checked task - no list item - - * [ ] not an unchecked task - too many spaces - * [x ] not a checked task - too many spaces - * [] not an unchecked task - no spaces - * Not a task [ ] - not at beginning -EOT - - @source_text_dash = <<EOT.gsub(/^\s{8}/, '') - - [ ] valid unchecked task - - [x] valid lowercase checked task - - [X] valid uppercase checked task - - [ ] valid unchecked nested task - - [x] valid checked nested task -EOT - end - - it 'should render checkboxes at beginning of asterisk list items' do - rendered_text = markdown(@source_text_asterisk, parse_tasks: true) - - expect(rendered_text).to match(/<input.*checkbox.*valid unchecked task/) - expect(rendered_text).to match( - /<input.*checkbox.*valid lowercase checked task/ - ) - expect(rendered_text).to match( - /<input.*checkbox.*valid uppercase checked task/ - ) - end - - it 'should render checkboxes at beginning of dash list items' do - rendered_text = markdown(@source_text_dash, parse_tasks: true) - - expect(rendered_text).to match(/<input.*checkbox.*valid unchecked task/) - expect(rendered_text).to match( - /<input.*checkbox.*valid lowercase checked task/ - ) - expect(rendered_text).to match( - /<input.*checkbox.*valid uppercase checked task/ - ) - end - - it 'should render checkboxes for nested tasks' do - rendered_text = markdown(@source_text_asterisk, parse_tasks: true) - - expect(rendered_text).to match( - /<input.*checkbox.*valid unchecked nested task/ - ) - expect(rendered_text).to match( - /<input.*checkbox.*valid checked nested task/ - ) - end - - it 'should not be confused by whitespace before bullets' do - rendered_text_asterisk = markdown(@source_text_asterisk, - parse_tasks: true) - rendered_text_dash = markdown(@source_text_dash, parse_tasks: true) - - expect(rendered_text_asterisk).to match( - /<input.*checkbox.*valid unchecked nested task/ - ) - expect(rendered_text_asterisk).to match( - /<input.*checkbox.*valid checked nested task/ - ) - expect(rendered_text_dash).to match( - /<input.*checkbox.*valid unchecked nested task/ - ) - expect(rendered_text_dash).to match( - /<input.*checkbox.*valid checked nested task/ - ) - end - - it 'should not render checkboxes outside of list items' do - rendered_text = markdown(@source_text_asterisk, parse_tasks: true) - - expect(rendered_text).not_to match( - /<input.*checkbox.*not an unchecked task - no list item/ - ) - expect(rendered_text).not_to match( - /<input.*checkbox.*not a checked task - no list item/ - ) - end - - it 'should not render checkboxes with invalid formatting' do - rendered_text = markdown(@source_text_asterisk, parse_tasks: true) - - expect(rendered_text).not_to match( - /<input.*checkbox.*not an unchecked task - too many spaces/ - ) - expect(rendered_text).not_to match( - /<input.*checkbox.*not a checked task - too many spaces/ - ) - expect(rendered_text).not_to match( - /<input.*checkbox.*not an unchecked task - no spaces/ - ) - expect(rendered_text).not_to match( - /Not a task.*<input.*checkbox.*not at beginning/ - ) - end - end end diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb index 1e64a201942..0b7e3b1d11f 100644 --- a/spec/helpers/labels_helper_spec.rb +++ b/spec/helpers/labels_helper_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' describe LabelsHelper do - it { expect(text_color_for_bg('#EEEEEE')).to eq('#333') } - it { expect(text_color_for_bg('#222E2E')).to eq('#FFF') } + it { expect(text_color_for_bg('#EEEEEE')).to eq('#333333') } + it { expect(text_color_for_bg('#222E2E')).to eq('#FFFFFF') } end diff --git a/spec/lib/gitlab/markdown/commit_range_reference_filter_spec.rb b/spec/lib/gitlab/markdown/commit_range_reference_filter_spec.rb new file mode 100644 index 00000000000..5ebdc8926e2 --- /dev/null +++ b/spec/lib/gitlab/markdown/commit_range_reference_filter_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe CommitRangeReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:project) } + let(:commit1) { project.repository.commit } + let(:commit2) { project.repository.commit("HEAD~2") } + + it 'requires project context' do + expect { described_class.call('Commit Range 1c002d..d200c1', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Commit Range #{commit1.id}..#{commit2.id}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'internal reference' do + let(:reference) { "#{commit1.id}...#{commit2.id}" } + let(:reference2) { "#{commit1.id}..#{commit2.id}" } + + it 'links to a valid two-dot reference' do + doc = filter("See #{reference2}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_compare_url(project.namespace, project, from: "#{commit1.id}^", to: commit2.id) + end + + it 'links to a valid three-dot reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_compare_url(project.namespace, project, from: commit1.id, to: commit2.id) + end + + it 'links to a valid short ID' do + reference = "#{commit1.short_id}...#{commit2.id}" + reference2 = "#{commit1.id}...#{commit2.short_id}" + + expect(filter("See #{reference}").css('a').first.text).to eq reference + expect(filter("See #{reference2}").css('a').first.text).to eq reference2 + end + + it 'links with adjacent text' do + doc = filter("See (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid commit IDs' do + exp = act = "See #{commit1.id.reverse}...#{commit2.id}" + + expect(project).to receive(:valid_repo?).and_return(true) + expect(project.repository).to receive(:commit).with(commit1.id.reverse) + expect(filter(act).to_html).to eq exp + end + + it 'includes a title attribute' do + doc = filter("See #{reference}") + expect(doc.css('a').first.attr('title')).to eq "Commits #{commit1.id} through #{commit2.id}" + end + + it 'includes default classes' do + doc = filter("See #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit_range' + end + + it 'includes an optional custom class' do + doc = filter("See #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path option' do + doc = filter("See #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.namespace_project_compare_url(project.namespace, project, from: commit1.id, to: commit2.id, only_path: true) + end + end + + context 'cross-project reference' do + let(:namespace) { create(:namespace, name: 'cross-reference') } + let(:project2) { create(:project, namespace: namespace) } + let(:commit1) { project.repository.commit } + let(:commit2) { project.repository.commit("HEAD~2") } + let(:reference) { "#{project2.path_with_namespace}@#{commit1.id}...#{commit2.id}" } + + context 'when user can access reference' do + before { allow_cross_reference! } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_compare_url(project2.namespace, project2, from: commit1.id, to: commit2.id) + end + + it 'links with adjacent text' do + doc = filter("Fixed (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid commit IDs on the referenced project' do + exp = act = "Fixed #{project2.path_with_namespace}##{commit1.id.reverse}...#{commit2.id}" + expect(filter(act).to_html).to eq exp + + exp = act = "Fixed #{project2.path_with_namespace}##{commit1.id}...#{commit2.id.reverse}" + expect(filter(act).to_html).to eq exp + end + end + + context 'when user cannot access reference' do + before { disallow_cross_reference! } + + it 'ignores valid references' do + exp = act = "See #{reference}" + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/commit_reference_filter_spec.rb b/spec/lib/gitlab/markdown/commit_reference_filter_spec.rb new file mode 100644 index 00000000000..71fd2db2c58 --- /dev/null +++ b/spec/lib/gitlab/markdown/commit_reference_filter_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe CommitReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:project) } + let(:commit) { project.repository.commit } + + it 'requires project context' do + expect { described_class.call('Commit 1c002d', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Commit #{commit.id}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'internal reference' do + let(:reference) { commit.id } + + # Let's test a variety of commit SHA sizes just to be paranoid + [6, 8, 12, 18, 20, 32, 40].each do |size| + it "links to a valid reference of #{size} characters" do + doc = filter("See #{reference[0...size]}") + + expect(doc.css('a').first.text).to eq reference[0...size] + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_commit_url(project.namespace, project, reference) + end + end + + it 'links with adjacent text' do + doc = filter("See (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid commit IDs' do + exp = act = "See #{reference.reverse}" + + expect(project).to receive(:valid_repo?).and_return(true) + expect(project.repository).to receive(:commit).with(reference.reverse) + expect(filter(act).to_html).to eq exp + end + + it 'includes a title attribute' do + doc = filter("See #{reference}") + expect(doc.css('a').first.attr('title')).to eq commit.link_title + end + + it 'escapes the title attribute' do + allow_any_instance_of(Commit).to receive(:title).and_return(%{"></a>whatever<a title="}) + + doc = filter("See #{reference}") + expect(doc.text).to eq "See #{commit.id}" + end + + it 'includes default classes' do + doc = filter("See #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-commit' + end + + it 'includes an optional custom class' do + doc = filter("See #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("See #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.namespace_project_commit_url(project.namespace, project, reference, only_path: true) + end + end + + context 'cross-project reference' do + let(:namespace) { create(:namespace, name: 'cross-reference') } + let(:project2) { create(:project, namespace: namespace) } + let(:commit) { project.repository.commit } + let(:reference) { "#{project2.path_with_namespace}@#{commit.id}" } + + context 'when user can access reference' do + before { allow_cross_reference! } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_commit_url(project2.namespace, project2, commit.id) + end + + it 'links with adjacent text' do + doc = filter("Fixed (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid commit IDs on the referenced project' do + exp = act = "Committed #{project2.path_with_namespace}##{commit.id.reverse}" + expect(filter(act).to_html).to eq exp + end + end + + context 'when user cannot access reference' do + before { disallow_cross_reference! } + + it 'ignores valid references' do + exp = act = "See #{reference}" + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/cross_project_reference_spec.rb b/spec/lib/gitlab/markdown/cross_project_reference_spec.rb new file mode 100644 index 00000000000..4698d6138c2 --- /dev/null +++ b/spec/lib/gitlab/markdown/cross_project_reference_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe CrossProjectReference do + # context in the html-pipeline sense, not in the rspec sense + let(:context) do + { + current_user: double('user'), + project: double('project') + } + end + + include described_class + + describe '#project_from_ref' do + context 'when no project was referenced' do + it 'returns the project from context' do + expect(project_from_ref(nil)).to eq context[:project] + end + end + + context 'when referenced project does not exist' do + it 'returns nil' do + expect(project_from_ref('invalid/reference')).to be_nil + end + end + + context 'when referenced project exists' do + let(:project2) { double('referenced project') } + + before do + expect(Project).to receive(:find_with_namespace). + with('cross/reference').and_return(project2) + end + + context 'and the user has permission to read it' do + it 'returns the referenced project' do + expect(self).to receive(:user_can_reference_project?). + with(project2).and_return(true) + + expect(project_from_ref('cross/reference')).to eq project2 + end + end + + context 'and the user does not have permission to read it' do + it 'returns nil' do + expect(self).to receive(:user_can_reference_project?). + with(project2).and_return(false) + + expect(project_from_ref('cross/reference')).to be_nil + end + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/external_issue_reference_filter_spec.rb b/spec/lib/gitlab/markdown/external_issue_reference_filter_spec.rb new file mode 100644 index 00000000000..27e930ef7da --- /dev/null +++ b/spec/lib/gitlab/markdown/external_issue_reference_filter_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe ExternalIssueReferenceFilter do + include ReferenceFilterSpecHelper + + def helper + IssuesHelper + end + + let(:project) { create(:empty_project) } + let(:issue) { double('issue', iid: 123) } + + context 'JIRA issue references' do + let(:reference) { "JIRA-#{issue.iid}" } + + before do + jira = project.create_jira_service + + props = { + 'title' => 'JIRA tracker', + 'project_url' => 'http://jira.example/issues/?jql=project=A', + 'issues_url' => 'http://jira.example/browse/:id', + 'new_issue_url' => 'http://jira.example/secure/CreateIssue.jspa' + } + + jira.update_attributes(properties: props, active: true) + end + + after do + project.jira_service.destroy + end + + it 'requires project context' do + expect { described_class.call('Issue JIRA-123', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Issue JIRA-#{issue.iid}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + it 'ignores valid references when using default tracker' do + expect(project).to receive(:default_issues_tracker?).and_return(true) + + exp = act = "Issue #{reference}" + expect(filter(act).to_html).to eq exp + end + + %w(pre code a style).each do |elem| + it "ignores references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Issue #{reference}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + it 'links to a valid reference' do + doc = filter("Issue #{reference}") + expect(doc.css('a').first.attr('href')) + .to eq helper.url_for_issue(reference, project) + end + + it 'links to the external tracker' do + doc = filter("Issue #{reference}") + link = doc.css('a').first.attr('href') + + expect(link).to eq "http://jira.example/browse/#{reference}" + end + + it 'links with adjacent text' do + doc = filter("Issue (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{reference}<\/a>\.\)/) + end + + it 'includes a title attribute' do + doc = filter("Issue #{reference}") + expect(doc.css('a').first.attr('title')).to eq "Issue in JIRA tracker" + end + + it 'escapes the title attribute' do + allow(project.external_issue_tracker).to receive(:title). + and_return(%{"></a>whatever<a title="}) + + doc = filter("Issue #{reference}") + expect(doc.text).to eq "Issue #{reference}" + end + + it 'includes default classes' do + doc = filter("Issue #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue' + end + + it 'includes an optional custom class' do + doc = filter("Issue #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Issue #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).to eq helper.url_for_issue("#{reference}", project, only_path: true) + end + end + end +end diff --git a/spec/lib/gitlab/markdown/issue_reference_filter_spec.rb b/spec/lib/gitlab/markdown/issue_reference_filter_spec.rb new file mode 100644 index 00000000000..f95b37d6954 --- /dev/null +++ b/spec/lib/gitlab/markdown/issue_reference_filter_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe IssueReferenceFilter do + include ReferenceFilterSpecHelper + + def helper + IssuesHelper + end + + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + + it 'requires project context' do + expect { described_class.call('Issue #123', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Issue ##{issue.iid}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'internal reference' do + let(:reference) { "##{issue.iid}" } + + it 'ignores valid references when using non-default tracker' do + expect(project).to receive(:issue_exists?).with(issue.iid).and_return(false) + + exp = act = "Issue ##{issue.iid}" + expect(filter(act).to_html).to eq exp + end + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq helper.url_for_issue(issue.iid, project) + end + + it 'links with adjacent text' do + doc = filter("Fixed (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid issue IDs' do + exp = act = "Fixed ##{issue.iid + 1}" + + expect(project).to receive(:issue_exists?).with(issue.iid + 1) + expect(filter(act).to_html).to eq exp + end + + it 'includes a title attribute' do + doc = filter("Issue #{reference}") + expect(doc.css('a').first.attr('title')).to eq "Issue: #{issue.title}" + end + + it 'escapes the title attribute' do + issue.update_attribute(:title, %{"></a>whatever<a title="}) + + doc = filter("Issue #{reference}") + expect(doc.text).to eq "Issue #{reference}" + end + + it 'includes default classes' do + doc = filter("Issue #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue' + end + + it 'includes an optional custom class' do + doc = filter("Issue #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Issue #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq helper.url_for_issue(issue.iid, project, only_path: true) + end + end + + context 'cross-project reference' do + let(:namespace) { create(:namespace, name: 'cross-reference') } + let(:project2) { create(:empty_project, namespace: namespace) } + let(:issue) { create(:issue, project: project2) } + let(:reference) { "#{project2.path_with_namespace}##{issue.iid}" } + + context 'when user can access reference' do + before { allow_cross_reference! } + + it 'ignores valid references when cross-reference project uses external tracker' do + expect_any_instance_of(Project).to receive(:issue_exists?). + with(issue.iid).and_return(false) + + exp = act = "Issue ##{issue.iid}" + expect(filter(act).to_html).to eq exp + end + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq helper.url_for_issue(issue.iid, project2) + end + + it 'links with adjacent text' do + doc = filter("Fixed (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid issue IDs on the referenced project' do + exp = act = "Fixed #{project2.path_with_namespace}##{issue.iid + 1}" + + expect(filter(act).to_html).to eq exp + end + end + + context 'when user cannot access reference' do + before { disallow_cross_reference! } + + it 'ignores valid references' do + exp = act = "See #{reference}" + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/label_reference_filter_spec.rb b/spec/lib/gitlab/markdown/label_reference_filter_spec.rb new file mode 100644 index 00000000000..c84e568e172 --- /dev/null +++ b/spec/lib/gitlab/markdown/label_reference_filter_spec.rb @@ -0,0 +1,148 @@ +require 'spec_helper' +require 'html/pipeline' + +module Gitlab::Markdown + describe LabelReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:empty_project) } + let(:label) { create(:label, project: project) } + let(:reference) { "~#{label.id}" } + + it 'requires project context' do + expect { described_class.call('Label ~123', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Label #{reference}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + it 'includes default classes' do + doc = filter("Label #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-label' + end + + it 'includes an optional custom class' do + doc = filter("Label #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Label #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.namespace_project_issues_url(project.namespace, project, label_name: label.name, only_path: true) + end + + describe 'label span element' do + it 'includes default classes' do + doc = filter("Label #{reference}") + expect(doc.css('a span').first.attr('class')).to eq 'label color-label' + end + + it 'includes a style attribute' do + doc = filter("Label #{reference}") + expect(doc.css('a span').first.attr('style')).to match(/\Abackground-color: #\h{6}; color: #\h{6}\z/) + end + end + + context 'Integer-based references' do + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_issues_url(project.namespace, project, label_name: label.name) + end + + it 'links with adjacent text' do + doc = filter("Label (#{reference}.)") + expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + end + + it 'ignores invalid label IDs' do + exp = act = "Label ~#{label.id + 1}" + + expect(filter(act).to_html).to eq exp + end + end + + context 'String-based single-word references' do + let(:label) { create(:label, name: 'gfm', project: project) } + let(:reference) { "~#{label.name}" } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_issues_url(project.namespace, project, label_name: label.name) + expect(doc.text).to eq 'See gfm' + end + + it 'links with adjacent text' do + doc = filter("Label (#{reference}.)") + expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + end + + it 'ignores invalid label names' do + exp = act = "Label ~#{label.name.reverse}" + + expect(filter(act).to_html).to eq exp + end + end + + context 'String-based multi-word references in quotes' do + let(:label) { create(:label, name: 'gfm references', project: project) } + + context 'in single quotes' do + let(:reference) { "~'#{label.name}'" } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_issues_url(project.namespace, project, label_name: label.name) + expect(doc.text).to eq 'See gfm references' + end + + it 'links with adjacent text' do + doc = filter("Label (#{reference}.)") + expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + end + + it 'ignores invalid label names' do + exp = act = "Label ~'#{label.name.reverse}'" + + expect(filter(act).to_html).to eq exp + end + end + + context 'in double quotes' do + let(:reference) { %(~"#{label.name}") } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_issues_url(project.namespace, project, label_name: label.name) + expect(doc.text).to eq 'See gfm references' + end + + it 'links with adjacent text' do + doc = filter("Label (#{reference}.)") + expect(doc.to_html).to match(%r(\(<a.+><span.+>#{label.name}</span></a>\.\))) + end + + it 'ignores invalid label names' do + exp = act = %(Label ~"#{label.name.reverse}") + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/merge_request_reference_filter_spec.rb b/spec/lib/gitlab/markdown/merge_request_reference_filter_spec.rb new file mode 100644 index 00000000000..0f66442269b --- /dev/null +++ b/spec/lib/gitlab/markdown/merge_request_reference_filter_spec.rb @@ -0,0 +1,114 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe MergeRequestReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:project) } + let(:merge) { create(:merge_request, source_project: project) } + + it 'requires project context' do + expect { described_class.call('MergeRequest !123', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Merge !#{merge.iid}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'internal reference' do + let(:reference) { "!#{merge.iid}" } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_merge_request_url(project.namespace, project, merge) + end + + it 'links with adjacent text' do + doc = filter("Merge (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid merge IDs' do + exp = act = "Merge !#{merge.iid + 1}" + + expect(filter(act).to_html).to eq exp + end + + it 'includes a title attribute' do + doc = filter("Merge #{reference}") + expect(doc.css('a').first.attr('title')).to eq "Merge Request: #{merge.title}" + end + + it 'escapes the title attribute' do + merge.update_attribute(:title, %{"></a>whatever<a title="}) + + doc = filter("Merge #{reference}") + expect(doc.text).to eq "Merge #{reference}" + end + + it 'includes default classes' do + doc = filter("Merge #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-merge_request' + end + + it 'includes an optional custom class' do + doc = filter("Merge #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Merge #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.namespace_project_merge_request_url(project.namespace, project, merge, only_path: true) + end + end + + context 'cross-project reference' do + let(:namespace) { create(:namespace, name: 'cross-reference') } + let(:project2) { create(:project, namespace: namespace) } + let(:merge) { create(:merge_request, source_project: project2) } + let(:reference) { "#{project2.path_with_namespace}!#{merge.iid}" } + + context 'when user can access reference' do + before { allow_cross_reference! } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_merge_request_url(project2.namespace, + project, merge) + end + + it 'links with adjacent text' do + doc = filter("Merge (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid merge IDs on the referenced project' do + exp = act = "Merge #{project2.path_with_namespace}!#{merge.iid + 1}" + + expect(filter(act).to_html).to eq exp + end + end + + context 'when user cannot access reference' do + before { disallow_cross_reference! } + + it 'ignores valid references' do + exp = act = "See #{reference}" + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/snippet_reference_filter_spec.rb b/spec/lib/gitlab/markdown/snippet_reference_filter_spec.rb new file mode 100644 index 00000000000..79533a90b55 --- /dev/null +++ b/spec/lib/gitlab/markdown/snippet_reference_filter_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe SnippetReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:empty_project) } + let(:snippet) { create(:project_snippet, project: project) } + let(:reference) { "$#{snippet.id}" } + + it 'requires project context' do + expect { described_class.call('Snippet $123', {}) }. + to raise_error(ArgumentError, /:project/) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Snippet #{reference}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'internal reference' do + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')).to eq urls. + namespace_project_snippet_url(project.namespace, project, snippet) + end + + it 'links with adjacent text' do + doc = filter("Snippet (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid snippet IDs' do + exp = act = "Snippet $#{snippet.id + 1}" + + expect(filter(act).to_html).to eq exp + end + + it 'includes a title attribute' do + doc = filter("Snippet #{reference}") + expect(doc.css('a').first.attr('title')).to eq "Snippet: #{snippet.title}" + end + + it 'escapes the title attribute' do + snippet.update_attribute(:title, %{"></a>whatever<a title="}) + + doc = filter("Snippet #{reference}") + expect(doc.text).to eq "Snippet #{reference}" + end + + it 'includes default classes' do + doc = filter("Snippet #{reference}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-snippet' + end + + it 'includes an optional custom class' do + doc = filter("Snippet #{reference}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Snippet #{reference}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.namespace_project_snippet_url(project.namespace, project, snippet, only_path: true) + end + end + + context 'cross-project reference' do + let(:namespace) { create(:namespace, name: 'cross-reference') } + let(:project2) { create(:empty_project, namespace: namespace) } + let(:snippet) { create(:project_snippet, project: project2) } + let(:reference) { "#{project2.path_with_namespace}$#{snippet.id}" } + + context 'when user can access reference' do + before { allow_cross_reference! } + + it 'links to a valid reference' do + doc = filter("See #{reference}") + + expect(doc.css('a').first.attr('href')). + to eq urls.namespace_project_snippet_url(project2.namespace, project2, snippet) + end + + it 'links with adjacent text' do + doc = filter("See (#{reference}.)") + expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(reference)}<\/a>\.\)/) + end + + it 'ignores invalid snippet IDs on the referenced project' do + exp = act = "See #{project2.path_with_namespace}$#{snippet.id + 1}" + + expect(filter(act).to_html).to eq exp + end + end + + context 'when user cannot access reference' do + before { disallow_cross_reference! } + + it 'ignores valid references' do + exp = act = "See #{reference}" + + expect(filter(act).to_html).to eq exp + end + end + end + end +end diff --git a/spec/lib/gitlab/markdown/user_reference_filter_spec.rb b/spec/lib/gitlab/markdown/user_reference_filter_spec.rb new file mode 100644 index 00000000000..a5eb927072e --- /dev/null +++ b/spec/lib/gitlab/markdown/user_reference_filter_spec.rb @@ -0,0 +1,98 @@ +require 'spec_helper' + +module Gitlab::Markdown + describe UserReferenceFilter do + include ReferenceFilterSpecHelper + + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + + it 'requires project context' do + expect { described_class.call('Example @mention', {}) }. + to raise_error(ArgumentError, /:project/) + end + + it 'ignores invalid users' do + exp = act = 'Hey @somebody' + expect(filter(act).to_html).to eq(exp) + end + + %w(pre code a style).each do |elem| + it "ignores valid references contained inside '#{elem}' element" do + exp = act = "<#{elem}>Hey @#{user.username}</#{elem}>" + expect(filter(act).to_html).to eq exp + end + end + + context 'mentioning a user' do + it 'links to a User' do + doc = filter("Hey @#{user.username}") + expect(doc.css('a').first.attr('href')).to eq urls.user_url(user) + end + + # TODO (rspeicher): This test might be overkill + it 'links to a User with a period' do + user = create(:user, name: 'alphA.Beta') + + doc = filter("Hey @#{user.username}") + expect(doc.css('a').length).to eq 1 + end + + # TODO (rspeicher): This test might be overkill + it 'links to a User with an underscore' do + user = create(:user, name: 'ping_pong_king') + + doc = filter("Hey @#{user.username}") + expect(doc.css('a').length).to eq 1 + end + end + + context 'mentioning a group' do + let(:group) { create(:group) } + let(:user) { create(:user) } + + it 'links to a Group that the current user can read' do + group.add_user(user, Gitlab::Access::DEVELOPER) + + doc = filter("Hey @#{group.name}", current_user: user) + expect(doc.css('a').first.attr('href')).to eq urls.group_url(group) + end + + it 'ignores references to a Group that the current user cannot read' do + doc = filter("Hey @#{group.name}", current_user: user) + expect(doc.to_html).to eq "Hey @#{group.name}" + end + end + + it 'links with adjacent text' do + skip 'TODO (rspeicher): Re-enable when usernames can\'t end in periods.' + doc = filter("Mention me (@#{user.username}.)") + expect(doc.to_html).to match(/\(<a.+>@#{user.username}<\/a>\.\)/) + end + + it 'supports a special @all mention' do + doc = filter("Hey @all") + expect(doc.css('a').length).to eq 1 + expect(doc.css('a').first.attr('href')) + .to eq urls.namespace_project_url(project.namespace, project) + end + + it 'includes default classes' do + doc = filter("Hey @#{user.username}") + expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member' + end + + it 'includes an optional custom class' do + doc = filter("Hey @#{user.username}", reference_class: 'custom') + expect(doc.css('a').first.attr('class')).to include 'custom' + end + + it 'supports an :only_path context' do + doc = filter("Hey @#{user.username}", only_path: true) + link = doc.css('a').first.attr('href') + + expect(link).not_to match %r(https?://) + expect(link).to eq urls.user_path(user) + end + end +end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index c9fb62b61ae..6fba140f69d 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -74,7 +74,7 @@ describe Gitlab::ReferenceExtractor do end it 'handles all possible kinds of references' do - accessors = Gitlab::Markdown::TYPES.map { |t| "#{t}s".to_sym } + accessors = described_class::TYPES.map { |t| "#{t}s".to_sym } expect(subject).to respond_to(*accessors) end @@ -106,6 +106,15 @@ describe Gitlab::ReferenceExtractor do expect(subject.merge_requests).to eq([@m1, @m0]) end + it 'accesses valid labels' do + @l0 = create(:label, title: 'one', project: project) + @l1 = create(:label, title: 'two', project: project) + @l2 = create(:label) + + subject.analyze("~#{@l0.id}, ~999, ~#{@l2.id}, ~#{@l1.id}") + expect(subject.labels).to eq([@l0, @l1]) + end + it 'accesses valid snippets' do @s0 = create(:project_snippet, project: project) @s1 = create(:project_snippet, project: project) diff --git a/spec/support/reference_filter_spec_helper.rb b/spec/support/reference_filter_spec_helper.rb new file mode 100644 index 00000000000..bcee5715cad --- /dev/null +++ b/spec/support/reference_filter_spec_helper.rb @@ -0,0 +1,47 @@ +# Common methods and setup for Gitlab::Markdown reference filter specs +# +# Must be included into specs manually +module ReferenceFilterSpecHelper + extend ActiveSupport::Concern + + included do + before { set_default_url_options } + end + + # Allow *_url helpers to work + def set_default_url_options + Rails.application.routes.default_url_options = { + host: 'example.foo' + } + end + + # Shortcut to Rails' auto-generated routes helpers, to avoid including the + # module + def urls + Rails.application.routes.url_helpers + end + + # Perform `call` on the described class + # + # Automatically passes the current `project` value to the context if none is + # provided. + # + # html - String text to pass to the filter's `call` method. + # contexts - Hash context for the filter. (default: {project: project}) + # + # Returns the String text returned by the filter's `call` method. + def filter(html, contexts = {}) + contexts.reverse_merge!(project: project) + described_class.call(html, contexts) + end + + def allow_cross_reference! + allow_any_instance_of(described_class). + to receive(:user_can_reference_project?).and_return(true) + end + + def disallow_cross_reference! + allow_any_instance_of(described_class). + to receive(:user_can_reference_project?).and_return(false) + end +end |