feat: support complete USG resource (#44)

* feat: add support for UPNP and Geo IP filtering to USG settings resource

* feat: support complete USG settings resource

* fix messages in required_together_if.go

* improve docs of USG resource

* tests: require version at least 9.0 for unbind_wan_monitors

* feat: require version at least 8.5 for dns_verification

* fix: use go-unifi 1.5.2 to fix NTP

* require 7.0 or later for timeout preference

* require 7.0 or later for geo IP filtering
This commit is contained in:
Mateusz Filipowicz
2025-03-11 02:17:24 +01:00
committed by GitHub
parent 35c74bf59d
commit fcea1e0ba4
24 changed files with 3596 additions and 76 deletions

10
go.mod
View File

@@ -10,8 +10,8 @@ require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/biter777/countries v1.7.5
github.com/deckarep/golang-set/v2 v2.7.0
github.com/filipowm/go-unifi v1.5.0
github.com/golangci/golangci-lint v1.64.5
github.com/filipowm/go-unifi v1.5.2
github.com/golangci/golangci-lint v1.64.6
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/terraform-plugin-docs v0.21.0
github.com/hashicorp/terraform-plugin-framework v1.14.1
@@ -27,7 +27,7 @@ require (
)
require (
4d63.com/gocheckcompilerdirectives v1.2.1 // indirect
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect
@@ -219,7 +219,7 @@ require (
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.5 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect
@@ -299,7 +299,7 @@ require (
github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect

8
go.sum
View File

@@ -1,5 +1,6 @@
4d63.com/gocheckcompilerdirectives v1.2.1 h1:AHcMYuw56NPjq/2y615IGg2kYkBdTvOaojYCBcRE7MA=
4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs=
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
@@ -278,6 +279,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/filipowm/go-unifi v1.5.0 h1:DgnPKA23urg4jG3k2jaoigbk1Mt22QB0nNE/ItgNZTU=
github.com/filipowm/go-unifi v1.5.0/go.mod h1:dr+YNQ1Y2EjOTttAkWAMWMU/J8iv+FueAxZKmptnVEI=
github.com/filipowm/go-unifi v1.5.1 h1:dXdu4ei3ta+r8TAXc2CxPGEdBW01Bxhf1sxBgDreeWk=
github.com/filipowm/go-unifi v1.5.1/go.mod h1:ldtL5szykvR9fPWB9GlGZqaJGxKd8IWLQY5M8O1PDQ8=
github.com/filipowm/go-unifi v1.5.2 h1:S974Fi3BWt7qm0P8G/SwUVOjzgWTSjNJGV0jW5yD6K0=
github.com/filipowm/go-unifi v1.5.2/go.mod h1:ldtL5szykvR9fPWB9GlGZqaJGxKd8IWLQY5M8O1PDQ8=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA=
github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -391,6 +396,7 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint v1.64.5 h1:5omC86XFBKXZgCrVdUWU+WNHKd+CWCxNx717KXnzKZY=
github.com/golangci/golangci-lint v1.64.5/go.mod h1:WZnwq8TF0z61h3jLQ7Sk5trcP7b3kUFxLD6l1ivtdvU=
github.com/golangci/golangci-lint v1.64.6/go.mod h1:Wz9q+6EVuqGQ94GQ96RB2mjpcZYTOGhBhbt4O7REPu4=
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs=
github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo=
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
@@ -565,6 +571,7 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.5 h1:CdnJh63tcDe53vG+RebdpdXJTc9atMgGqdx8LXxiilg=
github.com/kkHAIKE/contextcheck v1.1.5/go.mod h1:O930cpht4xb1YQpK+1+AgoM3mFsvxr7uyFptcnWTYUA=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -809,6 +816,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.0 h1:unbRd941gNa8SS77YznHXOYVBDgWcF9xhzECdm8juZc=
github.com/rogpeppe/go-internal v1.14.0/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.3.5 h1:cShyguSwUEeC0jS7ylOiG/idnd1TpJ1LfHGpV3oJmPU=
github.com/ryancurrah/gomodguard v1.3.5/go.mod h1:MXlEPQRxgfPQa62O8wzK3Ozbkv9Rkqr+wKjSxTdsNJE=

View File

@@ -50,7 +50,7 @@ func TestAccSettingUsg_mdns_v7(t *testing.T) {
})
}
func TestAccSettingUsg_dhcpRelay(t *testing.T) {
func TestAccSettingUsg_dhcpRelayServers(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
@@ -81,6 +81,495 @@ func TestAccSettingUsg_site(t *testing.T) {
})
}
func TestAccSettingUsg_geoIpFiltering(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7",
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_geoIpFilteringBasic(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "KP"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_geoIpFilteringAllow(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "allow"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "US"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CA"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "GB"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_geoIpFilteringDirections(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "ingress"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "2"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_geoIpFilteringDisabled(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "false"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_geoIpFilteringBasic(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.block", "block"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.traffic_direction", "both"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.#", "3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "RU"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "CN"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "geo_ip_filtering.countries.*", "KP"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
},
})
}
func TestAccSettingUsg_upnp(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_upnpBasic(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_upnpAdvanced(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.nat_pmp_enabled", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.secure_mode", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.wan_interface", "WAN"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_upnpDisabled(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "upnp.enabled", "false"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
},
})
}
func TestAccSettingUsg_dnsVerification(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.5",
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_dnsVerification(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.domain"),
resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.primary_dns_server"),
resource.TestCheckResourceAttrSet("unifi_setting_usg.test", "dns_verification.secondary_dns_server"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.setting_preference", "auto"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_dnsVerificationUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.domain", "example.com"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.primary_dns_server", "1.1.1.1"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.secondary_dns_server", "1.0.0.1"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dns_verification.setting_preference", "manual"),
),
},
},
})
}
func TestAccSettingUsg_tcpTimeouts(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_tcpTimeouts(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_timeout", "10"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.established_timeout", "3600"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_wait_timeout", "20"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.fin_wait_timeout", "30"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.last_ack_timeout", "30"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_recv_timeout", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_sent_timeout", "120"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.time_wait_timeout", "120"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_tcpTimeoutsUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_timeout", "20"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.established_timeout", "7200"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.close_wait_timeout", "40"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.fin_wait_timeout", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.last_ack_timeout", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_recv_timeout", "120"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.syn_sent_timeout", "240"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tcp_timeouts.time_wait_timeout", "240"),
),
},
},
})
}
func TestAccSettingUsg_arpCache(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_arpCache(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "custom"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_arpCacheUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "120"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "normal"),
),
},
},
})
}
func TestAccSettingUsg_dhcpConfig(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_dhcpConfig(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_use_dnsmasq", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dnsmasq_all_servers", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_dhcpConfigUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_use_dnsmasq", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dnsmasq_all_servers", "false"),
),
},
},
})
}
func TestAccSettingUsg_dhcpRelayConfig(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_dhcpRelayConfig(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.agents_packets", "forward"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.hop_count", "5"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.max_size", "1400"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.port", "67"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay_servers.#", "2"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.4"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_dhcpRelayConfigUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.agents_packets", "replace"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.hop_count", "10"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.max_size", "64"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay.port", "68"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcp_relay_servers.#", "3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.5"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.6"),
resource.TestCheckTypeSetElemAttr("unifi_setting_usg.test", "dhcp_relay_servers.*", "10.1.2.7"),
),
},
},
})
}
func TestAccSettingUsg_networkTools(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_networkTools(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "echo_server", "echo.example.com"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
},
})
}
func TestAccSettingUsg_protocolModules(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_protocolModules(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "gre_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "h323_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "pptp_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "sip_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_protocolModulesUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "gre_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "h323_module", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "pptp_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "sip_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "false"),
),
},
},
})
}
func TestAccSettingUsg_icmpAndLldp(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_icmpAndLldp(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "icmp_timeout", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "lldp_enable_all", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_icmpAndLldpUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "icmp_timeout", "120"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "lldp_enable_all", "false"),
),
},
},
})
}
func TestAccSettingUsg_mssClamp(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_mssClamp(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp", "auto"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp_mss", "1452"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_mssClampUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp", "custom"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "mss_clamp_mss", "1400"),
),
},
},
})
}
func TestAccSettingUsg_offloadSettings(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_offloadSettings(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_accounting", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_l2_blocking", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_sch", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_offloadSettingsUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_accounting", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_l2_blocking", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "offload_sch", "false"),
),
},
},
})
}
func TestAccSettingUsg_timeoutSettings(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7",
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_timeoutSettings(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "600"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "timeout_setting_preference", "auto"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_timeoutSettingsUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "1200"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "timeout_setting_preference", "manual"),
),
},
},
})
}
func TestAccSettingUsg_redirectsAndSecurity(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_redirectsAndSecurity(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "receive_redirects", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "send_redirects", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_redirectsAndSecurityUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "receive_redirects", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "send_redirects", "false"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "false"),
),
},
},
})
}
func TestAccSettingUsg_udp(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_udp(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_other_timeout", "30"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "120"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_udpUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_other_timeout", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "240"),
),
},
},
})
}
func TestAccSettingUsg_unbindWanMonitor(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9",
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_unbindWanMonitor(true),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "unbind_wan_monitors", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
{
Config: testAccSettingUsgConfig_unbindWanMonitor(false),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("unifi_setting_usg.test", "unbind_wan_monitors", "false"),
),
},
},
})
}
func TestAccSettingUsg_comprehensive(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7",
Lock: &settingUsgLock,
Steps: []resource.TestStep{
{
Config: testAccSettingUsgConfig_comprehensive(),
Check: resource.ComposeTestCheckFunc(
// ARP Cache
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_base_reachable", "60"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "arp_cache_timeout", "custom"),
// DHCP Config
resource.TestCheckResourceAttr("unifi_setting_usg.test", "broadcast_ping", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "dhcpd_hostfile_update", "true"),
// Protocol Modules (sample)
resource.TestCheckResourceAttr("unifi_setting_usg.test", "ftp_module", "true"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "tftp_module", "true"),
// Timeouts
resource.TestCheckResourceAttr("unifi_setting_usg.test", "other_timeout", "600"),
resource.TestCheckResourceAttr("unifi_setting_usg.test", "udp_stream_timeout", "120"),
// Security
resource.TestCheckResourceAttr("unifi_setting_usg.test", "syn_cookies", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_usg.test"),
},
})
}
func testAccSettingUsgConfig_mdns(mdns bool) string {
return fmt.Sprintf(`
resource "unifi_setting_usg" "test" {
@@ -111,3 +600,443 @@ resource "unifi_setting_usg" "test" {
}
`
}
func testAccSettingUsgConfig_geoIpFilteringBasic() string {
return `
resource "unifi_setting_usg" "test" {
geo_ip_filtering = {
enabled = true
countries = ["RU", "CN", "KP"]
}
}
`
}
func testAccSettingUsgConfig_geoIpFilteringAllow() string {
return `
resource "unifi_setting_usg" "test" {
geo_ip_filtering = {
enabled = true
block = "allow"
countries = ["US", "CA", "GB"]
}
}
`
}
func testAccSettingUsgConfig_geoIpFilteringDirections() string {
return `
resource "unifi_setting_usg" "test" {
geo_ip_filtering = {
enabled = true
traffic_direction = "ingress"
countries = ["RU", "CN"]
}
}
`
}
func testAccSettingUsgConfig_geoIpFilteringDisabled() string {
return `
resource "unifi_setting_usg" "test" {
geo_ip_filtering = {
enabled = false
}
}
`
}
func testAccSettingUsgConfig_upnpBasic() string {
return `
resource "unifi_setting_usg" "test" {
upnp = {
enabled = true
}
}
`
}
func testAccSettingUsgConfig_upnpAdvanced() string {
return `
resource "unifi_setting_usg" "test" {
upnp = {
enabled = true
nat_pmp_enabled = true
secure_mode = true
wan_interface = "WAN"
}
}
`
}
func testAccSettingUsgConfig_upnpDisabled() string {
return `
resource "unifi_setting_usg" "test" {
upnp = {
enabled = false
}
}
`
}
func testAccSettingUsgConfig_dnsVerification() string {
return `
resource "unifi_setting_usg" "test" {
dns_verification = {
setting_preference = "auto"
}
}
`
}
func testAccSettingUsgConfig_dnsVerificationUpdated() string {
return `
resource "unifi_setting_usg" "test" {
dns_verification = {
domain = "example.com"
primary_dns_server = "1.1.1.1"
secondary_dns_server = "1.0.0.1"
setting_preference = "manual"
}
}
`
}
func testAccSettingUsgConfig_tcpTimeouts() string {
return `
resource "unifi_setting_usg" "test" {
tcp_timeouts = {
close_timeout = 10
established_timeout = 3600
close_wait_timeout = 20
fin_wait_timeout = 30
last_ack_timeout = 30
syn_recv_timeout = 60
syn_sent_timeout = 120
time_wait_timeout = 120
}
}
`
}
func testAccSettingUsgConfig_tcpTimeoutsUpdated() string {
return `
resource "unifi_setting_usg" "test" {
tcp_timeouts = {
close_timeout = 20
established_timeout = 7200
close_wait_timeout = 40
fin_wait_timeout = 60
last_ack_timeout = 60
syn_recv_timeout = 120
syn_sent_timeout = 240
time_wait_timeout = 240
}
}
`
}
func testAccSettingUsgConfig_arpCache() string {
return `
resource "unifi_setting_usg" "test" {
arp_cache_base_reachable = 60
arp_cache_timeout = "custom"
}
`
}
func testAccSettingUsgConfig_dhcpConfig() string {
return `
resource "unifi_setting_usg" "test" {
broadcast_ping = true
dhcpd_hostfile_update = true
dhcpd_use_dnsmasq = true
dnsmasq_all_servers = true
}
`
}
func testAccSettingUsgConfig_dhcpRelayConfig() string {
return `
resource "unifi_setting_usg" "test" {
dhcp_relay = {
agents_packets = "forward"
hop_count = 5
max_size = 1400
port = 67
}
dhcp_relay_servers = ["10.1.2.3","10.1.2.4"]
}
`
}
func testAccSettingUsgConfig_dhcpRelayConfigUpdated() string {
return `
resource "unifi_setting_usg" "test" {
dhcp_relay = {
agents_packets = "replace"
hop_count = 10
max_size = 64
port = 68
}
dhcp_relay_servers = ["10.1.2.5","10.1.2.6","10.1.2.7"]
}
`
}
func testAccSettingUsgConfig_networkTools() string {
return `
resource "unifi_setting_usg" "test" {
echo_server = "echo.example.com"
}
`
}
func testAccSettingUsgConfig_protocolModules() string {
return `
resource "unifi_setting_usg" "test" {
ftp_module = true
gre_module = true
h323_module = true
pptp_module = true
sip_module = true
tftp_module = true
}
`
}
func testAccSettingUsgConfig_icmpAndLldp() string {
return `
resource "unifi_setting_usg" "test" {
icmp_timeout = 60
lldp_enable_all = true
}
`
}
func testAccSettingUsgConfig_mssClamp() string {
return `
resource "unifi_setting_usg" "test" {
mss_clamp = "auto"
mss_clamp_mss = 1452
}
`
}
func testAccSettingUsgConfig_offloadSettings() string {
return `
resource "unifi_setting_usg" "test" {
offload_accounting = true
offload_l2_blocking = true
offload_sch = true
}
`
}
func testAccSettingUsgConfig_timeoutSettings() string {
return `
resource "unifi_setting_usg" "test" {
other_timeout = 600
timeout_setting_preference = "auto"
}
`
}
func testAccSettingUsgConfig_redirectsAndSecurity() string {
return `
resource "unifi_setting_usg" "test" {
receive_redirects = false
send_redirects = true
syn_cookies = true
}
`
}
func testAccSettingUsgConfig_udp() string {
return `
resource "unifi_setting_usg" "test" {
udp_other_timeout = 30
udp_stream_timeout = 120
}
`
}
func testAccSettingUsgConfig_comprehensive() string {
return `
resource "unifi_setting_usg" "test" {
// ARP Cache Configuration
arp_cache_base_reachable = 60
arp_cache_timeout = "custom"
// DHCP Configuration
broadcast_ping = true
dhcpd_hostfile_update = true
dhcpd_use_dnsmasq = true
dnsmasq_all_servers = true
// DHCP Relay
dhcp_relay = {
agents_packets = "forward"
hop_count = 5
}
dhcp_relay_servers = ["10.1.2.3", "10.1.2.4"]
// Network Tools
echo_server = "echo.example.com"
// Protocol Modules
ftp_module = true
gre_module = true
tftp_module = true
// ICMP & LLDP
icmp_timeout = 20
lldp_enable_all = true
// MSS Clamp
mss_clamp = "auto"
mss_clamp_mss = 1452
// Offload Settings
offload_accounting = true
offload_l2_blocking = true
// Timeout Settings
other_timeout = 600
timeout_setting_preference = "auto"
// TCP Settings
tcp_timeouts = {
close_timeout = 10
established_timeout = 3600
close_wait_timeout = 20
fin_wait_timeout = 30
last_ack_timeout = 30
syn_recv_timeout = 60
syn_sent_timeout = 120
time_wait_timeout = 120
}
// Redirects & Security
receive_redirects = false
send_redirects = true
syn_cookies = true
// UDP
udp_other_timeout = 30
udp_stream_timeout = 120
// Geo IP Filtering
geo_ip_filtering = {
enabled = true
block = "block"
countries = ["RU", "CN"]
traffic_direction = "both"
}
// UPNP Settings
upnp = {
enabled = true
nat_pmp_enabled = true
secure_mode = true
}
}
`
}
func testAccSettingUsgConfig_arpCacheUpdated() string {
return `
resource "unifi_setting_usg" "test" {
arp_cache_base_reachable = 120
arp_cache_timeout = "normal"
}
`
}
func testAccSettingUsgConfig_dhcpConfigUpdated() string {
return `
resource "unifi_setting_usg" "test" {
broadcast_ping = false
dhcpd_hostfile_update = false
dhcpd_use_dnsmasq = false
dnsmasq_all_servers = false
}
`
}
func testAccSettingUsgConfig_protocolModulesUpdated() string {
return `
resource "unifi_setting_usg" "test" {
ftp_module = false
gre_module = true
h323_module = false
pptp_module = true
sip_module = true
tftp_module = false
}
`
}
func testAccSettingUsgConfig_icmpAndLldpUpdated() string {
return `
resource "unifi_setting_usg" "test" {
icmp_timeout = 120
lldp_enable_all = false
}
`
}
func testAccSettingUsgConfig_mssClampUpdated() string {
return `
resource "unifi_setting_usg" "test" {
mss_clamp = "custom"
mss_clamp_mss = 1400
}
`
}
func testAccSettingUsgConfig_offloadSettingsUpdated() string {
return `
resource "unifi_setting_usg" "test" {
offload_accounting = false
offload_l2_blocking = false
offload_sch = false
}
`
}
func testAccSettingUsgConfig_timeoutSettingsUpdated() string {
return `
resource "unifi_setting_usg" "test" {
other_timeout = 1200
timeout_setting_preference = "manual"
}
`
}
func testAccSettingUsgConfig_redirectsAndSecurityUpdated() string {
return `
resource "unifi_setting_usg" "test" {
receive_redirects = true
send_redirects = false
syn_cookies = false
}
`
}
func testAccSettingUsgConfig_udpUpdated() string {
return `
resource "unifi_setting_usg" "test" {
udp_other_timeout = 60
udp_stream_timeout = 240
}
`
}
func testAccSettingUsgConfig_unbindWanMonitor(enabled bool) string {
return fmt.Sprintf(`
resource "unifi_setting_usg" "test" {
unbind_wan_monitors = %t
}
`, enabled)
}

View File

@@ -1,6 +1,7 @@
package base
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -22,8 +23,8 @@ type ResourceModel interface {
GetID() string
GetRawID() types.String
SetID(string)
AsUnifiModel() (interface{}, diag.Diagnostics)
Merge(interface{}) diag.Diagnostics
AsUnifiModel(context.Context) (interface{}, diag.Diagnostics)
Merge(context.Context, interface{}) diag.Diagnostics
}
type Model struct {

View File

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

View File

@@ -87,13 +87,14 @@ func (b *BaseSettingResource[T]) Create(ctx context.Context, req resource.Create
if resp.Diagnostics.HasError() {
return
}
body, diags := plan.AsUnifiModel()
site := b.client.ResolveSite(plan)
body, diags := plan.AsUnifiModel(ctx)
if diags.HasError() {
resp.Diagnostics.Append(diags...)
return
}
site := b.client.ResolveSite(plan)
res, err := b.updater(ctx, b.client, site, body)
if err != nil {
@@ -104,7 +105,7 @@ func (b *BaseSettingResource[T]) Create(ctx context.Context, req resource.Create
resp.Diagnostics.AddError("Error creating settings", fmt.Sprintf("No %[1]s settings returned from the UniFi controller. %[1]s might not be supported on this controller", b.typeName))
return
}
plan.Merge(res)
plan.Merge(ctx, res)
plan.SetSite(site)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
@@ -128,7 +129,7 @@ func (b *BaseSettingResource[T]) read(ctx context.Context, site string, state T,
return
}
if res != nil {
state.Merge(res)
state.Merge(ctx, res)
}
}
@@ -172,7 +173,7 @@ func (b *BaseSettingResource[T]) Update(ctx context.Context, req resource.Update
if resp.Diagnostics.HasError() {
return
}
body, diags := plan.AsUnifiModel()
body, diags := plan.AsUnifiModel(ctx)
if diags.HasError() {
resp.Diagnostics.Append(diags...)
return
@@ -184,7 +185,7 @@ func (b *BaseSettingResource[T]) Update(ctx context.Context, req resource.Update
resp.Diagnostics.AddError("Error updating settings", err.Error())
return
}
state.Merge(res)
state.Merge(ctx, res)
state.SetSite(site)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

View File

@@ -24,7 +24,7 @@ type autoSpeedtestModel struct {
Enabled types.Bool `tfsdk:"enabled"`
}
func (d *autoSpeedtestModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *autoSpeedtestModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
return &unifi.SettingAutoSpeedtest{
ID: d.ID.ValueString(),
CronExpr: d.CronExpression.ValueString(),
@@ -32,7 +32,7 @@ func (d *autoSpeedtestModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
}, diag.Diagnostics{}
}
func (d *autoSpeedtestModel) Merge(other interface{}) diag.Diagnostics {
func (d *autoSpeedtestModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
if typed, ok := other.(*unifi.SettingAutoSpeedtest); ok {
d.ID = types.StringValue(typed.ID)
d.CronExpression = types.StringValue(typed.CronExpr)

View File

@@ -26,7 +26,7 @@ type countryModel struct {
CodeNumeric types.Int32 `tfsdk:"code_numeric"`
}
func (d *countryModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *countryModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
code := countries.ByName(d.Code.ValueString())
return &unifi.SettingCountry{
ID: d.ID.ValueString(),
@@ -34,7 +34,7 @@ func (d *countryModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
}, diag.Diagnostics{}
}
func (d *countryModel) Merge(other interface{}) diag.Diagnostics {
func (d *countryModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
if typed, ok := other.(*unifi.SettingCountry); ok {
d.ID = types.StringValue(typed.ID)
code := countries.ByNumeric(typed.Code)

View File

@@ -1,6 +1,7 @@
package settings
import (
"context"
"github.com/filipowm/go-unifi/unifi"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stretchr/testify/assert"
@@ -26,7 +27,7 @@ func TestSettingCountry_ProperCountryCodeMappingFromModel(t *testing.T) {
model := countryModel{
Code: types.StringValue(tc.code),
}
unifiModel, _ := model.AsUnifiModel()
unifiModel, _ := model.AsUnifiModel(context.Background())
typed, ok := unifiModel.(*unifi.SettingCountry)
assert.True(t, ok)
assert.Equal(t, tc.expectedNumericCode, typed.Code)
@@ -54,7 +55,7 @@ func TestSettingCountry_ProperCountryCodeMappingToModel(t *testing.T) {
Code: tc.numericCode,
}
model := countryModel{}
model.Merge(unifiModel)
model.Merge(context.Background(), unifiModel)
assert.Equal(t, tc.expectedCode, model.Code.ValueString())
})
}

View File

@@ -18,7 +18,7 @@ type localeModel struct {
Timezone types.String `tfsdk:"timezone"`
}
func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *localeModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingLocale{
@@ -29,7 +29,7 @@ func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
return model, diags
}
func (d *localeModel) Merge(other interface{}) diag.Diagnostics {
func (d *localeModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingLocale)

View File

@@ -16,7 +16,7 @@ type magicSiteToSiteVpnModel struct {
Enabled types.Bool `tfsdk:"enabled"`
}
func (d *magicSiteToSiteVpnModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *magicSiteToSiteVpnModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingMagicSiteToSiteVpn{
@@ -27,7 +27,7 @@ func (d *magicSiteToSiteVpnModel) AsUnifiModel() (interface{}, diag.Diagnostics)
return model, diags
}
func (d *magicSiteToSiteVpnModel) Merge(other interface{}) diag.Diagnostics {
func (d *magicSiteToSiteVpnModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingMagicSiteToSiteVpn)

View File

@@ -16,7 +16,7 @@ type networkOptimizationModel struct {
Enabled types.Bool `tfsdk:"enabled"`
}
func (d *networkOptimizationModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *networkOptimizationModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingNetworkOptimization{
@@ -27,7 +27,7 @@ func (d *networkOptimizationModel) AsUnifiModel() (interface{}, diag.Diagnostics
return model, diags
}
func (d *networkOptimizationModel) Merge(other interface{}) diag.Diagnostics {
func (d *networkOptimizationModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingNetworkOptimization)

View File

@@ -26,7 +26,7 @@ type ntpModel struct {
Mode types.String `tfsdk:"mode"`
}
func (d *ntpModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *ntpModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingNtp{
@@ -56,7 +56,7 @@ func (d *ntpModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
return model, diags
}
func (d *ntpModel) Merge(other interface{}) diag.Diagnostics {
func (d *ntpModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingNtp)

View File

@@ -1,6 +1,7 @@
package settings
import (
"context"
"github.com/filipowm/go-unifi/unifi"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stretchr/testify/assert"
@@ -21,7 +22,7 @@ func TestNtpModel_AsUnifiModel_Auto(t *testing.T) {
model.ID = types.StringValue("test-id")
// Convert to UnifiModel
unifiModel, diags := model.AsUnifiModel()
unifiModel, diags := model.AsUnifiModel(context.Background())
// Verify no diagnostics errors
assert.False(t, diags.HasError())
@@ -138,7 +139,7 @@ func TestNtpModel_AsUnifiModel_Manual(t *testing.T) {
model.ID = types.StringValue("test-id")
// Convert to UnifiModel
unifiModel, diags := model.AsUnifiModel()
unifiModel, diags := model.AsUnifiModel(context.Background())
// Verify no diagnostics errors
assert.False(t, diags.HasError())

View File

@@ -18,7 +18,7 @@ type sslInspectionModel struct {
State types.String `tfsdk:"state"`
}
func (d *sslInspectionModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *sslInspectionModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingSslInspection{
@@ -29,7 +29,7 @@ func (d *sslInspectionModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
return model, diags
}
func (d *sslInspectionModel) Merge(other interface{}) diag.Diagnostics {
func (d *sslInspectionModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingSslInspection)

View File

@@ -19,7 +19,7 @@ type teleportModel struct {
Subnet types.String `tfsdk:"subnet"`
}
func (d *teleportModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
func (d *teleportModel) AsUnifiModel(_ context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingTeleport{
@@ -31,7 +31,7 @@ func (d *teleportModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
return model, diags
}
func (d *teleportModel) Merge(other interface{}) diag.Diagnostics {
func (d *teleportModel) Merge(_ context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingTeleport)

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ var (
_ datasource.ConfigValidator = &RequiredNoneIfValidator{}
_ provider.ConfigValidator = &RequiredNoneIfValidator{}
_ resource.ConfigValidator = &RequiredNoneIfValidator{}
_ validator.Object = &RequiredNoneIfValidator{}
)
type RequiredNoneIfValidator struct {
@@ -27,6 +28,10 @@ type RequiredNoneIfValidator struct {
TargetExpressions path.Expressions
}
func (v RequiredNoneIfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredNoneIfValidator) Description(ctx context.Context) string {
return v.MarkdownDescription(ctx)
}

View File

@@ -0,0 +1,194 @@
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 = &RequiredTogetherIfValidator{}
_ provider.ConfigValidator = &RequiredTogetherIfValidator{}
_ resource.ConfigValidator = &RequiredTogetherIfValidator{}
_ validator.Object = &RequiredTogetherIfValidator{}
_ validator.String = &RequiredTogetherIfValidator{}
_ validator.Bool = &RequiredTogetherIfValidator{}
)
type RequiredTogetherIfValidator struct {
ConditionPath path.Expression
ConditionValue attr.Value
TargetExpressions path.Expressions
CheckOnlyIfSet bool // When true, only checks if the condition value is set (not null), not its actual value
}
func (v RequiredTogetherIfValidator) ValidateBool(ctx context.Context, req validator.BoolRequest, resp *validator.BoolResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredTogetherIfValidator) Description(ctx context.Context) string {
return v.MarkdownDescription(ctx)
}
func (v RequiredTogetherIfValidator) MarkdownDescription(_ context.Context) string {
if v.CheckOnlyIfSet {
return fmt.Sprintf("If %s is set, these attributes must be configured together: %s", v.ConditionPath, v.TargetExpressions)
}
return fmt.Sprintf("If %s equals %s, these attributes must be configured together: %s", v.ConditionPath, v.ConditionValue, v.TargetExpressions)
}
func (v RequiredTogetherIfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredTogetherIfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredTogetherIfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredTogetherIfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredTogetherIfValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) {
resp.Diagnostics.Append(v.Validate(ctx, req.Config)...)
}
func (v RequiredTogetherIfValidator) 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)
}
func (v RequiredTogetherIfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics {
diags := diag.Diagnostics{}
if !v.shouldValidate(ctx, config) {
return diags
}
// Condition matched, now apply the RequiredTogether 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)
}
}
// Return early if all paths were null
//if len(configuredPaths) == 0 {
// return diags
//}
// If there are unknown values, we cannot know if the validator should
// succeed or not
if len(unknownPaths) > 0 {
return diags
}
// If configured paths does not equal all matched paths, then something
// was missing
if len(configuredPaths) != len(foundPaths) {
diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic(
foundPaths[0],
v.Description(ctx),
))
}
return diags
}
// ValidateString method to implement the validator.String interface
func (v RequiredTogetherIfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
resp.Diagnostics.Append(v.Validate(ctx, req.Config)...)
}
// RequiredTogetherIf creates a validator for string type attributes that ensures
// a set of target attributes are configured together if a condition attribute equals a specific value.
func RequiredTogetherIf(conditionPath path.Expression, conditionValue attr.Value, targetExpressions ...path.Expression) RequiredTogetherIfValidator {
return RequiredTogetherIfValidator{
ConditionPath: conditionPath,
ConditionValue: conditionValue,
TargetExpressions: targetExpressions,
CheckOnlyIfSet: false,
}
}
// RequiredTogetherIfSet creates a validator that ensures a set of target attributes
// are configured together if a condition attribute is set (not null), regardless of its value.
func RequiredTogetherIfSet(conditionPath path.Expression, targetExpressions ...path.Expression) RequiredTogetherIfValidator {
return RequiredTogetherIfValidator{
ConditionPath: conditionPath,
ConditionValue: nil, // Not used for this validator
TargetExpressions: targetExpressions,
CheckOnlyIfSet: true,
}
}

View File

@@ -0,0 +1,875 @@
package validators_test
import (
"context"
"fmt"
"testing"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/stretchr/testify/require"
"github.com/hashicorp/terraform-plugin-framework/path"
"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"
)
// Helper function to convert types.Int32 to tftypes.Value
func numberToTfValue(value types.Int32) tftypes.Value {
if value.IsNull() {
return tftypes.NewValue(tftypes.Number, nil)
} else if value.IsUnknown() {
return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue)
}
return tftypes.NewValue(tftypes.Number, float64(value.ValueInt32()))
}
// Common test case structure for string conditions
type requiredTogetherIfStringConditionTestCase struct {
condition types.String
field1 types.String
field2 types.String
expectError bool
expectErrorText string
}
// Common test case structure for bool conditions
type requiredTogetherIfBoolConditionTestCase struct {
condition types.Bool
field1 types.String
field2 types.String
expectError bool
expectErrorText string
}
// Common test case structure for int32 conditions
type int32ConditionTestCase struct {
condition types.Int32
field1 types.String
field2 types.String
expectError bool
expectErrorText string
}
// Function to create a schema object with string condition
func createRequiredIfStringConditionSchema() 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 createRequiredIfBoolConditionSchema() 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 schema object with int32 condition
func createInt32ConditionSchema() schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"condition": schema.Int32Attribute{
Optional: true,
},
"field1": schema.StringAttribute{
Optional: true,
},
"field2": schema.StringAttribute{
Optional: true,
},
},
}
}
// Function to create a config with string condition
func createRequiredIfStringConditionConfig(schema schema.Schema, testCase requiredTogetherIfStringConditionTestCase) 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 createRequiredIfBoolConditionConfig(schema schema.Schema, testCase requiredTogetherIfBoolConditionTestCase) 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),
},
),
}
}
// Function to create a config with int32 condition
func createInt32ConditionConfig(schema schema.Schema, testCase int32ConditionTestCase) tfsdk.Config {
return tfsdk.Config{
Schema: schema,
Raw: tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"condition": tftypes.Number,
"field1": tftypes.String,
"field2": tftypes.String,
},
},
map[string]tftypes.Value{
"condition": numberToTfValue(testCase.condition),
"field1": stringToTfValue(testCase.field1),
"field2": stringToTfValue(testCase.field2),
},
),
}
}
// Function to verify validation results
func verifyValidationResults(t *testing.T, response interface{}, testCase interface{}) {
t.Helper()
var hasDiagnosticError bool
var diagnosticErrors []interface{}
// Extract diagnostic errors based on the type
switch d := response.(type) {
case resource.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case *resource.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case datasource.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case *datasource.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case provider.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case *provider.ValidateConfigResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case validator.StringResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
case *validator.StringResponse:
hasDiagnosticError = d.Diagnostics.HasError()
if hasDiagnosticError {
diagnosticErrors = []interface{}{d.Diagnostics.Errors()[0]}
}
default:
t.Fatalf("Unsupported response type: %T", response)
}
// Verify results based on test case type
switch tc := testCase.(type) {
case requiredTogetherIfStringConditionTestCase:
if tc.expectError {
assert.True(t, hasDiagnosticError)
if hasDiagnosticError {
assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText)
}
} else {
assert.False(t, hasDiagnosticError)
}
case requiredTogetherIfBoolConditionTestCase:
if tc.expectError {
assert.True(t, hasDiagnosticError)
if hasDiagnosticError {
assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText)
}
} else {
assert.False(t, hasDiagnosticError)
}
case int32ConditionTestCase:
if tc.expectError {
require.True(t, hasDiagnosticError)
if hasDiagnosticError {
assert.Contains(t, fmt.Sprintf("%v", diagnosticErrors[0]), tc.expectErrorText)
}
} else {
assert.False(t, hasDiagnosticError)
}
default:
t.Fatalf("Unsupported test case type: %T", testCase)
}
}
func TestRequiredTogetherIf(t *testing.T) {
t.Parallel()
testCases := map[string]requiredTogetherIfStringConditionTestCase{
"condition-matches-all-fields-set": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-matches-field1-missing": {
condition: types.StringValue("expected"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals \"expected\", these attributes must be configured together",
},
"condition-matches-field2-missing": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringNull(),
expectError: true,
expectErrorText: "If condition equals \"expected\", these attributes must be configured together",
},
"condition-matches-both-fields-missing": {
condition: types.StringValue("expected"),
field1: types.StringNull(),
field2: types.StringNull(),
expectError: true,
},
"condition-does-not-match": {
condition: types.StringValue("different"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-is-null": {
condition: types.StringNull(),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-is-unknown": {
condition: types.StringUnknown(),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := createRequiredIfStringConditionSchema()
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("expected"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createRequiredIfStringConditionConfig(schemaObject, testCase)
request := validator.StringRequest{
ConfigValue: testCase.condition,
Config: config,
Path: path.Root("condition"),
}
response := validator.StringResponse{}
val.ValidateString(ctx, request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestRequiredTogetherIfWithBoolCondition(t *testing.T) {
t.Parallel()
testCases := map[string]requiredTogetherIfBoolConditionTestCase{
"condition-true-all-fields-set": {
condition: types.BoolValue(true),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-true-field1-missing": {
condition: types.BoolValue(true),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals true, these attributes must be configured together",
},
"condition-false-fields-missing": {
condition: types.BoolValue(false),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
schemaObject := createRequiredIfBoolConditionSchema()
// Create a validator with Bool condition
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.BoolValue(true),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createRequiredIfBoolConditionConfig(schemaObject, testCase)
request := resource.ValidateConfigRequest{
Config: config,
}
response := resource.ValidateConfigResponse{}
val.ValidateResource(context.Background(), request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestRequiredTogetherIfWithNumberCondition(t *testing.T) {
t.Parallel()
testCases := map[string]int32ConditionTestCase{
"condition-matches-all-fields-set": {
condition: types.Int32Value(42),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-matches-field-missing": {
condition: types.Int32Value(42),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals 42, these attributes must be configured together",
},
"condition-does-not-match": {
condition: types.Int32Value(24),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := createInt32ConditionSchema()
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.Int32Value(42),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createInt32ConditionConfig(schemaObject, testCase)
request := resource.ValidateConfigRequest{
Config: config,
}
response := resource.ValidateConfigResponse{}
val.ValidateResource(ctx, request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestResourceRequiredTogetherIf(t *testing.T) {
t.Parallel()
testCases := map[string]requiredTogetherIfStringConditionTestCase{
"condition-matches-all-fields-set": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-matches-field-missing": {
condition: types.StringValue("expected"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals \"expected\", these attributes must be configured together",
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := createRequiredIfStringConditionSchema()
// Test the RequiredTogetherIf function
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("expected"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createRequiredIfStringConditionConfig(schemaObject, testCase)
request := resource.ValidateConfigRequest{
Config: config,
}
response := resource.ValidateConfigResponse{}
val.ValidateResource(ctx, request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestDataSourceRequiredTogetherIf(t *testing.T) {
t.Parallel()
testCases := map[string]requiredTogetherIfStringConditionTestCase{
"condition-matches-all-fields-set": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-matches-field-missing": {
condition: types.StringValue("expected"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals \"expected\", these attributes must be configured together",
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := createRequiredIfStringConditionSchema()
// Test the RequiredTogetherIf function with a datasource validator
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("expected"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createRequiredIfStringConditionConfig(schemaObject, testCase)
request := datasource.ValidateConfigRequest{
Config: config,
}
response := datasource.ValidateConfigResponse{}
val.ValidateDataSource(ctx, request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestProviderRequiredTogetherIf(t *testing.T) {
t.Parallel()
testCases := map[string]requiredTogetherIfStringConditionTestCase{
"condition-matches-all-fields-set": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"condition-matches-field-missing": {
condition: types.StringValue("expected"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition equals \"expected\", these attributes must be configured together",
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := createRequiredIfStringConditionSchema()
// Test the RequiredTogetherIf function with a provider validator
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("expected"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
config := createRequiredIfStringConditionConfig(schemaObject, testCase)
request := provider.ValidateConfigRequest{
Config: config,
}
response := provider.ValidateConfigResponse{}
val.ValidateProvider(ctx, request, &response)
verifyValidationResults(t, response, testCase)
})
}
}
func TestRequiredTogetherIfWithNonBooleanCondition(t *testing.T) {
t.Parallel()
type customType struct {
Name string
}
testStruct := customType{Name: "test"}
testCases := map[string]struct {
condValue types.String
expectedValue any
field1 types.String
field2 types.String
expectError bool
matcherChanged bool
}{
"custom-object-not-equal": {
condValue: types.StringValue("test"),
expectedValue: testStruct,
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true, // Changed to true - we expect validation to fail when the condition matches
matcherChanged: true,
},
"same-string-different-value": {
condValue: types.StringValue("test"),
expectedValue: "different",
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: false,
matcherChanged: false,
},
"different-types": {
condValue: types.StringValue("123"),
expectedValue: 123,
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true, // Changed to true - condition equals 123 and field1 is null, should cause validation to fail
matcherChanged: false,
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := schema.Schema{
Attributes: map[string]schema.Attribute{
"condition": schema.StringAttribute{
Optional: true,
},
"field1": schema.StringAttribute{
Optional: true,
},
"field2": schema.StringAttribute{
Optional: true,
},
},
}
expectedValue := testCase.expectedValue
var v validators.RequiredTogetherIfValidator
if testCase.matcherChanged {
// Using the string "test" as the expected condition value
v = validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("test"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
} else {
// Normal string comparison using the string representation of expectedValue
v = validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue(fmt.Sprintf("%v", expectedValue)),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
}
config := tfsdk.Config{
Schema: schemaObject,
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.condValue),
"field1": stringToTfValue(testCase.field1),
"field2": stringToTfValue(testCase.field2),
},
),
}
request := resource.ValidateConfigRequest{
Config: config,
}
response := resource.ValidateConfigResponse{}
v.ValidateResource(ctx, request, &response)
if testCase.expectError {
assert.True(t, response.Diagnostics.HasError())
} else {
assert.False(t, response.Diagnostics.HasError())
}
})
}
}
func TestRequiredTogetherIfWithUnknownTargetPaths(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
condition types.String
field1 types.String
field2 types.String
fieldPath string // Path that will be used in the validator but doesn't exist in schema
expectError bool
expectErrorText string
}{
"unknown-path-condition-matches": {
condition: types.StringValue("expected"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
fieldPath: "unknown_field",
expectError: true, // Changed to true since the validator errors when a path doesn't exist
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
schemaObject := schema.Schema{
Attributes: map[string]schema.Attribute{
"condition": schema.StringAttribute{
Optional: true,
},
"field1": schema.StringAttribute{
Optional: true,
},
"field2": schema.StringAttribute{
Optional: true,
},
},
}
val := validators.RequiredTogetherIf(
path.MatchRoot("condition"),
types.StringValue("expected"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
path.MatchRoot(testCase.fieldPath),
)
config := tfsdk.Config{
Schema: schemaObject,
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),
},
),
}
request := resource.ValidateConfigRequest{
Config: config,
}
response := resource.ValidateConfigResponse{}
val.ValidateResource(ctx, request, &response)
if testCase.expectError {
assert.True(t, response.Diagnostics.HasError())
assert.Contains(t, response.Diagnostics.Errors()[0].Detail(), testCase.expectErrorText)
} else {
assert.False(t, response.Diagnostics.HasError())
}
})
}
}
func TestRequiredTogetherIfSet(t *testing.T) {
schema := createRequiredIfStringConditionSchema()
testCases := map[string]requiredTogetherIfStringConditionTestCase{
"No condition value specified": {
condition: types.StringNull(),
field1: types.StringValue("value1"),
field2: types.StringNull(),
expectError: false,
},
"Unknown condition value": {
condition: types.StringUnknown(),
field1: types.StringValue("value1"),
field2: types.StringNull(),
expectError: false,
},
"Condition set, both fields set": {
condition: types.StringValue("any_value"),
field1: types.StringValue("value1"),
field2: types.StringValue("value2"),
expectError: false,
},
"Condition set, neither field set": {
condition: types.StringValue("any_value"),
field1: types.StringNull(),
field2: types.StringNull(),
expectError: true,
},
"Condition set, only field1 set": {
condition: types.StringValue("any_value"),
field1: types.StringValue("value1"),
field2: types.StringNull(),
expectError: true,
expectErrorText: "If condition is set, these attributes must be configured together: [field1,field2]",
},
"Condition set, only field2 set": {
condition: types.StringValue("any_value"),
field1: types.StringNull(),
field2: types.StringValue("value2"),
expectError: true,
expectErrorText: "If condition is set, these attributes must be configured together: [field1,field2]",
},
"Condition set, field1 set, field2 unknown": {
condition: types.StringValue("any_value"),
field1: types.StringValue("value1"),
field2: types.StringUnknown(),
expectError: false,
},
"Condition set, field1 unknown, field2 set": {
condition: types.StringValue("any_value"),
field1: types.StringUnknown(),
field2: types.StringValue("value2"),
expectError: false,
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
config := createRequiredIfStringConditionConfig(schema, testCase)
v := validators.RequiredTogetherIfSet(
path.MatchRoot("condition"),
path.MatchRoot("field1"),
path.MatchRoot("field2"),
)
request := resource.ValidateConfigRequest{
Config: config,
}
response := &resource.ValidateConfigResponse{}
v.ValidateResource(context.Background(), request, response)
verifyValidationResults(t, response, testCase)
})
}
}

View File

@@ -0,0 +1,126 @@
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/tfsdk"
)
var (
_ datasource.ConfigValidator = &RequiredValueIfValidator{}
_ provider.ConfigValidator = &RequiredValueIfValidator{}
_ resource.ConfigValidator = &RequiredValueIfValidator{}
)
// RequiredValueIfValidator validates that if a condition attribute is set to a specific value,
// then a target attribute must be set to a specific value.
type RequiredValueIfValidator struct {
ConditionPath path.Expression
ConditionValue attr.Value
TargetPath path.Expression
TargetValue attr.Value
}
func (v RequiredValueIfValidator) Description(ctx context.Context) string {
return v.MarkdownDescription(ctx)
}
func (v RequiredValueIfValidator) MarkdownDescription(_ context.Context) string {
return fmt.Sprintf("If %s equals %v, then %s must equal %v", v.ConditionPath, v.ConditionValue, v.TargetPath, v.TargetValue)
}
func (v RequiredValueIfValidator) ValidateDataSource(ctx context.Context, req datasource.ValidateConfigRequest, resp *datasource.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredValueIfValidator) ValidateProvider(ctx context.Context, req provider.ValidateConfigRequest, resp *provider.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredValueIfValidator) ValidateResource(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredValueIfValidator) ValidateEphemeralResource(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) {
resp.Diagnostics = v.Validate(ctx, req.Config)
}
func (v RequiredValueIfValidator) 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
return conditionValueMatches(ctx, conditionValue, v.ConditionValue)
}
func (v RequiredValueIfValidator) Validate(ctx context.Context, config tfsdk.Config) diag.Diagnostics {
diags := diag.Diagnostics{}
if !v.shouldValidate(ctx, config) {
return diags
}
// Condition matched, now validate that the target attribute has the required value
matchedPaths, matchedPathsDiags := config.PathMatches(ctx, v.TargetPath)
diags.Append(matchedPathsDiags...)
if diags.HasError() || len(matchedPaths) == 0 {
return diags
}
// Get the value of the target attribute
var targetValue attr.Value
getTargetDiags := config.GetAttribute(ctx, matchedPaths[0], &targetValue)
diags.Append(getTargetDiags...)
if diags.HasError() {
return diags
}
// Skip validation if the target value is unknown
if targetValue.IsUnknown() {
return diags
}
// If the target value is null or doesn't match the required value, add a diagnostic
if targetValue.IsNull() || !conditionValueMatches(ctx, targetValue, v.TargetValue) {
diags.Append(validatordiag.InvalidAttributeCombinationDiagnostic(
matchedPaths[0],
fmt.Sprintf("When %s is set to %v, %s must be set to %v", v.ConditionPath, v.ConditionValue, v.TargetPath, v.TargetValue),
))
}
return diags
}
// RequiredValueIf creates a validator that ensures if a condition attribute equals a specific value,
// then a target attribute must equal a specific value.
func RequiredValueIf(conditionPath path.Expression, conditionValue attr.Value, targetPath path.Expression, targetValue attr.Value) RequiredValueIfValidator {
return RequiredValueIfValidator{
ConditionPath: conditionPath,
ConditionValue: conditionValue,
TargetPath: targetPath,
TargetValue: targetValue,
}
}

View File

@@ -0,0 +1,201 @@
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 requiredValueIfTestCase struct {
conditionValue types.String
targetValue types.String
expectError bool
}
// Function to create a schema object for RequiredValueIf tests
func createRequiredValueIfSchema() schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"condition_attr": schema.StringAttribute{
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"target_attr": schema.StringAttribute{
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
// Function to create a config for RequiredValueIf tests
func createRequiredValueIfConfig(schema schema.Schema, testCase requiredValueIfTestCase) tfsdk.Config {
var conditionValue, targetValue tftypes.Value
if testCase.conditionValue.IsNull() {
conditionValue = tftypes.NewValue(tftypes.String, nil)
} else if testCase.conditionValue.IsUnknown() {
conditionValue = tftypes.NewValue(tftypes.String, tftypes.UnknownValue)
} else {
conditionValue = tftypes.NewValue(tftypes.String, testCase.conditionValue.ValueString())
}
if testCase.targetValue.IsNull() {
targetValue = tftypes.NewValue(tftypes.String, nil)
} else if testCase.targetValue.IsUnknown() {
targetValue = tftypes.NewValue(tftypes.String, tftypes.UnknownValue)
} else {
targetValue = tftypes.NewValue(tftypes.String, testCase.targetValue.ValueString())
}
return tfsdk.Config{
Schema: schema,
Raw: tftypes.NewValue(tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"condition_attr": tftypes.String,
"target_attr": tftypes.String,
},
}, map[string]tftypes.Value{
"condition_attr": conditionValue,
"target_attr": targetValue,
}),
}
}
func TestRequiredValueIf(t *testing.T) {
schema := createRequiredValueIfSchema()
testCases := map[string]requiredValueIfTestCase{
"condition match target match": {
conditionValue: types.StringValue("active"),
targetValue: types.StringValue("enabled"),
expectError: false,
},
"condition match target mismatch": {
conditionValue: types.StringValue("active"),
targetValue: types.StringValue("disabled"),
expectError: true,
},
"condition match target null": {
conditionValue: types.StringValue("active"),
targetValue: types.StringNull(),
expectError: true,
},
"condition match target unknown": {
conditionValue: types.StringValue("active"),
targetValue: types.StringUnknown(),
expectError: false,
},
"condition mismatch": {
conditionValue: types.StringValue("inactive"),
targetValue: types.StringValue("disabled"),
expectError: false,
},
"condition null": {
conditionValue: types.StringNull(),
targetValue: types.StringNull(),
expectError: false,
},
"condition unknown": {
conditionValue: types.StringUnknown(),
targetValue: types.StringNull(),
expectError: false,
},
}
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
config := createRequiredValueIfConfig(schema, testCase)
validator := validators.RequiredValueIf(
path.MatchRoot("condition_attr"),
types.StringValue("active"),
path.MatchRoot("target_attr"),
types.StringValue("enabled"),
)
diags := validator.Validate(context.Background(), config)
if testCase.expectError {
assert.True(t, diags.HasError(), "expected error, but got none")
} else {
assert.False(t, diags.HasError(), "expected no error, but got: %v", diags)
}
})
}
}
func TestResourceRequiredValueIf(t *testing.T) {
schema := createRequiredValueIfSchema()
testCase := requiredValueIfTestCase{
conditionValue: types.StringValue("active"),
targetValue: types.StringValue("disabled"),
expectError: true,
}
config := createRequiredValueIfConfig(schema, testCase)
validator := validators.RequiredValueIf(
path.MatchRoot("condition_attr"),
types.StringValue("active"),
path.MatchRoot("target_attr"),
types.StringValue("enabled"),
)
resp := &resource.ValidateConfigResponse{}
validator.ValidateResource(context.Background(), resource.ValidateConfigRequest{Config: config}, resp)
assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none")
}
func TestDataSourceRequiredValueIf(t *testing.T) {
schema := createRequiredValueIfSchema()
testCase := requiredValueIfTestCase{
conditionValue: types.StringValue("active"),
targetValue: types.StringValue("disabled"),
expectError: true,
}
config := createRequiredValueIfConfig(schema, testCase)
validator := validators.RequiredValueIf(
path.MatchRoot("condition_attr"),
types.StringValue("active"),
path.MatchRoot("target_attr"),
types.StringValue("enabled"),
)
resp := &datasource.ValidateConfigResponse{}
validator.ValidateDataSource(context.Background(), datasource.ValidateConfigRequest{Config: config}, resp)
assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none")
}
func TestProviderRequiredValueIf(t *testing.T) {
schema := createRequiredValueIfSchema()
testCase := requiredValueIfTestCase{
conditionValue: types.StringValue("active"),
targetValue: types.StringValue("disabled"),
expectError: true,
}
config := createRequiredValueIfConfig(schema, testCase)
validator := validators.RequiredValueIf(
path.MatchRoot("condition_attr"),
types.StringValue("active"),
path.MatchRoot("target_attr"),
types.StringValue("enabled"),
)
resp := &provider.ValidateConfigResponse{}
validator.ValidateProvider(context.Background(), provider.ValidateConfigRequest{Config: config}, resp)
assert.True(t, resp.Diagnostics.HasError(), "expected error, but got none")
}

View File

@@ -3,10 +3,21 @@ package utils
import (
"context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func DefaultEmptyList(elementType attr.Type) defaults.List {
return listdefault.StaticValue(EmptyList(elementType))
}
func EmptyList(elementType attr.Type) types.List {
return types.ListValueMust(elementType, []attr.Value{})
}
func ListElementsAs(list types.List, target interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
if !base.IsDefined(list) {
@@ -17,3 +28,32 @@ func ListElementsAs(list types.List, target interface{}) diag.Diagnostics {
}
return diags
}
func ListElementsToString(ctx context.Context, list types.List) (string, diag.Diagnostics) {
diags := diag.Diagnostics{}
if !base.IsDefined(list) {
return "", diags
}
if list.ElementType(ctx) == types.StringType {
var target []string
diags.Append(ListElementsAs(list, &target)...)
if diags.HasError() {
return "", diags
}
return JoinNonEmpty(target, ","), diags
}
diags.AddError("List is not a list of types.StringType", "List is not a list of strings")
return "", diags
}
func StringToListElements(ctx context.Context, value string) (types.List, diag.Diagnostics) {
countries := SplitAndTrim(value, ",")
if len(countries) == 0 {
return types.ListNull(types.StringType), diag.Diagnostics{}
}
list, diags := types.ListValueFrom(ctx, types.StringType, countries)
if diags.HasError() {
return types.ListNull(types.StringType), diags
}
return list, diags
}

View File

@@ -3,6 +3,7 @@ package utils
import (
"fmt"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"strings"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
@@ -38,3 +39,35 @@ func StringSliceToSet(src []string) *schema.Set {
func IsStringValueNotEmpty(s basetypes.StringValue) bool {
return !s.IsUnknown() && !s.IsNull() && s.ValueString() != ""
}
// JoinNonEmpty joins non-empty strings from a slice with the specified separator.
// Empty strings in the slice are filtered out.
func JoinNonEmpty(elements []string, separator string) string {
var nonEmpty []string
for _, elem := range elements {
if elem != "" {
nonEmpty = append(nonEmpty, elem)
}
}
return strings.Join(nonEmpty, separator)
}
// SplitAndTrim splits a string by the specified separator and trims whitespace from each element.
// Empty strings after trimming are filtered out.
func SplitAndTrim(s string, separator string) []string {
if s == "" {
return []string{}
}
parts := strings.Split(s, separator)
var result []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}