diff --git a/doc/ci/yaml/inputs.md b/doc/ci/yaml/inputs.md index 1af53d666ce407189c0eadd4ca346b4c996d8999..26d0711066889afef806fb78da1e921c4114fe8d 100644 --- a/doc/ci/yaml/inputs.md +++ b/doc/ci/yaml/inputs.md @@ -140,22 +140,44 @@ Details: spec: inputs: test: - default: '0123456789' + default: 'test $MY_VAR' --- test-job: - script: echo $[[ inputs.test | truncate(1,3) ]] + script: echo $[[ inputs.test | expand_vars | truncate(5,8) ]] ``` -In this example: +In this example, assuming the input uses the default value and `$MY_VAR` is an unmasked project variable with value `my value`: -- The function [`truncate`](#truncate) applies to the value of `inputs.test`. -- Assuming the value of `inputs.test` is `0123456789`, then the output of `script` would be `echo 123`. +1. First, the function [`expand_vars`](#expand_vars) expands the value to `test my value`. +1. Then [`truncate`](#truncate) applies to `test my value` with a character offset of `5` and length `8`. +1. The output of `script` would be `echo my value`. ### Predefined interpolation functions +#### `expand_vars` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/387632) in GitLab 16.5. + +Use `expand_vars` to expand [CI/CD variables](../variables/index.md) in the input value. + +Only variables you can [use with the `include` keyword](includes.md#use-variables-with-include) and which are +**not** [masked](../variables/index.md#mask-a-cicd-variable) can be expanded. +[Nested variable expansion](../variables/where_variables_can_be_used.md#nested-variable-expansion) is not supported. + +Example: + +```yaml +$[[ inputs.test | expand_vars ]] +``` + +Assuming the value of `inputs.test` is `test $MY_VAR`, and the variable `$MY_VAR` is unmasked +with a value of `my value`, then the output would be `test my value`. + #### `truncate` +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/409462) in GitLab 16.3. + Use `truncate` to shorten the interpolated value. For example: - `truncate(,)` diff --git a/lib/expand_variables.rb b/lib/expand_variables.rb index f565eb105ae2bb561fe7d4bda8f2a1b7946afc80..ad5aabfa1f3b181f5845ddfe1600a04f1b8ed147 100644 --- a/lib/expand_variables.rb +++ b/lib/expand_variables.rb @@ -1,18 +1,24 @@ # frozen_string_literal: true module ExpandVariables + VariableExpansionError = Class.new(StandardError) + VARIABLES_REGEXP = /\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/ class << self - def expand(value, variables, expand_file_refs: true) + def expand(value, variables, expand_file_refs: true, fail_on_masked: false) replace_with(value, variables) do |collection, last_match| - match_or_blank_value(collection, last_match, expand_file_refs: expand_file_refs) + match_or_blank_value( + collection, last_match, expand_file_refs: expand_file_refs, fail_on_masked: fail_on_masked + ) end end - def expand_existing(value, variables, expand_file_refs: true) + def expand_existing(value, variables, expand_file_refs: true, fail_on_masked: false) replace_with(value, variables) do |collection, last_match| - match_or_original_value(collection, last_match, expand_file_refs: expand_file_refs) + match_or_original_value( + collection, last_match, expand_file_refs: expand_file_refs, fail_on_masked: fail_on_masked + ) end end @@ -36,12 +42,14 @@ def replace_with(value, variables) end end - def match_or_blank_value(collection, last_match, expand_file_refs:) + def match_or_blank_value(collection, last_match, expand_file_refs:, fail_on_masked:) match = last_match[1] || last_match[2] replacement = collection[match] if replacement.nil? nil + elsif fail_on_masked && replacement.masked? + raise VariableExpansionError, 'masked variables cannot be expanded' elsif replacement.file? expand_file_refs ? replacement.value : last_match else @@ -49,8 +57,10 @@ def match_or_blank_value(collection, last_match, expand_file_refs:) end end - def match_or_original_value(collection, last_match, expand_file_refs:) - match_or_blank_value(collection, last_match, expand_file_refs: expand_file_refs) || last_match[0] + def match_or_original_value(collection, last_match, expand_file_refs:, fail_on_masked:) + match_or_blank_value( + collection, last_match, expand_file_refs: expand_file_refs, fail_on_masked: fail_on_masked + ) || last_match[0] end end end diff --git a/lib/gitlab/ci/config/external/file/base.rb b/lib/gitlab/ci/config/external/file/base.rb index e3a87c8576f216e8147ec9313d13eb9d41cecf45..b3c802e565786b5adb30eed87f164e0a96c4d12d 100644 --- a/lib/gitlab/ci/config/external/file/base.rb +++ b/lib/gitlab/ci/config/external/file/base.rb @@ -114,7 +114,9 @@ def content_inputs def content_result context.logger.instrument(:config_file_fetch_content_hash) do - ::Gitlab::Ci::Config::Yaml::Loader.new(content, inputs: content_inputs).load + ::Gitlab::Ci::Config::Yaml::Loader.new( + content, inputs: content_inputs, variables: context.variables + ).load end end strong_memoize_attr :content_result diff --git a/lib/gitlab/ci/config/interpolation/block.rb b/lib/gitlab/ci/config/interpolation/block.rb index cf8420f924ef40b03b48fe86eb2ecb5273103196..aec19299e86feca4263ce6b8981aacc01f14b4d1 100644 --- a/lib/gitlab/ci/config/interpolation/block.rb +++ b/lib/gitlab/ci/config/interpolation/block.rb @@ -62,7 +62,7 @@ def evaluate! return @errors.concat(access.errors) unless access.valid? return @errors.push('too many functions in interpolation block') if functions.count > MAX_FUNCTIONS - result = Interpolation::FunctionsStack.new(functions).evaluate(access.value) + result = Interpolation::FunctionsStack.new(functions, ctx).evaluate(access.value) if result.success? @value = result.value diff --git a/lib/gitlab/ci/config/interpolation/context.rb b/lib/gitlab/ci/config/interpolation/context.rb index f5e7db032915fd2686730fcd0509ce82aec084a2..19ea619f7da3758558c79f1015b1e22dfec9f85b 100644 --- a/lib/gitlab/ci/config/interpolation/context.rb +++ b/lib/gitlab/ci/config/interpolation/context.rb @@ -14,8 +14,11 @@ class Context MAX_DEPTH = 3 - def initialize(hash) - @context = hash + attr_reader :variables + + def initialize(data, variables: []) + @data = data + @variables = Ci::Variables::Collection.fabricate(variables) raise ContextTooComplexError if depth > MAX_DEPTH end @@ -32,25 +35,25 @@ def errors end def depth - deep_depth(@context) + deep_depth(@data) end def fetch(field) - @context.fetch(field) + @data.fetch(field) end def key?(name) - @context.key?(name) + @data.key?(name) end def to_h - @context.to_h + @data.to_h end private - def deep_depth(context, depth = 0) - values = context.values.map do |value| + def deep_depth(data, depth = 0) + values = data.values.map do |value| if value.is_a?(Hash) deep_depth(value, depth + 1) else @@ -61,10 +64,10 @@ def deep_depth(context, depth = 0) values.max.to_i end - def self.fabricate(context) + def self.fabricate(context, variables: []) case context when Hash - new(context) + new(context, variables: variables) when Interpolation::Context context else diff --git a/lib/gitlab/ci/config/interpolation/functions/base.rb b/lib/gitlab/ci/config/interpolation/functions/base.rb index b9ce8cdc5bc7e2e0e43f66033a1022773ed39158..b04152a1558b135843f7cbed88c371fbc429c9c1 100644 --- a/lib/gitlab/ci/config/interpolation/functions/base.rb +++ b/lib/gitlab/ci/config/interpolation/functions/base.rb @@ -20,9 +20,10 @@ def self.matches?(function_expression) function_expression_pattern.match?(function_expression) end - def initialize(function_expression) + def initialize(function_expression, ctx) @errors = [] @function_args = parse_args(function_expression) + @ctx = ctx end def valid? @@ -35,10 +36,11 @@ def execute(_input_value) private - attr_reader :function_args + attr_reader :function_args, :ctx def error(message) errors << "error in `#{self.class.name}` function: #{message}" + nil end def parse_args(function_expression) diff --git a/lib/gitlab/ci/config/interpolation/functions/expand_vars.rb b/lib/gitlab/ci/config/interpolation/functions/expand_vars.rb new file mode 100644 index 0000000000000000000000000000000000000000..658964018b53a04e1fbc5eb37d19f49cce6d04f6 --- /dev/null +++ b/lib/gitlab/ci/config/interpolation/functions/expand_vars.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Interpolation + module Functions + class ExpandVars < Base + def self.function_expression_pattern + /^#{name}$/ + end + + def self.name + 'expand_vars' + end + + def execute(input_value) + unless input_value.is_a?(String) + error("invalid input type: #{self.class.name} can only be used with string inputs") + return + end + + ExpandVariables.expand_existing(input_value, ctx.variables, fail_on_masked: true) + rescue ExpandVariables::VariableExpansionError => e + error("variable expansion error: #{e.message}") + nil + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/interpolation/functions_stack.rb b/lib/gitlab/ci/config/interpolation/functions_stack.rb index 951d1121d4f130a1a25ef144380d2a0268d99501..4cb3e67b3e3dab6033319fa82e54105d0f929252 100644 --- a/lib/gitlab/ci/config/interpolation/functions_stack.rb +++ b/lib/gitlab/ci/config/interpolation/functions_stack.rb @@ -16,12 +16,14 @@ def success? end FUNCTIONS = [ - Functions::Truncate + Functions::Truncate, + Functions::ExpandVars ].freeze attr_reader :errors - def initialize(function_expressions) + def initialize(function_expressions, ctx) + @ctx = ctx @errors = [] @functions = build_stack(function_expressions) end @@ -48,14 +50,14 @@ def evaluate(input_value) private - attr_reader :functions + attr_reader :functions, :ctx def build_stack(function_expressions) function_expressions.map do |function_expression| matching_function = FUNCTIONS.find { |function| function.matches?(function_expression) } if matching_function.present? - matching_function.new(function_expression) + matching_function.new(function_expression, ctx) else message = "no function matching `#{function_expression}`: " \ 'check that the function name, arguments, and types are correct' diff --git a/lib/gitlab/ci/config/interpolation/interpolator.rb b/lib/gitlab/ci/config/interpolation/interpolator.rb index 95c419d74278ee0f83958cf607fc5ca88a2482e6..5b21b777c1d7de32ae9bf10897efd73ce7a89da3 100644 --- a/lib/gitlab/ci/config/interpolation/interpolator.rb +++ b/lib/gitlab/ci/config/interpolation/interpolator.rb @@ -8,11 +8,12 @@ module Interpolation # Performs CI config file interpolation, and surfaces all possible interpolation errors. # class Interpolator - attr_reader :config, :args, :errors + attr_reader :config, :args, :variables, :errors - def initialize(config, args) + def initialize(config, args, variables) @config = config @args = args.to_h + @variables = variables @errors = [] @interpolated = false end @@ -86,7 +87,7 @@ def inputs end def context - @context ||= Context.new({ inputs: inputs.to_hash }) + @context ||= Context.new({ inputs: inputs.to_hash }, variables: variables) end def template diff --git a/lib/gitlab/ci/config/yaml/loader.rb b/lib/gitlab/ci/config/yaml/loader.rb index 5d56061a8bb4717066f684467f564031c7db535a..c659ad5b8d1b651a1a5a0aff1892d167814688a9 100644 --- a/lib/gitlab/ci/config/yaml/loader.rb +++ b/lib/gitlab/ci/config/yaml/loader.rb @@ -10,9 +10,10 @@ class Loader AVAILABLE_TAGS = [Config::Yaml::Tags::Reference].freeze MAX_DOCUMENTS = 2 - def initialize(content, inputs: {}) + def initialize(content, inputs: {}, variables: []) @content = content @inputs = inputs + @variables = variables end def load @@ -20,7 +21,7 @@ def load return yaml_result unless yaml_result.valid? - interpolator = Interpolation::Interpolator.new(yaml_result, inputs) + interpolator = Interpolation::Interpolator.new(yaml_result, inputs, variables) interpolator.interpolate! @@ -34,7 +35,7 @@ def load private - attr_reader :content, :inputs + attr_reader :content, :inputs, :variables def load_uninterpolated_yaml Yaml::Result.new(config: load_yaml!, error: nil) diff --git a/lib/gitlab/ci/variables/collection/item.rb b/lib/gitlab/ci/variables/collection/item.rb index dc810d51eb444b09435813e5577d79c9b7aa5013..2334db0718fa8fbae1004b4800acbd47512a476c 100644 --- a/lib/gitlab/ci/variables/collection/item.rb +++ b/lib/gitlab/ci/variables/collection/item.rb @@ -34,6 +34,10 @@ def file? @variable.fetch(:file) end + def masked? + @variable.fetch(:masked) + end + def [](key) @variable.fetch(key) end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index ad73665326ab34fcae95b8edf7aab1e1f6943cc7..695e63b6db16b0c83b53a1a0e00a9fc577812b22 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -187,6 +187,102 @@ end end + shared_examples 'masked variable expansion with fail_on_masked true' do |expander| + using RSpec::Parameterized::TableSyntax + + subject { expander.call(value, variables, fail_on_masked: true) } + + where do + { + 'simple expansion with a masked variable': { + value: 'key$variable', + variables: [ + { key: 'variable', value: 'value', masked: true } + ] + }, + 'complex expansion with a masked variable': { + value: 'key${variable}${variable2}', + variables: [ + { key: 'variable', value: 'value', masked: true }, + { key: 'variable2', value: 'result', masked: false } + ] + }, + 'expansion using % with a masked variable': { + value: 'key%variable%', + variables: [ + { key: 'variable', value: 'value', masked: true } + ] + } + } + end + + with_them do + it 'raises an error' do + expect { subject }.to raise_error( + ExpandVariables::VariableExpansionError, /masked variables cannot be expanded/ + ) + end + end + + context 'expansion without a masked variable' do + let(:value) { 'key$variable${variable2}' } + + let(:variables) do + [ + { key: 'variable', value: 'value', masked: false }, + { key: 'variable2', value: 'result', masked: false } + ] + end + + it { is_expected.to eq('keyvalueresult') } + end + end + + shared_examples 'masked variable expansion with fail_on_masked false' do |expander| + using RSpec::Parameterized::TableSyntax + + subject { expander.call(value, variables, fail_on_masked: false) } + + where do + { + 'simple expansion with a masked variable': { + value: 'key$variable', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value', masked: true } + ] + }, + 'complex expansion with a masked variable': { + value: 'key${variable}${variable2}', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value', masked: true }, + { key: 'variable2', value: 'result', masked: false } + ] + }, + 'expansion using % with a masked variable': { + value: 'key%variable%', + result: 'keyvalue', + variables: [ + { key: 'variable', value: 'value', masked: true } + ] + }, + 'expansion without a masked variable': { + value: 'key$variable${variable2}', + result: 'keyvalueresult', + variables: [ + { key: 'variable', value: 'value', masked: false }, + { key: 'variable2', value: 'result', masked: false } + ] + } + } + end + + with_them do + it { is_expected.to eq(result) } + end + end + describe '#expand' do context 'table tests' do it_behaves_like 'common variable expansion', described_class.method(:expand) @@ -195,6 +291,10 @@ it_behaves_like 'file variable expansion with expand_file_refs false', described_class.method(:expand) + it_behaves_like 'masked variable expansion with fail_on_masked true', described_class.method(:expand) + + it_behaves_like 'masked variable expansion with fail_on_masked false', described_class.method(:expand) + context 'with missing variables' do using RSpec::Parameterized::TableSyntax @@ -265,6 +365,10 @@ it_behaves_like 'file variable expansion with expand_file_refs false', described_class.method(:expand_existing) + it_behaves_like 'masked variable expansion with fail_on_masked true', described_class.method(:expand) + + it_behaves_like 'masked variable expansion with fail_on_masked false', described_class.method(:expand) + context 'with missing variables' do using RSpec::Parameterized::TableSyntax diff --git a/spec/lib/gitlab/ci/config/interpolation/context_spec.rb b/spec/lib/gitlab/ci/config/interpolation/context_spec.rb index c90866c986a33f37d2be51e342677aa1b5d6da44..56a572312ebd52f8fe0f3cdb6751aab81d3b634e 100644 --- a/spec/lib/gitlab/ci/config/interpolation/context_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/context_spec.rb @@ -17,6 +17,12 @@ end end + describe '.new' do + it 'returns variables as a Variables::Collection object' do + expect(subject.variables.class).to eq(Gitlab::Ci::Variables::Collection) + end + end + describe '#to_h' do it 'returns the context hash' do expect(subject.to_h).to eq(ctx) diff --git a/spec/lib/gitlab/ci/config/interpolation/functions/base_spec.rb b/spec/lib/gitlab/ci/config/interpolation/functions/base_spec.rb index c193e88dbe2e721ec854df9c5eddcc17dcf586ab..a2b575afb6ffa040c49a985b7524a3c0499e2407 100644 --- a/spec/lib/gitlab/ci/config/interpolation/functions/base_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/functions/base_spec.rb @@ -18,6 +18,6 @@ def self.name it 'defines an expected interface for child classes' do expect { described_class.function_expression_pattern }.to raise_error(NotImplementedError) expect { described_class.name }.to raise_error(NotImplementedError) - expect { custom_function_klass.new('test').execute('input') }.to raise_error(NotImplementedError) + expect { custom_function_klass.new('test', nil).execute('input') }.to raise_error(NotImplementedError) end end diff --git a/spec/lib/gitlab/ci/config/interpolation/functions/expand_vars_spec.rb b/spec/lib/gitlab/ci/config/interpolation/functions/expand_vars_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2a627b435d3a9e8f857a87200eaee4f1f6ae863d --- /dev/null +++ b/spec/lib/gitlab/ci/config/interpolation/functions/expand_vars_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Config::Interpolation::Functions::ExpandVars, feature_category: :pipeline_composition do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'value1', masked: false }, + { key: 'VAR2', value: 'value2', masked: false }, + { key: 'NESTED_VAR', value: '$MY_VAR', masked: false }, + { key: 'MASKED_VAR', value: 'masked', masked: true } + ]) + end + + let(:function_expression) { 'expand_vars' } + let(:ctx) { Gitlab::Ci::Config::Interpolation::Context.new({}, variables: variables) } + + subject(:function) { described_class.new(function_expression, ctx) } + + describe '#execute' do + let(:input_value) { '$VAR1' } + + subject(:execute) { function.execute(input_value) } + + it 'expands the variable' do + expect(execute).to eq('value1') + expect(function).to be_valid + end + + context 'when the variable contains another variable' do + let(:input_value) { '$NESTED_VAR' } + + it 'does not expand the inner variable' do + expect(execute).to eq('$MY_VAR') + expect(function).to be_valid + end + end + + context 'when the variable is masked' do + let(:input_value) { '$MASKED_VAR' } + + it 'returns an error' do + expect(execute).to be_nil + expect(function).not_to be_valid + expect(function.errors).to contain_exactly( + 'error in `expand_vars` function: variable expansion error: masked variables cannot be expanded' + ) + end + end + + context 'when the variable is unknown' do + let(:input_value) { '$UNKNOWN_VAR' } + + it 'does not expand the variable' do + expect(execute).to eq('$UNKNOWN_VAR') + expect(function).to be_valid + end + end + + context 'when there are multiple variables' do + let(:input_value) { '${VAR1} $VAR2 %VAR1%' } + + it 'expands the variables' do + expect(execute).to eq('value1 value2 value1') + expect(function).to be_valid + end + end + + context 'when the input is not a string' do + let(:input_value) { 100 } + + it 'returns an error' do + expect(execute).to be_nil + expect(function).not_to be_valid + expect(function.errors).to contain_exactly( + 'error in `expand_vars` function: invalid input type: expand_vars can only be used with string inputs' + ) + end + end + end + + describe '.matches?' do + it 'matches exactly the expand_vars function with no arguments' do + expect(described_class.matches?('expand_vars')).to be_truthy + expect(described_class.matches?('expand_vars()')).to be_falsey + expect(described_class.matches?('expand_vars(1)')).to be_falsey + expect(described_class.matches?('unknown')).to be_falsey + end + end +end diff --git a/spec/lib/gitlab/ci/config/interpolation/functions/truncate_spec.rb b/spec/lib/gitlab/ci/config/interpolation/functions/truncate_spec.rb index c521eff9811b3693a5c78e7054c93fa8a6e00d23..93e5d4ef48cf49ff1c81199ddc1c22f82e0edee4 100644 --- a/spec/lib/gitlab/ci/config/interpolation/functions/truncate_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/functions/truncate_spec.rb @@ -12,7 +12,7 @@ end it 'truncates the given input' do - function = described_class.new('truncate(1,2)') + function = described_class.new('truncate(1,2)', nil) output = function.execute('test') @@ -22,7 +22,7 @@ context 'when given a non-string input' do it 'returns an error' do - function = described_class.new('truncate(1,2)') + function = described_class.new('truncate(1,2)', nil) function.execute(100) diff --git a/spec/lib/gitlab/ci/config/interpolation/functions_stack_spec.rb b/spec/lib/gitlab/ci/config/interpolation/functions_stack_spec.rb index 881f092c440ec52c145433dac6367d6c7ec1430a..9ac0ef05c619cabe58462b3949e5a3415484d900 100644 --- a/spec/lib/gitlab/ci/config/interpolation/functions_stack_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/functions_stack_spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' RSpec.describe Gitlab::Ci::Config::Interpolation::FunctionsStack, feature_category: :pipeline_composition do let(:functions) { ['truncate(0,4)', 'truncate(1,2)'] } let(:input_value) { 'test_input_value' } - subject { described_class.new(functions).evaluate(input_value) } + subject { described_class.new(functions, nil).evaluate(input_value) } it 'modifies the given input value according to the function expressions' do expect(subject).to be_success diff --git a/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb b/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb index 804164c933a801a1f0a27850c5cd8748eef1bec3..c924323837b642e570d62a76159d22e6c506316b 100644 --- a/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/interpolator_spec.rb @@ -7,7 +7,7 @@ let(:result) { ::Gitlab::Ci::Config::Yaml::Result.new(config: [header, content]) } - subject { described_class.new(result, arguments) } + subject { described_class.new(result, arguments, []) } context 'when input data is valid' do let(:header) do diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb index f7c6f7f51df4ede8c8d171a4dcd75ab622aab7fc..d96c8f1bd0cf6cd30afeff5b8b18e985b6c181c6 100644 --- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Variables::Collection::Item do +RSpec.describe Gitlab::Ci::Variables::Collection::Item, feature_category: :secrets_management do let(:variable_key) { 'VAR' } let(:variable_value) { 'something' } let(:expected_value) { variable_value } @@ -217,6 +217,25 @@ end end + describe '#masked?' do + let(:variable_hash) { { key: variable_key, value: variable_value } } + let(:item) { described_class.new(**variable_hash) } + + context 'when :masked is not specified' do + it 'returns false' do + expect(item.masked?).to eq(false) + end + end + + context 'when :masked is specified as true' do + let(:variable_hash) { { key: variable_key, value: variable_value, masked: true } } + + it 'returns true' do + expect(item.masked?).to eq(true) + end + end + end + describe '#to_runner_variable' do context 'when variable is not a file-related' do it 'returns a runner-compatible hash representation' do