mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-04-04 21:00:39 -04:00
Signed-off-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org> Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
366 lines
10 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|