improvements

stacks-variable-validations
sahar-azizighannad 2 months ago
parent 14116ad820
commit ec5959f80a

@ -80,14 +80,14 @@ func (cr *CheckRule) validateSelfReferences(checkType string, addr addrs.Resourc
return diags
}
// DecodeCheckRuleBlock decodes the contents of the given block as a check rule.
// decodeCheckRuleBlock decodes the contents of the given block as a check rule.
//
// Unlike most of our "decode..." functions, this one can be applied to blocks
// of various types as long as their body structures are "check-shaped". The
// function takes the containing block only because some error messages will
// refer to its location, and the returned object's DeclRange will be the
// block's header.
func DecodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) {
func decodeCheckRuleBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) {
var diags hcl.Diagnostics
cr := &CheckRule{
DeclRange: block.DefRange,
@ -230,7 +230,7 @@ func decodeCheckBlock(block *hcl.Block, override bool) (*Check, hcl.Diagnostics)
check.DataResource = data
}
case "assert":
assert, moreDiags := DecodeCheckRuleBlock(block, override)
assert, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
if !moreDiags.HasErrors() {
check.Asserts = append(check.Asserts, assert)

@ -212,7 +212,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
switch block.Type {
case "validation":
vv, moreDiags := DecodeCheckRuleBlock(block, override)
vv, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
diags = append(diags, checkVariableValidationBlock(v.Name, vv)...)
@ -443,7 +443,7 @@ func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostic
for _, block := range content.Blocks {
switch block.Type {
case "precondition":
cr, moreDiags := DecodeCheckRuleBlock(block, override)
cr, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
o.Preconditions = append(o.Preconditions, cr)
case "postcondition":

@ -285,7 +285,7 @@ func decodeResourceBlock(block *hcl.Block, override bool, allowExperiments bool)
for _, block := range lcContent.Blocks {
switch block.Type {
case "precondition", "postcondition":
cr, moreDiags := DecodeCheckRuleBlock(block, override)
cr, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
moreDiags = cr.validateSelfReferences(block.Type, r.Addr())
@ -497,7 +497,7 @@ func decodeEphemeralBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagn
for _, block := range lcContent.Blocks {
switch block.Type {
case "precondition", "postcondition":
cr, moreDiags := DecodeCheckRuleBlock(block, override)
cr, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
moreDiags = cr.validateSelfReferences(block.Type, r.Addr())
@ -673,7 +673,7 @@ func decodeDataBlock(block *hcl.Block, override, nested bool) (*Resource, hcl.Di
for _, block := range lcContent.Blocks {
switch block.Type {
case "precondition", "postcondition":
cr, moreDiags := DecodeCheckRuleBlock(block, override)
cr, moreDiags := decodeCheckRuleBlock(block, override)
diags = append(diags, moreDiags...)
moreDiags = cr.validateSelfReferences(block.Type, r.Addr())

@ -697,7 +697,7 @@ func decodeTestRunBlock(block *hcl.Block, file *TestFile, experimentsAllowed boo
for _, block := range content.Blocks {
switch block.Type {
case "assert":
cr, crDiags := DecodeCheckRuleBlock(block, false)
cr, crDiags := decodeCheckRuleBlock(block, false)
diags = append(diags, crDiags...)
if !crDiags.HasErrors() {
r.CheckRules = append(r.CheckRules, cr)

@ -0,0 +1,72 @@
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package stackconfig
import "github.com/hashicorp/hcl/v2"
// CheckRule represents a custom validation rule for a stack input variable.
//
// This is the stacks-specific equivalent of configs.CheckRule in the core
// Terraform package. It is intentionally duplicated here to maintain
// separation between stacks and core Terraform, allowing each to evolve
// independently.
type CheckRule struct {
// Condition is an expression that must evaluate to true if the validation
// passes, or false if it fails. The expression may only refer to the
// variable being validated (via var.<name>).
Condition hcl.Expression
// ErrorMessage is an expression that evaluates to the error message shown
// to the user when the condition is false. It must evaluate to a string.
ErrorMessage hcl.Expression
DeclRange hcl.Range
}
// decodeCheckRuleBlock decodes a validation block for stack input variables.
// This is duplicated from the core configs package to maintain separation between
// stacks and core Terraform, allowing each to evolve independently.
func decodeCheckRuleBlock(block *hcl.Block) (*CheckRule, hcl.Diagnostics) {
var diags hcl.Diagnostics
cr := &CheckRule{
DeclRange: block.DefRange,
}
content, hclDiags := block.Body.Content(checkRuleBlockSchema)
diags = append(diags, hclDiags...)
if attr, exists := content.Attributes["condition"]; exists {
cr.Condition = attr.Expr
if len(cr.Condition.Variables()) == 0 {
// A condition expression that doesn't refer to any variable is
// pointless, because its result would always be a constant.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid validation expression",
Detail: "The condition expression must refer to at least one object from elsewhere in the configuration, or else its result would not be checking anything.",
Subject: cr.Condition.Range().Ptr(),
})
}
}
if attr, exists := content.Attributes["error_message"]; exists {
cr.ErrorMessage = attr.Expr
}
return cr, diags
}
var checkRuleBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "condition",
Required: true,
},
{
Name: "error_message",
Required: true,
},
},
}

@ -7,7 +7,6 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig/typeexpr"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
@ -29,7 +28,7 @@ type InputVariable struct {
// that provided values meet the specified constraints.
// Each CheckRule includes a condition expression that must evaluate to true,
// and an error message to display if the validation fails.
Validations []*configs.CheckRule
Validations []*CheckRule
DeclRange tfdiags.SourceRange
}
@ -103,7 +102,7 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos
// Decode the validation block into a CheckRule structure.
// This only validates the syntax and structure of the validation block itself,
// not the actual runtime validation of input values.
vv, hclDiags := configs.DecodeCheckRuleBlock(block, false)
vv, hclDiags := decodeCheckRuleBlock(block)
diags = diags.Append(hclDiags)
// Only add the validation rule if it was successfully parsed.
// If there were errors (e.g., missing condition or error_message),

@ -12,13 +12,13 @@ import (
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/lang"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/objchange"
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -147,10 +147,7 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
}
}
// First, apply any defaults that are declared in the
// configuration.
// Next, convert the value to the expected type.
// Convert the value to the expected type.
val, err = convert.Convert(val, wantTy)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
@ -189,17 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
}
}
// Mark the value before validation so that validation can detect
// sensitive/ephemeral marks and avoid leaking protected values in error messages.
val = cfg.markValue(val)
// Evaluate custom validation rules against the input value.
// Validation is skipped during ValidatePhase because:
// 1. Input variable values are not available during validate (only during plan/apply)
// 2. Validation conditions may reference resources or other runtime values
// Validation is skipped during ValidatePhase because actual input values
// are not yet available at that phase — only plan/apply provide them.
// This matches the behavior of core Terraform's variable validation.
if phase != ValidatePhase {
moreDiags := v.evalVariableValidations(ctx, val, phase)
diags = diags.Append(moreDiags)
}
return cfg.markValue(val), diags
return val, diags
default:
definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase)
@ -208,6 +208,9 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
allVals := definedByCallInst.InputVariableValues(ctx, phase)
val := allVals.GetAttr(v.addr.Item.Name)
// Mark the value before validation to prevent leaking sensitive/ephemeral data.
val = cfg.markValue(val)
// Evaluate custom validation rules for values from stack call instances.
// Skip during ValidatePhase as values are not yet available.
if phase != ValidatePhase {
@ -215,19 +218,22 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va
diags = diags.Append(moreDiags)
}
return cfg.markValue(val), diags
return val, diags
case definedByRemovedCallInst != nil:
allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase)
val := allVals.GetAttr(v.addr.Item.Name)
// Evaluate validation rules even for removed stack instances.
// Mark the value before validation to prevent leaking sensitive/ephemeral data.
val = cfg.markValue(val)
// Evaluate validation rules for removed stack instances.
// Skip during ValidatePhase as values are not yet available.
if phase != ValidatePhase {
moreDiags := v.evalVariableValidations(ctx, val, phase)
diags = diags.Append(moreDiags)
}
return cfg.markValue(val), diags
return val, diags
default:
// We seem to belong to a call instance that doesn't actually
// exist in the configuration. That either means that
@ -391,9 +397,11 @@ func (v *InputVariable) tracingName() string {
// during config loading; this function evaluates those rules against actual input values.
//
// The validation process:
// 1. Creates an HCL evaluation context with the variable's value and available functions
// 2. Evaluates each validation rule's condition expression
// 3. If the condition returns false, evaluates the error_message and reports a diagnostic
// 1. Creates an HCL evaluation context with the variable's value and available functions
// 2. Evaluates each validation rule's condition and error_message expressions
// 3. Always validates the error_message structure (sensitive/ephemeral marks are flagged
// regardless of whether the condition passes or fails)
// 4. If the condition is false, reports an "Invalid value for variable" diagnostic
//
// This follows the same approach as core Terraform's evalVariableValidations, including
// handling of sensitive values, unknown values, and error message evaluation.
@ -406,25 +414,29 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val
return diags
}
// Get the available functions from the stack scope.
// This allows validation conditions to use built-in functions like length(), regex(), etc.
// Get provider-defined functions from the stack scope.
// These will be combined with built-in functions below.
functions, moreDiags := v.stack.ExternalFunctions(ctx)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// If we can't get the function table, we can't evaluate validation expressions
// that depend on functions. Return early to avoid confusing downstream errors.
return diags
}
// Create a scope to get the function table.
// We don't need a full evaluation context, just the functions.
// Create a scope to get the complete function table (provider-defined + built-in).
// fakeScope.Functions() will combine the provider functions with built-in functions
// like length(), regex(), etc. We don't need a full evaluation context, just the functions.
fakeScope := &lang.Scope{
Data: nil, // not a real scope; can't actually make an evalcontext
BaseDir: ".",
PureOnly: phase != ApplyPhase,
ConsoleMode: false,
PlanTimestamp: v.stack.PlanTimestamp(),
ExternalFuncs: functions,
}
// Create an HCL evaluation context with the variable value and functions.
// The variable is made available as var.<name> within validation expressions.
// This mirrors how validation conditions are evaluated in core Terraform.
hclCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"var": cty.ObjectVal(map[string]cty.Value{
@ -449,24 +461,28 @@ func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Val
// This function handles the evaluation of one validation block's condition and error_message.
// It follows the same logic as core Terraform's variable validation:
//
// 1. Evaluates the condition expression
// 2. Handles unknown/null/invalid results appropriately
// 3. If condition is false, evaluates the error_message
// 4. Checks for sensitive/ephemeral values in error messages
// 5. Constructs a diagnostic with the error message and validation rule location
// 1. Evaluates the condition and error_message expressions up front
// 2. Handles unknown/null/invalid condition results appropriately
// 3. Always validates the error_message structure — sensitive/ephemeral marks
// in the message are flagged even when the condition passes
// 4. Returns early if the condition passes (after reporting any message issues)
// 5. Otherwise constructs an "Invalid value for variable" diagnostic
//
// Parameters:
// - validation: The validation rule to evaluate (contains condition and error_message expressions)
// - hclCtx: The HCL evaluation context with the variable value and functions
// - valueRng: The source range of the variable declaration (for diagnostic reporting)
func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics {
func evalVariableValidation(validation *stackconfig.CheckRule, hclCtx *hcl.EvalContext, valueRng hcl.Range) tfdiags.Diagnostics {
const errInvalidCondition = "Invalid variable validation result"
const errInvalidValue = "Invalid value for variable"
var diags tfdiags.Diagnostics
// Evaluate the validation condition expression
// Evaluate both condition and error message up front. The error message is
// always inspected for structural problems (sensitive/ephemeral marks), not only
// when the condition fails.
result, moreDiags := validation.Condition.Value(hclCtx)
diags = diags.Append(moreDiags)
errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx)
if moreDiags.HasErrors() {
// If we couldn't evaluate the condition at all (syntax error, etc.),
@ -495,7 +511,8 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte
}
// Convert result to boolean
result, err := convert.Convert(result, cty.Bool)
var err error
result, err = convert.Convert(result, cty.Bool)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
@ -512,66 +529,91 @@ func evalVariableValidation(validation *configs.CheckRule, hclCtx *hcl.EvalConte
// The marks don't affect the validation result, only how we handle the error message.
result, _ = result.Unmark()
// If the condition evaluated to true, the validation passed.
if result.True() {
return diags
}
// Validation failed - now evaluate the error_message to show to the user.
errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx)
// Always process and validate the error_message expression, even when the condition
// passes — an invalid error message (sensitive, ephemeral, non-string, etc.) should
// be flagged regardless of whether the check succeeds or fails.
diags = diags.Append(errorDiags)
var errorMessage string
if !errorDiags.HasErrors() && errorValue.IsKnown() && !errorValue.IsNull() {
errorValue, err := convert.Convert(errorValue, cty.String)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid error message",
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "Failed to evaluate condition error message."
} else {
// Check for sensitive/ephemeral marks
if marks.Has(errorValue, marks.Sensitive) {
if !errorDiags.HasErrors() {
if !errorValue.IsKnown() {
if !result.True() {
// An unknown error message is only a problem when the condition actually
// fails, since we need to display it to the user.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to sensitive values",
Detail: "The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.",
Subject: validation.ErrorMessage.Range().Ptr(),
Severity: hcl.DiagError,
Summary: "Invalid error message",
Detail: "Unsuitable value for error message: expression refers to values that won't be known until the apply phase.",
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included a sensitive value, so it will not be displayed."
} else if marks.Has(errorValue, marks.Ephemeral) {
return diags
}
} else if !errorValue.IsNull() {
errorValue, err = convert.Convert(errorValue, cty.String)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to ephemeral values",
Detail: "The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.",
Subject: validation.ErrorMessage.Range().Ptr(),
Severity: hcl.DiagError,
Summary: "Invalid error message",
Detail: fmt.Sprintf("Unsuitable value for error message: %s.", tfdiags.FormatError(err)),
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included an ephemeral value, so it will not be displayed."
} else {
errorMessage = strings.TrimSpace(errorValue.AsString())
// Check for sensitive/ephemeral marks; these are flagged even when
// the condition passes, since the error message is structurally invalid.
if marks.Has(errorValue, marks.Sensitive) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to sensitive values",
Detail: `The error expression used to explain this condition refers to sensitive values. Terraform will not display the resulting message.
You can correct this by removing references to sensitive values, or by carefully using the nonsensitive() function if the expression will not reveal the sensitive data.`,
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included a sensitive value, so it will not be displayed."
} else if marks.Has(errorValue, marks.Ephemeral) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Error message refers to ephemeral values",
Detail: `The error expression used to explain this condition refers to ephemeral values. Terraform will not display the resulting message.
You can correct this by removing references to ephemeral values, or by carefully using the ephemeralasnull() function if the expression will not reveal the ephemeral data.`,
Subject: validation.ErrorMessage.Range().Ptr(),
Expression: validation.ErrorMessage,
EvalContext: hclCtx,
})
errorMessage = "The error message included an ephemeral value, so it will not be displayed."
} else {
errorMessage = strings.TrimSpace(errorValue.AsString())
}
}
}
} else {
}
if errorMessage == "" {
errorMessage = "Failed to evaluate condition error message."
}
// If the condition evaluated to true, the validation passed. We've validated
// the error message above, so any structural issues are already reported.
if result.True() {
return diags
}
// Construct the validation failure diagnostic.
// The detail includes both the custom error message and a reference to where
// the validation rule is defined, helping users locate the validation in their config.
detail := fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.",
errorMessage,
validation.DeclRange.String())
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: errInvalidValue,
Detail: detail,
Subject: &valueRng,
Severity: hcl.DiagError,
Summary: errInvalidValue,
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", errorMessage, validation.DeclRange.String()),
Subject: &valueRng,
Expression: validation.Condition,
EvalContext: hclCtx,
})
return diags

@ -5,10 +5,15 @@ package stackeval
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hcltest"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
"google.golang.org/protobuf/encoding/prototext"
@ -17,9 +22,13 @@ import (
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/promising"
"github.com/hashicorp/terraform/internal/providers"
testing_provider "github.com/hashicorp/terraform/internal/providers/testing"
"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
"github.com/hashicorp/terraform/internal/stacks/stackconfig"
"github.com/hashicorp/terraform/internal/stacks/stackplan"
"github.com/hashicorp/terraform/internal/stacks/stackstate"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func TestInputVariableValue(t *testing.T) {
@ -487,3 +496,604 @@ func TestInputVariablePlanChanges(t *testing.T) {
})
}
}
// TestEvalVariableValidation tests the evalVariableValidation function directly,
// covering all the "invalid" cases: sensitive/ephemeral values in the error message,
// unknown/null condition results, and unknown error messages. These tests
// exercise the logic independently of the full stack-evaluator machinery.
func TestEvalVariableValidation(t *testing.T) {
// parseExpr parses a real HCL expression from a source string.
parseExpr := func(t *testing.T, src string) hcl.Expression {
t.Helper()
expr, diags := hclsyntax.ParseExpression([]byte(src), "test.hcl", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
t.Fatalf("failed to parse expression %q: %s", src, diags.Error())
}
return expr
}
// makeFakeRule builds a minimal stackconfig.CheckRule from two expressions.
makeFakeRule := func(condition, errorMessage hcl.Expression) *stackconfig.CheckRule {
return &stackconfig.CheckRule{
Condition: condition,
ErrorMessage: errorMessage,
DeclRange: hcl.Range{
Filename: "test.hcl",
Start: hcl.Pos{Line: 2, Column: 1},
End: hcl.Pos{Line: 5, Column: 1},
},
}
}
// makeVarCtx builds an HCL evaluation context that exposes var.foo = val.
makeVarCtx := func(val cty.Value) *hcl.EvalContext {
return &hcl.EvalContext{
Variables: map[string]cty.Value{
"var": cty.ObjectVal(map[string]cty.Value{
"foo": val,
}),
},
}
}
valueRange := hcl.Range{
Filename: "test.hcl",
Start: hcl.Pos{Line: 1, Column: 1},
End: hcl.Pos{Line: 1, Column: 10},
}
// --- Basic pass/fail ---
t.Run("condition passes, clean message → no diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertNoDiags(t, diags)
})
t.Run("condition fails, clean message → Invalid value for variable", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Value must be 'good'.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Sensitive error message ---
t.Run("condition passes, sensitive error_message → flagged even on success", func(t *testing.T) {
// The error_message evaluates to a sensitive string even though the
// condition passes. This structural problem must always be reported.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
// Condition passed, so there must be no "Invalid value for variable".
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed")
}
}
})
t.Run("condition fails, sensitive error_message → both diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Contains secret").Mark(marks.Sensitive)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Ephemeral error message ---
t.Run("condition passes, ephemeral error_message → flagged even on success", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
// Condition passed, so there must be no "Invalid value for variable".
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' diagnostic when condition passed")
}
}
})
t.Run("condition fails, ephemeral error_message → both diagnostics", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.StringVal("Contains ephemeral").Mark(marks.Ephemeral)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
// --- Unknown / null condition results ---
t.Run("condition result unknown → no diagnostics", func(t *testing.T) {
// Unknown condition means we cannot determine validity yet; skip quietly.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.UnknownVal(cty.Bool)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.UnknownVal(cty.String)), valueRange)
assertNoDiags(t, diags)
})
t.Run("condition result null → Invalid variable validation result", func(t *testing.T) {
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.NullVal(cty.Bool)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid variable validation result"
})
})
// --- Unknown error message ---
t.Run("error message unknown, condition fails → Invalid error message (no failure diag)", func(t *testing.T) {
// We must flag the unknown error message but must NOT also emit
// "Invalid value for variable" because we return early.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.UnknownVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid error message"
})
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when error message is unknown")
}
}
})
t.Run("error message unknown, condition passes → no diagnostic about error message", func(t *testing.T) {
// Unknown error message is only a problem when the condition actually
// fails (because we'd need to display it). When the condition passes
// the unknown error message must be silently ignored.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
hcltest.MockExprLiteral(cty.UnknownVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), valueRange)
assertNoDiags(t, diags)
})
// --- Sensitive variable value in condition expression ---
t.Run("sensitive variable value, plain error message, condition fails → only Invalid value for variable", func(t *testing.T) {
// var.foo carries a sensitive mark. The condition expression
// (var.foo == "good") evaluates to a sensitive bool; Unmark() peels
// off the mark so the check works correctly. The error_message is a
// plain literal → no "Error message refers to sensitive values" diag.
hclCtx := makeVarCtx(cty.StringVal("bad").Mark(marks.Sensitive))
rule := makeFakeRule(
parseExpr(t, `var.foo == "good"`),
hcltest.MockExprLiteral(cty.StringVal("Value is not allowed.")),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
for _, d := range diags {
if d.Description().Summary == "Error message refers to sensitive values" {
t.Errorf("unexpected 'Error message refers to sensitive values' when error message is plain text")
}
}
})
t.Run("sensitive variable referenced in error message, condition fails → both diagnostics", func(t *testing.T) {
// When the error_message interpolates a sensitive variable the
// evaluated message is itself sensitive — both the sensitive-value
// diagnostic and the generic failure diagnostic must be emitted.
hclCtx := makeVarCtx(cty.StringVal("secret").Mark(marks.Sensitive))
rule := makeFakeRule(
parseExpr(t, `var.foo == "good"`),
parseExpr(t, `"Value '${var.foo}' is not allowed."`),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to sensitive values"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
t.Run("ephemeral variable referenced in error message, condition passes → flagged even on success", func(t *testing.T) {
// The condition passes but the error_message references an ephemeral
// variable, making the message itself ephemeral. This structural
// problem must still be reported.
hclCtx := makeVarCtx(cty.StringVal("good").Mark(marks.Ephemeral))
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.True),
parseExpr(t, `"Value '${var.foo}' is not allowed."`),
)
diags := evalVariableValidation(rule, hclCtx, valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Error message refers to ephemeral values"
})
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when condition passed")
}
}
})
// --- Condition evaluation error ---
t.Run("condition evaluation error → early return with HCL error", func(t *testing.T) {
// When the condition expression itself fails to evaluate (e.g. it references
// an undefined variable), evalVariableValidation must return early with the
// evaluation error and must NOT emit "Invalid value for variable" or
// "Invalid variable validation result".
rule := makeFakeRule(
parseExpr(t, "undefined_var.foo"),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
// hclCtx only has "var", so "undefined_var" is unknown → evaluation error.
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
if !diags.HasErrors() {
t.Fatal("expected at least one error diagnostic, got none")
}
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' on condition evaluation error")
}
if d.Description().Summary == "Invalid variable validation result" {
t.Errorf("unexpected 'Invalid variable validation result' on condition evaluation error")
}
}
})
// --- Non-bool condition result ---
t.Run("condition result is non-bool (list) → Invalid variable validation result", func(t *testing.T) {
// A condition that returns a list (or any value that cannot be converted
// to bool) hits the convert.Convert(result, cty.Bool) failure path.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)),
hcltest.MockExprLiteral(cty.StringVal("Value is invalid.")),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("anything")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid variable validation result"
})
// Must return early — no "Invalid value for variable" should follow.
for _, d := range diags {
if d.Description().Summary == "Invalid value for variable" {
t.Errorf("unexpected 'Invalid value for variable' when condition type conversion failed")
}
}
})
// --- Null error message ---
t.Run("null error message, condition fails → Invalid value for variable with fallback text", func(t *testing.T) {
// A null error_message is skipped during string conversion; the framework
// falls back to "Failed to evaluate condition error message." in the
// detail of the "Invalid value for variable" diagnostic.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.NullVal(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable" &&
strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.")
})
})
// --- Non-string error message ---
t.Run("non-string error message (list), condition fails → Invalid error message + fallback in failure diag", func(t *testing.T) {
// An error_message that evaluates to a list (unconvertible to string)
// hits the convert.Convert(errorValue, cty.String) failure path. Both
// "Invalid error message" and "Invalid value for variable" (with the
// fallback text) must be emitted.
rule := makeFakeRule(
hcltest.MockExprLiteral(cty.False),
hcltest.MockExprLiteral(cty.ListValEmpty(cty.String)),
)
diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("bad")), valueRange)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid error message"
})
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable" &&
strings.Contains(d.Description().Detail, "Failed to evaluate condition error message.")
})
})
}
// TestInputVariableValidation exercises evalVariableValidations end-to-end
// through CheckValue, using the "validation" fixture that declares variables
// with validation blocks.
func TestInputVariableValidation(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation")
tests := map[string]struct {
varName string
inputVal cty.Value
wantSummaries []string // diagnostics that MUST be present (by Summary)
wantNoErrors bool // if true, no error diagnostics are expected
}{
// --- validated (plain error message) ---
"validated: clean pass": {
varName: "validated",
inputVal: cty.StringVal("good"),
wantNoErrors: true,
},
"validated: clean fail": {
varName: "validated",
inputVal: cty.StringVal("bad"),
wantSummaries: []string{"Invalid value for variable"},
},
// --- with_msg_ref (error message interpolates var.with_msg_ref) ---
"with_msg_ref: clean pass": {
varName: "with_msg_ref",
inputVal: cty.StringVal("good"),
wantNoErrors: true,
},
"with_msg_ref: clean fail": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad"),
wantSummaries: []string{"Invalid value for variable"},
},
"with_msg_ref: sensitive value passes → error message diag only": {
// Condition passes, but the interpolated error_message is sensitive
// → we should still flag the structural problem.
varName: "with_msg_ref",
inputVal: cty.StringVal("good").Mark(marks.Sensitive),
wantSummaries: []string{"Error message refers to sensitive values"},
},
"with_msg_ref: sensitive value fails → both diags": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad").Mark(marks.Sensitive),
wantSummaries: []string{
"Error message refers to sensitive values",
"Invalid value for variable",
},
},
"with_msg_ref: ephemeral value passes → error message diag only": {
varName: "with_msg_ref",
inputVal: cty.StringVal("good").Mark(marks.Ephemeral),
wantSummaries: []string{"Error message refers to ephemeral values"},
},
"with_msg_ref: ephemeral value fails → both diags": {
varName: "with_msg_ref",
inputVal: cty.StringVal("bad").Mark(marks.Ephemeral),
wantSummaries: []string{
"Error message refers to ephemeral values",
"Invalid value for variable",
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
tc.varName: tc.inputVal,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: tc.varName})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
if tc.wantNoErrors {
if diags.HasErrors() {
t.Errorf("unexpected errors: %s", diags.Err())
}
return
}
for _, wantSummary := range tc.wantSummaries {
wantSummary := wantSummary // capture for closure
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == wantSummary
})
}
})
})
}
}
// TestInputVariableValidationWithProviderFunction verifies that provider-defined
// functions can be called inside a variable validation condition expression.
// It uses the "validation_provider_function" fixture together with a mock provider
// that exposes a simple "upper" string function.
func TestInputVariableValidationWithProviderFunction(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation_provider_function")
providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/testing")
newMockProvider := func(t *testing.T) (*testing_provider.MockProvider, providers.Factory) {
t.Helper()
mockProvider := &testing_provider.MockProvider{
GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
Functions: map[string]providers.FunctionDecl{
"upper": {
Parameters: []providers.FunctionParam{
{Name: "input", Type: cty.String},
},
ReturnType: cty.String,
Summary: "Converts a string to upper-case.",
},
},
},
CallFunctionFn: func(req providers.CallFunctionRequest) providers.CallFunctionResponse {
if req.FunctionName != "upper" {
return providers.CallFunctionResponse{
Err: fmt.Errorf("unexpected function call: %s", req.FunctionName),
}
}
input, _ := req.Arguments[0].Unmark()
return providers.CallFunctionResponse{
Result: cty.StringVal(strings.ToUpper(input.AsString())),
}
},
}
return mockProvider, providers.FactoryFixed(mockProvider)
}
t.Run("passes validation", func(t *testing.T) {
_, providerFactory := newMockProvider(t)
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"foo": cty.StringVal("hello"), // upper("hello") == "HELLO" → condition passes
},
ProviderFactories: ProviderFactories{
providerTypeAddr: providerFactory,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
assertNoDiags(t, diags)
})
})
t.Run("fails validation", func(t *testing.T) {
_, providerFactory := newMockProvider(t)
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"foo": cty.StringVal("world"), // upper("world") == "WORLD" ≠ "HELLO" → condition fails
},
ProviderFactories: ProviderFactories{
providerTypeAddr: providerFactory,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "foo"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
assertMatchingDiag(t, diags, func(d tfdiags.Diagnostic) bool {
return d.Severity() == tfdiags.Error &&
d.Description().Summary == "Invalid value for variable"
})
})
})
}
// TestInputVariableMultipleValidationRules verifies that when a variable has
// more than one validation block, every failing rule produces its own
// diagnostic — i.e., all rules are evaluated and none are short-circuited.
//
// The "multi_rule" variable in the "validation" fixture has two rules:
//
// Rule 1: length(var.multi_rule) >= 5
// Rule 2: var.multi_rule != "bad"
//
// The value "bad" has length 3 (< 5) and equals "bad", so it violates both
// rules simultaneously, giving us exactly two "Invalid value for variable"
// diagnostics.
func TestInputVariableMultipleValidationRules(t *testing.T) {
cfg := testStackConfig(t, "input_variable", "validation")
tests := map[string]struct {
inputVal cty.Value
wantErrCount int // expected number of "Invalid value for variable" diagnostics
}{
"passes both rules": {
inputVal: cty.StringVal("hello"), // length 5 >= 5, != "bad"
wantErrCount: 0,
},
"fails first rule only": {
inputVal: cty.StringVal("hi"), // length 2 < 5, != "bad"
wantErrCount: 1,
},
"fails second rule only": {
// length("hello!") = 6 >= 5 → first passes; "hello!" != "bad" → second passes.
// To fail only the second rule we need length >= 5 AND value == "bad".
// "bad" itself has length 3, so the only way to isolate rule 2 failure
// is with a longer value that equals "bad" — impossible for a plain
// string. We therefore omit this sub-case and rely on the unit-level
// TestEvalVariableValidation coverage instead.
inputVal: cty.StringVal("hello"), // deliberately a pass case
wantErrCount: 0,
},
"fails both rules": {
inputVal: cty.StringVal("bad"), // length 3 < 5 AND == "bad"
wantErrCount: 2,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
main := testEvaluator(t, testEvaluatorOpts{
Config: cfg,
InputVariableValues: map[string]cty.Value{
"multi_rule": tc.inputVal,
},
})
inPromisingTask(t, func(ctx context.Context, t *testing.T) {
mainStack := main.MainStack()
rootVar := mainStack.InputVariable(stackaddrs.InputVariable{Name: "multi_rule"})
_, diags := rootVar.CheckValue(ctx, InspectPhase)
var failCount int
for _, d := range diags {
if d.Severity() == tfdiags.Error && d.Description().Summary == "Invalid value for variable" {
failCount++
}
}
if failCount != tc.wantErrCount {
t.Errorf("expected %d 'Invalid value for variable' diagnostic(s), got %d; diags:\n%s",
tc.wantErrCount, failCount, diags.ErrWithWarnings())
}
})
})
}
}

@ -0,0 +1,43 @@
# A variable with a simple validation that does not reference the variable
# value inside the error_message.
variable "validated" {
type = string
validation {
condition = var.validated != "bad"
error_message = "Value must not be 'bad'."
}
}
# A variable whose error_message expression interpolates the variable value.
# When the input carries a sensitive or ephemeral mark, evaluating the
# interpolation causes the error_message result to inherit that mark which
# is the behaviour we want to exercise.
variable "with_msg_ref" {
type = string
validation {
condition = var.with_msg_ref != "bad"
error_message = "Got disallowed value '${var.with_msg_ref}'."
}
}
# A variable with two validation blocks to verify that all rules are evaluated
# and all failures are reported independently.
#
# Inputs that trigger both failures simultaneously:
# "bad" — length("bad") = 3 < 5 AND "bad" == "bad"
variable "multi_rule" {
type = string
validation {
condition = length(var.multi_rule) >= 5
error_message = "Value must be at least 5 characters long."
}
validation {
condition = var.multi_rule != "bad"
error_message = "Value must not be 'bad'."
}
}

@ -0,0 +1,25 @@
# This fixture is used by TestInputVariableValidationWithProviderFunction to
# verify that provider-defined functions can be called inside a validation
# condition expression.
#
# The mock test provider exposes a single function "upper" that converts a
# string to upper-case; the validation here checks that the given value
# equals "HELLO" when converted to upper-case.
required_providers {
testing = {
source = "terraform.io/builtin/testing"
}
}
provider "testing" "main" {}
variable "foo" {
type = string
validation {
condition = provider::testing::upper(var.foo) == "HELLO"
error_message = "Value must equal 'hello' (case-insensitive)."
}
}

@ -6607,6 +6607,85 @@ func TestPlan_variableValidationAdvanced(t *testing.T) {
},
wantErrorMessages: []string{"Tag values must be 1-256 characters."},
},
// Invalid error message tests - these verify that invalid error messages
// are caught even when validation passes or fails
"invalid-error-message-sensitive-in-error": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("short"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.NumberIntVal(5),
"api_key": cty.StringVal("abcdef0123456789"),
},
wantErrorMessages: []string{
"error expression used to explain this condition refers to sensitive values",
},
},
"invalid-error-message-ephemeral-in-error": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("short_token"),
"count_value": cty.NumberIntVal(5),
"api_key": cty.StringVal("abcdef0123456789"),
},
wantErrorMessages: []string{
"error expression used to explain this condition refers to ephemeral values",
},
},
"invalid-error-message-not-string": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.NumberIntVal(-5),
"api_key": cty.StringVal("abcdef0123456789"),
},
// When error_message is not a string type, we get the raw value in the validation failure
wantErrorMessages: []string{
"-5",
},
},
"invalid-error-message-sensitive-even-when-passing": {
configPath: path.Join("with-single-input", "validation-invalid-error-message"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"password": cty.StringVal("SecurePass123"),
"token": cty.StringVal("abcdef0123456789abcdef0123456789"),
"count_value": cty.NumberIntVal(5),
"api_key": cty.StringVal("abcdef0123456789abcdef0123456789abcdef0123456789"),
},
// This tests that we evaluate error_message even when validation passes
wantErrorMessages: []string{
"error expression used to explain this condition refers to sensitive values",
},
},
// Provider function tests
"provider-functions-pass": {
configPath: path.Join("with-single-input", "validation-provider-functions"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"echo_value": cty.StringVal("test_value"),
"combined": cty.StringVal("long_enough"),
},
wantErrorMessages: nil,
},
"provider-functions-fail": {
configPath: path.Join("with-single-input", "validation-provider-functions"),
planInputVars: map[string]cty.Value{
"input": cty.StringVal("test"),
"echo_value": cty.StringVal("test"),
"combined": cty.StringVal("short"),
},
wantErrorMessages: []string{
"Combined value must be longer than 5 characters after echo",
},
},
}
for name, tc := range testCases {

@ -0,0 +1,68 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
# Test case: error_message references sensitive value
variable "password" {
type = string
sensitive = true
validation {
condition = length(var.password) >= 8
error_message = "Password '${var.password}' is too short."
}
}
# Test case: error_message references ephemeral value
variable "token" {
type = string
ephemeral = true
validation {
condition = length(var.token) == 32
error_message = "Token '${var.token}' is invalid."
}
}
# Test case: error_message that is not a string
variable "count_value" {
type = number
validation {
condition = var.count_value > 0
error_message = var.count_value # Invalid: should be a string
}
}
# Test case: error_message references sensitive value even when validation passes
variable "api_key" {
type = string
sensitive = true
validation {
condition = length(var.api_key) >= 16
error_message = "API key '${var.api_key}' must be at least 16 characters."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}

@ -0,0 +1,45 @@
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
variable "input" {
type = string
default = "default"
}
# Test case: Validation using provider-defined function in condition
variable "echo_value" {
type = string
validation {
condition = provider::testing::echo(var.echo_value) == var.echo_value
error_message = "Echo function did not return the same value."
}
}
# Test case: Validation using provider function with built-in functions
variable "combined" {
type = string
validation {
condition = length(provider::testing::echo(var.combined)) > 5
error_message = "Combined value must be longer than 5 characters after echo."
}
}
component "self" {
source = "../"
providers = {
testing = provider.testing.default
}
inputs = {
input = var.input
}
}
provider "testing" "default" {}
Loading…
Cancel
Save