| // 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/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{ | |
| 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{ | |
| 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{ | |
| 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) | |
| 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), | |
| View: view, | |
| ProviderSource: providerSource, | |
| } | |
| init := &InitCommand{ | |
| Meta: meta, | |
| } | |
| code := init.Run(nil) | |
| output := done(t) | |
| if code != 0 { | |
| t.Fatalf("expected status code 0 but got %d: %s", code, output.Stderr()) | |
| } | |
| streams, done = terminal.StreamsForTesting(t) | |
| view = views.NewView(streams) | |
| meta.View = view | |
| 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 | |
| additionalArgs []string | |
| valid bool | |
| }{ | |
| {"validate-valid", nil, true}, | |
| {"validate-invalid", nil, false}, | |
| {"validate-invalid/missing_quote", nil, false}, | |
| {"validate-invalid/missing_var", nil, false}, | |
| {"validate-invalid/multiple_providers", nil, false}, | |
| {"validate-invalid/multiple_modules", nil, false}, | |
| {"validate-invalid/multiple_resources", nil, false}, | |
| {"validate-invalid/duplicate_import_targets", nil, false}, | |
| {"validate-invalid/outputs", nil, false}, | |
| {"validate-invalid/incorrectmodulename", nil, false}, | |
| {"validate-invalid/interpolation", nil, false}, | |
| {"validate-invalid/missing_defined_var", nil, true}, | |
| {"validate-invalid/with_lint_warning", []string{"-lint=core:unused-variable"}, true}, | |
| } | |
| cmpOpts := func(t *testing.T) cmp.Options { | |
| return 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(strings.ReplaceAll(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(wantBytes, &want) | |
| if err != nil { | |
| t.Fatalf("failed to unmarshal expected JSON: %s", err) | |
| } | |
| output, code := setupTest(t, tc.path, append(tc.additionalArgs, "-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)) { | |
| 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) | |
| } | |
| }) | |
| } | |
| } |