// SPDX-License-Identifier: MPL-2.0 package provider import ( "context" "os" "path/filepath" "slices" "strconv" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-go/tftypes" ) const ( defaultId = "" defaultDirectory = "." defaultMode = "0600" defaultProtected = "false" defaultHmacSecretKey = "" ) var booleanFields = []string{"protected", "fake"} func TestLocalResourceMetadata(t *testing.T) { t.Run("Metadata function", func(t *testing.T) { testCases := []struct { name string fit LocalResource want resource.MetadataResponse }{ {"Basic test", LocalResource{}, resource.MetadataResponse{TypeName: "file_local"}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { res := resource.MetadataResponse{} tc.fit.Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "file"}, &res) got := res if got != tc.want { t.Errorf("%#v.Metadata() is %v; want %v", tc.fit, got, tc.want) } }) } }) } func TestLocalSchema(t *testing.T) { t.Run("Schema function", func(t *testing.T) { testCases := []struct { name string fit LocalResource want resource.SchemaResponse }{ {"Basic test", LocalResource{}, *getLocalResourceSchema()}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { r := resource.SchemaResponse{} tc.fit.Schema(context.Background(), resource.SchemaRequest{}, &r) got := r if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("Schema() mismatch (-want +got):\n%s", diff) } }) } }) } func TestLocalResourceCreate(t *testing.T) { t.Run("Create function", func(t *testing.T) { testCases := []struct { name string fit LocalResource have resource.CreateRequest want resource.CreateResponse tearDownPath string }{ { "Basic", LocalResource{}, // have getCreateRequest(t, map[string]string{ "id": defaultId, "name": "test_basic.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a basic test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, // this should use the hard coded hmac secret key for unprotected files }), // want getCreateResponse(t, map[string]string{ "id": "3de642fb91d2fb0ce02fe66c3d19ebdf44cbc6a2ebcc2dad22f1950b67c1217f", "name": "test_basic.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a basic test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }), filepath.Join(defaultDirectory, "test_basic.tmp"), }, { "Protected", LocalResource{}, // have getCreateRequest(t, map[string]string{ "id": "4ccd8ec7ea24e0524c8aba459fbf3a2649ec3cd96a1c8f9dfb326cc57a9d3127", "name": "test_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // want getCreateResponse(t, map[string]string{ "id": "4ccd8ec7ea24e0524c8aba459fbf3a2649ec3cd96a1c8f9dfb326cc57a9d3127", "name": "test_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), filepath.Join(defaultDirectory, "test_protected.tmp"), }, { "Protected using key from environment", LocalResource{}, // have getCreateRequest(t, map[string]string{ "id": "59fed8691a76c7693fc9dcd4fda28390a1fd3090114bc64f3e5a3abe312a92f5", "name": "test_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a test", "protected": "true", "hmac_secret_key": defaultHmacSecretKey, // this relies on TF_FILE_HMAC_SECRET_KEY=thisisasupersecretkey in your environment }), // want getCreateResponse(t, map[string]string{ "id": "59fed8691a76c7693fc9dcd4fda28390a1fd3090114bc64f3e5a3abe312a92f5", "name": "test_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a test", "protected": "true", "hmac_secret_key": defaultHmacSecretKey, }), filepath.Join(defaultDirectory, "test_protected.tmp"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var plannedState LocalResourceModel if diags := tc.have.Plan.Get(context.Background(), &plannedState); diags.HasError() { t.Errorf("Failed to get planned state: %v", diags) } plannedProtected := plannedState.Protected.ValueBool() plannedHmacSecretKey := plannedState.HmacSecretKey.ValueString() if plannedProtected && plannedHmacSecretKey == "" { t.Setenv("TF_FILE_HMAC_SECRET_KEY", "thisisasupersecretkey") } r := getCreateResponseContainer() tc.fit.Create(context.Background(), tc.have, &r) defer teardown(tc.tearDownPath) got := r if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("Create() mismatch (-want +got):\n%s", diff) } }) } }) } func TestLocalResourceRead(t *testing.T) { t.Run("Read function", func(t *testing.T) { testCases := []struct { name string fit LocalResource have resource.ReadRequest want resource.ReadResponse setup map[string]string tearDownPath string }{ { "Unprotected", LocalResource{}, // have getReadRequest(t, map[string]string{ "id": "60cef95046105ff4522c0c1f1aeeeba43d0d729dbcabdd8846c317c98cac60a2", "name": "read.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is an unprotected read test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }), // want getReadResponse(t, map[string]string{ "id": "60cef95046105ff4522c0c1f1aeeeba43d0d729dbcabdd8846c317c98cac60a2", "name": "read.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is an unprotected read test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }), map[string]string{ "mode": defaultMode, "path": filepath.Join(defaultDirectory, "read.tmp"), "contents": "this is an unprotected read test", }, filepath.Join(defaultDirectory, "read.tmp"), }, { "Protected", LocalResource{}, // have getReadRequest(t, map[string]string{ "id": "ec4407ba53b2c40ac2ac18ff7372a6fe6e4f7f8aa04f340503aefc7d9a5fa4e1", "name": "read_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a protected read test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // want getReadResponse(t, map[string]string{ "id": "ec4407ba53b2c40ac2ac18ff7372a6fe6e4f7f8aa04f340503aefc7d9a5fa4e1", "name": "read_protected.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a protected read test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // reality map[string]string{ "mode": defaultMode, "path": filepath.Join(defaultDirectory, "read_protected.tmp"), "contents": "this is a protected read test", }, filepath.Join(defaultDirectory, "read_protected.tmp"), }, { "Protected with content update", LocalResource{}, // have getReadRequest(t, map[string]string{ "id": "ec4407ba53b2c40ac2ac18ff7372a6fe6e4f7f8aa04f340503aefc7d9a5fa4e1", "name": "read_protected_content.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a protected read test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // want getReadResponse(t, map[string]string{ "id": "84326116e261654e44ca3cb73fa026580853794062d472bc817b7ec2c82ff648", "name": "read_protected_content.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a change in contents in the real file", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // reality map[string]string{ "mode": defaultMode, "path": filepath.Join(defaultDirectory, "read_protected_content.tmp"), "contents": "this is a change in contents in the real file", }, filepath.Join(defaultDirectory, "read_protected_content.tmp"), }, { "Protected with mode update", LocalResource{}, // have getReadRequest(t, map[string]string{ "id": "ec4407ba53b2c40ac2ac18ff7372a6fe6e4f7f8aa04f340503aefc7d9a5fa4e1", "name": "read_protected_mode.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a protected read test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // want getReadResponse(t, map[string]string{ "id": "ec4407ba53b2c40ac2ac18ff7372a6fe6e4f7f8aa04f340503aefc7d9a5fa4e1", "name": "read_protected_mode.tmp", "directory": defaultDirectory, "mode": "0755", "contents": "this is a protected read test", "protected": "true", "hmac_secret_key": "this-is-a-test-key", }), // reality map[string]string{ "mode": "0755", "path": filepath.Join(defaultDirectory, "read_protected_mode.tmp"), "contents": "this is a protected read test", }, filepath.Join(defaultDirectory, "read_protected_mode.tmp"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { setup(tc.setup) defer teardown(tc.tearDownPath) r := getReadResponseContainer() tc.fit.Read(context.Background(), tc.have, &r) got := r if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("Read() mismatch (-want +got):\n%s", diff) } }) } }) } func TestLocalResourceUpdate(t *testing.T) { t.Run("Update function", func(t *testing.T) { testCases := []struct { name string fit LocalResource have resource.UpdateRequest want resource.UpdateResponse setup map[string]string tearDownPath string }{ { "Basic test", LocalResource{}, // have getUpdateRequest(t, map[string]map[string]string{ "priorState": { "id": defaultId, "name": "update_basic.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is an update test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }, "plan": { "id": defaultId, "name": "update_basic.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a basic update test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }, }), // want getUpdateResponse(t, map[string]string{ "id": "0ec41eee6c157a3f7e50b78d586ee2ddb4d6e93b6de8bdf6d9354cf720e89549", "name": "update_basic.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a basic update test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }), // setup map[string]string{ "mode": defaultMode, "path": filepath.Join(defaultDirectory, "update_basic.tmp"), "contents": "this is an update test", }, filepath.Join(defaultDirectory, "update_basic.tmp"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { setup(tc.setup) defer teardown(tc.tearDownPath) r := getUpdateResponseContainer() tc.fit.Update(context.Background(), tc.have, &r) got := r var plannedState LocalResourceModel if diags := tc.have.Plan.Get(context.Background(), &plannedState); diags.HasError() { t.Errorf("Failed to get planned state: %v", diags) } plannedContents := plannedState.Contents.ValueString() plannedFilePath := filepath.Join(plannedState.Directory.ValueString(), plannedState.Name.ValueString()) contentsAfterUpdate, err := os.ReadFile(plannedFilePath) if err != nil { t.Errorf("Failed to read file for update verification: %s", err) } if string(contentsAfterUpdate) != plannedContents { t.Errorf("File content was not updated correctly. Got %q, want %q", string(contentsAfterUpdate), plannedContents) } if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("Update() mismatch (-want +got):\n%s", diff) } }) } }) } func TestLocalResourceDelete(t *testing.T) { t.Run("Delete function", func(t *testing.T) { testCases := []struct { name string fit LocalResource have resource.DeleteRequest want resource.DeleteResponse setup map[string]string tearDownPath string }{ { "Basic test", LocalResource{}, // have getDeleteRequest(t, map[string]string{ "id": "fd6fb8621c4850c228190f4d448ce30881a32609d6b4c7341d48d0027e597567", "name": "delete.tmp", "directory": defaultDirectory, "mode": defaultMode, "contents": "this is a delete test", "protected": defaultProtected, "hmac_secret_key": defaultHmacSecretKey, }), // want getDeleteResponse(), // setup map[string]string{ "mode": defaultMode, "path": filepath.Join(defaultDirectory, "delete.tmp"), "contents": "this is a delete test", }, filepath.Join(defaultDirectory, "delete.tmp"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { setup(tc.setup) r := getDeleteResponseContainer() tc.fit.Delete(context.Background(), tc.have, &r) got := r // Verify the file was actually deleted from disk if _, err := os.Stat(tc.setup["path"]); !os.IsNotExist(err) { t.Errorf("Expected file to be deleted, but it still exists.") } // verify that the file was removed from state if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("Update() mismatch (-want +got):\n%s", diff) } }) } }) } // *** Test Helper Functions *** // func getCreateRequest(t *testing.T, data map[string]string) resource.CreateRequest { planMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant if value == "" { planMap[key] = tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue) } else { v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } planMap[key] = tftypes.NewValue(tftypes.Bool, v) } } else { if value == "" { planMap[key] = tftypes.NewValue(tftypes.String, tftypes.UnknownValue) } else { planMap[key] = tftypes.NewValue(tftypes.String, value) } } } planValue := tftypes.NewValue(getObjectAttributeTypes(), planMap) return resource.CreateRequest{ Plan: tfsdk.Plan{ Raw: planValue, Schema: getLocalResourceSchema().Schema, }, } } func getCreateResponseContainer() resource.CreateResponse { return resource.CreateResponse{ State: tfsdk.State{Schema: getLocalResourceSchema().Schema}, } } func getCreateResponse(t *testing.T, data map[string]string) resource.CreateResponse { stateMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } stateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) return resource.CreateResponse{ State: tfsdk.State{ Raw: stateValue, Schema: getLocalResourceSchema().Schema, }, } } func getReadRequest(t *testing.T, data map[string]string) resource.ReadRequest { stateMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } stateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) return resource.ReadRequest{ State: tfsdk.State{ Raw: stateValue, Schema: getLocalResourceSchema().Schema, }, } } func getReadResponseContainer() resource.ReadResponse { return resource.ReadResponse{ State: tfsdk.State{Schema: getLocalResourceSchema().Schema}, } } func getReadResponse(t *testing.T, data map[string]string) resource.ReadResponse { stateMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } stateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) return resource.ReadResponse{ State: tfsdk.State{ Raw: stateValue, Schema: getLocalResourceSchema().Schema, }, } } func getUpdateRequest(t *testing.T, data map[string]map[string]string) resource.UpdateRequest { stateMap := make(map[string]tftypes.Value) for key, value := range data["priorState"] { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } priorStateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) planMap := make(map[string]tftypes.Value) for key, value := range data["plan"] { if slices.Contains(booleanFields, key) { // booleanFields is a constant if value == "" { planMap[key] = tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue) } else { v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } planMap[key] = tftypes.NewValue(tftypes.Bool, v) } } else { if value == "" { planMap[key] = tftypes.NewValue(tftypes.String, tftypes.UnknownValue) } else { planMap[key] = tftypes.NewValue(tftypes.String, value) } } } planValue := tftypes.NewValue(getObjectAttributeTypes(), planMap) return resource.UpdateRequest{ State: tfsdk.State{ Raw: priorStateValue, Schema: getLocalResourceSchema().Schema, }, Plan: tfsdk.Plan{ Raw: planValue, Schema: getLocalResourceSchema().Schema, }, } } func getUpdateResponseContainer() resource.UpdateResponse { return resource.UpdateResponse{ State: tfsdk.State{Schema: getLocalResourceSchema().Schema}, } } func getUpdateResponse(t *testing.T, data map[string]string) resource.UpdateResponse { stateMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } stateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) return resource.UpdateResponse{ State: tfsdk.State{ Raw: stateValue, Schema: getLocalResourceSchema().Schema, }, } } func getDeleteRequest(t *testing.T, data map[string]string) resource.DeleteRequest { stateMap := make(map[string]tftypes.Value) for key, value := range data { if slices.Contains(booleanFields, key) { // booleanFields is a constant v, err := strconv.ParseBool(value) if err != nil { t.Errorf("Error converting %s to bool %s: ", value, err.Error()) } stateMap[key] = tftypes.NewValue(tftypes.Bool, v) } else { stateMap[key] = tftypes.NewValue(tftypes.String, value) } } stateValue := tftypes.NewValue(getObjectAttributeTypes(), stateMap) return resource.DeleteRequest{ State: tfsdk.State{ Raw: stateValue, Schema: getLocalResourceSchema().Schema, }, } } func getDeleteResponseContainer() resource.DeleteResponse { // A delete response does not need a schema as it results in a null state. return resource.DeleteResponse{} } func getDeleteResponse() resource.DeleteResponse { return resource.DeleteResponse{ State: tfsdk.State{ Raw: tftypes.Value{}, Schema: nil, }, } } func getObjectAttributeTypes() tftypes.Object { return tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "id": tftypes.String, "name": tftypes.String, "directory": tftypes.String, "mode": tftypes.String, "contents": tftypes.String, "hmac_secret_key": tftypes.String, "protected": tftypes.Bool, }, } } func getLocalResourceSchema() *resource.SchemaResponse { var testResource LocalResource r := &resource.SchemaResponse{} testResource.Schema(context.Background(), resource.SchemaRequest{}, r) return r } func setup(data map[string]string) { modeInt, _ := strconv.ParseUint(data["mode"], 8, 32) _ = os.WriteFile(data["path"], []byte(data["contents"]), os.FileMode(modeInt)) } func teardown(path string) { os.Remove(path) }