diff --git a/internal/provider/acctest/resource_setting_ntp_test.go b/internal/provider/acctest/resource_setting_ntp_test.go new file mode 100644 index 0000000..94bc62b --- /dev/null +++ b/internal/provider/acctest/resource_setting_ntp_test.go @@ -0,0 +1,158 @@ +package acctest + +import ( + "fmt" + pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "regexp" + "sync" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var settingNtpLock = &sync.Mutex{} + +func TestAccSettingNtp(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7.3", + Lock: settingNtpLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingNtpModeOnly("auto"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "site", "default"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_1"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_2"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_3"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_4"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "mode", "auto"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ntp.test", plancheck.ResourceActionCreate), + }, + pt.ImportStepWithSite("unifi_setting_ntp.test"), + { + Config: testAccSettingNtpConfig2Servers("time.google.com", "pool.ntp.org", "manual"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_1", "time.google.com"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_2", "pool.ntp.org"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_3"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_4"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "mode", "manual"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ntp.test", plancheck.ResourceActionUpdate), + }, + pt.ImportStepWithSite("unifi_setting_ntp.test"), + { + Config: testAccSettingNtpConfig2Servers("0.pool.ntp.org", "1.pool.ntp.org", "manual"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_1", "0.pool.ntp.org"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_2", "1.pool.ntp.org"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_3"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_4"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "mode", "manual"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ntp.test", plancheck.ResourceActionUpdate), + }, + { + Config: testAccSettingNtpConfig2Servers("192.168.1.10", "10.0.0.1", "manual"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_1", "192.168.1.10"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_2", "10.0.0.1"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_3"), + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "ntp_server_4"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "mode", "manual"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ntp.test", plancheck.ResourceActionUpdate), + }, + { + Config: testAccSettingNtpConfig4Servers("time.cloudflare.com", "8.8.8.8", "1.1.1.1", "2.2.2.2", "manual"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_ntp.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_1", "time.cloudflare.com"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_2", "8.8.8.8"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_3", "1.1.1.1"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "ntp_server_4", "2.2.2.2"), + resource.TestCheckResourceAttr("unifi_setting_ntp.test", "mode", "manual"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ntp.test", plancheck.ResourceActionUpdate), + }, + }, + }) +} + +func TestAccSettingNtpInvalid(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7.3", + Lock: settingNtpLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingNtpSimpleConfig("http://invalid-server.com", "auto"), + ExpectError: regexp.MustCompile("is not a valid"), + }, + { + Config: testAccSettingNtpSimpleConfig("192.168.1", "auto"), + ExpectError: regexp.MustCompile("is not a valid"), + }, + { + Config: testAccSettingNtpSimpleConfig("time.google.com", "invalid"), + ExpectError: regexp.MustCompile(`must be one of`), + }, + { + Config: testAccSettingNtpSimpleConfig("time.google.com", "auto"), + ExpectError: regexp.MustCompile(`must not be configured`), + }, + { + Config: testAccSettingNtpModeOnly("manual"), + ExpectError: regexp.MustCompile(`At least one of`), + }, + }, + }) +} + +func testAccSettingNtpModeOnly(mode string) string { + return fmt.Sprintf(` +resource "unifi_setting_ntp" "test" { + mode = %q +} +`, mode) +} + +func testAccSettingNtpConfig2Servers(server1, server2, mode string) string { + return fmt.Sprintf(` +resource "unifi_setting_ntp" "test" { + ntp_server_1 = %q + ntp_server_2 = %q + mode = %q +} +`, server1, server2, mode) +} + +func testAccSettingNtpConfig4Servers(server1, server2, server3, server4, mode string) string { + return fmt.Sprintf(` +resource "unifi_setting_ntp" "test" { + ntp_server_1 = %q + ntp_server_2 = %q + ntp_server_3 = %q + ntp_server_4 = %q + mode = %q +} +`, server1, server2, server3, server4, mode) +} + +func testAccSettingNtpSimpleConfig(server string, mode string) string { + return fmt.Sprintf(` +resource "unifi_setting_ntp" "test" { + ntp_server_1 = %q + mode = %q +} +`, server, mode) +} diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index ab626a1..54ec7fb 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -104,8 +104,8 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq path.Root("api_url"), "Unknown UniFi Controller API URL", "The provider cannot create the UniFi Controller API client as there is an unknown configuration value "+ - "for the API endpoint. Either target apply the source of the value first, set the value statically in "+ - "the configuration, or use the UNIFI_API environment variable.", + "for the API endpoint. Either target apply the source of the value first, set the value statically in "+ + "the configuration, or use the UNIFI_API environment variable.", ) } @@ -178,6 +178,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource settings.NewCountryResource, settings.NewLocaleResource, settings.NewMagicSiteToSiteVpnResource, + settings.NewNtpResource, } } diff --git a/internal/provider/settings/resource_setting_ntp.go b/internal/provider/settings/resource_setting_ntp.go new file mode 100644 index 0000000..b1d3f2f --- /dev/null +++ b/internal/provider/settings/resource_setting_ntp.go @@ -0,0 +1,178 @@ +package settings + +import ( + "context" + "github.com/filipowm/go-unifi/unifi" + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// ntpModel represents the data model for NTP (Network Time Protocol) settings. +// It defines how NTP servers are configured for a UniFi site. +type ntpModel struct { + base.Model + NtpServer1 types.String `tfsdk:"ntp_server_1"` + NtpServer2 types.String `tfsdk:"ntp_server_2"` + NtpServer3 types.String `tfsdk:"ntp_server_3"` + NtpServer4 types.String `tfsdk:"ntp_server_4"` + Mode types.String `tfsdk:"mode"` +} + +func (d *ntpModel) AsUnifiModel() (interface{}, diag.Diagnostics) { + diags := diag.Diagnostics{} + + model := &unifi.SettingNtp{ + ID: d.ID.ValueString(), + SettingPreference: d.Mode.ValueString(), + } + if d.Mode.ValueString() == "auto" { + model.NtpServer1 = "" + model.NtpServer2 = "" + model.NtpServer3 = "" + model.NtpServer4 = "" + } else { + if !base.IsEmptyString(d.NtpServer1) { + model.NtpServer1 = d.NtpServer1.ValueString() + } + if !base.IsEmptyString(d.NtpServer2) { + model.NtpServer2 = d.NtpServer2.ValueString() + } + if !base.IsEmptyString(d.NtpServer3) { + model.NtpServer3 = d.NtpServer3.ValueString() + } + if !base.IsEmptyString(d.NtpServer4) { + model.NtpServer4 = d.NtpServer4.ValueString() + } + } + + return model, diags +} + +func (d *ntpModel) Merge(other interface{}) diag.Diagnostics { + diags := diag.Diagnostics{} + + model, ok := other.(*unifi.SettingNtp) + if !ok { + diags.AddError("Cannot merge", "Cannot merge type that is not *unifi.SettingNtp") + return diags + } + + d.ID = types.StringValue(model.ID) + d.Mode = types.StringValue(model.SettingPreference) + + if model.NtpServer1 != "" { + d.NtpServer1 = types.StringValue(model.NtpServer1) + } + if model.NtpServer2 != "" { + d.NtpServer2 = types.StringValue(model.NtpServer2) + } + if model.NtpServer3 != "" { + d.NtpServer3 = types.StringValue(model.NtpServer3) + } + if model.NtpServer4 != "" { + d.NtpServer4 = types.StringValue(model.NtpServer4) + } + return diags +} + +var ( + _ base.ResourceModel = &ntpModel{} + _ resource.Resource = &ntpResource{} + _ resource.ResourceWithConfigure = &ntpResource{} + _ resource.ResourceWithImportState = &ntpResource{} + _ resource.ResourceWithConfigValidators = &ntpResource{} +) + +type ntpResource struct { + *BaseSettingResource[*ntpModel] +} + +func (r *ntpResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + validators.RequiredNoneIf(path.MatchRoot("mode"), types.StringValue("auto"), path.MatchRoot("ntp_server_1"), path.MatchRoot("ntp_server_2"), path.MatchRoot("ntp_server_3"), path.MatchRoot("ntp_server_4")), + validators.ResourceIf(path.MatchRoot("mode"), + types.StringValue("manual"), + resourcevalidator.AtLeastOneOf(path.MatchRoot("ntp_server_1"), path.MatchRoot("ntp_server_2"), path.MatchRoot("ntp_server_3"), path.MatchRoot("ntp_server_4")), + ), + } +} + +func (r *ntpResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + serverValidators := func() []validator.String { + return []validator.String{ + stringvalidator.Any(validators.Hostname(), validators.IPv4()), + } + } + resp.Schema = schema.Schema{ + MarkdownDescription: "The `unifi_setting_ntp` resource allows you to configure Network Time Protocol (NTP) server settings for your UniFi network.\n\n" + + "NTP servers provide time synchronization for your network devices. This resource supports both automatic and manual NTP configuration modes.", + Attributes: map[string]schema.Attribute{ + "id": base.ID(), + "site": base.SiteAttribute(), + "ntp_server_1": schema.StringAttribute{ + MarkdownDescription: "Primary NTP server hostname or IP address. Must be a valid hostname (e.g., `pool.ntp.org`) or IPv4 address. " + + "Only applicable when `mode` is set to `manual`.", + Optional: true, + Computed: true, + Validators: serverValidators(), + }, + "ntp_server_2": schema.StringAttribute{ + MarkdownDescription: "Secondary NTP server hostname or IP address. Must be a valid hostname (e.g., `time.google.com`) or IPv4 address. " + + "Only applicable when `mode` is set to `manual`.", + Optional: true, + Computed: true, + Validators: serverValidators(), + }, + "ntp_server_3": schema.StringAttribute{ + MarkdownDescription: "Tertiary NTP server hostname or IP address. Must be a valid hostname or IPv4 address. " + + "Only applicable when `mode` is set to `manual`.", + Optional: true, + Computed: true, + Validators: serverValidators(), + }, + "ntp_server_4": schema.StringAttribute{ + MarkdownDescription: "Quaternary NTP server hostname or IP address. Must be a valid hostname or IPv4 address. " + + "Only applicable when `mode` is set to `manual`.", + Optional: true, + Computed: true, + Validators: serverValidators(), + }, + "mode": schema.StringAttribute{ + MarkdownDescription: "NTP server configuration mode. Valid values are:\n" + + "* `auto` - Use NTP servers configured on the controller\n" + + "* `manual` - Use custom NTP servers specified in this resource\n\n" + + "When set to `auto`, all NTP server fields will be cleared. " + + "When set to `manual`, at least one NTP server must be specified.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("auto", "manual"), + }, + }, + }, + } +} + +// NewNtpResource creates a new instance of the NTP resource. +func NewNtpResource() resource.Resource { + r := &ntpResource{} + r.BaseSettingResource = NewBaseSettingResource( + "unifi_setting_ntp", + func() *ntpModel { return &ntpModel{} }, + func(ctx context.Context, client *base.Client, site string) (interface{}, error) { + return client.GetSettingNtp(ctx, site) + }, + func(ctx context.Context, client *base.Client, site string, body interface{}) (interface{}, error) { + return client.UpdateSettingNtp(ctx, site, body.(*unifi.SettingNtp)) + }, + ) + return r +} diff --git a/internal/provider/settings/resource_setting_ntp_test.go b/internal/provider/settings/resource_setting_ntp_test.go new file mode 100644 index 0000000..49e3907 --- /dev/null +++ b/internal/provider/settings/resource_setting_ntp_test.go @@ -0,0 +1,161 @@ +package settings + +import ( + "github.com/filipowm/go-unifi/unifi" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNtpModel_AsUnifiModel_Auto(t *testing.T) { + t.Parallel() + + // Test case for "auto" mode + model := ntpModel{ + Mode: types.StringValue("auto"), + NtpServer1: types.StringValue("time.google.com"), + NtpServer2: types.StringValue("pool.ntp.org"), + NtpServer3: types.StringValue("0.pool.ntp.org"), + NtpServer4: types.StringValue("1.pool.ntp.org"), + } + model.ID = types.StringValue("test-id") + + // Convert to UnifiModel + unifiModel, diags := model.AsUnifiModel() + + // Verify no diagnostics errors + assert.False(t, diags.HasError()) + + // Verify correct type conversion + typed, ok := unifiModel.(*unifi.SettingNtp) + assert.True(t, ok, "Expected model to be *unifi.SettingNtp") + + // Verify ID and mode are set correctly + assert.Equal(t, "test-id", typed.ID) + assert.Equal(t, "auto", typed.SettingPreference) + + // In auto mode, all server fields should be empty regardless of input values + assert.Equal(t, "", typed.NtpServer1) + assert.Equal(t, "", typed.NtpServer2) + assert.Equal(t, "", typed.NtpServer3) + assert.Equal(t, "", typed.NtpServer4) +} + +func TestNtpModel_AsUnifiModel_Manual(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + server1 types.String + server2 types.String + server3 types.String + server4 types.String + expected1 string + expected2 string + expected3 string + expected4 string + }{ + { + name: "All servers set", + server1: types.StringValue("time.google.com"), + server2: types.StringValue("pool.ntp.org"), + server3: types.StringValue("0.pool.ntp.org"), + server4: types.StringValue("1.pool.ntp.org"), + expected1: "time.google.com", + expected2: "pool.ntp.org", + expected3: "0.pool.ntp.org", + expected4: "1.pool.ntp.org", + }, + { + name: "Only one server set", + server1: types.StringValue("time.google.com"), + server2: types.StringNull(), + server3: types.StringNull(), + server4: types.StringNull(), + expected1: "time.google.com", + expected2: "", + expected3: "", + expected4: "", + }, + { + name: "Mixed null, unknown and empty values", + server1: types.StringValue("time.google.com"), + server2: types.StringNull(), + server3: types.StringUnknown(), + server4: types.StringValue(""), + expected1: "time.google.com", + expected2: "", + expected3: "", + expected4: "", + }, + { + name: "All null values", + server1: types.StringNull(), + server2: types.StringNull(), + server3: types.StringNull(), + server4: types.StringNull(), + expected1: "", + expected2: "", + expected3: "", + expected4: "", + }, + { + name: "All unknown values", + server1: types.StringUnknown(), + server2: types.StringUnknown(), + server3: types.StringUnknown(), + server4: types.StringUnknown(), + expected1: "", + expected2: "", + expected3: "", + expected4: "", + }, + { + name: "All empty string values", + server1: types.StringValue(""), + server2: types.StringValue(""), + server3: types.StringValue(""), + server4: types.StringValue(""), + expected1: "", + expected2: "", + expected3: "", + expected4: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create model with manual mode and test case values + model := ntpModel{ + Mode: types.StringValue("manual"), + NtpServer1: tc.server1, + NtpServer2: tc.server2, + NtpServer3: tc.server3, + NtpServer4: tc.server4, + } + model.ID = types.StringValue("test-id") + + // Convert to UnifiModel + unifiModel, diags := model.AsUnifiModel() + + // Verify no diagnostics errors + assert.False(t, diags.HasError()) + + // Verify correct type conversion + typed, ok := unifiModel.(*unifi.SettingNtp) + assert.True(t, ok, "Expected model to be *unifi.SettingNtp") + + // Verify ID and mode are set correctly + assert.Equal(t, "test-id", typed.ID) + assert.Equal(t, "manual", typed.SettingPreference) + + // Verify server values based on test case expectations + assert.Equal(t, tc.expected1, typed.NtpServer1) + assert.Equal(t, tc.expected2, typed.NtpServer2) + assert.Equal(t, tc.expected3, typed.NtpServer3) + assert.Equal(t, tc.expected4, typed.NtpServer4) + }) + } +} diff --git a/internal/provider/validators/country_code_test.go b/internal/provider/validators/country_code_test.go index 1bb1895..928e0f2 100644 --- a/internal/provider/validators/country_code_test.go +++ b/internal/provider/validators/country_code_test.go @@ -1,7 +1,8 @@ -package validators +package validators_test import ( "context" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "github.com/stretchr/testify/assert" "testing" ) @@ -25,7 +26,7 @@ func TestCountryCodeValidation(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - v := countryCodeAlpha2Validator{} + v := validators.CountryCodeAlpha2() req, resp := newStringValidatorRequestResponse(tc.code) v.ValidateString(context.Background(), req, resp) assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) diff --git a/internal/provider/validators/helpers.go b/internal/provider/validators/helpers.go new file mode 100644 index 0000000..7d48aeb --- /dev/null +++ b/internal/provider/validators/helpers.go @@ -0,0 +1,18 @@ +package validators + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" +) + +// conditionValueMatches checks if the condition value matches the expected value +func conditionValueMatches(ctx context.Context, condition, expected attr.Value) bool { + // If types don't match, can't be equal + if condition.Type(ctx) != expected.Type(ctx) { + return false + } + if condition.IsNull() { + return true + } + return condition.Equal(expected) +} diff --git a/internal/provider/validators/helpers_test.go b/internal/provider/validators/helpers_test.go index 136648a..196d582 100644 --- a/internal/provider/validators/helpers_test.go +++ b/internal/provider/validators/helpers_test.go @@ -1,10 +1,11 @@ -package validators +package validators_test import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) func newStringValidatorRequestResponse(value string) (validator.StringRequest, *validator.StringResponse) { @@ -17,3 +18,23 @@ func newStringValidatorRequestResponse(value string) (validator.StringRequest, * } return req, &resp } + +// Helper function to convert types.String to tftypes.Value +func stringToTfValue(value types.String) tftypes.Value { + if value.IsNull() { + return tftypes.NewValue(tftypes.String, nil) + } else if value.IsUnknown() { + return tftypes.NewValue(tftypes.String, tftypes.UnknownValue) + } + return tftypes.NewValue(tftypes.String, value.ValueString()) +} + +// Helper function to convert types.Bool to tftypes.Value +func boolToTfValue(value types.Bool) tftypes.Value { + if value.IsNull() { + return tftypes.NewValue(tftypes.Bool, nil) + } else if value.IsUnknown() { + return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue) + } + return tftypes.NewValue(tftypes.Bool, value.ValueBool()) +} diff --git a/internal/provider/validators/hostname.go b/internal/provider/validators/hostname.go new file mode 100644 index 0000000..5c1360b --- /dev/null +++ b/internal/provider/validators/hostname.go @@ -0,0 +1,65 @@ +package validators + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/base" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// A regex pattern for validating hostnames without protocol schemes +// This matches hostnames according to RFC 1035 with some limitations +var hostnameRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`) + +// Hostname returns a validator which ensures that the string value is a valid hostname without protocol schemes. +func Hostname() validator.String { + return hostnameValidator{} +} + +type hostnameValidator struct{} + +func (v hostnameValidator) Description(_ context.Context) string { + return "must be a valid hostname (without protocol scheme)" +} + +func (v hostnameValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v hostnameValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + value := req.ConfigValue + if !base.IsDefined(value) { + return + } + + val := value.ValueString() + + // Check if the hostname has a scheme (which it shouldn't) + if strings.Contains(val, "://") { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q should not include a protocol scheme (e.g., http:// or https://)", val), + ), + ) + return + } + + // Convert to lowercase for validation + hostname := strings.ToLower(val) + + if !hostnameRegex.MatchString(hostname) { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is not a valid hostname", val), + ), + ) + } +} diff --git a/internal/provider/validators/hostname_test.go b/internal/provider/validators/hostname_test.go new file mode 100644 index 0000000..7bf1d19 --- /dev/null +++ b/internal/provider/validators/hostname_test.go @@ -0,0 +1,105 @@ +package validators_test + +import ( + "context" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestHostnameValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + expectError bool + } + tests := map[string]testCase{ + "unknown": { + val: types.StringUnknown(), + }, + "null": { + val: types.StringNull(), + }, + "valid-simple": { + val: types.StringValue("example.com"), + }, + "valid-subdomain": { + val: types.StringValue("sub.example.com"), + }, + "valid-multiple-subdomains": { + val: types.StringValue("a.b.c.example.com"), + }, + "valid-with-hyphen": { + val: types.StringValue("my-hostname.example.com"), + }, + "valid-with-numbers": { + val: types.StringValue("example123.com"), + }, + "valid-tld-with-numbers": { + val: types.StringValue("example.co2"), + }, + "invalid-with-scheme": { + val: types.StringValue("http://example.com"), + expectError: true, + }, + "invalid-with-https-scheme": { + val: types.StringValue("https://example.com"), + expectError: true, + }, + "invalid-with-path": { + val: types.StringValue("example.com/path"), + expectError: true, + }, + "invalid-with-port": { + val: types.StringValue("example.com:8080"), + expectError: true, + }, + "invalid-with-underscore": { + val: types.StringValue("invalid_hostname.com"), + expectError: true, + }, + "invalid-single-label": { + val: types.StringValue("localhost"), + expectError: true, + }, + "invalid-ends-with-hyphen": { + val: types.StringValue("hostname-.com"), + expectError: true, + }, + "invalid-begins-with-hyphen": { + val: types.StringValue("-hostname.com"), + expectError: true, + }, + "invalid-empty-string": { + val: types.StringValue(""), + expectError: true, + }, + "invalid-special-chars": { + val: types.StringValue("hostname!.com"), + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + request := validator.StringRequest{ + ConfigValue: test.val, + } + response := validator.StringResponse{} + validators.Hostname().ValidateString(context.Background(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + } +} diff --git a/internal/provider/validators/if.go b/internal/provider/validators/if.go new file mode 100644 index 0000000..bc0112d --- /dev/null +++ b/internal/provider/validators/if.go @@ -0,0 +1,140 @@ +package validators + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var ( + _ datasource.ConfigValidator = &IfValidator{} + _ provider.ConfigValidator = &IfValidator{} + _ resource.ConfigValidator = &IfValidator{} +) + +type ifValidatorBase struct { + ConditionPath path.Expression + ConditionValue attr.Value + CheckOnlyIfSet bool // When true, only checks if the condition value is set (not null), not its actual value +} + +func (v ifValidatorBase) shouldValidate(ctx context.Context, config tfsdk.Config) bool { + // First check the condition attribute's value + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, v.ConditionPath) + if matchedPathsDiags.HasError() || len(matchedPaths) == 0 { + return false + } + + // Get the value of the condition attribute + var conditionValue attr.Value + getConditionDiags := config.GetAttribute(ctx, matchedPaths[0], &conditionValue) + if getConditionDiags.HasError() { + return false + } + + // If the condition attribute is null or unknown, skip validation + if conditionValue.IsNull() || conditionValue.IsUnknown() { + return false + } + + // Check if the condition matches + if v.CheckOnlyIfSet { + return !conditionValue.IsNull() + } + return conditionValueMatches(ctx, conditionValue, v.ConditionValue) +} + +type IfValidator struct { + ifValidatorBase + resourceValidators []resource.ConfigValidator + providerValidators []provider.ConfigValidator + datasourceValidators []datasource.ConfigValidator +} + +func (v IfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v IfValidator) MarkdownDescription(_ context.Context) string { + if v.CheckOnlyIfSet { + return fmt.Sprintf("If %q is set, then check validators", v.ConditionPath) + } + return fmt.Sprintf("If %q equals %s, then check validators", v.ConditionPath, v.ConditionValue) +} + +func (v IfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + if !v.shouldValidate(ctx, req.Config) { + return + } + for _, v := range v.datasourceValidators { + v.ValidateDataSource(ctx, req, resp) + } +} + +func (v IfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + if !v.shouldValidate(ctx, req.Config) { + return + } + for _, v := range v.providerValidators { + v.ValidateProvider(ctx, req, resp) + } +} + +func (v IfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + if !v.shouldValidate(ctx, req.Config) { + return + } + + for _, v := range v.resourceValidators { + v.ValidateResource(ctx, req, resp) + } +} + +func ResourceIf(conditionPath path.Expression, conditionValue attr.Value, validators ...resource.ConfigValidator) IfValidator { + return IfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + ConditionValue: conditionValue, + CheckOnlyIfSet: false, + }, + resourceValidators: validators, + } +} + +func ResourceIfSet(conditionPath path.Expression, validators ...resource.ConfigValidator) IfValidator { + return IfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + ConditionValue: nil, + CheckOnlyIfSet: true, + }, + resourceValidators: validators, + } +} + +func ProviderIfSet(conditionPath path.Expression, validators ...provider.ConfigValidator) IfValidator { + return IfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + ConditionValue: nil, + CheckOnlyIfSet: true, + }, + providerValidators: validators, + } +} +func DatasourceIfSet(conditionPath path.Expression, validators ...datasource.ConfigValidator) IfValidator { + return IfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + ConditionValue: nil, + CheckOnlyIfSet: true, + }, + datasourceValidators: validators, + } +} diff --git a/internal/provider/validators/if_test.go b/internal/provider/validators/if_test.go new file mode 100644 index 0000000..1127ec1 --- /dev/null +++ b/internal/provider/validators/if_test.go @@ -0,0 +1,580 @@ +package validators_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" +) + +// Common test case structure for string conditions +type stringConditionTestCase struct { + condition types.String + field1 types.String +} + +// Common test case structure for bool conditions +type boolConditionTestCase struct { + condition types.Bool + field1 types.String +} + +// Function to create a schema object with string condition +func createStringConditionSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.StringAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Function to create a schema object with bool condition +func createBoolConditionSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.BoolAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + }, + } +} + +// Function to create a config with string condition +func createStringConditionConfig(schema schema.Schema, testCase stringConditionTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.String, + "field1": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": stringToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + }, + ), + } +} + +// Function to create a config with bool condition +func createBoolConditionConfig(schema schema.Schema, testCase boolConditionTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.Bool, + "field1": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": boolToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + }, + ), + } +} + +// Mock validators +type mockResourceValidator struct { + called bool +} + +func (v *mockResourceValidator) Description(ctx context.Context) string { + return "Mock Resource Validator" +} + +func (v *mockResourceValidator) MarkdownDescription(ctx context.Context) string { + return "Mock Resource Validator" +} + +func (v *mockResourceValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + v.called = true +} + +type mockProviderValidator struct { + called bool +} + +func (v *mockProviderValidator) Description(ctx context.Context) string { + return "Mock Provider Validator" +} + +func (v *mockProviderValidator) MarkdownDescription(ctx context.Context) string { + return "Mock Provider Validator" +} + +func (v *mockProviderValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + v.called = true +} + +type mockDatasourceValidator struct { + called bool +} + +func (v *mockDatasourceValidator) Description(ctx context.Context) string { + return "Mock Datasource Validator" +} + +func (v *mockDatasourceValidator) MarkdownDescription(ctx context.Context) string { + return "Mock Datasource Validator" +} + +func (v *mockDatasourceValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + v.called = true +} + +// Test ResourceIf with string condition +func TestResourceIf(t *testing.T) { + testCases := map[string]struct { + createValidator func(mock *mockResourceValidator) validators.IfValidator + conditionValue string + testCase stringConditionTestCase + expectedCalled bool + }{ + "matching_condition": { + createValidator: func(mock *mockResourceValidator) validators.IfValidator { + return validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock, + ) + }, + conditionValue: "test", + testCase: stringConditionTestCase{ + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + }, + expectedCalled: true, + }, + "non_matching_condition": { + createValidator: func(mock *mockResourceValidator) validators.IfValidator { + return validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock, + ) + }, + conditionValue: "test", + testCase: stringConditionTestCase{ + condition: types.StringValue("not-test"), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "null_condition": { + createValidator: func(mock *mockResourceValidator) validators.IfValidator { + return validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock, + ) + }, + conditionValue: "test", + testCase: stringConditionTestCase{ + condition: types.StringNull(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "unknown_condition": { + createValidator: func(mock *mockResourceValidator) validators.IfValidator { + return validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock, + ) + }, + conditionValue: "test", + testCase: stringConditionTestCase{ + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mock := &mockResourceValidator{} + validator := testCase.createValidator(mock) + + ctx := context.Background() + schema := createStringConditionSchema() + config := createStringConditionConfig(schema, testCase.testCase) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateResource(ctx, request, response) + + assert.Equal(t, testCase.expectedCalled, mock.called) + }) + } +} + +// Test ResourceIfSet with string condition +func TestResourceIfSet(t *testing.T) { + testCases := map[string]struct { + testCase stringConditionTestCase + expectedCalled bool + }{ + "condition_set": { + testCase: stringConditionTestCase{ + condition: types.StringValue("any-value"), + field1: types.StringValue("value1"), + }, + expectedCalled: true, + }, + "condition_null": { + testCase: stringConditionTestCase{ + condition: types.StringNull(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "condition_unknown": { + testCase: stringConditionTestCase{ + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mock := &mockResourceValidator{} + validator := validators.ResourceIfSet( + path.MatchRoot("condition"), + mock, + ) + + ctx := context.Background() + schema := createStringConditionSchema() + config := createStringConditionConfig(schema, testCase.testCase) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateResource(ctx, request, response) + + assert.Equal(t, testCase.expectedCalled, mock.called) + }) + } +} + +// Test ProviderIfSet with string condition +func TestProviderIfSet(t *testing.T) { + testCases := map[string]struct { + testCase stringConditionTestCase + expectedCalled bool + }{ + "condition_set": { + testCase: stringConditionTestCase{ + condition: types.StringValue("any-value"), + field1: types.StringValue("value1"), + }, + expectedCalled: true, + }, + "condition_null": { + testCase: stringConditionTestCase{ + condition: types.StringNull(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "condition_unknown": { + testCase: stringConditionTestCase{ + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mock := &mockProviderValidator{} + validator := validators.ProviderIfSet( + path.MatchRoot("condition"), + mock, + ) + + ctx := context.Background() + schema := createStringConditionSchema() + config := createStringConditionConfig(schema, testCase.testCase) + + request := provider.ValidateConfigRequest{ + Config: config, + } + + response := &provider.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateProvider(ctx, request, response) + + assert.Equal(t, testCase.expectedCalled, mock.called) + }) + } +} + +// Test DatasourceIfSet with string condition +func TestDatasourceIfSet(t *testing.T) { + testCases := map[string]struct { + testCase stringConditionTestCase + expectedCalled bool + }{ + "condition_set": { + testCase: stringConditionTestCase{ + condition: types.StringValue("any-value"), + field1: types.StringValue("value1"), + }, + expectedCalled: true, + }, + "condition_null": { + testCase: stringConditionTestCase{ + condition: types.StringNull(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "condition_unknown": { + testCase: stringConditionTestCase{ + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mock := &mockDatasourceValidator{} + validator := validators.DatasourceIfSet( + path.MatchRoot("condition"), + mock, + ) + + ctx := context.Background() + schema := createStringConditionSchema() + config := createStringConditionConfig(schema, testCase.testCase) + + request := datasource.ValidateConfigRequest{ + Config: config, + } + + response := &datasource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateDataSource(ctx, request, response) + + assert.Equal(t, testCase.expectedCalled, mock.called) + }) + } +} + +// Test the Description and MarkdownDescription methods for both variants +func TestIfValidatorDescription(t *testing.T) { + t.Run("ResourceIf description", func(t *testing.T) { + mock := &mockResourceValidator{} + validator := validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock, + ) + + ctx := context.Background() + desc := validator.Description(ctx) + mdDesc := validator.MarkdownDescription(ctx) + + expectedDesc := `If "condition" equals "test", then check validators` + assert.Equal(t, expectedDesc, desc) + assert.Equal(t, expectedDesc, mdDesc) + }) + + t.Run("ResourceIfSet description", func(t *testing.T) { + mock := &mockResourceValidator{} + validator := validators.ResourceIfSet( + path.MatchRoot("condition"), + mock, + ) + + ctx := context.Background() + desc := validator.Description(ctx) + mdDesc := validator.MarkdownDescription(ctx) + + expectedDesc := `If "condition" is set, then check validators` + assert.Equal(t, expectedDesc, desc) + assert.Equal(t, expectedDesc, mdDesc) + }) +} + +// Test with bool condition +func TestResourceIfWithBoolCondition(t *testing.T) { + testCases := map[string]struct { + testCase boolConditionTestCase + expectedCalled bool + }{ + "matching_true_condition": { + testCase: boolConditionTestCase{ + condition: types.BoolValue(true), + field1: types.StringValue("value1"), + }, + expectedCalled: true, + }, + "non_matching_false_condition": { + testCase: boolConditionTestCase{ + condition: types.BoolValue(false), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "null_condition": { + testCase: boolConditionTestCase{ + condition: types.BoolNull(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + "unknown_condition": { + testCase: boolConditionTestCase{ + condition: types.BoolUnknown(), + field1: types.StringValue("value1"), + }, + expectedCalled: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mock := &mockResourceValidator{} + validator := validators.ResourceIf( + path.MatchRoot("condition"), + types.BoolValue(true), + mock, + ) + + ctx := context.Background() + schema := createBoolConditionSchema() + config := createBoolConditionConfig(schema, testCase.testCase) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateResource(ctx, request, response) + + assert.Equal(t, testCase.expectedCalled, mock.called) + }) + } +} + +// Test with missing path +func TestIfValidatorWithMissingPath(t *testing.T) { + mock := &mockResourceValidator{} + validator := validators.ResourceIf( + path.MatchRoot("non_existent"), + types.StringValue("test"), + mock, + ) + + ctx := context.Background() + schema := createStringConditionSchema() + testCase := stringConditionTestCase{ + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + } + config := createStringConditionConfig(schema, testCase) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateResource(ctx, request, response) + + // The validator should not be called because the path doesn't exist + assert.False(t, mock.called) +} + +// Test with multiple validators +func TestIfValidatorWithMultipleValidators(t *testing.T) { + mock1 := &mockResourceValidator{} + mock2 := &mockResourceValidator{} + mock3 := &mockResourceValidator{} + + validator := validators.ResourceIf( + path.MatchRoot("condition"), + types.StringValue("test"), + mock1, mock2, mock3, + ) + + ctx := context.Background() + schema := createStringConditionSchema() + testCase := stringConditionTestCase{ + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + } + config := createStringConditionConfig(schema, testCase) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{ + Diagnostics: diag.Diagnostics{}, + } + + validator.ValidateResource(ctx, request, response) + + // All validators should be called + assert.True(t, mock1.called) + assert.True(t, mock2.called) + assert.True(t, mock3.called) +} diff --git a/internal/provider/validators/ipv4.go b/internal/provider/validators/ipv4.go new file mode 100644 index 0000000..1502e68 --- /dev/null +++ b/internal/provider/validators/ipv4.go @@ -0,0 +1,47 @@ +package validators + +import ( + "context" + "fmt" + "net" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// IPv4 returns a validator which ensures that a string value is a valid IPv4 address. +func IPv4() validator.String { + return ipv4Validator{} +} + +var _ validator.String = ipv4Validator{} + +type ipv4Validator struct{} + +func (v ipv4Validator) Description(ctx context.Context) string { + return "value must be a valid IPv4 address" +} + +func (v ipv4Validator) MarkdownDescription(ctx context.Context) string { + return "value must be a valid IPv4 address" +} + +func (v ipv4Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + value := req.ConfigValue.ValueString() + if value == "" { + return + } + + ip := net.ParseIP(value) + if ip == nil || ip.To4() == nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid IPv4 Address", + fmt.Sprintf("Value %q is not a valid IPv4 address", value), + ) + return + } +} diff --git a/internal/provider/validators/ipv4_test.go b/internal/provider/validators/ipv4_test.go new file mode 100644 index 0000000..9fea8b9 --- /dev/null +++ b/internal/provider/validators/ipv4_test.go @@ -0,0 +1,73 @@ +package validators_test + +import ( + "context" + "testing" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestIPv4Validator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.String + expectError bool + } + tests := map[string]testCase{ + "unknown": { + val: types.StringUnknown(), + }, + "null": { + val: types.StringNull(), + }, + "empty": { + val: types.StringValue(""), + }, + "valid ipv4": { + val: types.StringValue("192.168.1.1"), + }, + "valid ipv4 with leading zeros": { + val: types.StringValue("192.168.001.001"), + expectError: true, + }, + "invalid ipv4 - out of range": { + val: types.StringValue("192.168.1.256"), + expectError: true, + }, + "invalid ipv4 - incomplete": { + val: types.StringValue("192.168.1"), + expectError: true, + }, + "invalid ipv4 - ipv6": { + val: types.StringValue("::1"), + expectError: true, + }, + "invalid ipv4 - characters": { + val: types.StringValue("not-an-ip"), + expectError: true, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.StringRequest{ + ConfigValue: test.val, + } + resp := validator.StringResponse{} + validators.IPv4().ValidateString(context.Background(), req, &resp) + + if !test.expectError && resp.Diagnostics.HasError() { + t.Fatalf("got unexpected error: %s", resp.Diagnostics.Errors()[0].Detail()) + } + + if test.expectError && !resp.Diagnostics.HasError() { + t.Fatalf("expected error but got none") + } + }) + } +} diff --git a/internal/provider/validators/required_none_if.go b/internal/provider/validators/required_none_if.go new file mode 100644 index 0000000..513a782 --- /dev/null +++ b/internal/provider/validators/required_none_if.go @@ -0,0 +1,151 @@ +package validators + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +var ( + _ datasource.ConfigValidator = &RequiredNoneIfValidator{} + _ provider.ConfigValidator = &RequiredNoneIfValidator{} + _ resource.ConfigValidator = &RequiredNoneIfValidator{} +) + +type RequiredNoneIfValidator struct { + ifValidatorBase + TargetExpressions path.Expressions +} + +func (v RequiredNoneIfValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v RequiredNoneIfValidator) MarkdownDescription(_ context.Context) string { + if v.CheckOnlyIfSet { + return fmt.Sprintf("If %q is set, any of those attributes must not be configured: %s", v.ConditionPath, v.TargetExpressions) + } + return fmt.Sprintf("If %q equals %s, any of those attributes must not be configured: %s", v.ConditionPath, v.ConditionValue, v.TargetExpressions) +} + +func (v RequiredNoneIfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredNoneIfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredNoneIfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredNoneIfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + resp.Diagnostics = v.Validate(ctx, req.Config) +} + +func (v RequiredNoneIfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics { + diags := diag.Diagnostics{} + if !v.shouldValidate(ctx, config) { + return diags + } + + // Condition matched, now apply the RequiredNone validation + configuredPaths := path.Paths{} + foundPaths := path.Paths{} + unknownPaths := path.Paths{} + + // Check that all target attributes are present + for _, expression := range v.TargetExpressions { + matchedPaths, matchedPathsDiags := config.PathMatches(ctx, expression) + diags.Append(matchedPathsDiags...) + + // Collect all errors + if matchedPathsDiags.HasError() { + continue + } + + // Capture all matched paths + foundPaths.Append(matchedPaths...) + + for _, matchedPath := range matchedPaths { + var value attr.Value + getAttributeDiags := config.GetAttribute(ctx, matchedPath, &value) + diags.Append(getAttributeDiags...) + + // Collect all errors + if getAttributeDiags.HasError() { + continue + } + + // If value is unknown, collect the path to skip validation later + if value.IsUnknown() { + unknownPaths.Append(matchedPath) + continue + } + + // If value is null, move onto the next one + if value.IsNull() { + continue + } + + // Value is known and not null, it is configured + configuredPaths.Append(matchedPath) + } + } + + if len(unknownPaths) > 0 { + return diags + } + + // If configured paths does not equal all matched paths, then something + // was missing + if len(configuredPaths) > 0 { + diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic( + foundPaths[0], + v.Description(ctx), + )) + } + + return diags +} + +// ValidateString method to implement the validator.String interface +func (v RequiredNoneIfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + resp.Diagnostics.Append(v.Validate(ctx, req.Config)...) +} + +// RequiredNoneIf creates a validator for attributes that ensures +// a set of target attributes are not configured together if a condition attribute equals a specific value. +func RequiredNoneIf(conditionPath path.Expression, conditionValue attr.Value, targetExpressions ...path.Expression) RequiredNoneIfValidator { + return RequiredNoneIfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + ConditionValue: conditionValue, + CheckOnlyIfSet: false, + }, + TargetExpressions: targetExpressions, + } +} + +// RequiredNoneIfSet creates a validator that ensures a set of target attributes +// are configured not together if a condition attribute is set (not null), regardless of its value. +func RequiredNoneIfSet(conditionPath path.Expression, targetExpressions ...path.Expression) RequiredNoneIfValidator { + return RequiredNoneIfValidator{ + ifValidatorBase: ifValidatorBase{ + ConditionPath: conditionPath, + CheckOnlyIfSet: true, + }, + TargetExpressions: targetExpressions, + } +} diff --git a/internal/provider/validators/required_none_if_test.go b/internal/provider/validators/required_none_if_test.go new file mode 100644 index 0000000..9235986 --- /dev/null +++ b/internal/provider/validators/required_none_if_test.go @@ -0,0 +1,532 @@ +package validators_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/stretchr/testify/assert" + + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" +) + +// Common test case structure for string conditions +type requiredNoneIfTestCase struct { + condition types.String + field1 types.String + field2 types.String + expectError bool + expectErrorText string +} + +// Common test case structure for bool conditions +type requiredNoneIfBoolTestCase struct { + condition types.Bool + field1 types.String + field2 types.String + expectError bool + expectErrorText string +} + +// Function to create a schema object with string condition +func createRequiredNoneIfSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.StringAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "field2": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Function to create a schema object with bool condition +func createRequiredNoneIfBoolSchema() schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "condition": schema.BoolAttribute{ + Optional: true, + }, + "field1": schema.StringAttribute{ + Optional: true, + }, + "field2": schema.StringAttribute{ + Optional: true, + }, + }, + } +} + +// Function to create a config with string condition +func createRequiredNoneIfConfig(schema schema.Schema, testCase requiredNoneIfTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.String, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": stringToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } +} + +// Function to create a config with bool condition +func createRequiredNoneIfBoolConfig(schema schema.Schema, testCase requiredNoneIfBoolTestCase) tfsdk.Config { + return tfsdk.Config{ + Schema: schema, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "condition": tftypes.Bool, + "field1": tftypes.String, + "field2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "condition": boolToTfValue(testCase.condition), + "field1": stringToTfValue(testCase.field1), + "field2": stringToTfValue(testCase.field2), + }, + ), + } +} + +// Test RequiredNoneIf with string condition +func TestRequiredNoneIf(t *testing.T) { + testCases := map[string]requiredNoneIfTestCase{ + "matching_condition_all_configured": { + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If \"condition\" equals \"test\", any of those attributes must not be configured: [field1,field2]", + }, + "matching_condition_one_configured": { + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: true, + expectErrorText: "If \"condition\" equals \"test\", any of those attributes must not be configured: [field1,field2]", + }, + "matching_condition_none_configured": { + condition: types.StringValue("test"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + "non_matching_condition_all_configured": { + condition: types.StringValue("non-test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "matching_condition_unknown_values": { + condition: types.StringValue("test"), + field1: types.StringUnknown(), + field2: types.StringValue("value2"), + expectError: false, // Unknown values should skip validation + }, + "null_condition_all_configured": { + condition: types.StringNull(), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "unknown_condition_all_configured": { + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + diagnostics := validator.Validate(ctx, config) + + if testCase.expectError { + assert.True(t, diagnostics.HasError()) + if testCase.expectErrorText != "" { + found := false + for _, diag := range diagnostics { + if diag.Detail() != "" && diag.Detail() == testCase.expectErrorText { + found = true + break + } + } + assert.True(t, found, "Expected error text not found") + } + } else { + assert.False(t, diagnostics.HasError()) + } + }) + } +} + +// Test RequiredNoneIfSet with string condition +func TestRequiredNoneIfSet(t *testing.T) { + testCases := map[string]requiredNoneIfTestCase{ + "condition_set_all_configured": { + condition: types.StringValue("any-value"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If \"condition\" is set, any of those attributes must not be configured: [field1,field2]", + }, + "condition_set_one_configured": { + condition: types.StringValue("any-value"), + field1: types.StringValue("value1"), + field2: types.StringNull(), + expectError: true, + expectErrorText: "If \"condition\" is set, any of those attributes must not be configured: [field1,field2]", + }, + "condition_set_none_configured": { + condition: types.StringValue("any-value"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + "condition_null_all_configured": { + condition: types.StringNull(), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + "condition_unknown_all_configured": { + condition: types.StringUnknown(), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIfSet( + path.MatchRoot("condition"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + diagnostics := validator.Validate(ctx, config) + + if testCase.expectError { + assert.True(t, diagnostics.HasError()) + if testCase.expectErrorText != "" { + found := false + for _, diag := range diagnostics { + if diag.Detail() != "" && diag.Detail() == testCase.expectErrorText { + found = true + break + } + } + assert.True(t, found, "Expected error text not found") + } + } else { + assert.False(t, diagnostics.HasError()) + } + }) + } +} + +// Test RequiredNoneIf with boolean condition +func TestRequiredNoneIfWithBoolCondition(t *testing.T) { + testCases := map[string]requiredNoneIfBoolTestCase{ + "matching_true_condition_all_configured": { + condition: types.BoolValue(true), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + expectErrorText: "If \"condition\" equals true, any of those attributes must not be configured: [field1,field2]", + }, + "matching_true_condition_none_configured": { + condition: types.BoolValue(true), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + "non_matching_false_condition_all_configured": { + condition: types.BoolValue(false), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfBoolSchema() + config := createRequiredNoneIfBoolConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.BoolValue(true), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + diagnostics := validator.Validate(ctx, config) + + if testCase.expectError { + assert.True(t, diagnostics.HasError()) + if testCase.expectErrorText != "" { + found := false + for _, diag := range diagnostics { + if diag.Detail() != "" && diag.Detail() == testCase.expectErrorText { + found = true + break + } + } + assert.True(t, found, "Expected error text not found") + } + } else { + assert.False(t, diagnostics.HasError()) + } + }) + } +} + +// Test ValidateDataSource method +func TestRequiredNoneIfValidateDataSource(t *testing.T) { + testCases := map[string]requiredNoneIfTestCase{ + "matching_condition_all_configured": { + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + }, + "matching_condition_none_configured": { + condition: types.StringValue("test"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + request := datasource.ValidateConfigRequest{ + Config: config, + } + + response := &datasource.ValidateConfigResponse{} + + validator.ValidateDataSource(ctx, request, response) + + if testCase.expectError { + assert.True(t, response.Diagnostics.HasError()) + } else { + assert.False(t, response.Diagnostics.HasError()) + } + }) + } +} + +// Test ValidateProvider method +func TestRequiredNoneIfValidateProvider(t *testing.T) { + testCases := map[string]requiredNoneIfTestCase{ + "matching_condition_all_configured": { + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + }, + "matching_condition_none_configured": { + condition: types.StringValue("test"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + request := provider.ValidateConfigRequest{ + Config: config, + } + + response := &provider.ValidateConfigResponse{} + + validator.ValidateProvider(ctx, request, response) + + if testCase.expectError { + assert.True(t, response.Diagnostics.HasError()) + } else { + assert.False(t, response.Diagnostics.HasError()) + } + }) + } +} + +// Test ValidateResource method +func TestRequiredNoneIfValidateResource(t *testing.T) { + testCases := map[string]requiredNoneIfTestCase{ + "matching_condition_all_configured": { + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + expectError: true, + }, + "matching_condition_none_configured": { + condition: types.StringValue("test"), + field1: types.StringNull(), + field2: types.StringNull(), + expectError: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + request := resource.ValidateConfigRequest{ + Config: config, + } + + response := &resource.ValidateConfigResponse{} + + validator.ValidateResource(ctx, request, response) + + if testCase.expectError { + assert.True(t, response.Diagnostics.HasError()) + } else { + assert.False(t, response.Diagnostics.HasError()) + } + }) + } +} + +// Test the Description and MarkdownDescription methods for both variants +func TestRequiredNoneIfDescription(t *testing.T) { + t.Run("RequiredNoneIf description", func(t *testing.T) { + validator := validators.RequiredNoneIf( + path.MatchRoot("condition"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + ctx := context.Background() + desc := validator.Description(ctx) + mdDesc := validator.MarkdownDescription(ctx) + + expectedDesc := `If "condition" equals "test", any of those attributes must not be configured: [field1,field2]` + assert.Equal(t, expectedDesc, desc) + assert.Equal(t, expectedDesc, mdDesc) + }) + + t.Run("RequiredNoneIfSet description", func(t *testing.T) { + validator := validators.RequiredNoneIfSet( + path.MatchRoot("condition"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + ctx := context.Background() + desc := validator.Description(ctx) + mdDesc := validator.MarkdownDescription(ctx) + + expectedDesc := `If "condition" is set, any of those attributes must not be configured: [field1,field2]` + assert.Equal(t, expectedDesc, desc) + assert.Equal(t, expectedDesc, mdDesc) + }) +} + +// Test with missing path +func TestRequiredNoneIfWithMissingPath(t *testing.T) { + ctx := context.Background() + schema := createRequiredNoneIfSchema() + testCase := requiredNoneIfTestCase{ + condition: types.StringValue("test"), + field1: types.StringValue("value1"), + field2: types.StringValue("value2"), + } + config := createRequiredNoneIfConfig(schema, testCase) + + validator := validators.RequiredNoneIf( + path.MatchRoot("non_existent"), + types.StringValue("test"), + path.MatchRoot("field1"), + path.MatchRoot("field2"), + ) + + diagnostics := validator.Validate(ctx, config) + + // Should not get an error because the condition path doesn't match + assert.False(t, diagnostics.HasError()) +} diff --git a/internal/provider/validators/string_length_exactly_test.go b/internal/provider/validators/string_length_exactly_test.go index d7ed6e3..c212a19 100644 --- a/internal/provider/validators/string_length_exactly_test.go +++ b/internal/provider/validators/string_length_exactly_test.go @@ -1,8 +1,9 @@ -package validators +package validators_test import ( "context" "fmt" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "github.com/stretchr/testify/assert" "testing" ) @@ -26,7 +27,7 @@ func TestStringLengthExactlyValidation(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("%s-expected-length-%d", tc.value, tc.length), func(t *testing.T) { t.Parallel() - v := StringLengthExactly(tc.length) + v := validators.StringLengthExactly(tc.length) req, resp := newStringValidatorRequestResponse(tc.value) v.ValidateString(context.Background(), req, resp) assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) diff --git a/internal/provider/validators/timezone_test.go b/internal/provider/validators/timezone_test.go index faf3c68..0bb3f8c 100644 --- a/internal/provider/validators/timezone_test.go +++ b/internal/provider/validators/timezone_test.go @@ -1,7 +1,8 @@ -package validators +package validators_test import ( "context" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "testing" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -71,7 +72,7 @@ func TestTimezoneValidator(t *testing.T) { ConfigValue: test.val, } response := validator.StringResponse{} - Timezone().ValidateString(context.Background(), request, &response) + validators.Timezone().ValidateString(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/internal/provider/validators/url_test.go b/internal/provider/validators/url_test.go index 177edc1..5c96b60 100644 --- a/internal/provider/validators/url_test.go +++ b/internal/provider/validators/url_test.go @@ -1,7 +1,8 @@ -package validators +package validators_test import ( "context" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "testing" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -59,7 +60,7 @@ func TestURLValidator(t *testing.T) { ConfigValue: test.val, } response := validator.StringResponse{} - URL().ValidateString(context.Background(), request, &response) + validators.URL().ValidateString(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") @@ -114,7 +115,7 @@ func TestHTTPSURLValidator(t *testing.T) { ConfigValue: test.val, } response := validator.StringResponse{} - HTTPSUrl().ValidateString(context.Background(), request, &response) + validators.HTTPSUrl().ValidateString(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error")