diff --git a/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml b/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml new file mode 100644 index 0000000000..af43f8ea8e --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260305-103721.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: Add input variable validation for Stacks +time: 2026-03-05T10:37:21.047704-05:00 +custom: + Issue: "38240" diff --git a/internal/stacks/stackconfig/checks.go b/internal/stacks/stackconfig/checks.go new file mode 100644 index 0000000000..e4afb6dc1d --- /dev/null +++ b/internal/stacks/stackconfig/checks.go @@ -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.). + 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, + }, + }, +} diff --git a/internal/stacks/stackconfig/input_variable.go b/internal/stacks/stackconfig/input_variable.go index 87315917e3..6f8fbb7a72 100644 --- a/internal/stacks/stackconfig/input_variable.go +++ b/internal/stacks/stackconfig/input_variable.go @@ -23,6 +23,12 @@ type InputVariable struct { Sensitive bool Ephemeral bool + // Validations contains custom validation rules for this variable. + // These rules are evaluated at runtime during the plan phase to ensure + // 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 []*CheckRule DeclRange tfdiags.SourceRange } @@ -89,13 +95,25 @@ func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnos diags = diags.Append(hclDiags) } + // Process any nested blocks (currently only validation blocks are supported) for _, block := range content.Blocks { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Custom variable validation not yet supported", - Detail: "Input variables for a stack configuration do not yet support custom variable validation.", - Subject: block.DefRange.Ptr(), - }) + switch block.Type { + case "validation": + // 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 := 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), + // those errors are already captured in diags above. + if !hclDiags.HasErrors() { + ret.Validations = append(ret.Validations, vv) + } + default: + // Should not get here as the schema defines what blocks are allowed + panic("unhandled block type " + block.Type) + } } return ret, diags @@ -109,6 +127,8 @@ var inputVariableBlockSchema = &hcl.BodySchema{ {Name: "sensitive", Required: false}, {Name: "ephemeral", Required: false}, }, + // Validation blocks allow custom validation rules for input variables. + // Multiple validation blocks are allowed per variable. Blocks: []hcl.BlockHeaderSchema{ {Type: "validation"}, }, diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index 24e5073e18..48a883f71c 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -6,16 +6,19 @@ package stackeval import ( "context" "fmt" + "strings" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" + "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" @@ -144,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{ @@ -186,9 +186,20 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va } } - // TODO: check the value against any custom validation rules - // declared in the configuration. - return cfg.markValue(val), diags + // 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 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 val, diags default: definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase) @@ -197,18 +208,32 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va allVals := definedByCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) - return cfg.markValue(val), diags + // Evaluate custom validation rules for values from stack call instances. + // Skip during ValidatePhase as values are not yet available. + if phase != ValidatePhase { + moreDiags := v.evalVariableValidations(ctx, val, phase) + diags = diags.Append(moreDiags) + } + + return val, diags case definedByRemovedCallInst != nil: allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) val := allVals.GetAttr(v.addr.Item.Name) - // TODO: check the value against any custom validation rules - // declared in the configuration. + // Mark the value before validation to prevent leaking sensitive/ephemeral data. + val = cfg.markValue(val) - return cfg.markValue(val), diags + // 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 val, diags default: // We seem to belong to a call instance that doesn't actually // exist in the configuration. That either means that @@ -364,6 +389,229 @@ func (v *InputVariable) tracingName() string { return v.addr.String() } +// evalVariableValidations evaluates all custom validation rules for this input variable +// against the given value, returning diagnostics if any validations fail. +// +// This function implements runtime validation checking, which is distinct from the +// config-time parsing done in stackconfig. The validation rules were parsed and stored +// 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 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. +func (v *InputVariable) evalVariableValidations(ctx context.Context, val cty.Value, phase EvalPhase) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + rules := v.config.config.Validations + if len(rules) == 0 { + // No validation rules defined, nothing to check + return diags + } + + // 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 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, + PlanTimestamp: v.stack.PlanTimestamp(), + ExternalFuncs: functions, + } + + // Create an HCL evaluation context with the variable value and functions. + // The variable is made available as var. within validation expressions. + hclCtx := &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + v.addr.Item.Name: val, + }), + }, + Functions: fakeScope.Functions(), + } + + // Evaluate each validation rule independently. + // Multiple validation failures will all be reported. + for _, validation := range rules { + moreDiags := evalVariableValidation(validation, hclCtx, v.config.config.DeclRange.ToHCL()) + diags = diags.Append(moreDiags) + } + + return diags +} + +// evalVariableValidation evaluates a single validation rule against a variable value. +// +// 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 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 *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 + + result, moreDiags := validation.Condition.Value(hclCtx) + diags = diags.Append(moreDiags) + + if moreDiags.HasErrors() { + // If we couldn't evaluate the condition at all (syntax error, etc.), + // return early. The error is already in diags. + return diags + } + + // If the condition result is unknown, we can't determine validity yet. + // This can happen when the condition references computed values. + // Skip validation for now - it will be checked during apply if needed. + if !result.IsKnown() { + return diags + } + + // Check if the result is null + if result.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: "Validation condition expression must return either true or false, not null.", + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Convert result to boolean + var err error + result, err = convert.Convert(result, cty.Bool) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errInvalidCondition, + Detail: fmt.Sprintf("Invalid validation condition result value: %s.", tfdiags.FormatError(err)), + Subject: validation.Condition.Range().Ptr(), + Expression: validation.Condition, + EvalContext: hclCtx, + }) + return diags + } + + // Remove any marks (sensitive, ephemeral) before checking the boolean value. + // The marks don't affect the validation result, only how we handle the error message. + result, _ = result.Unmark() + + // Always evaluate the error_message expression, even when the condition passes — + // unknown, sensitive, or ephemeral values in the message are structural problems + // regardless of whether the check succeeds or fails. + errorValue, errorDiags := validation.ErrorMessage.Value(hclCtx) + diags = diags.Append(errorDiags) + + var errorMessage string + if !errorDiags.HasErrors() { + if !errorValue.IsKnown() { + diags = diags.Append(&hcl.Diagnostic{ + 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, + }) + 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: "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, + }) + } else { + // 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()) + } + } + } + } + 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. + diags = diags.Append(&hcl.Diagnostic{ + 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 +} + // ExternalInputValue represents the value of an input variable provided // from outside the stack configuration. type ExternalInputValue struct { diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go index 67a69ccbff..e8c3f27457 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -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,617 @@ 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 only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT also be emitted. + 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 → Invalid error message only", func(t *testing.T) { + // An unknown error_message is always a structural problem: the validation + // block is invalid regardless of whether the condition passes or fails, + // because Terraform can never safely display the message. + // We return early on the unknown message, so "Invalid value for variable" + // must NOT be emitted even though the condition passed. + rule := makeFakeRule( + hcltest.MockExprLiteral(cty.True), + hcltest.MockExprLiteral(cty.UnknownVal(cty.String)), + ) + diags := evalVariableValidation(rule, makeVarCtx(cty.StringVal("good")), 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") + } + } + }) + + // --- 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()) + } + }) + }) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl new file mode 100644 index 0000000000..5e80f82b09 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation/validation.tfcomponent.hcl @@ -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'." + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl new file mode 100644 index 0000000000..15b9ac35ea --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/validation_provider_function/validation-provider-function.tfcomponent.hcl @@ -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)." + } +} diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 9586557244..33c699ed22 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6590,3 +6590,360 @@ func TestPlanWithDeferredActionInvocation(t *testing.T) { } } } + +// TestPlan_variableValidationAdvanced tests advanced variable validation scenarios +func TestPlan_variableValidationAdvanced(t *testing.T) { + ctx := context.Background() + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + fakePlanTimestamp, _ := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + + testCases := map[string]struct { + configPath string + planInputVars map[string]cty.Value + wantErrorMessages []string // Just check for error message presence, not exact diagnostic structure + }{ + // Type validation tests + "types-number-pass": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: nil, + }, + "types-number-out-of-range": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(150), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Number must be between 0 and 100."}, + }, + "types-list-too-many": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("1"), cty.StringVal("2"), cty.StringVal("3"), cty.StringVal("4"), cty.StringVal("5"), cty.StringVal("6")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List must contain 1-5 items."}, + }, + "types-list-empty-string": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item"), cty.StringVal("")}), + "map_input": cty.MapVal(map[string]cty.Value{"required_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"List items cannot be empty strings."}, + }, + "types-map-missing-key": { + configPath: path.Join("with-single-input", "validation-types"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "number_input": cty.NumberIntVal(50), + "list_input": cty.ListVal([]cty.Value{cty.StringVal("item1")}), + "map_input": cty.MapVal(map[string]cty.Value{"other_key": cty.StringVal("value")}), + }, + wantErrorMessages: []string{"Map must contain 'required_key'."}, + }, + + // Sensitive variable validation tests + "sensitive-password-pass": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: nil, + }, + "sensitive-password-too-short": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("Short1"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must be at least 8 characters long."}, + }, + "sensitive-password-no-uppercase": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("securepass123"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one uppercase letter."}, + }, + "sensitive-password-no-number": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass"), + "api_key": cty.StringVal("abcdef0123456789abcdef0123456789"), + }, + wantErrorMessages: []string{"Password must contain at least one number."}, + }, + "sensitive-api-key-wrong-length": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("abc123"), + }, + wantErrorMessages: []string{"API key must be exactly 32 characters."}, + }, + "sensitive-api-key-invalid-chars": { + configPath: path.Join("with-single-input", "validation-sensitive"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "password": cty.StringVal("SecurePass123"), + "api_key": cty.StringVal("ABCDEF0123456789ABCDEF0123456789"), + }, + wantErrorMessages: []string{"API key must only contain lowercase hex characters."}, + }, + + // Complex validation tests + "complex-email-pass": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: nil, + }, + "complex-email-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("not-an-email"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid email address."}, + }, + "complex-ip-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("999.999.999.999"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Must be a valid IPv4 address."}, + }, + "complex-environment-invalid": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("test"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Environment must be dev, staging, or prod."}, + }, + "complex-tags-invalid-key": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"Owner": cty.StringVal("team"), "owner": cty.StringVal("team")}), + }, + wantErrorMessages: []string{"Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens."}, + }, + "complex-tags-missing-owner": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tags must include 'owner' key."}, + }, + "complex-tags-empty-value": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal(""), "env": cty.StringVal("dev")}), + }, + wantErrorMessages: []string{"Tag values must be 1-256 characters."}, + }, + "complex-tags-value-too-long": { + configPath: path.Join("with-single-input", "validation-complex"), + planInputVars: map[string]cty.Value{ + "input": cty.StringVal("test"), + "email": cty.StringVal("user@example.com"), + "ip_address": cty.StringVal("192.168.1.1"), + "environment": cty.StringVal("dev"), + "tags": cty.MapVal(map[string]cty.Value{"owner": cty.StringVal("team"), "description": cty.StringVal(strings.Repeat("x", 257))}), + }, + 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.SetVal([]cty.Value{cty.StringVal("a")}), + "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.SetVal([]cty.Value{cty.StringVal("a")}), + "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.SetValEmpty(cty.String), // empty set fails condition; a set cannot be converted to a string + "api_key": cty.StringVal("abcdef0123456789"), + }, + // A set value cannot be converted to a string, so we get an "Invalid error message" diagnostic + // rather than the condition failure message. + wantErrorMessages: []string{ + "Unsuitable value for error message", + }, + }, + "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.SetVal([]cty.Value{cty.StringVal("a")}), + "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 { + t.Run(name, func(t *testing.T) { + cfg := loadMainBundleConfigForTest(t, tc.configPath) + + req := PlanRequest{ + Config: cfg, + InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { + inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(tc.planInputVars)) + for k, v := range tc.planInputVars { + inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} + } + return inputs + }(), + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + ForcePlanTimestamp: &fakePlanTimestamp, + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + _, diags := collectPlanOutput(changesCh, diagsCh) + + // Check that we get the expected error messages + if tc.wantErrorMessages == nil { + if len(diags) > 0 { + t.Errorf("expected no diagnostics, got: %s", diags.ErrWithWarnings()) + } + } else { + if len(diags) == 0 { + t.Fatalf("expected diagnostics with messages %v, got none", tc.wantErrorMessages) + } + // Check that all expected error messages are present + for _, wantMsg := range tc.wantErrorMessages { + found := false + for _, diag := range diags { + if strings.Contains(diag.Description().Detail, wantMsg) { + found = true + break + } + } + if !found { + t.Errorf("expected error message %q not found in diagnostics: %s", wantMsg, diags.ErrWithWarnings()) + } + } + } + }) + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl new file mode 100644 index 0000000000..eb8d61bf4e --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-complex/validation-complex.tfcomponent.hcl @@ -0,0 +1,71 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "email" { + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", var.email)) + error_message = "Must be a valid email address." + } +} + +variable "ip_address" { + type = string + + validation { + condition = can(regex("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", var.ip_address)) + error_message = "Must be a valid IPv4 address." + } +} + +variable "environment" { + type = string + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "tags" { + type = map(string) + + validation { + condition = alltrue([for k, v in var.tags : can(regex("^[a-z][a-z0-9-]*$", k))]) + error_message = "Tag keys must start with lowercase letter and contain only lowercase letters, numbers, and hyphens." + } + + validation { + condition = alltrue([for k, v in var.tags : length(v) > 0 && length(v) <= 256]) + error_message = "Tag values must be 1-256 characters." + } + + validation { + condition = contains(keys(var.tags), "owner") + error_message = "Tags must include 'owner' key." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl new file mode 100644 index 0000000000..ed7d2fffd2 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-invalid-error-message/validation-invalid-error-message.tfcomponent.hcl @@ -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 (and cannot be converted to one) +variable "count_value" { + type = set(string) + + validation { + condition = length(var.count_value) > 0 + error_message = var.count_value # Invalid: a set cannot be converted to 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" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl new file mode 100644 index 0000000000..ae6770daaf --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-provider-functions/validation-provider-functions.tfcomponent.hcl @@ -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" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl new file mode 100644 index 0000000000..7b17506956 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-sensitive/validation-sensitive.tfcomponent.hcl @@ -0,0 +1,60 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "password" { + type = string + sensitive = true + + validation { + condition = length(var.password) >= 8 + error_message = "Password must be at least 8 characters long." + } + + validation { + condition = can(regex("[A-Z]", var.password)) + error_message = "Password must contain at least one uppercase letter." + } + + validation { + condition = can(regex("[0-9]", var.password)) + error_message = "Password must contain at least one number." + } +} + +variable "api_key" { + type = string + sensitive = true + + validation { + condition = length(var.api_key) == 32 + error_message = "API key must be exactly 32 characters." + } + + validation { + condition = can(regex("^[a-f0-9]+$", var.api_key)) + error_message = "API key must only contain lowercase hex characters." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl new file mode 100644 index 0000000000..80945407ad --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/validation-types/validation-types.tfcomponent.hcl @@ -0,0 +1,57 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +variable "input" { + type = string + default = "default" +} + +variable "number_input" { + type = number + + validation { + condition = var.number_input > 0 && var.number_input < 100 + error_message = "Number must be between 0 and 100." + } +} + +variable "list_input" { + type = list(string) + + validation { + condition = length(var.list_input) > 0 && length(var.list_input) <= 5 + error_message = "List must contain 1-5 items." + } + + validation { + condition = alltrue([for s in var.list_input : length(s) > 0]) + error_message = "List items cannot be empty strings." + } +} + +variable "map_input" { + type = map(string) + + validation { + condition = contains(keys(var.map_input), "required_key") + error_message = "Map must contain 'required_key'." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +} + +provider "testing" "default" {} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl new file mode 100644 index 0000000000..b4068e1b6c --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/variable-validation/variable-validation.tfcomponent.hcl @@ -0,0 +1,44 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +variable "input" { + type = string + + validation { + condition = length(var.input) > 5 + error_message = "Input must be longer than 5 characters." + } + + validation { + condition = startswith(var.input, "H") + error_message = "Input must start with H." + } + + validation { + condition = !contains(["bad", "invalid", "nope"], var.input) + error_message = "Input cannot be 'bad', 'invalid', or 'nope'." + } + + validation { + condition = can(regex("^[A-Z]", var.input)) + error_message = "Input must start with an uppercase letter." + } +} + +component "self" { + source = "../" + + providers = { + testing = provider.testing.default + } + + inputs = { + input = var.input + } +}