opentofu/internal/repl/session_test.go
Martin Atkins 6522f73249 tofu: Context.Eval now takes a context.Context
This continues our ongoing effort to get a coherent chain of
context.Context all the way from "package main" to all of our calls to
external components.

Context.Eval doesn't yet do anything with its new context, but we'll
plumb this deeper in future.

All of the _test.go file updates here are purely mechanical additions of
the extra argument. No test is materially modified by this change, which
is intentional to get some assurance that isn't a breaking change.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2024-11-19 10:15:21 -08:00

454 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 repl
import (
"context"
"flag"
"os"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/initwd"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/tofu"
_ "github.com/opentofu/opentofu/internal/logging"
)
func TestMain(m *testing.M) {
flag.Parse()
os.Exit(m.Run())
}
func TestSession_basicState(t *testing.T) {
state := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"id":"bar"}`),
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
addrs.NoKey,
)
s.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance.Child("module", addrs.NoKey)),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"id":"bar"}`),
},
addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule,
},
addrs.NoKey,
)
})
t.Run("basic", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "test_instance.foo.id",
Output: `"bar"`,
},
},
})
})
t.Run("missing resource", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "test_instance.bar.id",
Error: true,
ErrorContains: `A managed resource "test_instance" "bar" has not been declared`,
},
},
})
})
t.Run("missing module", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "module.child",
Error: true,
ErrorContains: `No module call named "child" is declared in the root module.`,
},
},
})
})
t.Run("missing module referencing just one output", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "module.child.foo",
Error: true,
ErrorContains: `No module call named "child" is declared in the root module.`,
},
},
})
})
t.Run("missing module output", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "module.module.foo",
Error: true,
ErrorContains: `Unsupported attribute: This object does not have an attribute named "foo"`,
},
},
})
})
t.Run("type function", func(t *testing.T) {
testSession(t, testSessionTest{
State: state,
Inputs: []testSessionInput{
{
Input: "type(test_instance.foo)",
Output: `object({
id: string,
})`,
},
},
})
})
}
func TestSession_stateless(t *testing.T) {
t.Run("exit", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: "exit",
Exit: true,
},
},
})
})
t.Run("help", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: "help",
OutputContains: "allows you to",
},
},
})
})
t.Run("help with spaces", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: "help ",
OutputContains: "allows you to",
},
},
})
})
t.Run("basic math", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: "1 + 5",
Output: "6",
},
},
})
})
t.Run("missing resource", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: "test_instance.bar.id",
Error: true,
ErrorContains: `resource "test_instance" "bar" has not been declared`,
},
},
})
})
t.Run("type function", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: `type("foo")`,
Output: "string",
},
},
})
})
t.Run("type type is type", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: `type(type("foo"))`,
Output: "type",
},
},
})
})
t.Run("interpolating type with strings is not possible", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: `"quin${type([])}"`,
Error: true,
ErrorContains: "Invalid template interpolation value",
},
},
})
})
t.Run("type function cannot be used in expressions", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: `[for i in [1, "two", true]: type(i)]`,
Output: "",
Error: true,
ErrorContains: "Invalid use of type function",
},
},
})
})
t.Run("type equality checks are not permitted", func(t *testing.T) {
testSession(t, testSessionTest{
Inputs: []testSessionInput{
{
Input: `type("foo") == type("bar")`,
Output: "",
Error: true,
ErrorContains: "Invalid use of type function",
},
},
})
})
}
func testSession(t *testing.T, test testSessionTest) {
t.Helper()
p := &tofu.MockProvider{}
p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_instance": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
},
},
},
},
}
config, _, cleanup, configDiags := initwd.LoadConfigForTests(t, "testdata/config-fixture", "tests")
defer cleanup()
if configDiags.HasErrors() {
t.Fatalf("unexpected problems loading config: %s", configDiags.Err())
}
// Build the TF context
ctx, diags := tofu.NewContext(&tofu.ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): providers.FactoryFixed(p),
},
})
if diags.HasErrors() {
t.Fatalf("failed to create context: %s", diags.Err())
}
state := test.State
if state == nil {
state = states.NewState()
}
scope, diags := ctx.Eval(context.Background(), config, state, addrs.RootModuleInstance, &tofu.EvalOpts{})
if diags.HasErrors() {
t.Fatalf("failed to create scope: %s", diags.Err())
}
// Ensure that any console-only functions are available
scope.ConsoleMode = true
// Build the session
s := &Session{
Scope: scope,
}
// Test the inputs. We purposely don't use subtests here because
// the inputs don't represent subtests, but a sequence of stateful
// operations.
for _, input := range test.Inputs {
result, exit, diags := s.Handle(input.Input)
if exit != input.Exit {
t.Fatalf("incorrect 'exit' result %t; want %t", exit, input.Exit)
}
if (diags.HasErrors()) != input.Error {
t.Fatalf("%q: unexpected errors: %s", input.Input, diags.Err())
}
if diags.HasErrors() {
if input.ErrorContains != "" {
if !strings.Contains(diags.Err().Error(), input.ErrorContains) {
t.Fatalf(
"%q: diagnostics should contain: %q\n\n%s",
input.Input, input.ErrorContains, diags.Err(),
)
}
}
continue
}
if input.Output != "" && result != input.Output {
t.Fatalf(
"%q: expected:\n\n%s\n\ngot:\n\n%s",
input.Input, input.Output, result)
}
if input.OutputContains != "" && !strings.Contains(result, input.OutputContains) {
t.Fatalf(
"%q: expected contains:\n\n%s\n\ngot:\n\n%s",
input.Input, input.OutputContains, result)
}
}
}
type testSessionTest struct {
State *states.State // State to use
Module string // Module name in testdata to load
// Inputs are the list of test inputs that are run in order.
// Each input can test the output of each step.
Inputs []testSessionInput
}
// testSessionInput is a single input to test for a session.
type testSessionInput struct {
Input string // Input string
Output string // Exact output string to check
OutputContains string
Error bool // Error is true if error is expected
Exit bool // Exit is true if exiting is expected
ErrorContains string
}
func TestTypeString(t *testing.T) {
tests := []struct {
Input cty.Value
Want string
}{
// Primitives
{
cty.StringVal("a"),
"string",
},
{
cty.NumberIntVal(42),
"number",
},
{
cty.BoolVal(true),
"bool",
},
// Collections
{
cty.EmptyObjectVal,
`object({})`,
},
{
cty.EmptyTupleVal,
`tuple([])`,
},
{
cty.ListValEmpty(cty.String),
`list(string)`,
},
{
cty.MapValEmpty(cty.String),
`map(string)`,
},
{
cty.SetValEmpty(cty.String),
`set(string)`,
},
{
cty.ListVal([]cty.Value{cty.StringVal("a")}),
`list(string)`,
},
{
cty.ListVal([]cty.Value{cty.ListVal([]cty.Value{cty.NumberIntVal(42)})}),
`list(list(number))`,
},
{
cty.ListVal([]cty.Value{cty.MapValEmpty(cty.String)}),
`list(map(string))`,
},
{
cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("bar"),
})}),
"list(\n object({\n foo: string,\n }),\n)",
},
// Unknowns and Nulls
{
cty.UnknownVal(cty.String),
"string",
},
{
cty.NullVal(cty.Object(map[string]cty.Type{
"foo": cty.String,
})),
"object({\n foo: string,\n})",
},
{ // irrelevant marks do nothing
cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{
"foo": cty.StringVal("bar").Mark("ignore me"),
})}),
"list(\n object({\n foo: string,\n }),\n)",
},
}
for _, test := range tests {
got := typeString(test.Input.Type())
if got != test.Want {
t.Errorf("wrong result:\n%s", cmp.Diff(got, test.Want))
}
}
}