Files
opentf/internal/configs/static_scope_test.go
Andrei Ciobanu 013097b631 Ephemeral variables (#3108)
Signed-off-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
2025-09-10 07:45:23 -04:00

366 lines
10 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package configs
import (
"fmt"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
)
func TestStaticScope_GetInputVariable(t *testing.T) {
test_ident := StaticIdentifier{
Subject: "local.test_scenario",
}
t.Run("valid", func(t *testing.T) {
// This covers an assortment of valid cases that we can test in a single
// pass because they are all independent of one another.
p := testParser(map[string]string{
"test.tf": `
variable "unconstrained" {
}
variable "string" {
type = string
}
variable "string_from_number" {
type = string
}
variable "list_from_tuple" {
type = list(string)
}
variable "set_from_tuple" {
type = set(string)
}
variable "map_from_object" {
type = map(string)
}
variable "non_nullable_default" {
type = string
default = "default value"
nullable = false
}
# NOTE: We're not currently testing the replacement of a
# completely-unset variable by its default here because
# that's currently the responsibility of each separate
# source of input variables included in a StaticModuleCall,
# rather than part of the StaticScope functionality.
variable "object_with_optional" {
type = object({
a = optional(string)
b = optional(string, "b default")
c = string
})
}
variable "nullable_null_string" {
type = string
default = "..." # Ignored because nullable and set to null in the tests below
}
variable "unknown_string" {
type = string
}
variable "sensitive_string" {
type = string
sensitive = true
}
variable "ephemeral_string" {
type = string
ephemeral = true
}
`,
})
tests := map[string]struct {
callerVal cty.Value
wantFinalVal cty.Value
}{
"unconstrained": {
cty.EmptyObjectVal,
cty.EmptyObjectVal,
},
"string": {
cty.StringVal("hello"),
cty.StringVal("hello"),
},
"string_from_number": {
cty.NumberIntVal(12),
cty.StringVal("12"),
},
"list_from_tuple": {
cty.TupleVal([]cty.Value{
cty.StringVal("a"),
cty.True,
cty.StringVal("a"),
}),
cty.ListVal([]cty.Value{
cty.StringVal("a"),
cty.StringVal("true"),
cty.StringVal("a"),
}),
},
"set_from_tuple": {
cty.TupleVal([]cty.Value{
cty.StringVal("a"),
cty.True,
cty.StringVal("a"),
}),
cty.SetVal([]cty.Value{
cty.StringVal("a"), // coalesced
cty.StringVal("true"),
}),
},
"map_from_object": {
cty.ObjectVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
"b": cty.False,
}),
cty.MapVal(map[string]cty.Value{
"a": cty.StringVal("a value"),
"b": cty.StringVal("false"),
}),
},
"non_nullable_default": {
cty.NullVal(cty.String),
cty.StringVal("default value"), // null replaced by default because nullable = false
},
"object_with_optional": {
cty.ObjectVal(map[string]cty.Value{
"c": cty.StringVal("c value"),
"d": cty.StringVal("d value"),
}),
cty.ObjectVal(map[string]cty.Value{
"a": cty.NullVal(cty.String),
"b": cty.StringVal("b default"),
"c": cty.StringVal("c value"),
// d dropped because it's not part of the type constraint
}),
},
"nullable_null_string": {
// This case is a historical design error that was later partially fixed by allowing
// nullable = false, but nullable is true by default for backward-compatibility.
cty.NullVal(cty.String),
cty.NullVal(cty.String), // nullable = true by default, so explicit null overrides default
},
"unknown_string": {
cty.UnknownVal(cty.String),
cty.UnknownVal(cty.String),
},
"sensitive_string": {
cty.StringVal("ssshhhhh it's a secret"),
cty.StringVal("ssshhhhh it's a secret").Mark(marks.Sensitive),
},
"ephemeral_string": {
cty.StringVal("ssshhhhh it's a secret"),
cty.StringVal("ssshhhhh it's a secret").Mark(marks.Ephemeral),
},
}
call := NewStaticModuleCall(
addrs.RootModule,
func(v *Variable) (cty.Value, hcl.Diagnostics) {
return tests[v.Name].callerVal, nil
},
".",
"irrelevant",
)
mod, diags := p.LoadConfigDir(".", call)
assertNoDiagnostics(t, diags)
// We'll make sure the config and the test cases remain consistent
// under future maintenence. A failure here suggests that the test
// is wrong, rather than the code under test.
for name := range mod.Variables {
if _, exists := tests[name]; !exists {
t.Errorf("no test case for declared variable %q", name)
}
}
for name := range tests {
if _, exists := mod.Variables[name]; !exists {
t.Errorf("no declared variable for test case %q", name)
}
}
eval := NewStaticEvaluator(mod, call)
scope := newStaticScope(eval, test_ident)
for name, test := range tests {
t.Run(name, func(t *testing.T) {
addr := addrs.InputVariable{Name: name}
gotFinalVal, moreDiags := scope.Data.GetInputVariable(t.Context(), addr, tfdiags.SourceRange{Filename: "test.tf"})
assertNoDiagnostics(t, moreDiags.ToHCL())
if !test.wantFinalVal.RawEquals(gotFinalVal) {
diff := cmp.Diff(test.wantFinalVal, gotFinalVal, ctydebug.CmpOptions)
t.Errorf("wrong result\ninput: %#v for %s\ngot: %#v\nwant: %#v\n\n%s", test.callerVal, addr, gotFinalVal, test.wantFinalVal, diff)
}
})
}
})
t.Run("incompatible type", func(t *testing.T) {
p := testParser(map[string]string{
"test.tf": `
variable "bad_default" {
type = list(string)
}
`,
})
call := NewStaticModuleCall(
addrs.RootModule,
func(v *Variable) (cty.Value, hcl.Diagnostics) {
return cty.StringVal("not a list"), nil
},
".",
"irrelevant",
)
mod, diags := p.LoadConfigDir(".", call)
assertNoDiagnostics(t, diags)
eval := NewStaticEvaluator(mod, call)
scope := newStaticScope(eval, test_ident)
addr := addrs.InputVariable{Name: "bad_default"}
_, moreDiags := scope.Data.GetInputVariable(t.Context(), addr, tfdiags.SourceRange{Filename: "test.tf"})
if !moreDiags.HasErrors() {
t.Fatal("unexpected success; want a type conversion error")
}
diagsStr := moreDiags.Err().Error()
// The part of the message we're matching on below is actually generated by upstream
// library cty, so if a future cty upgrade changes this message to something substantially
// similar then it's fine to change this test to match but the message should always
// be about the given value being of the wrong type.
if want := "list of string required"; !strings.Contains(diagsStr, want) {
t.Errorf("wrong error\ngot: %s\nwant message containing: %s", diagsStr, want)
}
})
t.Run("non-nullable null", func(t *testing.T) {
p := testParser(map[string]string{
"test.tf": `
variable "not_nullable" {
type = string
nullable = false
}
`,
})
call := NewStaticModuleCall(
addrs.RootModule,
func(v *Variable) (cty.Value, hcl.Diagnostics) {
return cty.NullVal(cty.String), nil
},
".",
"irrelevant",
)
mod, diags := p.LoadConfigDir(".", call)
assertNoDiagnostics(t, diags)
eval := NewStaticEvaluator(mod, call)
scope := newStaticScope(eval, test_ident)
addr := addrs.InputVariable{Name: "not_nullable"}
_, moreDiags := scope.Data.GetInputVariable(t.Context(), addr, tfdiags.SourceRange{Filename: "test.tf"})
if !moreDiags.HasErrors() {
t.Fatal("unexpected success; want an error about the variable being required")
}
diagsStr := moreDiags.Err().Error()
if want := "required variable may not be set to null"; !strings.Contains(diagsStr, want) {
t.Errorf("wrong error\ngot: %s\nwant message containing: %s", diagsStr, want)
}
})
}
func TestStaticScope_GetLocalValue(t *testing.T) {
test_ident := StaticIdentifier{
Subject: "local.test_scenario",
}
t.Run("valid", func(t *testing.T) {
p := testParser(map[string]string{
"test.tf": `
locals {
foo = "bar"
}
`,
})
call := NewStaticModuleCall(
addrs.RootModule,
func(v *Variable) (cty.Value, hcl.Diagnostics) {
var diags tfdiags.Diagnostics
diags = diags.Append(fmt.Errorf("no variables here"))
return cty.DynamicVal, diags.ToHCL()
},
".",
"irrelevant",
)
mod, diags := p.LoadConfigDir(".", call)
assertNoDiagnostics(t, diags)
eval := NewStaticEvaluator(mod, call)
scope := newStaticScope(eval, test_ident)
addr := addrs.LocalValue{Name: "foo"}
got, moreDiags := scope.Data.GetLocalValue(t.Context(), addr, tfdiags.SourceRange{Filename: "test.tf"})
want := cty.StringVal("bar")
assertNoDiagnostics(t, moreDiags.ToHCL())
if !got.RawEquals(want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, want)
}
})
t.Run("undeclared", func(t *testing.T) {
p := testParser(map[string]string{
"test.tf": `
# This module intentionally left blank
`,
})
call := NewStaticModuleCall(
addrs.RootModule,
func(v *Variable) (cty.Value, hcl.Diagnostics) {
var diags tfdiags.Diagnostics
diags = diags.Append(fmt.Errorf("no variables here"))
return cty.DynamicVal, diags.ToHCL()
},
".",
"irrelevant",
)
mod, diags := p.LoadConfigDir(".", call)
assertNoDiagnostics(t, diags)
eval := NewStaticEvaluator(mod, call)
scope := newStaticScope(eval, test_ident)
addr := addrs.LocalValue{Name: "nonexist"}
_, moreDiags := scope.Data.GetLocalValue(t.Context(), addr, tfdiags.SourceRange{Filename: "test.tf"})
if !moreDiags.HasErrors() {
t.Fatal("unexpected success; want error")
}
diagsStr := moreDiags.Err().Error()
if want := "Undefined local local.nonexist"; !strings.Contains(diagsStr, want) {
t.Errorf("wrong error\ngot: %s\nwant message containing: %s", diagsStr, want)
}
})
}