Commit 9efbff2d authored by Matthias Käppler's avatar Matthias Käppler

Merge branch 'changelog-generator' into 'master'

Add code for generating Markdown changelogs

See merge request gitlab-org/gitlab!50063
parents 7434169a e9cc000a
......@@ -1059,6 +1059,10 @@ class Repository
blob_data_at(sha, '.lfsconfig')
end
def changelog_config(ref = 'HEAD')
blob_data_at(ref, Gitlab::Changelog::Config::FILE_PATH)
end
def fetch_ref(source_repository, source_ref:, target_ref:)
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
......
# frozen_string_literal: true
module Gitlab
module Changelog
# A class used for committing a release's changelog to a Git repository.
class Committer
CommitError = Class.new(StandardError)
def initialize(project, user)
@project = project
@user = user
end
# Commits a release's changelog to a file on a branch.
#
# The `release` argument is a `Gitlab::Changelog::Release` for which to
# update the changelog.
#
# The `file` argument specifies the path to commit the changes to.
#
# The `branch` argument specifies the branch to commit the changes on.
#
# The `message` argument specifies the commit message to use.
def commit(release:, file:, branch:, message:)
# When retrying, we need to reprocess the existing changelog from
# scratch, otherwise we may end up throwing away changes. As such, all
# the logic is contained within the retry block.
Retriable.retriable(on: CommitError) do
commit = @project.commit(branch)
content = blob_content(file, commit)
# If the release has already been added (e.g. concurrently by another
# API call), we don't want to add it again.
break if content&.match?(release.header_start_pattern)
service = Files::MultiService.new(
@project,
@user,
commit_message: message,
branch_name: branch,
start_branch: branch,
actions: [
{
action: content ? 'update' : 'create',
content: Generator.new(content.to_s).add(release),
file_path: file,
last_commit_id: commit&.sha
}
]
)
result = service.execute
raise CommitError.new(result[:message]) if result[:status] != :success
end
end
def blob_content(file, commit = nil)
return unless commit
@project.repository.blob_at(commit.sha, file)&.data
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Changelog
# Configuration settings used when generating changelogs.
class Config
ConfigError = Class.new(StandardError)
# When rendering changelog entries, authors are not included.
AUTHORS_NONE = 'none'
# The path to the configuration file as stored in the project's Git
# repository.
FILE_PATH = '.gitlab/changelog_config.yml'
# The default date format to use for formatting release dates.
DEFAULT_DATE_FORMAT = '%Y-%m-%d'
# The default template to use for generating release sections.
DEFAULT_TEMPLATE = File.read(File.join(__dir__, 'template.tpl'))
attr_accessor :date_format, :categories, :template
def self.from_git(project)
if (yaml = project.repository.changelog_config)
from_hash(project, YAML.safe_load(yaml))
else
new(project)
end
end
def self.from_hash(project, hash)
config = new(project)
if (date = hash['date_format'])
config.date_format = date
end
if (template = hash['template'])
config.template = Template::Compiler.new.compile(template)
end
if (categories = hash['categories'])
if categories.is_a?(Hash)
config.categories = categories
else
raise ConfigError, 'The "categories" configuration key must be a Hash'
end
end
config
end
def initialize(project)
@project = project
@date_format = DEFAULT_DATE_FORMAT
@template = Template::Compiler.new.compile(DEFAULT_TEMPLATE)
@categories = {}
end
def contributor?(user)
@project.team.contributor?(user)
end
def category(name)
@categories[name] || name
end
def format_date(date)
date.strftime(@date_format)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Changelog
# Parsing and generating of Markdown changelogs.
class Generator
# The regex used to parse a release header.
RELEASE_REGEX =
/^##\s+(?<version>#{Gitlab::Regex.unbounded_semver_regex})/.freeze
# The `input` argument must be a `String` containing the existing
# changelog Markdown. If no changelog exists, this should be an empty
# `String`.
def initialize(input = '')
@lines = input.lines
@locations = {}
@lines.each_with_index do |line, index|
matches = line.match(RELEASE_REGEX)
next if !matches || !matches[:version]
@locations[matches[:version]] = index
end
end
# Generates the Markdown for the given release and returns the new
# changelog Markdown content.
#
# The `release` argument must be an instance of
# `Gitlab::Changelog::Release`.
def add(release)
versions = [release.version, *@locations.keys]
VersionSorter.rsort!(versions)
new_index = versions.index(release.version)
new_lines = @lines.dup
markdown = release.to_markdown
if (insert_after = versions[new_index + 1])
line_index = @locations[insert_after]
new_lines.insert(line_index, markdown)
else
# When adding to the end of the changelog, the previous section only
# has a single newline, resulting in the release section title
# following it immediately. When this is the case, we insert an extra
# empty line to keep the changelog readable in its raw form.
new_lines.push("\n") if versions.length > 1
new_lines.push(markdown.rstrip)
new_lines.push("\n")
end
new_lines.join
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Changelog
# A release to add to a changelog.
class Release
attr_reader :version
def initialize(version:, date:, config:)
@version = version
@date = date
@config = config
@entries = Hash.new { |h, k| h[k] = [] }
# This ensures that entries are presented in the same order as the
# categories Hash in the user's configuration.
@config.categories.values.each do |category|
@entries[category] = []
end
end
def add_entry(
title:,
commit:,
category:,
author: nil,
merge_request: nil
)
# When changing these fields, keep in mind that this needs to be
# backwards compatible. For example, you can't just remove a field as
# this will break the changelog generation process for existing users.
entry = {
'title' => title,
'commit' => {
'reference' => commit.to_reference(full: true),
'trailers' => commit.trailers
}
}
if author
entry['author'] = {
'reference' => author.to_reference(full: true),
'contributor' => @config.contributor?(author)
}
end
if merge_request
entry['merge_request'] = {
'reference' => merge_request.to_reference(full: true)
}
end
@entries[@config.category(category)] << entry
end
def to_markdown
# While not critical, we would like release sections to be separated by
# an empty line in the changelog; ensuring it's readable even in its
# raw form.
#
# Since it can be a bit tricky to get this right using Liquid, we
# enforce an empty line separator ourselves.
markdown =
@config.template.render('categories' => entries_for_template).strip
# The release header can't be changed using the Liquid template, as we
# need this to be in a known format. Without this restriction, we won't
# know where to insert a new release section in an existing changelog.
"## #{@version} (#{release_date})\n\n#{markdown}\n\n"
end
def header_start_pattern
/^##\s*#{Regexp.escape(@version)}/
end
private
def release_date
@config.format_date(@date)
end
def entries_for_template
@entries.map do |category, entries|
{
'title' => category,
'count' => entries.length,
'single_change' => entries.length == 1,
'entries' => entries
}
end
end
end
end
end
{% if categories %}
{% each categories %}
### {{ title }} ({% if single_change %}1 change{% else %}{{ count }} changes{% end %})
{% each entries %}
- [{{ title }}]({{ commit.reference }})\
{% if author.contributor %} by {{ author.reference }}{% end %}\
{% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %}
{% end %}
{% end %}
{% else %}
No changes.
{% end %}
# frozen_string_literal: true
module Gitlab
module Changelog
module Template
# Compiler is used for turning a minimal user templating language into an
# ERB template, without giving the user access to run arbitrary code.
#
# The template syntax is deliberately made as minimal as possible, and
# only supports the following:
#
# * Printing a value
# * Iterating over collections
# * if/else
#
# The syntax looks as follows:
#
# {% each users %}
#
# Name: {{user}}
# Likes cats: {% if likes_cats %}yes{% else %}no{% end %}
#
# {% end %}
#
# Newlines can be escaped by ending a line with a backslash. So this:
#
# foo \
# bar
#
# Is the same as this:
#
# foo bar
#
# Templates are compiled into ERB templates, while taking care to make
# sure the user can't run arbitrary code. By using ERB we can let it do
# the heavy lifting of rendering data; all we need to provide is a
# translation layer.
#
# # Security
#
# The template syntax this compiler exposes is safe to be used by
# untrusted users. Not only are they unable to run arbitrary code, the
# compiler also enforces a limit on the integer sizes and the number of
# nested loops. ERB tags added by the user are also disabled.
class Compiler
# A pattern to match a single integer, with an upper size limit.
#
# We enforce a limit of 10 digits (= a 32 bits integer) so users can't
# trigger the allocation of infinitely large bignums, or trigger
# RangeError errors when using such integers to access an array value.
INTEGER = /^\d{1,10}$/.freeze
# The name/path of a variable, such as `user.address.city`.
#
# It's important that this regular expression _doesn't_ allow for
# anything but letters, numbers, and underscores, otherwise a user may
# use those to "escape" our template and run arbirtary Ruby code. For
# example, take this variable:
#
# {{') ::Kernel.exit #'}}
#
# This would then be compiled into:
#
# <%= read(variables, '') ::Kernel.exit #'') %>
#
# Restricting the allowed characters makes this impossible.
VAR_NAME = /([\w\.]+)/.freeze
# A variable tag, such as `{{username}}`.
VAR = /{{ \s* #{VAR_NAME} \s* }}/x.freeze
# The opening tag for a statement.
STM_START = /{% \s*/x.freeze
# The closing tag for a statement.
STM_END = /\s* %}/x.freeze
# A regular `end` closing tag.
NORMAL_END = /#{STM_START} end #{STM_END}/x.freeze
# An `end` closing tag on its own line, without any non-whitespace
# preceding or following it.
#
# These tags need some special care to make it easier to control
# whitespace.
LONELY_END = /^\s*#{NORMAL_END}\s$/x.freeze
# An `else` tag.
ELSE = /#{STM_START} else #{STM_END}/x.freeze
# The start of an `each` tag.
EACH = /#{STM_START} each \s+ #{VAR_NAME} #{STM_END}/x.freeze
# The start of an `if` tag.
IF = /#{STM_START} if \s+ #{VAR_NAME} #{STM_END}/x.freeze
# The pattern to use for escaping newlines.
ESCAPED_NEWLINE = /\\\n$/.freeze
# The start tag for ERB tags. These tags will be escaped, preventing
# users FROM USING erb DIRECTLY.
ERB_START_TAG = '<%'
def compile(template)
transformed_lines = ['<% it = variables %>']
template.each_line { |line| transformed_lines << transform(line) }
Template.new(transformed_lines.join)
end
def transform(line)
line.gsub!(ESCAPED_NEWLINE, '')
line.gsub!(ERB_START_TAG, '<%%')
# This replacement ensures that "end" blocks on their own lines
# don't add extra newlines. Using an ERB -%> tag sadly swallows too
# many newlines.
line.gsub!(LONELY_END, '<% end %>')
line.gsub!(NORMAL_END, '<% end %>')
line.gsub!(ELSE, '<% else -%>')
line.gsub!(EACH) do
# No, `it; variables` isn't a syntax error. Using `;` marks
# `variables` as block-local, making it possible to re-assign it
# without affecting outer definitions of this variable. We use
# this to scope template variables to the right input Hash.
"<% each(#{read_path(Regexp.last_match(1))}) do |it; variables| -%><% variables = it -%>"
end
line.gsub!(IF) { "<% if truthy?(#{read_path(Regexp.last_match(1))}) -%>" }
line.gsub!(VAR) { "<%= #{read_path(Regexp.last_match(1))} %>" }
line
end
def read_path(path)
return path if path == 'it'
args = path.split('.')
args.map! { |arg| arg.match?(INTEGER) ? "#{arg}" : "'#{arg}'" }
"read(variables, #{args.join(', ')})"
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Changelog
module Template
# Context is used to provide a binding/context to ERB templates used for
# rendering changelogs.
#
# This class extends BasicObject so that we only expose the bare minimum
# needed to render the ERB template.
class Context < BasicObject
MAX_NESTED_LOOPS = 4
def initialize(variables)
@variables = variables
@loop_nesting = 0
end
def get_binding
::Kernel.binding
end
def each(value, &block)
max = MAX_NESTED_LOOPS
if @loop_nesting == max
::Kernel.raise(
::Template::TemplateError.new("You can only nest up to #{max} loops")
)
end
@loop_nesting += 1
result = value.each(&block) if value.respond_to?(:each)
@loop_nesting -= 1
result
end
# rubocop: disable Style/TrivialAccessors
def variables
@variables
end
# rubocop: enable Style/TrivialAccessors
def read(source, *steps)
current = source
steps.each do |step|
case current
when ::Hash
current = current[step]
when ::Array
return '' unless step.is_a?(::Integer)
current = current[step]
else
break
end
end
current
end
def truthy?(value)
value.respond_to?(:any?) ? value.any? : !!value
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Changelog
module Template
# A wrapper around an ERB template user for rendering changelogs.
class Template
TemplateError = Class.new(StandardError)
def initialize(erb)
# Don't change the trim mode, as this may require changes to the
# regular expressions used to turn the template syntax into ERB
# tags.
@erb = ERB.new(erb, trim_mode: '-')
end
def render(data)
context = Context.new(data).get_binding
# ERB produces a SyntaxError when processing templates, as it
# internally uses eval() for this.
@erb.result(context)
rescue SyntaxError
raise TemplateError.new("The template's syntax is invalid")
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Committer do
let(:project) { create(:project, :repository) }
let(:user) { project.creator }
let(:committer) { described_class.new(project, user) }
let(:config) { Gitlab::Changelog::Config.new(project) }
describe '#commit' do
context "when the release isn't in the changelog" do
it 'commits the changes' do
release = Gitlab::Changelog::Release
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
committer.commit(
release: release,
file: 'CHANGELOG.md',
branch: 'master',
message: 'Test commit'
)
content = project.repository.blob_at('master', 'CHANGELOG.md').data
expect(content).to eq(<<~MARKDOWN)
## 1.0.0 (2020-01-01)
No changes.
MARKDOWN
end
end
context 'when the release is already in the changelog' do
it "doesn't commit the changes" do
release = Gitlab::Changelog::Release
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
2.times do
committer.commit(
release: release,
file: 'CHANGELOG.md',
branch: 'master',
message: 'Test commit'
)
end
content = project.repository.blob_at('master', 'CHANGELOG.md').data
expect(content).to eq(<<~MARKDOWN)
## 1.0.0 (2020-01-01)
No changes.
MARKDOWN
end
end
context 'when committing the changes fails' do
it 'retries the operation' do
release = Gitlab::Changelog::Release
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
service = instance_spy(Files::MultiService)
errored = false
allow(Files::MultiService)
.to receive(:new)
.and_return(service)
allow(service).to receive(:execute) do
if errored
{ status: :success }
else
errored = true
{ status: :error }
end
end
expect do
committer.commit(
release: release,
file: 'CHANGELOG.md',
branch: 'master',
message: 'Test commit'
)
end.not_to raise_error
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Config do
let(:project) { build_stubbed(:project) }
describe '.from_git' do
it 'retrieves the configuration from Git' do
allow(project.repository)
.to receive(:changelog_config)
.and_return("---\ndate_format: '%Y'")
expect(described_class)
.to receive(:from_hash)
.with(project, 'date_format' => '%Y')
described_class.from_git(project)
end
it 'returns the default configuration when no YAML file exists in Git' do
allow(project.repository)
.to receive(:changelog_config)
.and_return(nil)
expect(described_class)
.to receive(:new)
.with(project)
described_class.from_git(project)
end
end
describe '.from_hash' do
it 'sets the configuration according to a Hash' do
config = described_class.from_hash(
project,
'date_format' => 'foo',
'template' => 'bar',
'categories' => { 'foo' => 'bar' }
)
expect(config.date_format).to eq('foo')
expect(config.template).to be_instance_of(Gitlab::Changelog::Template::Template)
expect(config.categories).to eq({ 'foo' => 'bar' })
end
it 'raises ConfigError when the categories are not a Hash' do
expect { described_class.from_hash(project, 'categories' => 10) }
.to raise_error(described_class::ConfigError)
end
end
describe '#contributor?' do
it 'returns true if a user is a contributor' do
user = build_stubbed(:author)
allow(project.team).to receive(:contributor?).with(user).and_return(true)
expect(described_class.new(project).contributor?(user)).to eq(true)
end
it "returns true if a user isn't a contributor" do
user = build_stubbed(:author)
allow(project.team).to receive(:contributor?).with(user).and_return(false)
expect(described_class.new(project).contributor?(user)).to eq(false)
end
end
describe '#category' do
it 'returns the name of a category' do
config = described_class.new(project)
config.categories['foo'] = 'Foo'
expect(config.category('foo')).to eq('Foo')
end
it 'returns the raw category name when no alternative name is configured' do
config = described_class.new(project)
expect(config.category('bla')).to eq('bla')
end
end
describe '#format_date' do
it 'formats a date according to the configured date format' do
config = described_class.new(project)
time = Time.utc(2021, 1, 5)
expect(config.format_date(time)).to eq('2021-01-05')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Generator do
describe '#add' do
let(:project) { build_stubbed(:project) }
let(:author) { build_stubbed(:user) }
let(:commit) { build_stubbed(:commit) }
let(:config) { Gitlab::Changelog::Config.new(project) }
it 'generates the Markdown for the first release' do
release = Gitlab::Changelog::Release.new(
version: '1.0.0',
date: Time.utc(2021, 1, 5),
config: config
)
release.add_entry(
title: 'This is a new change',
commit: commit,
category: 'added',
author: author
)
gen = described_class.new('')
expect(gen.add(release)).to eq(<<~MARKDOWN)
## 1.0.0 (2021-01-05)
### added (1 change)
- [This is a new change](#{commit.to_reference(full: true)})
MARKDOWN
end
it 'generates the Markdown for a newer release' do
release = Gitlab::Changelog::Release.new(
version: '2.0.0',
date: Time.utc(2021, 1, 5),
config: config
)
release.add_entry(
title: 'This is a new change',
commit: commit,
category: 'added',
author: author
)
gen = described_class.new(<<~MARKDOWN)
This is a changelog file.
## 1.0.0
This is the changelog for version 1.0.0.
MARKDOWN
expect(gen.add(release)).to eq(<<~MARKDOWN)
This is a changelog file.
## 2.0.0 (2021-01-05)
### added (1 change)
- [This is a new change](#{commit.to_reference(full: true)})
## 1.0.0
This is the changelog for version 1.0.0.
MARKDOWN
end
it 'generates the Markdown for a patch release' do
release = Gitlab::Changelog::Release.new(
version: '1.1.0',
date: Time.utc(2021, 1, 5),
config: config
)
release.add_entry(
title: 'This is a new change',
commit: commit,
category: 'added',
author: author
)
gen = described_class.new(<<~MARKDOWN)
This is a changelog file.
## 2.0.0
This is another release.
## 1.0.0
This is the changelog for version 1.0.0.
MARKDOWN
expect(gen.add(release)).to eq(<<~MARKDOWN)
This is a changelog file.
## 2.0.0
This is another release.
## 1.1.0 (2021-01-05)
### added (1 change)
- [This is a new change](#{commit.to_reference(full: true)})
## 1.0.0
This is the changelog for version 1.0.0.
MARKDOWN
end
it 'generates the Markdown for an old release' do
release = Gitlab::Changelog::Release.new(
version: '0.5.0',
date: Time.utc(2021, 1, 5),
config: config
)
release.add_entry(
title: 'This is a new change',
commit: commit,
category: 'added',
author: author
)
gen = described_class.new(<<~MARKDOWN)
This is a changelog file.
## 2.0.0
This is another release.
## 1.0.0
This is the changelog for version 1.0.0.
MARKDOWN
expect(gen.add(release)).to eq(<<~MARKDOWN)
This is a changelog file.
## 2.0.0
This is another release.
## 1.0.0
This is the changelog for version 1.0.0.
## 0.5.0 (2021-01-05)
### added (1 change)
- [This is a new change](#{commit.to_reference(full: true)})
MARKDOWN
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Release do
describe '#to_markdown' do
let(:config) { Gitlab::Changelog::Config.new(build_stubbed(:project)) }
let(:commit) { build_stubbed(:commit) }
let(:author) { build_stubbed(:user) }
let(:mr) { build_stubbed(:merge_request) }
let(:release) do
described_class
.new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config)
end
context 'when there are no entries' do
it 'includes a notice about the lack of entries' do
expect(release.to_markdown).to eq(<<~OUT)
## 1.0.0 (2021-01-05)
No changes.
OUT
end
end
context 'when all data is present' do
it 'includes all data' do
allow(config).to receive(:contributor?).with(author).and_return(true)
release.add_entry(
title: 'Entry title',
commit: commit,
category: 'fixed',
author: author,
merge_request: mr
)
expect(release.to_markdown).to eq(<<~OUT)
## 1.0.0 (2021-01-05)
### fixed (1 change)
- [Entry title](#{commit.to_reference(full: true)}) \
by #{author.to_reference(full: true)} \
([merge request](#{mr.to_reference(full: true)}))
OUT
end
end
context 'when no merge request is present' do
it "doesn't include a merge request link" do
allow(config).to receive(:contributor?).with(author).and_return(true)
release.add_entry(
title: 'Entry title',
commit: commit,
category: 'fixed',
author: author
)
expect(release.to_markdown).to eq(<<~OUT)
## 1.0.0 (2021-01-05)
### fixed (1 change)
- [Entry title](#{commit.to_reference(full: true)}) \
by #{author.to_reference(full: true)}
OUT
end
end
context 'when the author is not a contributor' do
it "doesn't include the author" do
allow(config).to receive(:contributor?).with(author).and_return(false)
release.add_entry(
title: 'Entry title',
commit: commit,
category: 'fixed',
author: author
)
expect(release.to_markdown).to eq(<<~OUT)
## 1.0.0 (2021-01-05)
### fixed (1 change)
- [Entry title](#{commit.to_reference(full: true)})
OUT
end
end
end
describe '#header_start_position' do
it 'returns a regular expression for finding the start of a release section' do
config = Gitlab::Changelog::Config.new(build_stubbed(:project))
release = described_class
.new(version: '1.0.0', date: Time.utc(2021, 1, 5), config: config)
expect(release.header_start_pattern).to eq(/^##\s*1\.0\.0/)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Template::Compiler do
def compile(template, data = {})
Gitlab::Changelog::Template::Compiler.new.compile(template).render(data)
end
describe '#compile' do
it 'compiles an empty template' do
expect(compile('')).to eq('')
end
it 'compiles a template with an undefined variable' do
expect(compile('{{number}}')).to eq('')
end
it 'compiles a template with a defined variable' do
expect(compile('{{number}}', 'number' => 42)).to eq('42')
end
it 'compiles a template with the special "it" variable' do
expect(compile('{{it}}', 'values' => 10)).to eq({ 'values' => 10 }.to_s)
end
it 'compiles a template containing an if statement' do
expect(compile('{% if foo %}yes{% end %}', 'foo' => true)).to eq('yes')
end
it 'compiles a template containing an if/else statement' do
expect(compile('{% if foo %}yes{% else %}no{% end %}', 'foo' => false))
.to eq('no')
end
it 'compiles a template that iterates over an Array' do
expect(compile('{% each numbers %}{{it}}{% end %}', 'numbers' => [1, 2, 3]))
.to eq('123')
end
it 'compiles a template that iterates over a Hash' do
output = compile(
'{% each pairs %}{{0}}={{1}}{% end %}',
'pairs' => { 'key' => 'value' }
)
expect(output).to eq('key=value')
end
it 'compiles a template that iterates over a Hash of Arrays' do
output = compile(
'{% each values %}{{key}}{% end %}',
'values' => [{ 'key' => 'value' }]
)
expect(output).to eq('value')
end
it 'compiles a template with a variable path' do
output = compile('{{foo.bar}}', 'foo' => { 'bar' => 10 })
expect(output).to eq('10')
end
it 'compiles a template with a variable path that uses an Array index' do
output = compile('{{foo.values.1}}', 'foo' => { 'values' => [10, 20] })
expect(output).to eq('20')
end
it 'compiles a template with a variable path that uses a Hash and a numeric index' do
output = compile('{{foo.1}}', 'foo' => { 'key' => 'value' })
expect(output).to eq('')
end
it 'compiles a template with a variable path that uses an Array and a String based index' do
output = compile('{{foo.numbers.bla}}', 'foo' => { 'numbers' => [10, 20] })
expect(output).to eq('')
end
it 'ignores ERB tags provided by the user' do
input = '<% exit %> <%= exit %> <%= foo -%>'
expect(compile(input)).to eq(input)
end
it 'removes newlines introduced by end statements on their own lines' do
output = compile(<<~TPL, 'foo' => true)
{% if foo %}
foo
{% end %}
TPL
expect(output).to eq("foo\n")
end
it 'supports escaping of trailing newlines' do
output = compile(<<~TPL)
foo \
bar\
baz
TPL
expect(output).to eq("foo barbaz\n")
end
# rubocop: disable Lint/InterpolationCheck
it 'ignores embedded Ruby expressions' do
input = '#{exit}'
expect(compile(input)).to eq(input)
end
# rubocop: enable Lint/InterpolationCheck
it 'ignores ERB tags inside variable tags' do
input = '{{<%= exit %>}}'
expect(compile(input)).to eq(input)
end
it 'ignores malicious code that tries to escape a variable' do
input = "{{') ::Kernel.exit # '}}"
expect(compile(input)).to eq(input)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment