Files
opentf/internal/command/validate_test.go
Martin Atkins a800d250e5 command: "go fix" on various files we've changed recently anyway
We don't typically just broadly run automatic rewriting tools like "go fix"
across our codebase because that tends to cause annoying and unnecessary
merge conflicts when we're backporting to earlier release branches.

But all of the files in this commit were changed in some non-trivial way
already during the OpenTofu v1.11 development period anyway, and so the
likelyhood we'd be able to successfully backport from them is reduced and
therefore this seems like a good opportunity to do some focused
modernization using "go fix".

My rules for what to include or not are admittedly quite "vibes-based", but
the general idea was:

 - Focusing on files under the "command" directory only, because that's
   already been an area of intentional refactoring during this development
   period.
 - If the existing diff in a file is already significantly larger than
   the changes the fixer proposed to make, or if the fixer is proposing
   to change a line that was already changed in this development period.
 - More willing to include "_test.go" files than non-test files, even if
   they hadn't changed as much already, just because backports from test
   files for bug fixes tend to be entirely new test cases more than they
   are modifications to existing test cases, and so the risk of conflicts
   is lower there.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2026-03-17 15:25:30 -07:00

449 lines
13 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 command
import (
"encoding/json"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/mitchellh/cli"
"github.com/opentofu/opentofu/internal/command/workdir"
"github.com/zclconf/go-cty/cty"
testing_command "github.com/opentofu/opentofu/internal/command/testing"
"github.com/opentofu/opentofu/internal/command/views"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/terminal"
)
func setupTest(t *testing.T, fixturepath string, args ...string) (*terminal.TestOutput, int) {
view, done := testView(t)
p := testProvider()
p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{
"test_instance": {
Block: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"ami": {Type: cty.String, Optional: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"network_interface": {
Nesting: configschema.NestingList,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"device_index": {Type: cty.String, Optional: true},
"description": {Type: cty.String, Optional: true},
"name": {Type: cty.String, Optional: true},
},
},
},
},
},
},
},
}
c := &ValidateCommand{
Meta: Meta{
WorkingDir: workdir.NewDir("."),
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args = append(args, "-no-color")
args = append(args, testFixturePath(fixturepath))
code := c.Run(args)
return done(t), code
}
func TestValidateCommand(t *testing.T) {
if output, code := setupTest(t, "validate-valid"); code != 0 {
t.Fatalf("unexpected non-successful exit code %d\n\n%s", code, output.Stderr())
}
}
func TestValidateCommandWithTfvarsFile(t *testing.T) {
// Create a temporary working directory that is empty because this test
// requires scanning the current working directory by validate command.
td := t.TempDir()
testCopyDir(t, testFixturePath("validate-valid/with-tfvars-file"), td)
t.Chdir(td)
view, done := testView(t)
c := &ValidateCommand{
Meta: Meta{
WorkingDir: workdir.NewDir("."),
testingOverrides: metaOverridesForProvider(testProvider()),
View: view,
},
}
args := []string{}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad %d\n\n%s", code, output.Stderr())
}
}
func TestValidateFailingCommand(t *testing.T) {
if output, code := setupTest(t, "validate-invalid"); code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
}
func TestValidateFailingCommandMissingQuote(t *testing.T) {
output, code := setupTest(t, "validate-invalid/missing_quote")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Invalid reference"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestValidateFailingCommandMissingVariable(t *testing.T) {
output, code := setupTest(t, "validate-invalid/missing_var")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Reference to undeclared input variable"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestSameProviderMultipleTimesShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/multiple_providers")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Duplicate provider configuration"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestSameModuleMultipleTimesShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/multiple_modules")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Duplicate module call"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestSameResourceMultipleTimesShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/multiple_resources")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `Error: Duplicate resource "aws_instance" configuration`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestSameImportTargetMultipleTimesShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/duplicate_import_targets")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `Error: Duplicate import configuration for "aws_instance.web"`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestUndefinedVariableAsImportIDShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/import_undefined_var")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `Error: Reference to undeclared input variable`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestUndefinedResourceAsImportTargetShouldSucceed(t *testing.T) {
// -generate-config-out is the reason we can have undefined resources as targets
output, code := setupTest(t, "validate-valid/import_undefined_resource")
if code != 0 {
t.Fatalf("Should have succeeded: %d\n\n%s", code, output.Stderr())
}
}
func TestDefinedResourceAsImportTargetShouldSucceed(t *testing.T) {
output, code := setupTest(t, "validate-valid/import_defined_resource")
if code != 0 {
t.Fatalf("Should have succeeded: %d\n\n%s", code, output.Stderr())
}
}
func TestDefinedVarAsImportIDShouldSucceed(t *testing.T) {
output, code := setupTest(t, "validate-valid/import_id_defined_var")
if code != 0 {
t.Fatalf("Should have succeeded: %d\n\n%s", code, output.Stderr())
}
}
func TestOutputWithoutValueShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/outputs")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `The argument "value" is required, but no definition was found.`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
wantError = `An argument named "values" is not expected here. Did you mean "value"?`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestModuleWithIncorrectNameShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/incorrectmodulename")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `Error: Invalid module instance name`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestWronglyUsedInterpolationShouldFail(t *testing.T) {
output, code := setupTest(t, "validate-invalid/interpolation")
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := `Error: Variables not allowed`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
wantError = `A single static variable reference is required`
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestMissingDefinedVar(t *testing.T) {
output, code := setupTest(t, "validate-invalid/missing_defined_var")
// This is allowed because validate tests only that variables are referenced
// correctly, not that they all have defined values.
if code != 0 {
t.Fatalf("Should have passed: %d\n\n%s", code, output.Stderr())
}
}
func TestValidateWithInvalidTestFile(t *testing.T) {
// We're reusing some testing configs that were written for testing the
// test command here, so we have to initialise things slightly differently
// to the other tests.
view, done := testView(t)
provider := testing_command.NewProvider(nil)
c := &ValidateCommand{
Meta: Meta{
WorkingDir: workdir.NewDir("."),
testingOverrides: metaOverridesForProvider(provider.Provider),
View: view,
},
}
var args []string
args = append(args, "-no-color")
args = append(args, testFixturePath("test/invalid"))
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Invalid `expect_failures` reference"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestValidateWithInvalidTestModule(t *testing.T) {
// We're reusing some testing configs that were written for testing the
// test command here, so we have to initialise things slightly differently
// to the other tests.
td := t.TempDir()
testCopyDir(t, testFixturePath(path.Join("test", "invalid-module")), td)
t.Chdir(td)
streams, done := terminal.StreamsForTesting(t)
view := views.NewView(streams)
ui := new(cli.MockUi)
provider := testing_command.NewProvider(nil)
providerSource, close := newMockProviderSource(t, map[string][]string{
"test": {"1.0.0"},
})
defer close()
meta := Meta{
WorkingDir: workdir.NewDir("."),
testingOverrides: metaOverridesForProvider(provider.Provider),
Ui: ui,
View: view,
Streams: streams,
ProviderSource: providerSource,
}
init := &InitCommand{
Meta: meta,
}
if code := init.Run(nil); code != 0 {
t.Fatalf("expected status code 0 but got %d: %s", code, ui.ErrorWriter)
}
c := &ValidateCommand{
Meta: meta,
}
var args []string
args = append(args, "-no-color")
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr())
}
wantError := "Error: Reference to undeclared input variable"
if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
}
}
func TestValidate_json(t *testing.T) {
tests := []struct {
path string
valid bool
}{
{"validate-valid", true},
{"validate-invalid", false},
{"validate-invalid/missing_quote", false},
{"validate-invalid/missing_var", false},
{"validate-invalid/multiple_providers", false},
{"validate-invalid/multiple_modules", false},
{"validate-invalid/multiple_resources", false},
{"validate-invalid/duplicate_import_targets", false},
{"validate-invalid/outputs", false},
{"validate-invalid/incorrectmodulename", false},
{"validate-invalid/interpolation", false},
{"validate-invalid/missing_defined_var", true},
}
cmpOpts := cmp.Options{
// Filenames are defined as Unix paths in `output.json`, this
// is needed to convert them to the correct path for the current OS.
cmp.FilterPath(
func(p cmp.Path) bool {
field := p.Last().String()
return field == `["filename"]`
},
//
cmp.Transformer("filename", func(filename any) string {
convertedFilename, ok := filename.(string)
if !ok {
t.Fatalf("failed to convert filename to string: %v", filename)
}
return filepath.FromSlash(convertedFilename)
}),
),
// Detail field contains file path along with other information. '/' are
// being replaced if Windows is the current OS.
cmp.FilterPath(
func(p cmp.Path) bool {
field := p.Last().String()
return field == `["detail"]`
},
cmp.Transformer("detail", func(detail any) string {
convertedDetail, ok := detail.(string)
if !ok {
t.Fatalf("failed to convert detail to string: %v", detail)
}
if runtime.GOOS == "windows" {
convertedDetail = strings.ReplaceAll(convertedDetail, `\`, `/`)
}
return convertedDetail
}),
),
}
for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) {
var want, got map[string]any
wantFile, err := os.Open(path.Join(testFixturePath(tc.path), "output.json"))
if err != nil {
t.Fatalf("failed to open output file: %s", err)
}
defer wantFile.Close()
wantBytes, err := io.ReadAll(wantFile)
if err != nil {
t.Fatalf("failed to read output file: %s", err)
}
err = json.Unmarshal([]byte(wantBytes), &want)
if err != nil {
t.Fatalf("failed to unmarshal expected JSON: %s", err)
}
output, code := setupTest(t, tc.path, "-json")
gotString := output.Stdout()
err = json.Unmarshal([]byte(gotString), &got)
if err != nil {
t.Fatalf("failed to unmarshal actual JSON: %s", err)
}
if !cmp.Equal(got, want, cmpOpts) {
t.Errorf("wrong output:\n %v\n", cmp.Diff(got, want))
t.Errorf("raw output:\n%s\n", gotString)
}
if tc.valid && code != 0 {
t.Errorf("wrong exit code: want 0, got %d", code)
} else if !tc.valid && code != 1 {
t.Errorf("wrong exit code: want 1, got %d", code)
}
if errorOutput := output.Stderr(); errorOutput != "" {
t.Errorf("unexpected error output:\n%s", errorOutput)
}
})
}
}