diff --git a/internal/provider/onepassword_item_resource.go b/internal/provider/onepassword_item_resource.go index 98aa3141..35ebb985 100644 --- a/internal/provider/onepassword_item_resource.go +++ b/internal/provider/onepassword_item_resource.go @@ -475,11 +475,11 @@ func itemToData(ctx context.Context, item *model.Item, data *OnePasswordItemReso data.ID = setStringValue(itemTerraformID(item)) data.UUID = setStringValue(item.ID) data.Vault = setStringValue(item.VaultID) - data.Title = setStringValue(item.Title) + data.Title = setStringValuePreservingEmpty(item.Title, data.Title) for _, u := range item.URLs { if u.Primary { - data.URL = setStringValue(u.URL) + data.URL = setStringValuePreservingEmpty(u.URL, data.URL) } } @@ -522,7 +522,7 @@ func itemToData(ctx context.Context, item *model.Item, data *OnePasswordItemReso } section.ID = setStringValue(s.ID) - section.Label = setStringValue(s.Label) + section.Label = setStringValuePreservingEmpty(s.Label, section.Label) var existingFields []OnePasswordItemResourceFieldModel if section.Field != nil { @@ -546,10 +546,10 @@ func itemToData(ctx context.Context, item *model.Item, data *OnePasswordItemReso } dataField.ID = setStringValue(f.ID) - dataField.Label = setStringValue(f.Label) + dataField.Label = setStringValuePreservingEmpty(f.Label, dataField.Label) dataField.Purpose = setStringValue(string(f.Purpose)) dataField.Type = setStringValue(string(f.Type)) - dataField.Value = setStringValue(f.Value) + dataField.Value = setStringValuePreservingEmpty(f.Value, dataField.Value) if f.Recipe != nil { charSets := map[string]bool{} @@ -585,24 +585,24 @@ func itemToData(ctx context.Context, item *model.Item, data *OnePasswordItemReso for _, f := range item.Fields { switch f.Purpose { case model.FieldPurposeUsername: - data.Username = setStringValue(f.Value) + data.Username = setStringValuePreservingEmpty(f.Value, data.Username) case model.FieldPurposePassword: data.Password = setStringValue(f.Value) case model.FieldPurposeNotes: - data.NoteValue = setStringValue(f.Value) + data.NoteValue = setStringValuePreservingEmpty(f.Value, data.NoteValue) default: if f.SectionID == "" { switch f.Label { case "username": - data.Username = setStringValue(f.Value) + data.Username = setStringValuePreservingEmpty(f.Value, data.Username) case "password": data.Password = setStringValue(f.Value) case "hostname", "server": - data.Hostname = setStringValue(f.Value) + data.Hostname = setStringValuePreservingEmpty(f.Value, data.Hostname) case "database": - data.Database = setStringValue(f.Value) + data.Database = setStringValuePreservingEmpty(f.Value, data.Database) case "port": - data.Port = setStringValue(f.Value) + data.Port = setStringValuePreservingEmpty(f.Value, data.Port) case "type": data.Type = setStringValue(f.Value) } diff --git a/internal/provider/util.go b/internal/provider/util.go index 1c871bc9..a8fcb738 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -23,3 +23,13 @@ func setStringValue(value string) basetypes.StringValue { } return types.StringValue(value) } + +// setStringValuePreservingEmpty preserves empty strings when they were explicitly set in Terraform +func setStringValuePreservingEmpty(value string, originalValue basetypes.StringValue) basetypes.StringValue { + // If original was explicitly set to empty string (not null), preserve it + if !originalValue.IsNull() && !originalValue.IsUnknown() && originalValue.ValueString() == "" && value == "" { + return types.StringValue("") + } + // Original behavior is to convert empty to null + return setStringValue(value) +} diff --git a/test/e2e/item_resource_test.go b/test/e2e/item_resource_test.go index a0d8e4d2..07b08df3 100644 --- a/test/e2e/item_resource_test.go +++ b/test/e2e/item_resource_test.go @@ -955,6 +955,172 @@ func TestAccItemResourcePasswordGenerationForAllCategories(t *testing.T) { } } +func TestAccItemResourceEmptyStringPreservation(t *testing.T) { + testVaultID := vault.GetTestVaultID(t) + + attrs := map[string]any{ + "title": "", + "category": "database", + "username": "", + "url": "", + "hostname": "", + "database": "", + "port": "", + "note_value": "", + "section": []map[string]any{ + { + "label": "", + "field": []map[string]any{ + { + "label": "test_field", + "value": "", + "type": "STRING", + }, + }, + }, + }, + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tfconfig.CreateConfigBuilder()( + tfconfig.ProviderConfig(), + tfconfig.ItemResourceConfig(testVaultID, attrs), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("onepassword_item.test_item", "title", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "username", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "url", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "hostname", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "database", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "port", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "note_value", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "section.0.label", ""), + ), + }, + }, + }) +} + +func TestAccItemResourceNullVsEmptyString(t *testing.T) { + testVaultID := vault.GetTestVaultID(t) + uniqueID := uuid.New().String() + + attrsWithoutFields := map[string]any{ + "title": addUniqueIDToTitle("Test Null vs Empty", uniqueID), + "category": "database", + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tfconfig.CreateConfigBuilder()( + tfconfig.ProviderConfig(), + tfconfig.ItemResourceConfig(testVaultID, attrsWithoutFields), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "username"), + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "url"), + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "hostname"), + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "database"), + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "port"), + resource.TestCheckNoResourceAttr("onepassword_item.test_item", "note_value"), + ), + }, + }, + }) +} + +func TestAccItemResourceClearFieldsToEmptyString(t *testing.T) { + testVaultID := vault.GetTestVaultID(t) + uniqueID := uuid.New().String() + title := addUniqueIDToTitle("Test Clear Fields", uniqueID) + + attrsWithValues := map[string]any{ + "title": title, + "category": "database", + "username": "testuser", + "hostname": "db.example.com", + "database": "mydb", + "port": "3306", + "note_value": "test_note", + "section": []map[string]any{ + { + "label": "test_section", + "field": []map[string]any{ + { + "label": "test_field", + "value": "test_value", + "type": "STRING", + }, + }, + }, + }, + } + + attrsCleared := map[string]any{ + "title": title, + "category": "database", + "username": "", + "hostname": "", + "database": "", + "port": "", + "note_value": "", + "section": []map[string]any{ + { + "label": "", + "field": []map[string]any{ + { + "label": "", + "value": "", + "type": "STRING", + }, + }, + }, + }, + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tfconfig.CreateConfigBuilder()( + tfconfig.ProviderConfig(), + tfconfig.ItemResourceConfig(testVaultID, attrsWithValues), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("onepassword_item.test_item", "username", "testuser"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "hostname", "db.example.com"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "database", "mydb"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "port", "3306"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "section.0.label", "test_section"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "section.0.field.0.label", "test_field"), + resource.TestCheckResourceAttr("onepassword_item.test_item", "note_value", "test_note"), + ), + }, + // Clear all fields + { + Config: tfconfig.CreateConfigBuilder()( + tfconfig.ProviderConfig(), + tfconfig.ItemResourceConfig(testVaultID, attrsCleared), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("onepassword_item.test_item", "username", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "hostname", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "database", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "port", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "section.0.label", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "section.0.field.0.label", ""), + resource.TestCheckResourceAttr("onepassword_item.test_item", "note_value", ""), + ), + }, + }, + }) +} + // addUniqueIDToTitle appends a UUID to the title to avoid conflicts in parallel test execution func addUniqueIDToTitle(title string, uniqueID string) string { return fmt.Sprintf("%s-%s", title, uniqueID)