feat: add NTP setting resource support with unifi_setting_ntp resource (#36)

* feat: add NTP setting resource support with `unifi_setting_ntp` resource

* linting

* fix missing method

* add missing validators
This commit is contained in:
Mateusz Filipowicz
2025-03-02 01:10:41 +01:00
committed by GitHub
parent a78667e669
commit 8b5ed14d8d
18 changed files with 2246 additions and 12 deletions

View File

@@ -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)
}

View File

@@ -104,8 +104,8 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq
path.Root("api_url"), path.Root("api_url"),
"Unknown UniFi Controller API URL", "Unknown UniFi Controller API URL",
"The provider cannot create the UniFi Controller API client as there is an unknown configuration value "+ "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 "+ "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.", "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.NewCountryResource,
settings.NewLocaleResource, settings.NewLocaleResource,
settings.NewMagicSiteToSiteVpnResource, settings.NewMagicSiteToSiteVpnResource,
settings.NewNtpResource,
} }
} }

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -1,7 +1,8 @@
package validators package validators_test
import ( import (
"context" "context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
) )
@@ -25,7 +26,7 @@ func TestCountryCodeValidation(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
v := countryCodeAlpha2Validator{} v := validators.CountryCodeAlpha2()
req, resp := newStringValidatorRequestResponse(tc.code) req, resp := newStringValidatorRequestResponse(tc.code)
v.ValidateString(context.Background(), req, resp) v.ValidateString(context.Background(), req, resp)
assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError())

View File

@@ -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)
}

View File

@@ -1,10 +1,11 @@
package validators package validators_test
import ( import (
"github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
) )
func newStringValidatorRequestResponse(value string) (validator.StringRequest, *validator.StringResponse) { func newStringValidatorRequestResponse(value string) (validator.StringRequest, *validator.StringResponse) {
@@ -17,3 +18,23 @@ func newStringValidatorRequestResponse(value string) (validator.StringRequest, *
} }
return req, &resp 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())
}

View File

@@ -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),
),
)
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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")
}
})
}
}

View File

@@ -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,
}
}

View File

@@ -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())
}

View File

@@ -1,8 +1,9 @@
package validators package validators_test
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
) )
@@ -26,7 +27,7 @@ func TestStringLengthExactlyValidation(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s-expected-length-%d", tc.value, tc.length), func(t *testing.T) { t.Run(fmt.Sprintf("%s-expected-length-%d", tc.value, tc.length), func(t *testing.T) {
t.Parallel() t.Parallel()
v := StringLengthExactly(tc.length) v := validators.StringLengthExactly(tc.length)
req, resp := newStringValidatorRequestResponse(tc.value) req, resp := newStringValidatorRequestResponse(tc.value)
v.ValidateString(context.Background(), req, resp) v.ValidateString(context.Background(), req, resp)
assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError()) assert.Equal(t, tc.validationFailed, resp.Diagnostics.HasError())

View File

@@ -1,7 +1,8 @@
package validators package validators_test
import ( import (
"context" "context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/schema/validator"
@@ -71,7 +72,7 @@ func TestTimezoneValidator(t *testing.T) {
ConfigValue: test.val, ConfigValue: test.val,
} }
response := validator.StringResponse{} response := validator.StringResponse{}
Timezone().ValidateString(context.Background(), request, &response) validators.Timezone().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError { if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error") t.Fatal("expected error, got no error")

View File

@@ -1,7 +1,8 @@
package validators package validators_test
import ( import (
"context" "context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/schema/validator"
@@ -59,7 +60,7 @@ func TestURLValidator(t *testing.T) {
ConfigValue: test.val, ConfigValue: test.val,
} }
response := validator.StringResponse{} response := validator.StringResponse{}
URL().ValidateString(context.Background(), request, &response) validators.URL().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError { if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error") t.Fatal("expected error, got no error")
@@ -114,7 +115,7 @@ func TestHTTPSURLValidator(t *testing.T) {
ConfigValue: test.val, ConfigValue: test.val,
} }
response := validator.StringResponse{} response := validator.StringResponse{}
HTTPSUrl().ValidateString(context.Background(), request, &response) validators.HTTPSUrl().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError { if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error") t.Fatal("expected error, got no error")