Commit e9cc000a authored by Yorick Peterse's avatar Yorick Peterse

Add code for generating Markdown changelogs

This adds the basic building blocks for generating changelogs from a
list of commits. The output is Markdown, and each release section is
generated using a minimal, custom template language.

In an early iteration we used Liquid, but this was found to suffer from
unresolved security issues. Alternative template languages had their own
share of issues that made using them not an option. The template engine
introduced in this commit is the bare minimum that we need for changelog
generation, and works by compiling the user provided template to an ERB
template; making sure the user can't run arbitrary code. Using ERB
allows us to offload the heavy lifting of a template engine to ERB,
instead of having to write and maintain such an engine ourselves.

The use of a template engine gives users control over how they want to
present their changelog data, without GitLab having to provide
potentially dozens of settings to enable this.

This builds on the changes introduced in merge requests
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49243 and
https://gitlab.com/gitlab-org/gitaly/-/merge_requests/2842.

See https://gitlab.com/gitlab-com/gl-infra/delivery/-/issues/1369 for
more information.
parent 45a49305
......@@ -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