// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package workdir import ( "encoding/json" "strings" "testing" "github.com/google/go-cmp/cmp" tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform/version" ) func TestParseBackendStateFile(t *testing.T) { tests := map[string]struct { Input string Want *BackendStateFile WantErr string }{ "empty": { Input: ``, WantErr: `invalid syntax: unexpected end of JSON input`, }, "empty but valid JSON syntax": { Input: `{}`, WantErr: `invalid syntax: no format version number`, }, "older version": { Input: `{ "version": 2, "terraform_version": "0.3.0" }`, WantErr: `unsupported backend state version 2; you may need to use Terraform CLI v0.3.0 to work in this directory`, }, "newer version": { Input: `{ "version": 4, "terraform_version": "54.23.9" }`, WantErr: `unsupported backend state version 4; you may need to use Terraform CLI v54.23.9 to work in this directory`, }, "legacy remote state is active": { Input: `{ "version": 3, "terraform_version": "0.8.0", "remote": { "anything": "goes" } }`, WantErr: `this working directory uses legacy remote state and so must first be upgraded using Terraform v0.9`, }, "active backend": { Input: `{ "version": 3, "terraform_version": "0.8.0", "backend": { "type": "treasure_chest_buried_on_a_remote_island", "config": {}, "hash" : 12345 } }`, Want: &BackendStateFile{ Version: 3, TFVersion: "0.8.0", Backend: &BackendConfigState{ Type: "treasure_chest_buried_on_a_remote_island", ConfigRaw: json.RawMessage("{}"), Hash: 12345, }, }, }, "active state_store": { Input: `{ "version": 3, "terraform_version": "9.9.9", "state_store": { "type": "foobar_baz", "config": { "bucket": "my-bucket", "region": "saturn" }, "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "config": { "credentials": "./creds.json" }, "hash" : 12345 }, "hash" : 12345 } }`, Want: &BackendStateFile{ Version: 3, TFVersion: "9.9.9", StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Watch out - the number of tabs in the last argument here are load-bearing Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", `{ "credentials": "./creds.json" }`), ConfigRaw: json.RawMessage(`{ "bucket": "my-bucket", "region": "saturn" }`), Hash: 12345, }, }, }, "detection of malformed state: conflicting 'backend' and 'state_store' sections": { Input: `{ "version": 3, "terraform_version": "9.9.9", "backend": { "type": "treasure_chest_buried_on_a_remote_island", "config": {}, "hash" : 12345 }, "state_store": { "type": "foobar_baz", "config": { "provider": "foobar", "bucket": "my-bucket" }, "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "hash" : 12345 }, "hash" : 12345 } }`, WantErr: `encountered a malformed backend state file that contains state for both a 'backend' and a 'state_store' block`, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { got, err := ParseBackendStateFile([]byte(test.Input)) if test.WantErr != "" { if err == nil { t.Fatalf("unexpected success\nwant error: %s", test.WantErr) } if got, want := err.Error(), test.WantErr; got != want { t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(test.Want, got); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestEncodeBackendStateFile(t *testing.T) { noVersionData := "" tfVersion := version.Version tests := map[string]struct { Input *BackendStateFile Envs map[string]string Want []byte WantErr string }{ "encoding a backend state file when state_store is in use": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", `{"foo": "bar"}`), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": \"1.2.3\",\n \"source\": \"registry.terraform.io/my-org/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "it's valid to record no version data when a builtin provider used for state store": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, noVersionData, string(tfaddr.BuiltInProviderHost), string(tfaddr.BuiltInProviderNamespace), "foobar", `{"foo": "bar"}`), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"terraform.io/builtin/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "it's valid to record no version data when a re-attached provider used for state store": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, noVersionData, "registry.terraform.io", "hashicorp", "foobar", `{"foo": "bar"}`), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Envs: map[string]string{ "TF_REATTACH_PROVIDERS": `{ "foobar": { "Protocol": "grpc", "ProtocolVersion": 6, "Pid": 12345, "Test": true, "Addr": { "Network": "unix", "String":"/var/folders/xx/abcde12345/T/plugin12345" } } }`, }, Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\",\n \"state_store\": {\n \"type\": \"foobar_baz\",\n \"provider\": {\n \"version\": null,\n \"source\": \"registry.terraform.io/hashicorp/foobar\",\n \"config\": {\n \"foo\": \"bar\"\n }\n },\n \"config\": {\n \"foo\": \"bar\"\n },\n \"hash\": 123\n }\n}"), }, "error when neither backend nor state_store config state are present": { Input: &BackendStateFile{}, Want: []byte("{\n \"version\": 3,\n \"terraform_version\": \"" + tfVersion + "\"\n}"), }, "error when the provider is neither builtin nor reattached and the provider version is missing": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, noVersionData, "registry.terraform.io", "my-org", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: provider version data is missing`, }, "error when the provider source's hostname is missing": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "my-org", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, }, "error when the provider source's hostname and namespace are missing ": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, }, "error when the provider source is completely missing ": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "", "", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Empty provider address: Expected address composed of hostname, provider namespace and name`, }, "error when both backend and state_store config state are present": { Input: &BackendStateFile{ Backend: &BackendConfigState{ Type: "foobar", ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `attempted to encode a malformed backend state file; it contains state for both a 'backend' and a 'state_store' block`, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { // Some test cases depend on ENVs, not all for k, v := range test.Envs { t.Setenv(k, v) } got, err := EncodeBackendStateFile(test.Input) if test.WantErr != "" { if err == nil { t.Fatalf("unexpected success\nwant error: %s", test.WantErr) } if !strings.Contains(err.Error(), test.WantErr) { t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(test.Want, got); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestBackendStateFile_DeepCopy(t *testing.T) { tests := map[string]struct { file *BackendStateFile }{ "Deep copy preserves state_store data": { file: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foo_bar", Provider: getTestProviderState(t, "1.2.3", "A", "B", "C", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, }, "Deep copy preserves backend data": { file: &BackendStateFile{ Backend: &BackendConfigState{ Type: "foobar", ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, }, "Deep copy preserves version and Terraform version data": { file: &BackendStateFile{ Version: 3, TFVersion: "9.9.9", }, }, } for tn, tc := range tests { t.Run(tn, func(t *testing.T) { copy := tc.file.DeepCopy() if diff := cmp.Diff(copy, tc.file); diff != "" { t.Fatalf("unexpected difference in backend state data:\n %s", diff) } }) } }