From f815ffef794b3f2db144e5f9b2af1f9ce71e272d Mon Sep 17 00:00:00 2001 From: Mateusz Filipowicz Date: Sat, 1 Mar 2025 18:03:58 +0100 Subject: [PATCH] feat: add locale setting resource support with `unifi_setting_locale` resource (#34) * feat: add locale setting resource support with `unifi_setting_locale` resource * lint --- .../acctest/resource_setting_locale_test.go | 70 ++++++++++ internal/provider/provider_v2.go | 7 + .../settings/resource_setting_locale.go | 88 ++++++++++++ internal/provider/validators/timezone.go | 93 +++++++++++++ internal/provider/validators/timezone_test.go | 85 ++++++++++++ internal/provider/validators/url.go | 92 +++++++++++++ internal/provider/validators/url_test.go | 128 ++++++++++++++++++ 7 files changed, 563 insertions(+) create mode 100644 internal/provider/acctest/resource_setting_locale_test.go create mode 100644 internal/provider/settings/resource_setting_locale.go create mode 100644 internal/provider/validators/timezone.go create mode 100644 internal/provider/validators/timezone_test.go create mode 100644 internal/provider/validators/url.go create mode 100644 internal/provider/validators/url_test.go diff --git a/internal/provider/acctest/resource_setting_locale_test.go b/internal/provider/acctest/resource_setting_locale_test.go new file mode 100644 index 0000000..52d4603 --- /dev/null +++ b/internal/provider/acctest/resource_setting_locale_test.go @@ -0,0 +1,70 @@ +package acctest + +import ( + "fmt" + pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "regexp" + "sync" + "testing" +) + +var settingLocaleLock = &sync.Mutex{} + +func TestAccSettingLocale(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7.3", + Lock: settingLocaleLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingLocaleConfig("America/New_York"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "America/New_York"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionCreate), + }, + pt.ImportStepWithSite("unifi_setting_locale.test"), + { + Config: testAccSettingLocaleConfig("Europe/London"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "Europe/London"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionUpdate), + }, + { + Config: testAccSettingLocaleConfig("UTC"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"), + resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "UTC"), + ), + ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionUpdate), + }, + }, + }) +} +func TestAccSettingLocaleInvalid(t *testing.T) { + AcceptanceTest(t, AcceptanceTestCase{ + VersionConstraint: ">= 7.3", + Lock: settingLocaleLock, + Steps: []resource.TestStep{ + { + Config: testAccSettingLocaleConfig("Invalid/Timezone"), + ExpectError: regexp.MustCompile("must be a valid IANA timezone identifier"), + }, + }, + }) +} + +func testAccSettingLocaleConfig(timezone string) string { + return fmt.Sprintf(` +resource "unifi_setting_locale" "test" { + timezone = %q +} +`, timezone) +} diff --git a/internal/provider/provider_v2.go b/internal/provider/provider_v2.go index 9ab21d3..70e5ee1 100644 --- a/internal/provider/provider_v2.go +++ b/internal/provider/provider_v2.go @@ -5,6 +5,7 @@ import ( "github.com/filipowm/terraform-provider-unifi/internal/provider/base" "github.com/filipowm/terraform-provider-unifi/internal/provider/dns" "github.com/filipowm/terraform-provider-unifi/internal/provider/settings" + "github.com/filipowm/terraform-provider-unifi/internal/provider/validators" "github.com/filipowm/terraform-provider-unifi/internal/utils" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -25,6 +26,10 @@ func NewV2(version string) func() provider.Provider { } } +var ( + _ provider.Provider = &unifiProvider{} +) + type unifiProvider struct { version string } @@ -64,6 +69,7 @@ func (p *unifiProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp MarkdownDescription: ProviderAPIURLDescription, Validators: []validator.String{ stringvalidator.LengthAtLeast(1), // workaround for `required: true`, because it fails on doc generation due to incorrectly detected difference between v1 and v2 + validators.HTTPSUrl(), }, Optional: true, }, @@ -170,6 +176,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource dns.NewDnsRecordResource, settings.NewAutoSpeedtestResource, settings.NewCountryResource, + settings.NewLocaleResource, } } diff --git a/internal/provider/settings/resource_setting_locale.go b/internal/provider/settings/resource_setting_locale.go new file mode 100644 index 0000000..b6446f3 --- /dev/null +++ b/internal/provider/settings/resource_setting_locale.go @@ -0,0 +1,88 @@ +package settings + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "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/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type localeModel struct { + base.Model + Timezone types.String `tfsdk:"timezone"` +} + +func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) { + diags := diag.Diagnostics{} + + model := &unifi.SettingLocale{ + ID: d.ID.ValueString(), + Timezone: d.Timezone.ValueString(), + } + + return model, diags +} + +func (d *localeModel) Merge(other interface{}) diag.Diagnostics { + diags := diag.Diagnostics{} + + model, ok := other.(*unifi.SettingLocale) + if !ok { + diags.AddError("Cannot merge", "Cannot merge type that is not *unifi.SettingLocale") + return diags + } + + d.ID = types.StringValue(model.ID) + d.Timezone = types.StringValue(model.Timezone) + + return diags +} + +var ( + _ base.ResourceModel = &localeModel{} + _ resource.Resource = &localeResource{} + _ resource.ResourceWithConfigure = &localeResource{} + _ resource.ResourceWithImportState = &localeResource{} +) + +type localeResource struct { + *BaseSettingResource[*localeModel] +} + +func (r *localeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages locale settings for a UniFi site.", + Attributes: map[string]schema.Attribute{ + "id": base.ID(), + "site": base.SiteAttribute(), + "timezone": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Timezone for the UniFi controller, e.g., `America/Los_Angeles`", + Validators: []validator.String{ + validators.Timezone(), + }, + }, + }, + } +} + +func NewLocaleResource() resource.Resource { + r := &localeResource{} + r.BaseSettingResource = NewBaseSettingResource( + "unifi_setting_locale", + func() *localeModel { return &localeModel{} }, + func(ctx context.Context, client *base.Client, site string) (interface{}, error) { + return client.GetSettingLocale(ctx, site) + }, + func(ctx context.Context, client *base.Client, site string, body interface{}) (interface{}, error) { + return client.UpdateSettingLocale(ctx, site, body.(*unifi.SettingLocale)) + }, + ) + return r +} diff --git a/internal/provider/validators/timezone.go b/internal/provider/validators/timezone.go new file mode 100644 index 0000000..58727ea --- /dev/null +++ b/internal/provider/validators/timezone.go @@ -0,0 +1,93 @@ +package validators + +import ( + "context" + "fmt" + "strings" + "time" + + "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" +) + +// Timezone returns a validator which ensures that the string value is a valid IANA timezone identifier +// according to the time.LoadLocation function. +func Timezone() validator.String { + return timezoneValidator{} +} + +type timezoneValidator struct{} + +func (v timezoneValidator) Description(_ context.Context) string { + return "must be a valid IANA timezone identifier (e.g., 'America/New_York')" +} + +func (v timezoneValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v timezoneValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + value := req.ConfigValue + if !base.IsDefined(value) { + return + } + + val := value.ValueString() + + // Check for empty string + if val == "" { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + "Timezone cannot be empty. Use a valid IANA timezone identifier like 'America/New_York'", + ), + ) + return + } + + // Check for proper case (IANA timezone identifiers are case-sensitive) + // Regions should start with uppercase + if val[0] >= 'a' && val[0] <= 'z' { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q has incorrect case. IANA timezone regions should start with uppercase (e.g., 'America/New_York')", val), + ), + ) + return + } + + // Try to load the timezone location + _, err := time.LoadLocation(val) + if err != nil { + // For better error messages, check common mistakes + if strings.Contains(val, "UTC") && val != "UTC" { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is not a valid timezone. For UTC offset use the standard 'UTC' timezone instead.", val), + ), + ) + } else if strings.Contains(val, " ") { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is not a valid timezone. Timezones should not contain spaces.", val), + ), + ) + } else { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is not a valid IANA timezone identifier. Use a value like 'America/New_York'", val), + ), + ) + } + } +} diff --git a/internal/provider/validators/timezone_test.go b/internal/provider/validators/timezone_test.go new file mode 100644 index 0000000..faf3c68 --- /dev/null +++ b/internal/provider/validators/timezone_test.go @@ -0,0 +1,85 @@ +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestTimezoneValidator(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-america": { + val: types.StringValue("America/Los_Angeles"), + }, + "valid-europe": { + val: types.StringValue("Europe/London"), + }, + "valid-asia": { + val: types.StringValue("Asia/Tokyo"), + }, + "valid-australia": { + val: types.StringValue("Australia/Sydney"), + }, + "valid-utc": { + val: types.StringValue("UTC"), + }, + "invalid-with-space": { + val: types.StringValue("America/New York"), + expectError: true, + }, + "invalid-nonexistent": { + val: types.StringValue("NonExistent/Timezone"), + expectError: true, + }, + "invalid-empty-string": { + val: types.StringValue(""), + expectError: true, + }, + "invalid-just-region": { + val: types.StringValue("America"), + expectError: true, + }, + "invalid-lowercase": { + val: types.StringValue("america/los_angeles"), + expectError: true, + }, + "invalid-utc-offset": { + val: types.StringValue("UTC+01:00"), + 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{} + Timezone().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/url.go b/internal/provider/validators/url.go new file mode 100644 index 0000000..2b5c6ef --- /dev/null +++ b/internal/provider/validators/url.go @@ -0,0 +1,92 @@ +package validators + +import ( + "context" + "fmt" + "net/url" + + "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" +) + +// URL returns a validator which ensures that the string value is a valid URL. +func URL() validator.String { + return urlValidator{requireHTTPS: false} +} + +// HTTPSUrl returns a validator which ensures that the string value is a valid HTTPS URL. +func HTTPSUrl() validator.String { + return urlValidator{requireHTTPS: true} +} + +type urlValidator struct { + requireHTTPS bool +} + +func (v urlValidator) Description(_ context.Context) string { + if v.requireHTTPS { + return "must be a valid HTTPS URL" + } + return "must be a valid URL" +} + +func (v urlValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v urlValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + value := req.ConfigValue + if !base.IsDefined(value) { + return + } + + val := value.ValueString() + parsedURL, err := url.Parse(val) + + if err != nil { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is not a valid URL: %s", val, err), + ), + ) + return + } + + // Check if URL has a scheme + if parsedURL.Scheme == "" { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is missing a scheme (e.g., http:// or https://)", val), + ), + ) + return + } + + // Check if HTTPS is required + if v.requireHTTPS && parsedURL.Scheme != "https" { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q must use HTTPS scheme", val), + ), + ) + return + } + + // Check if URL has a host + if parsedURL.Host == "" { + resp.Diagnostics.Append( + validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + v.Description(ctx), + fmt.Sprintf("%q is missing a host", val), + ), + ) + } +} diff --git a/internal/provider/validators/url_test.go b/internal/provider/validators/url_test.go new file mode 100644 index 0000000..177edc1 --- /dev/null +++ b/internal/provider/validators/url_test.go @@ -0,0 +1,128 @@ +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestURLValidator(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-http": { + val: types.StringValue("http://example.com"), + }, + "valid-https": { + val: types.StringValue("https://example.com"), + }, + "valid-with-path": { + val: types.StringValue("https://example.com/path"), + }, + "valid-with-query": { + val: types.StringValue("https://example.com/path?query=value"), + }, + "valid-with-port": { + val: types.StringValue("https://example.com:8443"), + }, + "invalid-no-scheme": { + val: types.StringValue("example.com"), + expectError: true, + }, + "invalid-no-host": { + val: types.StringValue("https://"), + expectError: true, + }, + "invalid-malformed": { + val: types.StringValue("htt ps://example.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{} + URL().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) + } + }) + } +} + +func TestHTTPSURLValidator(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-https": { + val: types.StringValue("https://example.com"), + }, + "valid-with-path": { + val: types.StringValue("https://example.com/path"), + }, + "invalid-http": { + val: types.StringValue("http://example.com"), + expectError: true, + }, + "invalid-no-scheme": { + val: types.StringValue("example.com"), + expectError: true, + }, + "invalid-no-host": { + val: types.StringValue("https://"), + 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{} + HTTPSUrl().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) + } + }) + } +}