diff --git a/integration/v4_to_v5/integration_test.go b/integration/v4_to_v5/integration_test.go index e7289cd..be230de 100644 --- a/integration/v4_to_v5/integration_test.go +++ b/integration/v4_to_v5/integration_test.go @@ -13,20 +13,21 @@ import ( _ "github.com/cloudflare/tf-migrate/internal/resources/dns_record" _ "github.com/cloudflare/tf-migrate/internal/resources/healthcheck" _ "github.com/cloudflare/tf-migrate/internal/resources/logpull_retention" - _ "github.com/cloudflare/tf-migrate/internal/resources/page_rule" _ "github.com/cloudflare/tf-migrate/internal/resources/managed_transforms" + _ "github.com/cloudflare/tf-migrate/internal/resources/page_rule" _ "github.com/cloudflare/tf-migrate/internal/resources/pages_project" _ "github.com/cloudflare/tf-migrate/internal/resources/r2_bucket" _ "github.com/cloudflare/tf-migrate/internal/resources/regional_hostname" - _ "github.com/cloudflare/tf-migrate/internal/resources/tiered_cache" _ "github.com/cloudflare/tf-migrate/internal/resources/spectrum_application" + _ "github.com/cloudflare/tf-migrate/internal/resources/tiered_cache" _ "github.com/cloudflare/tf-migrate/internal/resources/url_normalization_settings" _ "github.com/cloudflare/tf-migrate/internal/resources/workers_kv" _ "github.com/cloudflare/tf-migrate/internal/resources/workers_kv_namespace" + _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_application" _ "github.com/cloudflare/tf-migrate/internal/resources/workers_script" _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_group" - _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_service_token" _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_identity_provider" + _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_service_token" _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_dlp_custom_profile" _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_gateway_policy" _ "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_list" diff --git a/integration/v4_to_v5/testdata/zero_trust_access_application/expected/terraform.tfstate b/integration/v4_to_v5/testdata/zero_trust_access_application/expected/terraform.tfstate new file mode 100644 index 0000000..3cb64a4 --- /dev/null +++ b/integration/v4_to_v5/testdata/zero_trust_access_application/expected/terraform.tfstate @@ -0,0 +1,923 @@ +{ + "lineage": "test-zero-trust-access-applications-lineage", + "outputs": {}, + "resources": [ + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "minimal-aud-token", + "domain": "minimal.cort.terraform.cfapi.net", + "id": "minimal-app-id", + "name": "cftftest Minimal App", + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "minimal", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "self-hosted-aud", + "domain": "self-hosted.cort.terraform.cfapi.net", + "id": "minimal-self-hosted-id", + "name": "cftftest Self Hosted", + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "minimal_self_hosted", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "allowed_idps": [ + "idp-1", + "idp-2" + ], + "app_launcher_visible": true, + "aud": "maximal-aud", + "auto_redirect_to_identity": false, + "cors_headers": { + "allow_all_headers": false, + "allow_all_methods": false, + "allow_all_origins": false, + "allow_credentials": true, + "allowed_headers": [ + "Content-Type", + "Authorization" + ], + "allowed_methods": [ + "GET", + "POST", + "PUT", + "DELETE" + ], + "allowed_origins": [ + "https://app.cort.terraform.cfapi.net" + ], + "exposed_headers": [ + "X-Request-ID" + ], + "max_age": 3600 + }, + "custom_deny_message": "Access denied - contact admin", + "custom_deny_url": "https://deny.cort.terraform.cfapi.net", + "custom_non_identity_deny_url": "https://login.cort.terraform.cfapi.net", + "domain": "maximal.cort.terraform.cfapi.net", + "enable_binding_cookie": true, + "http_only_cookie_attribute": true, + "id": "maximal-app-id", + "logo_url": "https://logo.cort.terraform.cfapi.net/logo.png", + "name": "cftftest Maximal Self Hosted", + "policies": [ + { + "id": "policy-1", + "precedence": 1 + }, + { + "id": "policy-2", + "precedence": 2 + }, + { + "id": "policy-3", + "precedence": 3 + } + ], + "same_site_cookie_attribute": "strict", + "service_auth_401_redirect": true, + "session_duration": "24h", + "skip_interstitial": true, + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "maximal_self_hosted", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "saas-saml-aud", + "id": "saas-saml-id", + "name": "cftftest SAML SAAS", + "policies": [ + { + "id": "default-policy-id", + "precedence": 1 + } + ], + "saas_app": { + "auth_type": "saml", + "consumer_service_url": "https://saml.cort.terraform.cfapi.net/sso/saml", + "custom_attributes": [ + { + "name": "email", + "source": { + "name": "user_email" + } + }, + { + "friendly_name": "Department", + "name": "department", + "source": { + "name": "department" + } + } + ], + "idp_entity_id": "https://idp.cloudflare.com/entity-id", + "name_id_format": "email", + "public_key": "generated-public-key", + "sp_entity_id": "saml-app-cftftest", + "sso_endpoint": "https://sso.cloudflare.com/saml" + }, + "type": "saas" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "saas_saml", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "saas-oidc-aud", + "id": "saas-oidc-id", + "name": "cftftest OIDC SAAS", + "policies": [ + { + "id": "oidc-policy", + "precedence": 1 + } + ], + "saas_app": { + "app_launcher_url": "https://oidc.cort.terraform.cfapi.net/launch", + "auth_type": "oidc", + "client_id": "generated-client-id", + "client_secret": "generated-client-secret", + "custom_claims": [ + { + "name": "groups", + "scope": "groups", + "source": { + "name": "user_groups" + } + } + ], + "grant_types": [ + "authorization_code" + ], + "hybrid_and_implicit_options": { + "return_access_token_from_authorization_endpoint": true, + "return_id_token_from_authorization_endpoint": true + }, + "redirect_uris": [ + "https://oidc.cort.terraform.cfapi.net/callback" + ], + "refresh_token_options": { + "lifetime": "2160h" + }, + "scopes": [ + "openid", + "email", + "profile" + ] + }, + "type": "saas" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "saas_oidc", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "warp-single-aud", + "destinations": [ + { + "type": "public", + "uri": "tcp://internal.cort.terraform.cfapi.net:443" + } + ], + "id": "warp-single-id", + "name": "cftftest WARP Single", + "policies": [ + { + "id": "policy-1", + "precedence": 1 + }, + { + "id": "policy-2", + "precedence": 2 + }, + { + "id": "policy-3", + "precedence": 3 + } + ], + "type": "warp" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "warp_single", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "warp-multi-aud", + "destinations": [ + { + "type": "public", + "uri": "https://app1.internal" + }, + { + "type": "private", + "uri": "tcp://10.0.0.0/24:22" + }, + { + "type": "private", + "uri": "udp://192.168.1.0/24:53" + } + ], + "id": "warp-multi-id", + "name": "cftftest WARP Multi", + "policies": [ + { + "id": "warp-policy", + "precedence": 1 + } + ], + "type": "warp" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "warp_multi", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "warp-expressions-aud", + "destinations": [ + { + "type": "public", + "uri": "tcp://test-account-id.internal:443" + } + ], + "id": "warp-expressions-id", + "name": "cftftest WARP Expressions", + "policies": [ + { + "id": "expr-policy", + "precedence": 1 + } + ], + "type": "warp" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "warp_expressions", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "ssh-basic-aud", + "id": "ssh-basic-id", + "name": "cftftest SSH Basic", + "policies": [ + { + "id": "ssh-policy", + "precedence": 1 + } + ], + "target_criteria": [ + { + "port": 22, + "protocol": "ssh", + "target_attributes": { + "environment": [ + "production" + ], + "hostname": [ + "server1", + "server2" + ] + } + } + ], + "type": "ssh" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "ssh_basic", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "ssh-multi-aud", + "id": "ssh-multi-id", + "name": "cftftest SSH Multi", + "policies": [ + { + "id": "default-policy-id", + "precedence": 1 + } + ], + "target_criteria": [ + { + "port": 22, + "protocol": "ssh", + "target_attributes": { + "tier": [ + "web" + ] + } + }, + { + "port": 2222, + "protocol": "ssh", + "target_attributes": { + "tier": [ + "database" + ] + } + } + ], + "type": "ssh" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "ssh_multi_criteria", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "landing-page-aud", + "domain": "landing.cort.terraform.cfapi.net", + "id": "landing-page-id", + "landing_page_design": { + "body_bg_color": "#FFFFFF", + "footer_links": [ + { + "name": "Help Center", + "url": "https://help.cort.terraform.cfapi.net" + }, + { + "name": "Privacy Policy", + "url": "https://privacy.cort.terraform.cfapi.net" + } + ], + "header_bg_color": "#0051C3", + "logo_url": "https://logo.cort.terraform.cfapi.net/logo.png", + "message": "Please sign in to continue", + "title": "Welcome to cftftest" + }, + "name": "cftftest Landing Page", + "policies": [ + { + "id": "landing-policy", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_landing_page", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "minimal-landing-aud", + "domain": "minimal-landing.cort.terraform.cfapi.net", + "id": "minimal-landing-id", + "landing_page_design": { + "message": "Sign in required", + "title": "Welcome!" + }, + "name": "cftftest Minimal Landing", + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "minimal_landing", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "scim-aud", + "domain": "scim.cort.terraform.cfapi.net", + "id": "scim-id", + "name": "cftftest SCIM", + "policies": [ + { + "id": "scim-policy", + "precedence": 1 + } + ], + "scim_config": { + "authentication": { + "password": "scim_password", + "scheme": "httpbasic", + "user": "scim_user" + }, + "deactivate_on_delete": true, + "enabled": true, + "idp_uid": "email", + "mappings": [ + { + "enabled": true, + "operations": { + "create": true, + "delete": true, + "update": true + }, + "schema": "urn:ietf:params:scim:schemas:core:2.0:User" + } + ], + "remote_uri": "https://scim.cort.terraform.cfapi.net/v2" + }, + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_scim", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "custom-pages-aud", + "custom_pages": [ + "custom-forbidden-id", + "custom-identity-denied-id" + ], + "domain": "custom-pages.cort.terraform.cfapi.net", + "id": "custom-pages-id", + "name": "cftftest Custom Pages", + "policies": [ + { + "id": "pages-policy", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_custom_pages", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "self-hosted-domains-aud", + "id": "self-hosted-domains-id", + "name": "cftftest Self Hosted Domains", + "policies": [ + { + "id": "legacy-policy", + "precedence": 1 + } + ], + "self_hosted_domains": [ + "legacy1.cort.terraform.cfapi.net", + "legacy2.cort.terraform.cfapi.net" + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_self_hosted_domains", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "domain-type-aud", + "domain": "domain-type.cort.terraform.cfapi.net", + "id": "domain-type-id", + "name": "cftftest Domain Type", + "policies": [ + { + "id": "domain-policy", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_domain_type", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "count-0-aud", + "domain": "count-0.cort.terraform.cfapi.net", + "id": "count-0-id", + "name": "cftftest Count 0", + "policies": [ + { + "id": "count-policy-0", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "index_key": 0, + "schema_version": 0 + }, + { + "attributes": { + "account_id": "test-account-id", + "aud": "count-1-aud", + "domain": "count-1.cort.terraform.cfapi.net", + "id": "count-1-id", + "name": "cftftest Count 1", + "policies": [ + { + "id": "count-policy-1", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "index_key": 1, + "schema_version": 0 + }, + { + "attributes": { + "account_id": "test-account-id", + "aud": "count-2-aud", + "domain": "count-2.cort.terraform.cfapi.net", + "id": "count-2-id", + "name": "cftftest Count 2", + "policies": [ + { + "id": "count-policy-2", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "index_key": 2, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_count", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "for-each-dev-aud", + "domain": "dev.cort.terraform.cfapi.net", + "id": "for-each-dev-id", + "name": "cftftest dev", + "policies": [ + { + "id": "dev-policy", + "precedence": 1 + } + ], + "session_duration": "24h", + "type": "self_hosted" + }, + "index_key": "dev", + "schema_version": 0 + }, + { + "attributes": { + "account_id": "test-account-id", + "aud": "for-each-prod-aud", + "domain": "prod.cort.terraform.cfapi.net", + "id": "for-each-prod-id", + "name": "cftftest prod", + "policies": [ + { + "id": "prod-policy", + "precedence": 1 + } + ], + "session_duration": "8h", + "type": "self_hosted" + }, + "index_key": "prod", + "schema_version": 0 + }, + { + "attributes": { + "account_id": "test-account-id", + "aud": "for-each-staging-aud", + "domain": "staging.cort.terraform.cfapi.net", + "id": "for-each-staging-id", + "name": "cftftest staging", + "policies": [ + { + "id": "staging-policy", + "precedence": 1 + } + ], + "session_duration": "24h", + "type": "self_hosted" + }, + "index_key": "staging", + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_for_each", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "allowed_idps": [ + "idp-a", + "idp-b", + "idp-c" + ], + "aud": "complex-aud", + "auto_redirect_to_identity": false, + "cors_headers": { + "allow_credentials": false, + "allowed_methods": [ + "GET", + "POST" + ], + "allowed_origins": [ + "*" + ], + "max_age": 7200 + }, + "domain": "complex.cort.terraform.cfapi.net", + "footer_links": [ + { + "name": "Support", + "url": "https://support.cort.terraform.cfapi.net" + }, + { + "name": "Terms", + "url": "https://terms.cort.terraform.cfapi.net" + } + ], + "id": "complex-nested-id", + "name": "cftftest Complex", + "policies": [ + { + "id": "policy-1", + "precedence": 1 + }, + { + "id": "policy-2", + "precedence": 2 + }, + { + "id": "policy-3", + "precedence": 3 + } + ], + "session_duration": "12h", + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "complex_nested", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "var-refs-aud", + "domain": "var-refs.cort.terraform.cfapi.net", + "id": "var-refs-id", + "name": "cftftest Variable Refs", + "policies": [ + { + "id": "default-policy-id", + "precedence": 1 + }, + { + "id": "policy-1", + "precedence": 2 + }, + { + "id": "policy-2", + "precedence": 3 + }, + { + "id": "policy-3", + "precedence": 4 + } + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "with_var_refs", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "empty-policies-aud", + "domain": "empty-policies.cort.terraform.cfapi.net", + "id": "empty-policies-id", + "name": "cftftest Empty Policies", + "policies": [], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "empty_policies", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "sparse-aud", + "domain": "sparse.cort.terraform.cfapi.net", + "id": "sparse-id", + "name": "cftftest Sparse", + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "sparse", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "conditional-aud", + "domain": "conditional.cort.terraform.cfapi.net", + "id": "conditional-id", + "name": "cftftest Conditional", + "policies": [ + { + "id": "conditional-policy", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "index_key": 0, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "conditional", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + }, + { + "instances": [ + { + "attributes": { + "account_id": "test-account-id", + "aud": "special-chars-aud", + "custom_deny_message": "Access denied!\nPlease contact: admin@cort.terraform.cfapi.net\n\nFor help visit: https://help.cort.terraform.cfapi.net", + "domain": "special.cort.terraform.cfapi.net", + "id": "special-chars-id", + "name": "cftftest Special \"Chars\" & 'Quotes'", + "policies": [ + { + "id": "special-policy", + "precedence": 1 + } + ], + "type": "self_hosted" + }, + "schema_version": 0 + } + ], + "mode": "managed", + "name": "special_chars", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "type": "cloudflare_zero_trust_access_application" + } + ], + "serial": 1, + "terraform_version": "1.5.0", + "version": 4 +} diff --git a/integration/v4_to_v5/testdata/zero_trust_access_application/expected/zero_trust_access_application.tf b/integration/v4_to_v5/testdata/zero_trust_access_application/expected/zero_trust_access_application.tf new file mode 100644 index 0000000..b7e50b1 --- /dev/null +++ b/integration/v4_to_v5/testdata/zero_trust_access_application/expected/zero_trust_access_application.tf @@ -0,0 +1,406 @@ +variable "cloudflare_account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "cloudflare_zone_id" { + description = "Cloudflare zone ID" + type = string +} + +# Resource-specific variables with defaults +variable "app_prefix" { + type = string + default = "test" +} + +variable "enable_saas_apps" { + type = bool + default = true +} + +variable "policy_ids" { + type = list(string) + default = ["policy-1", "policy-2", "policy-3"] +} + +# Locals with common values +locals { + name_prefix = "cftftest" + common_account_id = var.cloudflare_account_id + app_domain_suffix = "cort.terraform.cfapi.net" + common_policies = ["default-policy-id"] +} + +# ============================================================================ +# Pattern Group 1: Basic Resources (Edge Cases) +# ============================================================================ + +# 1. Minimal resource - only required fields +resource "cloudflare_zero_trust_access_application" "minimal" { + account_id = local.common_account_id + name = "${local.name_prefix} Minimal App" + domain = "minimal.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 2. Minimal with type specification +resource "cloudflare_zero_trust_access_application" "minimal_self_hosted" { + account_id = local.common_account_id + name = "${local.name_prefix} Self Hosted" + domain = "self-hosted.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 3. Maximal self-hosted app - all common fields +resource "cloudflare_zero_trust_access_application" "maximal_self_hosted" { + account_id = local.common_account_id + name = "${local.name_prefix} Maximal Self Hosted" + domain = "maximal.${local.app_domain_suffix}" + type = "self_hosted" + session_duration = "24h" + auto_redirect_to_identity = false + enable_binding_cookie = true + http_only_cookie_attribute = true + same_site_cookie_attribute = "strict" + custom_deny_url = "https://deny.${local.app_domain_suffix}" + custom_deny_message = "Access denied - contact admin" + custom_non_identity_deny_url = "https://login.${local.app_domain_suffix}" + skip_interstitial = true + app_launcher_visible = true + service_auth_401_redirect = true + + cors_headers = { + allowed_methods = ["GET", "POST", "PUT", "DELETE"] + allowed_origins = ["https://app.${local.app_domain_suffix}"] + allowed_headers = ["Content-Type", "Authorization"] + allow_credentials = true + max_age = 3600 + } +} + +# ============================================================================ +# Pattern Group 2: SAAS Applications +# ============================================================================ + +# 4. SAML SAAS app with full configuration +resource "cloudflare_zero_trust_access_application" "saas_saml" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} SAML SAAS" + type = "saas" + + saas_app = { + consumer_service_url = "https://saml.${local.app_domain_suffix}/sso/saml" + sp_entity_id = "saml-app-${local.name_prefix}" + name_id_format = "email" + custom_attributes = [ + { + name = "email" + source = { + name = "user_email" + } + }, + { + name = "department" + friendly_name = "Department" + source = { + name = "department" + } + } + ] + } +} + +# 5. OIDC SAAS app +resource "cloudflare_zero_trust_access_application" "saas_oidc" { + account_id = local.common_account_id + name = "${local.name_prefix} OIDC SAAS" + type = "saas" + + saas_app = { + auth_type = "oidc" + app_launcher_url = "https://oidc.${local.app_domain_suffix}/launch" + grant_types = ["authorization_code"] + scopes = ["openid", "email", "profile"] + redirect_uris = ["https://oidc.${local.app_domain_suffix}/callback"] + custom_claims = [ + { + name = "groups" + scope = "groups" + source = { + name = "user_groups" + } + } + ] + hybrid_and_implicit_options = { + return_access_token_from_authorization_endpoint = true + return_id_token_from_authorization_endpoint = true + } + refresh_token_options = { + lifetime = "2160h" + } + } +} + +# ============================================================================ +# Pattern Group 3: WARP Applications with Destinations +# ============================================================================ + +# NOTE: WARP applications have special API behavior - they're forced to be +# named "Warp Login App" and don't respect custom configurations. Skipped for e2e tests. +# # 6. WARP app with multiple destinations +# resource "cloudflare_access_application" "warp_multi" { +# account_id = local.common_account_id +# name = "${local.name_prefix} WARP Multi" +# type = "warp" +# +# destinations { +# uri = "https://app1.internal" +# type = "public" +# } +# +# destinations { +# uri = "tcp://10.0.0.0/24:22" +# type = "private" +# } +# +# destinations { +# uri = "udp://192.168.1.0/24:53" +# type = "private" +# } +# } + +# ============================================================================ +# Pattern Group 4: SSH Applications with Target Criteria +# ============================================================================ + +# 9. SSH app (basic) +resource "cloudflare_zero_trust_access_application" "ssh_basic" { + account_id = local.common_account_id + name = "${local.name_prefix} SSH Basic" + type = "ssh" + domain = "ssh-basic.${local.app_domain_suffix}" +} + +# 10. SSH app (multi) +resource "cloudflare_zero_trust_access_application" "ssh_multi_criteria" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} SSH Multi" + type = "ssh" + domain = "ssh-multi.${local.app_domain_suffix}" +} + +# ============================================================================ +# Pattern Group 5: Apps with Landing Page Design +# ============================================================================ + +# NOTE: landing_page_design is not supported in v4 provider +# # 11. App with custom landing page +# resource "cloudflare_access_application" "with_landing_page" { +# account_id = local.common_account_id +# name = "${local.name_prefix} Landing Page" +# domain = "landing.${local.app_domain_suffix}" +# policies = ["landing-policy"] +# +# landing_page_design { +# title = "Welcome to ${local.name_prefix}" +# message = "Please sign in to continue" +# logo_url = "https://logo.${local.app_domain_suffix}/logo.png" +# header_bg_color = "#0051C3" +# body_bg_color = "#FFFFFF" +# footer_links { +# name = "Help Center" +# url = "https://help.${local.app_domain_suffix}" +# } +# footer_links { +# name = "Privacy Policy" +# url = "https://privacy.${local.app_domain_suffix}" +# } +# } +# } +# +# # 12. App with minimal landing page +# resource "cloudflare_access_application" "minimal_landing" { +# account_id = var.cloudflare_account_id +# name = "${local.name_prefix} Minimal Landing" +# domain = "minimal-landing.${local.app_domain_suffix}" +# +# landing_page_design { +# message = "Sign in required" +# } +# } + +# ============================================================================ +# Pattern Group 6: Apps with SCIM Configuration +# ============================================================================ + +# 13. App with SCIM config +# NOTE: Commented out - SCIM requires special account permissions +# resource "cloudflare_access_application" "with_scim" { +# account_id = local.common_account_id +# name = "${local.name_prefix} SCIM" +# domain = "scim.${local.app_domain_suffix}" +# +# scim_config { +# enabled = true +# remote_uri = "https://scim.${local.app_domain_suffix}/v2" +# idp_uid = "email" +# deactivate_on_delete = true +# +# authentication { +# scheme = "httpbasic" +# user = "scim_user" +# password = "scim_password" +# } +# +# mappings { +# schema = "urn:ietf:params:scim:schemas:core:2.0:User" +# enabled = true +# +# operations { +# create = true +# update = true +# delete = true +# } +# } +# } +# } + +# ============================================================================ +# Pattern Group 7: Apps with Custom Pages +# ============================================================================ + +# 14. App with custom pages +# NOTE: Commented out - custom_pages requires real page IDs +# resource "cloudflare_access_application" "with_custom_pages" { +# account_id = var.cloudflare_account_id +# name = "${local.name_prefix} Custom Pages" +# domain = "custom-pages.${local.app_domain_suffix}" +# custom_pages = toset(["custom-forbidden-id", "custom-identity-denied-id"]) +# } + +# ============================================================================ +# Pattern Group 8: Apps with Self Hosted Domains (Deprecated) +# ============================================================================ + +# 15. App with self_hosted_domains (deprecated field) +resource "cloudflare_zero_trust_access_application" "with_self_hosted_domains" { + account_id = local.common_account_id + name = "${local.name_prefix} Self Hosted Domains" + type = "self_hosted" + self_hosted_domains = ["legacy1.${local.app_domain_suffix}", "legacy2.${local.app_domain_suffix}"] +} + +# ============================================================================ +# Pattern Group 9: Apps with Domain Type (Removed Field) +# ============================================================================ + +# 16. App with domain_type field (to be removed in v5) +# NOTE: v4 only supports domain_type = "public" +resource "cloudflare_zero_trust_access_application" "with_domain_type" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Domain Type" + domain = "domain-type.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 10: Meta-Arguments (Count) +# ============================================================================ + +# 17-19. Apps created with count +resource "cloudflare_zero_trust_access_application" "with_count" { + count = 3 + + account_id = local.common_account_id + name = "${local.name_prefix} Count ${count.index}" + domain = "count-${count.index}.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 11: Apps with For Each +# ============================================================================ + +# 20-22. Apps created with for_each +resource "cloudflare_zero_trust_access_application" "with_for_each" { + for_each = toset(["dev", "staging", "prod"]) + + account_id = var.cloudflare_account_id + name = "${local.name_prefix} ${each.key}" + domain = "${each.key}.${local.app_domain_suffix}" + session_duration = each.key == "prod" ? "8h" : "24h" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 12: Complex Nested Structures +# ============================================================================ + +# 23. App with session_duration and cors_headers (compatible with type=self_hosted) +resource "cloudflare_zero_trust_access_application" "complex_nested" { + account_id = local.common_account_id + name = "${local.name_prefix} Complex" + domain = "complex.${local.app_domain_suffix}" + session_duration = "12h" + auto_redirect_to_identity = false + + type = "self_hosted" + cors_headers = { + allowed_methods = ["GET", "POST"] + allowed_origins = ["*"] + allow_credentials = false + max_age = 7200 + } +} + +# 24. App with variable references +resource "cloudflare_zero_trust_access_application" "with_var_refs" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Variable Refs" + domain = "var-refs.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 13: Edge Cases +# ============================================================================ + +# 25. App with empty policies array +resource "cloudflare_zero_trust_access_application" "empty_policies" { + account_id = local.common_account_id + name = "${local.name_prefix} Empty Policies" + domain = "empty-policies.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 26. App with only required fields and null optionals +resource "cloudflare_zero_trust_access_application" "sparse" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Sparse" + domain = "sparse.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 27. Conditional app +resource "cloudflare_zero_trust_access_application" "conditional" { + count = var.enable_saas_apps ? 1 : 0 + + account_id = local.common_account_id + name = "${local.name_prefix} Conditional" + domain = "conditional.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 14: Special Characters and Escaping +# ============================================================================ + +# 28. App with special characters (API restricts: ,.!:@?-) +resource "cloudflare_zero_trust_access_application" "special_chars" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Special \"Chars\" & 'Quotes'" + domain = "special.${local.app_domain_suffix}" + custom_deny_message = "Access denied - contact support" + type = "self_hosted" +} diff --git a/integration/v4_to_v5/testdata/zero_trust_access_application/input/terraform.tfstate b/integration/v4_to_v5/testdata/zero_trust_access_application/input/terraform.tfstate new file mode 100644 index 0000000..7bcfca7 --- /dev/null +++ b/integration/v4_to_v5/testdata/zero_trust_access_application/input/terraform.tfstate @@ -0,0 +1,854 @@ +{ + "version": 4, + "terraform_version": "1.5.0", + "serial": 1, + "lineage": "test-zero-trust-access-applications-lineage", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "minimal", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "minimal-app-id", + "account_id": "test-account-id", + "name": "cftftest Minimal App", + "domain": "minimal.cort.terraform.cfapi.net", + "type": "self_hosted", + "aud": "minimal-aud-token" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "minimal_self_hosted", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "minimal-self-hosted-id", + "account_id": "test-account-id", + "name": "cftftest Self Hosted", + "domain": "self-hosted.cort.terraform.cfapi.net", + "type": "self_hosted", + "aud": "self-hosted-aud" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "maximal_self_hosted", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "maximal-app-id", + "account_id": "test-account-id", + "name": "cftftest Maximal Self Hosted", + "domain": "maximal.cort.terraform.cfapi.net", + "type": "self_hosted", + "session_duration": "24h", + "auto_redirect_to_identity": false, + "enable_binding_cookie": true, + "http_only_cookie_attribute": true, + "same_site_cookie_attribute": "strict", + "custom_deny_url": "https://deny.cort.terraform.cfapi.net", + "custom_deny_message": "Access denied - contact admin", + "custom_non_identity_deny_url": "https://login.cort.terraform.cfapi.net", + "logo_url": "https://logo.cort.terraform.cfapi.net/logo.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "allowed_idps": [ + "idp-1", + "idp-2" + ], + "policies": [ + "policy-1", + "policy-2", + "policy-3" + ], + "aud": "maximal-aud", + "cors_headers": [ + { + "allowed_methods": [ + "GET", + "POST", + "PUT", + "DELETE" + ], + "allowed_origins": [ + "https://app.cort.terraform.cfapi.net" + ], + "allowed_headers": [ + "Content-Type", + "Authorization" + ], + "exposed_headers": [ + "X-Request-ID" + ], + "allow_credentials": true, + "max_age": 3600, + "allow_all_methods": false, + "allow_all_origins": false, + "allow_all_headers": false + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "saas_saml", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "saas-saml-id", + "account_id": "test-account-id", + "name": "cftftest SAML SAAS", + "type": "saas", + "policies": [ + "default-policy-id" + ], + "aud": "saas-saml-aud", + "saas_app": [ + { + "consumer_service_url": "https://saml.cort.terraform.cfapi.net/sso/saml", + "sp_entity_id": "saml-app-cftftest", + "name_id_format": "email", + "idp_entity_id": "https://idp.cloudflare.com/entity-id", + "public_key": "generated-public-key", + "sso_endpoint": "https://sso.cloudflare.com/saml", + "custom_attribute": [ + { + "name": "email", + "source": { + "name": "user_email" + } + }, + { + "name": "department", + "friendly_name": "Department", + "source": { + "name": "department" + } + } + ] + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "saas_oidc", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "saas-oidc-id", + "account_id": "test-account-id", + "name": "cftftest OIDC SAAS", + "type": "saas", + "policies": [ + "oidc-policy" + ], + "aud": "saas-oidc-aud", + "saas_app": [ + { + "auth_type": "oidc", + "app_launcher_url": "https://oidc.cort.terraform.cfapi.net/launch", + "grant_types": [ + "authorization_code" + ], + "scopes": [ + "openid", + "email", + "profile" + ], + "redirect_uris": [ + "https://oidc.cort.terraform.cfapi.net/callback" + ], + "client_id": "generated-client-id", + "client_secret": "generated-client-secret", + "custom_claim": [ + { + "name": "groups", + "scope": "groups", + "source": { + "name": "user_groups" + } + } + ], + "refresh_token_options": [ + { + "lifetime": "2160h" + } + ], + "hybrid_and_implicit_options": [ + { + "return_access_token_from_authorization_endpoint": true, + "return_id_token_from_authorization_endpoint": true + } + ] + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "warp_single", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "warp-single-id", + "account_id": "test-account-id", + "name": "cftftest WARP Single", + "type": "warp", + "policies": [ + "policy-1", + "policy-2", + "policy-3" + ], + "aud": "warp-single-aud", + "destinations": [ + { + "uri": "tcp://internal.cort.terraform.cfapi.net:443" + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "warp_multi", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "warp-multi-id", + "account_id": "test-account-id", + "name": "cftftest WARP Multi", + "type": "warp", + "policies": [ + "warp-policy" + ], + "aud": "warp-multi-aud", + "destinations": [ + { + "uri": "https://app1.internal", + "type": "public" + }, + { + "uri": "tcp://10.0.0.0/24:22", + "type": "private" + }, + { + "uri": "udp://192.168.1.0/24:53", + "type": "private" + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "warp_expressions", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "warp-expressions-id", + "account_id": "test-account-id", + "name": "cftftest WARP Expressions", + "type": "warp", + "policies": [ + "expr-policy" + ], + "aud": "warp-expressions-aud", + "destinations": [ + { + "uri": "tcp://test-account-id.internal:443" + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "ssh_basic", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "ssh-basic-id", + "account_id": "test-account-id", + "name": "cftftest SSH Basic", + "type": "ssh", + "policies": [ + "ssh-policy" + ], + "aud": "ssh-basic-aud", + "target_criteria": [ + { + "port": 22, + "protocol": "ssh", + "target_attributes": [ + { + "name": "hostname", + "values": [ + "server1", + "server2" + ] + }, + { + "name": "environment", + "values": [ + "production" + ] + } + ] + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "ssh_multi_criteria", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "ssh-multi-id", + "account_id": "test-account-id", + "name": "cftftest SSH Multi", + "type": "ssh", + "policies": [ + "default-policy-id" + ], + "aud": "ssh-multi-aud", + "target_criteria": [ + { + "port": 22, + "protocol": "ssh", + "target_attributes": [ + { + "name": "tier", + "values": [ + "web" + ] + } + ] + }, + { + "port": 2222, + "protocol": "ssh", + "target_attributes": [ + { + "name": "tier", + "values": [ + "database" + ] + } + ] + } + ] + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_landing_page", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "landing-page-id", + "account_id": "test-account-id", + "name": "cftftest Landing Page", + "domain": "landing.cort.terraform.cfapi.net", + "policies": [ + "landing-policy" + ], + "aud": "landing-page-aud", + "landing_page_design": [ + { + "title": "Welcome to cftftest", + "message": "Please sign in to continue", + "logo_url": "https://logo.cort.terraform.cfapi.net/logo.png", + "header_bg_color": "#0051C3", + "body_bg_color": "#FFFFFF", + "footer_links": [ + { + "name": "Help Center", + "url": "https://help.cort.terraform.cfapi.net" + }, + { + "name": "Privacy Policy", + "url": "https://privacy.cort.terraform.cfapi.net" + } + ] + } + ], + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "minimal_landing", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "minimal-landing-id", + "account_id": "test-account-id", + "name": "cftftest Minimal Landing", + "domain": "minimal-landing.cort.terraform.cfapi.net", + "aud": "minimal-landing-aud", + "landing_page_design": [ + { + "message": "Sign in required" + } + ], + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_scim", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "scim-id", + "account_id": "test-account-id", + "name": "cftftest SCIM", + "domain": "scim.cort.terraform.cfapi.net", + "policies": [ + "scim-policy" + ], + "aud": "scim-aud", + "scim_config": [ + { + "enabled": true, + "remote_uri": "https://scim.cort.terraform.cfapi.net/v2", + "idp_uid": "email", + "deactivate_on_delete": true, + "authentication": [ + { + "scheme": "httpbasic", + "user": "scim_user", + "password": "scim_password" + } + ], + "mappings": [ + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": true, + "operations": [ + { + "create": true, + "update": true, + "delete": true + } + ] + } + ] + } + ], + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_custom_pages", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "custom-pages-id", + "account_id": "test-account-id", + "name": "cftftest Custom Pages", + "domain": "custom-pages.cort.terraform.cfapi.net", + "policies": [ + "pages-policy" + ], + "custom_pages": [ + "custom-forbidden-id", + "custom-identity-denied-id" + ], + "aud": "custom-pages-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_self_hosted_domains", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "self-hosted-domains-id", + "account_id": "test-account-id", + "name": "cftftest Self Hosted Domains", + "type": "self_hosted", + "self_hosted_domains": [ + "legacy1.cort.terraform.cfapi.net", + "legacy2.cort.terraform.cfapi.net" + ], + "policies": [ + "legacy-policy" + ], + "aud": "self-hosted-domains-aud" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_domain_type", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "domain-type-id", + "account_id": "test-account-id", + "name": "cftftest Domain Type", + "domain": "domain-type.cort.terraform.cfapi.net", + "domain_type": "full", + "policies": [ + "domain-policy" + ], + "aud": "domain-type-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_count", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "index_key": 0, + "schema_version": 0, + "attributes": { + "id": "count-0-id", + "account_id": "test-account-id", + "name": "cftftest Count 0", + "domain": "count-0.cort.terraform.cfapi.net", + "policies": [ + "count-policy-0" + ], + "aud": "count-0-aud", + "type": "self_hosted" + } + }, + { + "index_key": 1, + "schema_version": 0, + "attributes": { + "id": "count-1-id", + "account_id": "test-account-id", + "name": "cftftest Count 1", + "domain": "count-1.cort.terraform.cfapi.net", + "policies": [ + "count-policy-1" + ], + "aud": "count-1-aud" + } + }, + { + "index_key": 2, + "schema_version": 0, + "attributes": { + "id": "count-2-id", + "account_id": "test-account-id", + "name": "cftftest Count 2", + "domain": "count-2.cort.terraform.cfapi.net", + "policies": [ + "count-policy-2" + ], + "aud": "count-2-aud" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_for_each", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "index_key": "dev", + "schema_version": 0, + "attributes": { + "id": "for-each-dev-id", + "account_id": "test-account-id", + "name": "cftftest dev", + "domain": "dev.cort.terraform.cfapi.net", + "session_duration": "24h", + "policies": [ + "dev-policy" + ], + "aud": "for-each-dev-aud", + "type": "self_hosted" + } + }, + { + "index_key": "prod", + "schema_version": 0, + "attributes": { + "id": "for-each-prod-id", + "account_id": "test-account-id", + "name": "cftftest prod", + "domain": "prod.cort.terraform.cfapi.net", + "session_duration": "8h", + "policies": [ + "prod-policy" + ], + "aud": "for-each-prod-aud" + } + }, + { + "index_key": "staging", + "schema_version": 0, + "attributes": { + "id": "for-each-staging-id", + "account_id": "test-account-id", + "name": "cftftest staging", + "domain": "staging.cort.terraform.cfapi.net", + "session_duration": "24h", + "policies": [ + "staging-policy" + ], + "aud": "for-each-staging-aud" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "complex_nested", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "complex-nested-id", + "account_id": "test-account-id", + "name": "cftftest Complex", + "domain": "complex.cort.terraform.cfapi.net", + "session_duration": "12h", + "auto_redirect_to_identity": false, + "allowed_idps": [ + "idp-a", + "idp-b", + "idp-c" + ], + "policies": [ + "policy-1", + "policy-2", + "policy-3" + ], + "aud": "complex-aud", + "cors_headers": [ + { + "allowed_methods": [ + "GET", + "POST" + ], + "allowed_origins": [ + "*" + ], + "allow_credentials": false, + "max_age": 7200 + } + ], + "footer_links": [ + { + "name": "Support", + "url": "https://support.cort.terraform.cfapi.net" + }, + { + "name": "Terms", + "url": "https://terms.cort.terraform.cfapi.net" + } + ], + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "with_var_refs", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "var-refs-id", + "account_id": "test-account-id", + "name": "cftftest Variable Refs", + "domain": "var-refs.cort.terraform.cfapi.net", + "policies": [ + "default-policy-id", + "policy-1", + "policy-2", + "policy-3" + ], + "aud": "var-refs-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "empty_policies", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "empty-policies-id", + "account_id": "test-account-id", + "name": "cftftest Empty Policies", + "domain": "empty-policies.cort.terraform.cfapi.net", + "policies": [], + "aud": "empty-policies-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "sparse", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "sparse-id", + "account_id": "test-account-id", + "name": "cftftest Sparse", + "domain": "sparse.cort.terraform.cfapi.net", + "aud": "sparse-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "conditional", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "index_key": 0, + "schema_version": 0, + "attributes": { + "id": "conditional-id", + "account_id": "test-account-id", + "name": "cftftest Conditional", + "domain": "conditional.cort.terraform.cfapi.net", + "policies": [ + "conditional-policy" + ], + "aud": "conditional-aud", + "type": "self_hosted" + } + } + ] + }, + { + "mode": "managed", + "type": "cloudflare_access_application", + "name": "special_chars", + "provider": "provider[\"registry.terraform.io/cloudflare/cloudflare\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "special-chars-id", + "account_id": "test-account-id", + "name": "cftftest Special \"Chars\" & 'Quotes'", + "domain": "special.cort.terraform.cfapi.net", + "custom_deny_message": "Access denied!\nPlease contact: admin@cort.terraform.cfapi.net\n\nFor help visit: https://help.cort.terraform.cfapi.net", + "policies": [ + "special-policy" + ], + "aud": "special-chars-aud", + "type": "self_hosted" + } + } + ] + } + ] +} diff --git a/integration/v4_to_v5/testdata/zero_trust_access_application/input/zero_trust_access_application.tf b/integration/v4_to_v5/testdata/zero_trust_access_application/input/zero_trust_access_application.tf new file mode 100644 index 0000000..b0a689d --- /dev/null +++ b/integration/v4_to_v5/testdata/zero_trust_access_application/input/zero_trust_access_application.tf @@ -0,0 +1,407 @@ +variable "cloudflare_account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "cloudflare_zone_id" { + description = "Cloudflare zone ID" + type = string +} + +# Resource-specific variables with defaults +variable "app_prefix" { + type = string + default = "test" +} + +variable "enable_saas_apps" { + type = bool + default = true +} + +variable "policy_ids" { + type = list(string) + default = ["policy-1", "policy-2", "policy-3"] +} + +# Locals with common values +locals { + name_prefix = "cftftest" + common_account_id = var.cloudflare_account_id + app_domain_suffix = "cort.terraform.cfapi.net" + common_policies = ["default-policy-id"] +} + +# ============================================================================ +# Pattern Group 1: Basic Resources (Edge Cases) +# ============================================================================ + +# 1. Minimal resource - only required fields +resource "cloudflare_zero_trust_access_application" "minimal" { + account_id = local.common_account_id + name = "${local.name_prefix} Minimal App" + domain = "minimal.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 2. Minimal with type specification +resource "cloudflare_zero_trust_access_application" "minimal_self_hosted" { + account_id = local.common_account_id + name = "${local.name_prefix} Self Hosted" + domain = "self-hosted.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 3. Maximal self-hosted app - all common fields +resource "cloudflare_zero_trust_access_application" "maximal_self_hosted" { + account_id = local.common_account_id + name = "${local.name_prefix} Maximal Self Hosted" + domain = "maximal.${local.app_domain_suffix}" + type = "self_hosted" + session_duration = "24h" + auto_redirect_to_identity = false + enable_binding_cookie = true + http_only_cookie_attribute = true + same_site_cookie_attribute = "strict" + custom_deny_url = "https://deny.${local.app_domain_suffix}" + custom_deny_message = "Access denied - contact admin" + custom_non_identity_deny_url = "https://login.${local.app_domain_suffix}" + skip_interstitial = true + app_launcher_visible = true + service_auth_401_redirect = true + + cors_headers { + allowed_methods = ["GET", "POST", "PUT", "DELETE"] + allowed_origins = ["https://app.${local.app_domain_suffix}"] + allowed_headers = ["Content-Type", "Authorization"] + allow_credentials = true + max_age = 3600 + } +} + +# ============================================================================ +# Pattern Group 2: SAAS Applications +# ============================================================================ + +# 4. SAML SAAS app with full configuration +resource "cloudflare_zero_trust_access_application" "saas_saml" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} SAML SAAS" + type = "saas" + + saas_app { + consumer_service_url = "https://saml.${local.app_domain_suffix}/sso/saml" + sp_entity_id = "saml-app-${local.name_prefix}" + name_id_format = "email" + + custom_attribute { + name = "email" + source { + name = "user_email" + } + } + + custom_attribute { + name = "department" + friendly_name = "Department" + source { + name = "department" + } + } + } +} + +# 5. OIDC SAAS app +resource "cloudflare_zero_trust_access_application" "saas_oidc" { + account_id = local.common_account_id + name = "${local.name_prefix} OIDC SAAS" + type = "saas" + + saas_app { + auth_type = "oidc" + app_launcher_url = "https://oidc.${local.app_domain_suffix}/launch" + grant_types = ["authorization_code"] + scopes = ["openid", "email", "profile"] + redirect_uris = ["https://oidc.${local.app_domain_suffix}/callback"] + + custom_claim { + name = "groups" + scope = "groups" + source { + name = "user_groups" + } + } + + hybrid_and_implicit_options { + return_access_token_from_authorization_endpoint = true + return_id_token_from_authorization_endpoint = true + } + + refresh_token_options { + lifetime = "2160h" + } + } +} + +# ============================================================================ +# Pattern Group 3: WARP Applications with Destinations +# ============================================================================ + +# NOTE: WARP applications have special API behavior - they're forced to be +# named "Warp Login App" and don't respect custom configurations. Skipped for e2e tests. +# # 6. WARP app with multiple destinations +# resource "cloudflare_access_application" "warp_multi" { +# account_id = local.common_account_id +# name = "${local.name_prefix} WARP Multi" +# type = "warp" +# +# destinations { +# uri = "https://app1.internal" +# type = "public" +# } +# +# destinations { +# uri = "tcp://10.0.0.0/24:22" +# type = "private" +# } +# +# destinations { +# uri = "udp://192.168.1.0/24:53" +# type = "private" +# } +# } + +# ============================================================================ +# Pattern Group 4: SSH Applications with Target Criteria +# ============================================================================ + +# 9. SSH app (basic) +resource "cloudflare_zero_trust_access_application" "ssh_basic" { + account_id = local.common_account_id + name = "${local.name_prefix} SSH Basic" + type = "ssh" + domain = "ssh-basic.${local.app_domain_suffix}" +} + +# 10. SSH app (multi) +resource "cloudflare_zero_trust_access_application" "ssh_multi_criteria" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} SSH Multi" + type = "ssh" + domain = "ssh-multi.${local.app_domain_suffix}" +} + +# ============================================================================ +# Pattern Group 5: Apps with Landing Page Design +# ============================================================================ + +# NOTE: landing_page_design is not supported in v4 provider +# # 11. App with custom landing page +# resource "cloudflare_access_application" "with_landing_page" { +# account_id = local.common_account_id +# name = "${local.name_prefix} Landing Page" +# domain = "landing.${local.app_domain_suffix}" +# policies = ["landing-policy"] +# +# landing_page_design { +# title = "Welcome to ${local.name_prefix}" +# message = "Please sign in to continue" +# logo_url = "https://logo.${local.app_domain_suffix}/logo.png" +# header_bg_color = "#0051C3" +# body_bg_color = "#FFFFFF" +# footer_links { +# name = "Help Center" +# url = "https://help.${local.app_domain_suffix}" +# } +# footer_links { +# name = "Privacy Policy" +# url = "https://privacy.${local.app_domain_suffix}" +# } +# } +# } +# +# # 12. App with minimal landing page +# resource "cloudflare_access_application" "minimal_landing" { +# account_id = var.cloudflare_account_id +# name = "${local.name_prefix} Minimal Landing" +# domain = "minimal-landing.${local.app_domain_suffix}" +# +# landing_page_design { +# message = "Sign in required" +# } +# } + +# ============================================================================ +# Pattern Group 6: Apps with SCIM Configuration +# ============================================================================ + +# 13. App with SCIM config +# NOTE: Commented out - SCIM requires special account permissions +# resource "cloudflare_access_application" "with_scim" { +# account_id = local.common_account_id +# name = "${local.name_prefix} SCIM" +# domain = "scim.${local.app_domain_suffix}" +# +# scim_config { +# enabled = true +# remote_uri = "https://scim.${local.app_domain_suffix}/v2" +# idp_uid = "email" +# deactivate_on_delete = true +# +# authentication { +# scheme = "httpbasic" +# user = "scim_user" +# password = "scim_password" +# } +# +# mappings { +# schema = "urn:ietf:params:scim:schemas:core:2.0:User" +# enabled = true +# +# operations { +# create = true +# update = true +# delete = true +# } +# } +# } +# } + +# ============================================================================ +# Pattern Group 7: Apps with Custom Pages +# ============================================================================ + +# 14. App with custom pages +# NOTE: Commented out - custom_pages requires real page IDs +# resource "cloudflare_access_application" "with_custom_pages" { +# account_id = var.cloudflare_account_id +# name = "${local.name_prefix} Custom Pages" +# domain = "custom-pages.${local.app_domain_suffix}" +# custom_pages = toset(["custom-forbidden-id", "custom-identity-denied-id"]) +# } + +# ============================================================================ +# Pattern Group 8: Apps with Self Hosted Domains (Deprecated) +# ============================================================================ + +# 15. App with self_hosted_domains (deprecated field) +resource "cloudflare_zero_trust_access_application" "with_self_hosted_domains" { + account_id = local.common_account_id + name = "${local.name_prefix} Self Hosted Domains" + type = "self_hosted" + self_hosted_domains = ["legacy1.${local.app_domain_suffix}", "legacy2.${local.app_domain_suffix}"] +} + +# ============================================================================ +# Pattern Group 9: Apps with Domain Type (Removed Field) +# ============================================================================ + +# 16. App with domain_type field (to be removed in v5) +# NOTE: v4 only supports domain_type = "public" +resource "cloudflare_zero_trust_access_application" "with_domain_type" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Domain Type" + domain = "domain-type.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 10: Meta-Arguments (Count) +# ============================================================================ + +# 17-19. Apps created with count +resource "cloudflare_zero_trust_access_application" "with_count" { + count = 3 + + account_id = local.common_account_id + name = "${local.name_prefix} Count ${count.index}" + domain = "count-${count.index}.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 11: Apps with For Each +# ============================================================================ + +# 20-22. Apps created with for_each +resource "cloudflare_zero_trust_access_application" "with_for_each" { + for_each = toset(["dev", "staging", "prod"]) + + account_id = var.cloudflare_account_id + name = "${local.name_prefix} ${each.key}" + domain = "${each.key}.${local.app_domain_suffix}" + session_duration = each.key == "prod" ? "8h" : "24h" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 12: Complex Nested Structures +# ============================================================================ + +# 23. App with session_duration and cors_headers (compatible with type=self_hosted) +resource "cloudflare_zero_trust_access_application" "complex_nested" { + account_id = local.common_account_id + name = "${local.name_prefix} Complex" + domain = "complex.${local.app_domain_suffix}" + session_duration = "12h" + auto_redirect_to_identity = false + + type = "self_hosted" + cors_headers { + allowed_methods = ["GET", "POST"] + allowed_origins = ["*"] + allow_credentials = false + max_age = 7200 + } +} + +# 24. App with variable references +resource "cloudflare_zero_trust_access_application" "with_var_refs" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Variable Refs" + domain = "var-refs.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 13: Edge Cases +# ============================================================================ + +# 25. App with empty policies array +resource "cloudflare_zero_trust_access_application" "empty_policies" { + account_id = local.common_account_id + name = "${local.name_prefix} Empty Policies" + domain = "empty-policies.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 26. App with only required fields and null optionals +resource "cloudflare_zero_trust_access_application" "sparse" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Sparse" + domain = "sparse.${local.app_domain_suffix}" + type = "self_hosted" +} + +# 27. Conditional app +resource "cloudflare_zero_trust_access_application" "conditional" { + count = var.enable_saas_apps ? 1 : 0 + + account_id = local.common_account_id + name = "${local.name_prefix} Conditional" + domain = "conditional.${local.app_domain_suffix}" + type = "self_hosted" +} + +# ============================================================================ +# Pattern Group 14: Special Characters and Escaping +# ============================================================================ + +# 28. App with special characters (API restricts: ,.!:@?-) +resource "cloudflare_zero_trust_access_application" "special_chars" { + account_id = var.cloudflare_account_id + name = "${local.name_prefix} Special \"Chars\" & 'Quotes'" + domain = "special.${local.app_domain_suffix}" + custom_deny_message = "Access denied - contact support" + type = "self_hosted" +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 694b156..f29bfc4 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -10,9 +10,9 @@ import ( "github.com/cloudflare/tf-migrate/internal/resources/custom_pages" "github.com/cloudflare/tf-migrate/internal/resources/dns_record" "github.com/cloudflare/tf-migrate/internal/resources/healthcheck" - "github.com/cloudflare/tf-migrate/internal/resources/load_balancer_monitor" "github.com/cloudflare/tf-migrate/internal/resources/list" "github.com/cloudflare/tf-migrate/internal/resources/list_item" + "github.com/cloudflare/tf-migrate/internal/resources/load_balancer_monitor" "github.com/cloudflare/tf-migrate/internal/resources/logpull_retention" "github.com/cloudflare/tf-migrate/internal/resources/logpush_job" "github.com/cloudflare/tf-migrate/internal/resources/managed_transforms" @@ -27,6 +27,7 @@ import ( "github.com/cloudflare/tf-migrate/internal/resources/url_normalization_settings" "github.com/cloudflare/tf-migrate/internal/resources/workers_kv" "github.com/cloudflare/tf-migrate/internal/resources/workers_kv_namespace" + "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_application" "github.com/cloudflare/tf-migrate/internal/resources/workers_script" "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_group" "github.com/cloudflare/tf-migrate/internal/resources/zero_trust_access_identity_provider" @@ -78,6 +79,7 @@ func RegisterAllMigrations() { workers_kv.NewV4ToV5Migrator() workers_kv_namespace.NewV4ToV5Migrator() workers_script.NewV4ToV5Migrator() + zero_trust_access_application.NewV4ToV5Migrator() zero_trust_access_group.NewV4ToV5Migrator() zero_trust_access_identity_provider.NewV4ToV5Migrator() zero_trust_access_mtls_hostname_settings.NewV4ToV5Migrator() diff --git a/internal/resources/zero_trust_access_application/v4_to_v5.go b/internal/resources/zero_trust_access_application/v4_to_v5.go new file mode 100644 index 0000000..6bd17d8 --- /dev/null +++ b/internal/resources/zero_trust_access_application/v4_to_v5.go @@ -0,0 +1,852 @@ +package zero_trust_access_application + +import ( + "fmt" + "strconv" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/cloudflare/tf-migrate/internal" + "github.com/cloudflare/tf-migrate/internal/transform" + tfhcl "github.com/cloudflare/tf-migrate/internal/transform/hcl" + "github.com/cloudflare/tf-migrate/internal/transform/state" +) + +type V4ToV5Migrator struct { +} + +func NewV4ToV5Migrator() transform.ResourceTransformer { + migrator := &V4ToV5Migrator{} + + // v4 had both cloudflare_access_application and cloudflare_zero_trust_access_application + internal.RegisterMigrator("cloudflare_access_application", "v4", "v5", migrator) + internal.RegisterMigrator("cloudflare_zero_trust_access_application", "v4", "v5", migrator) + + return migrator +} + +func (m *V4ToV5Migrator) GetResourceType() string { + return "cloudflare_zero_trust_access_application" +} + +func (m *V4ToV5Migrator) CanHandle(resourceType string) bool { + return resourceType == "cloudflare_access_application" || + resourceType == "cloudflare_zero_trust_access_application" +} + +// GetResourceRename implements the ResourceRenamer interface +func (m *V4ToV5Migrator) GetResourceRename() (string, string) { + return "cloudflare_access_application", "cloudflare_zero_trust_access_application" +} + +func (m *V4ToV5Migrator) Preprocess(content string) string { + return content +} + +func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite.Block) (*transform.TransformResult, error) { + // Rename resource type if it's the old name + resourceType := tfhcl.GetResourceType(block) + if resourceType == "cloudflare_access_application" { + tfhcl.RenameResourceType(block, "cloudflare_access_application", "cloudflare_zero_trust_access_application") + } + + body := block.Body() + + // V4 has type default = "self_hosted", default to this value if type is not specified in V4 config + tfhcl.EnsureAttribute(body, "type", "self_hosted") + + tfhcl.RemoveAttributes(body, "domain_type") + + tfhcl.ConvertBlocksToAttribute(body, "cors_headers", "cors_headers", nil) + tfhcl.ConvertBlocksToAttributeList(body, "destinations", nil) + tfhcl.ConvertBlocksToAttributeList(body, "footer_links", nil) + tfhcl.ConvertBlocksToAttribute(body, "landing_page_design", "landing_page_design", nil) + + tfhcl.ConvertArrayAttributeToObjectArray(body, "policies", func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "id": element, + "precedence": { + &hclwrite.Token{ + Type: hclsyntax.TokenNumberLit, + Bytes: []byte(strconv.Itoa(index + 1)), + }, + }, + } + }) + + tfhcl.RemoveFunctionWrapper(body, "allowed_idps", "toset") + tfhcl.RemoveFunctionWrapper(body, "custom_pages", "toset") + tfhcl.RemoveFunctionWrapper(body, "self_hosted_domains", "toset") + + m.transformSaasAppBlock(body) + m.transformScimConfigBlock(body) + m.transformTargetCriteriaBlocks(body) + + return &transform.TransformResult{ + Blocks: []*hclwrite.Block{block}, + RemoveOriginal: false, + }, nil +} + +func (m *V4ToV5Migrator) transformSaasAppBlock(body *hclwrite.Body) { + saasAppBlocks := tfhcl.FindBlocksByType(body, "saas_app") + if len(saasAppBlocks) == 0 { + return + } + + for _, saasAppBlock := range saasAppBlocks { + saasAppBody := saasAppBlock.Body() + + // Process custom_attribute blocks before converting to list + customAttrBlocks := tfhcl.FindBlocksByType(saasAppBody, "custom_attribute") + for _, customAttrBlock := range customAttrBlocks { + customAttrBody := customAttrBlock.Body() + // Convert source block + if sourceBlock := tfhcl.FindBlockByType(customAttrBody, "source"); sourceBlock != nil { + sourceBody := sourceBlock.Body() + // Convert source.name_by_idp from map to object array (SAML) + tfhcl.ConvertMapAttributeToObjectArray(sourceBody, "name_by_idp", func(key hclwrite.Tokens, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "idp_id": key, + "source_name": value, + } + }) + } + + tfhcl.ConvertSingleBlockToAttribute(customAttrBody, "source", "source") + } + + // Process custom_claim blocks before converting to list + customClaimBlocks := tfhcl.FindBlocksByType(saasAppBody, "custom_claim") + for _, customClaimBlock := range customClaimBlocks { + customClaimBody := customClaimBlock.Body() + // Convert source block to attribute + // NOTE: For custom_claims (OIDC), name_by_idp stays as a map, so no transformation needed + tfhcl.ConvertSingleBlockToAttribute(customClaimBody, "source", "source") + } + + tfhcl.ConvertBlocksToAttributeList(saasAppBody, "custom_attribute", nil) + tfhcl.RenameAttribute(saasAppBody, "custom_attribute", "custom_attributes") + + tfhcl.ConvertBlocksToAttributeList(saasAppBody, "custom_claim", nil) + tfhcl.RenameAttribute(saasAppBody, "custom_claim", "custom_claims") + + tfhcl.ConvertSingleBlockToAttribute(saasAppBody, "hybrid_and_implicit_options", "hybrid_and_implicit_options") + tfhcl.ConvertSingleBlockToAttribute(saasAppBody, "refresh_token_options", "refresh_token_options") + } + + tfhcl.ConvertSingleBlockToAttribute(body, "saas_app", "saas_app") +} + +func (m *V4ToV5Migrator) transformScimConfigBlock(body *hclwrite.Body) { + scimConfigBlocks := tfhcl.FindBlocksByType(body, "scim_config") + if len(scimConfigBlocks) == 0 { + return + } + + for _, scimConfigBlock := range scimConfigBlocks { + scimConfigBody := scimConfigBlock.Body() + + // Process authentication block + if authBlock := tfhcl.FindBlockByType(scimConfigBody, "authentication"); authBlock != nil { + authBody := authBlock.Body() + // Convert toset() for scopes attribute + tfhcl.RemoveFunctionWrapper(authBody, "scopes", "toset") + } + + // Convert authentication block to attribute + tfhcl.ConvertSingleBlockToAttribute(scimConfigBody, "authentication", "authentication") + + // Process mappings blocks + mappingsBlocks := tfhcl.FindBlocksByType(scimConfigBody, "mappings") + for _, mappingBlock := range mappingsBlocks { + mappingBody := mappingBlock.Body() + // Convert operations block to attribute + tfhcl.ConvertSingleBlockToAttribute(mappingBody, "operations", "operations") + } + + // Convert mappings blocks to list attribute + tfhcl.ConvertBlocksToAttributeList(scimConfigBody, "mappings", nil) + } + + // Convert scim_config block to attribute + tfhcl.ConvertSingleBlockToAttribute(body, "scim_config", "scim_config") +} + +func (m *V4ToV5Migrator) transformTargetCriteriaBlocks(body *hclwrite.Body) { + // Get all target_criteria blocks + targetCriteriaBlocks := tfhcl.FindBlocksByType(body, "target_criteria") + + // Convert nested target_attributes blocks within each target_criteria block to a map + for _, tcBlock := range targetCriteriaBlocks { + tcBody := tcBlock.Body() + // Convert target_attributes blocks to map attribute + m.convertTargetAttributesToMap(tcBody) + } + + // Then convert the outer target_criteria blocks to list attribute + tfhcl.ConvertBlocksToAttributeList(body, "target_criteria", nil) +} + +// convertTargetAttributesToMap converts target_attributes blocks to a map attribute +// where keys are the "name" values and values are the "values" arrays +func (m *V4ToV5Migrator) convertTargetAttributesToMap(body *hclwrite.Body) { + targetAttrBlocks := tfhcl.FindBlocksByType(body, "target_attributes") + if len(targetAttrBlocks) == 0 { + return + } + + // Build map tokens + var mapTokens hclwrite.Tokens + + // Opening brace + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenOBrace, + Bytes: []byte("{"), + }) + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + + // Process each target_attributes block + for _, block := range targetAttrBlocks { + blockBody := block.Body() + + // Get the name attribute (the map key) + nameAttr := blockBody.GetAttribute("name") + if nameAttr == nil { + continue + } + + // Get the values attribute (the map value) + valuesAttr := blockBody.GetAttribute("values") + if valuesAttr == nil { + continue + } + + // Add indentation + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenIdent, + Bytes: []byte(" "), + }) + + // Add the key (name value as a quoted string) + nameTokens := nameAttr.Expr().BuildTokens(nil) + mapTokens = append(mapTokens, nameTokens...) + + // Add equals sign + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenEqual, + Bytes: []byte(" = "), + }) + + // Add the value (values array) + valuesTokens := valuesAttr.Expr().BuildTokens(nil) + mapTokens = append(mapTokens, valuesTokens...) + + // Add newline + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + } + + // Closing brace + mapTokens = append(mapTokens, &hclwrite.Token{ + Type: hclsyntax.TokenCBrace, + Bytes: []byte("}"), + }) + + // Set the map attribute + body.SetAttributeRaw("target_attributes", mapTokens) + + // Remove the original blocks + for _, block := range targetAttrBlocks { + body.RemoveBlock(block) + } +} + +func (m *V4ToV5Migrator) TransformState(ctx *transform.Context, stateJSON gjson.Result, resourcePath, resourceName string) (string, error) { + result := stateJSON.String() + + // Handle both full state and single instance transformation + if stateJSON.Get("resources").Exists() { + return m.transformFullState(result, stateJSON, ctx) + } + + if !stateJSON.Exists() || !stateJSON.Get("attributes").Exists() { + return result, nil + } + + // Rename resource type if it's the old name (for single instance tests) + resourceType := stateJSON.Get("type").String() + if resourceType == "cloudflare_access_application" { + result, _ = sjson.Set(result, "type", "cloudflare_zero_trust_access_application") + } + + result = m.transformSingleInstance(result, stateJSON, ctx, resourcePath, resourceName) + + return result, nil +} + +func (m *V4ToV5Migrator) transformFullState(result string, stateJSON gjson.Result, ctx *transform.Context) (string, error) { + resources := stateJSON.Get("resources") + if !resources.Exists() { + return result, nil + } + + resources.ForEach(func(key, resource gjson.Result) bool { + resourceType := resource.Get("type").String() + + if !m.CanHandle(resourceType) { + return true // continue + } + + // Rename cloudflare_access_application to cloudflare_zero_trust_access_application + if resourceType == "cloudflare_access_application" { + resourcePath := "resources." + key.String() + ".type" + result, _ = sjson.Set(result, resourcePath, "cloudflare_zero_trust_access_application") + } + + resourceName := resource.Get("name").String() + instances := resource.Get("instances") + instances.ForEach(func(instKey, instance gjson.Result) bool { + instPath := "resources." + key.String() + ".instances." + instKey.String() + resourcePath := "resources." + key.String() + + attrs := instance.Get("attributes") + if attrs.Exists() { + instJSON := instance.String() + transformedInst := m.transformSingleInstance(instJSON, instance, ctx, resourcePath, resourceName) + transformedInstParsed := gjson.Parse(transformedInst) + result, _ = sjson.SetRaw(result, instPath, transformedInstParsed.Raw) + } + return true + }) + + return true + }) + + return result, nil +} + +func (m *V4ToV5Migrator) transformSingleInstance(result string, instance gjson.Result, ctx *transform.Context, resourcePath, resourceName string) string { + attrs := instance.Get("attributes") + + if !attrs.Exists() { + return result + } + + // Debug: Log CFGFiles status + if ctx != nil { + fmt.Printf("DEBUG transformSingleInstance: resource=%s, CFGFiles count=%d\n", resourceName, len(ctx.CFGFiles)) + if len(ctx.CFGFiles) > 0 { + fmt.Printf("DEBUG transformSingleInstance: CFGFiles keys: ") + for k := range ctx.CFGFiles { + fmt.Printf("%s, ", k) + } + fmt.Printf("\n") + } + } else { + fmt.Printf("DEBUG transformSingleInstance: ctx is nil for resource=%s\n", resourceName) + } + + // If ctx is nil, create an empty context so transformations can still run + if ctx == nil { + ctx = &transform.Context{ + Diagnostics: make(hcl.Diagnostics, 0), + } + } + + attrPath := "attributes" + + // Set default type to "self_hosted" if not present (V4 schema default) + result = state.EnsureField(result, attrPath, attrs, "type", "self_hosted") + + // Remove deprecated domain_type attribute + result = state.RemoveFields(result, attrPath, attrs, "domain_type") + + // Apply transformations in logical order + result = m.transformSetToListFields(result, attrs, attrPath) + result = m.transformCoorsHeaders(result, attrs, attrPath, ctx, resourceName) + result = m.transformLandingPageDesign(result, attrs, attrPath, ctx, resourceName) + result = m.transformSaasApp(result, attrs, attrPath, ctx, resourceName) + result = m.transformScimConfig(result, attrs, attrPath, ctx, resourceName) + result = m.transformPolicies(result, attrs, attrPath) + result = m.transformTargetCriteria(result, attrs, attrPath) + result = m.transformDestinations(result, attrPath) + + // Transform empty values to null for top-level attributes not explicitly set in config + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath, + FieldResult: gjson.Parse(result).Get(attrPath), + ResourceName: resourceName, + HCLAttributePath: "", + CanHandle: m.CanHandle, + }) + + // Always set schema_version + result, _ = sjson.Set(result, "schema_version", 0) + + return result +} + +// transformSetToListFields transforms set-typed fields to list-typed fields +func (m *V4ToV5Migrator) transformSetToListFields(result string, attrs gjson.Result, attrPath string) string { + // Transform allowed_idps from set to list (same values, different type metadata) + allowedIdPs := attrs.Get("allowed_idps") + if allowedIdPs.Exists() { + result, _ = sjson.Set(result, attrPath+".allowed_idps", allowedIdPs.Value()) + } + + // Transform custom_pages from set to list (same values, different type metadata) + customPages := attrs.Get("custom_pages") + if customPages.Exists() { + result, _ = sjson.Set(result, attrPath+".custom_pages", customPages.Value()) + } + + // Transform self_hosted_domains from set to list (same values, different type metadata) + selfHostedDomains := attrs.Get("self_hosted_domains") + if selfHostedDomains.Exists() { + result, _ = sjson.Set(result, attrPath+".self_hosted_domains", selfHostedDomains.Value()) + } + + return result +} + +func (m *V4ToV5Migrator) transformCoorsHeaders(result string, attrs gjson.Result, attrPath string, ctx *transform.Context, resourceName string) string { + // First, check if cors_headers field should be null based on HCL config + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath, + FieldResult: attrs, + ResourceName: resourceName, + HCLAttributePath: "cors_headers", + CanHandle: m.CanHandle, + }) + + // Re-parse to check if field still exists after transformation + attrs = gjson.Parse(result).Get(attrPath) + + // Transform cors_headers from array format to object format (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath, attrs, "cors_headers", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + transformedCorsHeaders := gjson.Parse(result).Get(attrPath + ".cors_headers") + if transformedCorsHeaders.Exists() { + maxAge := transformedCorsHeaders.Get("max_age") + if maxAge.Exists() { + result, _ = sjson.Set(result, attrPath+".cors_headers.max_age", state.ConvertToFloat64(maxAge)) + } + + // Transform empty values within cors_headers nested fields + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".cors_headers", + FieldResult: gjson.Parse(result).Get(attrPath + ".cors_headers"), + ResourceName: resourceName, + HCLAttributePath: "cors_headers", + CanHandle: m.CanHandle, + }) + } + + return result +} + +func (m *V4ToV5Migrator) transformLandingPageDesign(result string, attrs gjson.Result, attrPath string, ctx *transform.Context, resourceName string) string { + // First, check if landing_page_design field should be null based on HCL config + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath, + FieldResult: attrs, + ResourceName: resourceName, + HCLAttributePath: "landing_page_design", + CanHandle: m.CanHandle, + }) + + // Re-parse to check if field still exists after transformation + attrs = gjson.Parse(result).Get(attrPath) + + // Transform landing_page_design from array format to object format (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath, attrs, "landing_page_design", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + // Add default: landing_page_design.title = "Welcome!" if not present (only when landing_page_design exists) + transformedLandingPage := gjson.Parse(result).Get(attrPath + ".landing_page_design") + if transformedLandingPage.Exists() && transformedLandingPage.IsObject() { + result = state.EnsureField(result, attrPath+".landing_page_design", transformedLandingPage, "title", "Welcome!") + + // Transform empty values within landing_page_design nested fields + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".landing_page_design", + FieldResult: gjson.Parse(result).Get(attrPath + ".landing_page_design"), + ResourceName: resourceName, + HCLAttributePath: "landing_page_design", + CanHandle: m.CanHandle, + }) + } + + return result +} + +// transformSaasApp transforms the saas_app block and its nested structures +func (m *V4ToV5Migrator) transformSaasApp(result string, attrs gjson.Result, attrPath string, ctx *transform.Context, resourceName string) string { + // First, check if saas_app field should be null based on HCL config + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath, + FieldResult: attrs, + ResourceName: resourceName, + HCLAttributePath: "saas_app", + CanHandle: m.CanHandle, + }) + + // Re-parse to check if field still exists after transformation + attrs = gjson.Parse(result).Get(attrPath) + + // Transform saas_app from array format to object format (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath, attrs, "saas_app", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + // Transform nested MaxItems:1 fields within saas_app + transformedSaasApp := gjson.Parse(result).Get(attrPath + ".saas_app") + if transformedSaasApp.Exists() { + // First check if hybrid_and_implicit_options should be null + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".saas_app", + FieldResult: transformedSaasApp, + ResourceName: resourceName, + HCLAttributePath: "saas_app.hybrid_and_implicit_options", + CanHandle: m.CanHandle, + }) + + // Re-parse after transformation + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + + // Transform hybrid_and_implicit_options from array to object (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath+".saas_app", transformedSaasApp, "hybrid_and_implicit_options", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + // Transform empty values within hybrid_and_implicit_options nested fields + transformedHybrid := gjson.Parse(result).Get(attrPath + ".saas_app.hybrid_and_implicit_options") + if transformedHybrid.Exists() && transformedHybrid.IsObject() { + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".saas_app.hybrid_and_implicit_options", + FieldResult: transformedHybrid, + ResourceName: resourceName, + HCLAttributePath: "saas_app.hybrid_and_implicit_options", + CanHandle: m.CanHandle, + }) + } + + // First check if refresh_token_options should be null + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".saas_app", + FieldResult: transformedSaasApp, + ResourceName: resourceName, + HCLAttributePath: "saas_app.refresh_token_options", + CanHandle: m.CanHandle, + }) + + // Re-parse after transformation + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + + // Transform refresh_token_options from array to object (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath+".saas_app", transformedSaasApp, "refresh_token_options", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + // Transform empty values within refresh_token_options nested fields + transformedRefresh := gjson.Parse(result).Get(attrPath + ".saas_app.refresh_token_options") + if transformedRefresh.Exists() && transformedRefresh.IsObject() { + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".saas_app.refresh_token_options", + FieldResult: transformedRefresh, + ResourceName: resourceName, + HCLAttributePath: "saas_app.refresh_token_options", + CanHandle: m.CanHandle, + }) + } + + // Transform custom_attribute[].source from array to object for each item + customAttrs := transformedSaasApp.Get("custom_attribute") + if customAttrs.Exists() && customAttrs.IsArray() { + customAttrs.ForEach(func(idx, item gjson.Result) bool { + itemPath := attrPath + ".saas_app.custom_attribute." + idx.String() + result = state.TransformFieldArrayToObject(result, itemPath, item, "source", state.ArrayToObjectOptions{}) + return true + }) + + // Transform custom_attributes[].source.name_by_idp from map to list of objects (SAML) + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + customAttrs = transformedSaasApp.Get("custom_attribute") + if customAttrs.Exists() && customAttrs.IsArray() { + customAttrs.ForEach(func(idx, item gjson.Result) bool { + nameByIdp := item.Get("source.name_by_idp") + if nameByIdp.Exists() && nameByIdp.IsObject() { + // Convert map to array of objects with idp_id and source_name + var nameByIdpArray []map[string]interface{} + nameByIdp.ForEach(func(key, value gjson.Result) bool { + nameByIdpArray = append(nameByIdpArray, map[string]interface{}{ + "idp_id": key.String(), + "source_name": value.String(), + }) + return true + }) + result, _ = sjson.Set(result, attrPath+".saas_app.custom_attribute."+idx.String()+".source.name_by_idp", nameByIdpArray) + } + return true + }) + } + + // Rename custom_attribute to custom_attributes (plural) + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + result = state.RenameField(result, attrPath+".saas_app", transformedSaasApp, "custom_attribute", "custom_attributes") + } + + // Transform custom_claim[].source from array to object for each item + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + customClaims := transformedSaasApp.Get("custom_claim") + if customClaims.Exists() && customClaims.IsArray() { + customClaims.ForEach(func(idx, item gjson.Result) bool { + itemPath := attrPath + ".saas_app.custom_claim." + idx.String() + result = state.TransformFieldArrayToObject(result, itemPath, item, "source", state.ArrayToObjectOptions{}) + return true + }) + // Rename custom_claim to custom_claims (plural) + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + result = state.RenameField(result, attrPath+".saas_app", transformedSaasApp, "custom_claim", "custom_claims") + } + + // Add default: saas_app.auth_type = "saml" if not present + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + if transformedSaasApp.Exists() && transformedSaasApp.IsObject() { + result = state.EnsureField(result, attrPath+".saas_app", transformedSaasApp, "auth_type", "saml") + } + + // Transform empty values within saas_app (top level of saas_app) + transformedSaasApp = gjson.Parse(result).Get(attrPath + ".saas_app") + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".saas_app", + FieldResult: transformedSaasApp, + ResourceName: resourceName, + HCLAttributePath: "saas_app", + CanHandle: m.CanHandle, + }) + } + + return result +} + +// transformScimConfig transforms the scim_config block and its nested structures +func (m *V4ToV5Migrator) transformScimConfig(result string, attrs gjson.Result, attrPath string, ctx *transform.Context, resourceName string) string { + // First, check if scim_config field should be null based on HCL config + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath, + FieldResult: attrs, + ResourceName: resourceName, + HCLAttributePath: "scim_config", + CanHandle: m.CanHandle, + }) + + // Re-parse to check if field still exists after transformation + attrs = gjson.Parse(result).Get(attrPath) + + // Transform scim_config from array format to object format (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath, attrs, "scim_config", state.ArrayToObjectOptions{ + TransformEmptyToNull: true, + }) + + // Transform nested MaxItems:1 fields within scim_config + transformedScimConfig := gjson.Parse(result).Get(attrPath + ".scim_config") + if transformedScimConfig.Exists() && transformedScimConfig.IsObject() { + // First check if authentication should be null + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".scim_config", + FieldResult: transformedScimConfig, + ResourceName: resourceName, + HCLAttributePath: "scim_config.authentication", + CanHandle: m.CanHandle, + }) + + // Re-parse after transformation + transformedScimConfig = gjson.Parse(result).Get(attrPath + ".scim_config") + + // Transform authentication from array to object (if it still exists) + result = state.TransformFieldArrayToObject(result, attrPath+".scim_config", transformedScimConfig, "authentication", state.ArrayToObjectOptions{}) + + // Transform empty values within authentication nested fields + transformedAuth := gjson.Parse(result).Get(attrPath + ".scim_config.authentication") + if transformedAuth.Exists() && transformedAuth.IsObject() { + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".scim_config.authentication", + FieldResult: transformedAuth, + ResourceName: resourceName, + HCLAttributePath: "scim_config.authentication", + CanHandle: m.CanHandle, + }) + } + + // Transform mappings[].operations from array to object for each mapping + transformedScimConfig = gjson.Parse(result).Get(attrPath + ".scim_config") + mappings := transformedScimConfig.Get("mappings") + if mappings.Exists() && mappings.IsArray() { + mappings.ForEach(func(idx, mapping gjson.Result) bool { + itemPath := attrPath + ".scim_config.mappings." + idx.String() + + // First check if operations should be null + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: itemPath, + FieldResult: gjson.Parse(result).Get(itemPath), + ResourceName: resourceName, + HCLAttributePath: "scim_config.mappings.operations", + CanHandle: m.CanHandle, + }) + + // Re-parse after transformation + mapping = gjson.Parse(result).Get(itemPath) + + // Transform operations from array to object (if it still exists) + result = state.TransformFieldArrayToObject(result, itemPath, mapping, "operations", state.ArrayToObjectOptions{}) + + // Transform empty values within operations nested fields + transformedOps := gjson.Parse(result).Get(itemPath + ".operations") + if transformedOps.Exists() && transformedOps.IsObject() { + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: itemPath + ".operations", + FieldResult: transformedOps, + ResourceName: resourceName, + HCLAttributePath: "scim_config.mappings.operations", + CanHandle: m.CanHandle, + }) + } + return true + }) + } + + // Transform empty values within scim_config (top level of scim_config) + transformedScimConfig = gjson.Parse(result).Get(attrPath + ".scim_config") + result = transform.TransformEmptyValuesToNull(transform.TransformEmptyValuesToNullOptions{ + Ctx: ctx, + Result: result, + FieldPath: attrPath + ".scim_config", + FieldResult: transformedScimConfig, + ResourceName: resourceName, + HCLAttributePath: "scim_config", + CanHandle: m.CanHandle, + }) + } + + return result +} + +// transformPolicies transforms the policies field from string list to object list +func (m *V4ToV5Migrator) transformPolicies(result string, attrs gjson.Result, attrPath string) string { + // Transform policies from simple string list to complex object list + policies := attrs.Get("policies") + if policies.IsArray() { + var transformedPolicies []interface{} + policies.ForEach(func(idx, policy gjson.Result) bool { + if policy.Type == gjson.String { + // Convert string policy ID to object with id and precedence fields + transformedPolicies = append(transformedPolicies, map[string]interface{}{ + "id": policy.String(), + "precedence": idx.Int() + 1, + }) + } else { + // Keep as-is if already an object + transformedPolicies = append(transformedPolicies, policy.Value()) + } + return true + }) + if len(transformedPolicies) > 0 { + result, _ = sjson.Set(result, attrPath+".policies", transformedPolicies) + } + } + + return result +} + +// transformTargetCriteria transforms target_criteria nested structures +func (m *V4ToV5Migrator) transformTargetCriteria(result string, attrs gjson.Result, attrPath string) string { + // Transform target_criteria[].target_attributes from array of {name, values} to map + targetCriteria := attrs.Get("target_criteria") + if targetCriteria.Exists() && targetCriteria.IsArray() { + targetCriteria.ForEach(func(criteriaIdx, criteria gjson.Result) bool { + targetAttrs := criteria.Get("target_attributes") + if targetAttrs.Exists() && targetAttrs.IsArray() { + // Build map from array of {name, values} objects + attrMap := make(map[string]interface{}) + targetAttrs.ForEach(func(_, attr gjson.Result) bool { + name := attr.Get("name") + values := attr.Get("values") + if name.Exists() && values.Exists() { + attrMap[name.String()] = values.Value() + } + return true + }) + // Replace the array with the map + if len(attrMap) > 0 { + result, _ = sjson.Set(result, attrPath+".target_criteria."+criteriaIdx.String()+".target_attributes", attrMap) + } + } + return true + }) + } + + return result +} + +// transformDestinations adds default values to destinations +func (m *V4ToV5Migrator) transformDestinations(result string, attrPath string) string { + // Add default: destinations[].type = "public" if not present + destinations := gjson.Parse(result).Get(attrPath + ".destinations") + if destinations.Exists() && destinations.IsArray() { + destinations.ForEach(func(idx, dest gjson.Result) bool { + if !dest.Get("type").Exists() { + result, _ = sjson.Set(result, attrPath+".destinations."+idx.String()+".type", "public") + } + return true + }) + } + + return result +} diff --git a/internal/resources/zero_trust_access_application/v4_to_v5_test.go b/internal/resources/zero_trust_access_application/v4_to_v5_test.go new file mode 100644 index 0000000..915dfa2 --- /dev/null +++ b/internal/resources/zero_trust_access_application/v4_to_v5_test.go @@ -0,0 +1,3243 @@ +package zero_trust_access_application + +import ( + "testing" + + "github.com/cloudflare/tf-migrate/internal/testhelpers" +) + +func TestV4ToV5Transformation(t *testing.T) { + migrator := NewV4ToV5Migrator() + + t.Run("ConfigTransformation", func(t *testing.T) { + tests := []testhelpers.ConfigTestCase{ + // Migration V1 Tests + { + Name: "transform policies from list of strings to list of objects", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + cloudflare_zero_trust_access_policy.allow.id, + cloudflare_zero_trust_access_policy.deny.id + ] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + { + id = cloudflare_zero_trust_access_policy.allow.id + precedence = 1 + }, + { + id = cloudflare_zero_trust_access_policy.deny.id + precedence = 2 + } + ] +}`, + }, + { + Name: "transform policies with literal IDs", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = ["policy-id-1", "policy-id-2"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + { + id = "policy-id-1" + precedence = 1 + }, + { + id = "policy-id-2" + precedence = 2 + } + ] +}`, + }, + { + Name: "mixed references and literals", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + cloudflare_zero_trust_access_policy.allow.id, + "literal-policy-id", + cloudflare_zero_trust_access_policy.deny.id + ] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + { + id = cloudflare_zero_trust_access_policy.allow.id + precedence = 1 + }, + { + id = "literal-policy-id" + precedence = 2 + }, + { + id = cloudflare_zero_trust_access_policy.deny.id + precedence = 3 + } + ] +}`, + }, + { + Name: "handle old resource name references", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + cloudflare_access_policy.old_style.id + ] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + + policies = [ + { + id = cloudflare_access_policy.old_style.id + precedence = 1 + } + ] +}`, + }, + { + Name: "remove domain_type attribute", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + domain_type = "public" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "remove domain_type with other attributes preserved", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + domain_type = "public" + session_duration = "24h" + + cors_headers { + allow_all_origins = true + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + session_duration = "24h" + + type = "self_hosted" + cors_headers = { + allow_all_origins = true + } +}`, + }, + { + Name: "no domain_type to remove", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "convert single destinations block to list attribute", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = "https://example.com" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = "https://example.com" + } + ] +}`, + }, + { + Name: "convert multiple destinations blocks to list attribute", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = "https://example.com" + } + + destinations { + uri = "tcp://db.example.com:5432" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = "https://example.com" + }, + { + uri = "tcp://db.example.com:5432" + } + ] +}`, + }, + { + Name: "destinations block with multiple attributes", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = "https://app.example.com" + description = "Main application" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = "https://app.example.com" + description = "Main application" + } + ] +}`, + }, + { + Name: "no destinations blocks - no change", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "destinations blocks with variable references", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = var.app_uri + } + + destinations { + uri = local.db_connection + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = var.app_uri + }, + { + uri = local.db_connection + } + ] +}`, + }, + { + Name: "combined domain_type removal and destinations conversion", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + domain_type = "public" + + destinations { + uri = "https://example.com" + } + + policies = ["policy-id-1", "policy-id-2"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + policies = [ + { + id = "policy-id-1" + precedence = 1 + }, + { + id = "policy-id-2" + precedence = 2 + } + ] + destinations = [ + { + uri = "https://example.com" + } + ] +}`, + }, + { + Name: "all transformations together with allowed_idps", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + domain_type = "public" + allowed_idps = toset(["idp-1", "idp-2"]) + + destinations { + uri = "https://example.com" + } + + destinations { + uri = "tcp://db.example.com:5432" + } + + policies = [ + cloudflare_zero_trust_access_policy.allow.id, + "literal-policy-id" + ] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + allowed_idps = ["idp-1", "idp-2"] + + policies = [ + { + id = cloudflare_zero_trust_access_policy.allow.id + precedence = 1 + }, + { + id = "literal-policy-id" + precedence = 2 + } + ] + destinations = [ + { + uri = "https://example.com" + }, + { + uri = "tcp://db.example.com:5432" + } + ] +}`, + }, + { + Name: "transform toset to list for allowed_idps", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + allowed_idps = toset(["idp-1", "idp-2", "idp-3"]) +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + allowed_idps = ["idp-1", "idp-2", "idp-3"] +}`, + }, + { + Name: "handle already list format for allowed_idps", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + allowed_idps = ["idp-1", "idp-2"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + allowed_idps = ["idp-1", "idp-2"] +}`, + }, + { + Name: "transform toset for custom_pages", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + custom_pages = toset(["page1", "page2"]) +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + custom_pages = ["page1", "page2"] +}`, + }, + { + Name: "transform toset for self_hosted_domains", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + self_hosted_domains = toset(["page1", "page2"]) +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + self_hosted_domains = ["page1", "page2"] +}`, + }, + { + Name: "empty policies array", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + policies = [] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" + policies = [] +}`, + }, + { + Name: "destinations with expressions", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = format("https://%s.example.com", var.subdomain) + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = format("https://%s.example.com", var.subdomain) + } + ] +}`, + }, + { + Name: "destinations with conditional expression", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = var.use_ssl ? "https://app.example.com" : "http://app.example.com" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = var.use_ssl ? "https://app.example.com" : "http://app.example.com" + } + ] +}`, + }, + { + Name: "destinations block without uri", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + description = "Test destination" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + description = "Test destination" + } + ] +}`, + }, + { + Name: "empty destinations block", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + {} + ] +}`, + }, + { + Name: "multiple destinations with mixed content", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = "https://app1.example.com" + description = "Primary app" + } + + destinations { + } + + destinations { + uri = "tcp://db.example.com:3306" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = "https://app1.example.com" + description = "Primary app" + }, + {}, + { + uri = "tcp://db.example.com:3306" + } + ] +}`, + }, + // New Tests + { + Name: "Minimal resource with old name", + Input: `resource "cloudflare_access_application" "rename" { + account_id = "f037e56e89293a057740de681ac9abbe" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "rename" { + account_id = "f037e56e89293a057740de681ac9abbe" + type = "self_hosted" +}`, + }, + { + Name: "Minimal resource with new name - no change", + Input: `resource "cloudflare_zero_trust_access_application" "no_rename" { + account_id = "f037e56e89293a057740de681ac9abbe" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "no_rename" { + account_id = "f037e56e89293a057740de681ac9abbe" + type = "self_hosted" +}`, + }, + { + Name: "Minimal resource with basic, unchanged fields", + Input: `resource "cloudflare_access_application" "basic" { + account_id = "1234" + allow_authenticate_via_warp = true + app_launcher_logo_url = true + app_launcher_visible = true + auto_redirect_to_identity = true + bg_color = "#000000" + custom_deny_message = "message" + custom_deny_url = "www.example.com" + custom_non_identity_deny_url = "www.example.com" + domain = "test.example.com/admin" + enable_binding_cookie = true + header_bg_color = "#000000" + http_only_cookie_attribute = true + logo_url = "www.example.com" + name = "test" + options_preflight_bypass = true + same_site_cookie_attribute = "strict" + service_auth_401_redirect = true + session_duration = "24h" + skip_app_launcher_login_page = true + skip_interstitial = true + type = "self_hosted" + zone_id = "1234" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "basic" { + account_id = "1234" + allow_authenticate_via_warp = true + app_launcher_logo_url = true + app_launcher_visible = true + auto_redirect_to_identity = true + bg_color = "#000000" + custom_deny_message = "message" + custom_deny_url = "www.example.com" + custom_non_identity_deny_url = "www.example.com" + domain = "test.example.com/admin" + enable_binding_cookie = true + header_bg_color = "#000000" + http_only_cookie_attribute = true + logo_url = "www.example.com" + name = "test" + options_preflight_bypass = true + same_site_cookie_attribute = "strict" + service_auth_401_redirect = true + session_duration = "24h" + skip_app_launcher_login_page = true + skip_interstitial = true + type = "self_hosted" + zone_id = "1234" +}`, + }, + { + Name: "Minimal resource with string set, unchanged fields", + Input: `resource "cloudflare_zero_trust_access_application" "basic_string_set" { + account_id = "1234" + type = "self_hosted" + allowed_idps = ["1234", "5678"] + custom_pages = ["1234", "5678"] + self_hosted_domains = ["1234", "5678"] + tags = ["1234", "5678"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "basic_string_set" { + account_id = "1234" + type = "self_hosted" + allowed_idps = ["1234", "5678"] + custom_pages = ["1234", "5678"] + self_hosted_domains = ["1234", "5678"] + tags = ["1234", "5678"] +}`, + }, + { + Name: "Remove domain_type attribute", + Input: `resource "cloudflare_zero_trust_access_application" "remove_domain_type" { + account_id = "1234" + type = "self_hosted" + domain_type = "public" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "remove_domain_type" { + account_id = "1234" + type = "self_hosted" +}`, + }, + { + Name: "Resource with cors_headers", + Input: `resource "cloudflare_zero_trust_access_application" "cors_headers" { + account_id = "1234" + type = "self_hosted" + cors_headers { + allow_all_headers = true + allow_all_methods = true + allow_all_origins = true + allow_credentials = true + allowed_headers = ["string"] + allowed_methods = ["GET"] + allowed_origins = ["https://example.com"] + max_age = 1 + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "cors_headers" { + account_id = "1234" + type = "self_hosted" + cors_headers = { + allow_all_headers = true + allow_all_methods = true + allow_all_origins = true + allow_credentials = true + allowed_headers = ["string"] + allowed_methods = ["GET"] + allowed_origins = ["https://example.com"] + max_age = 1 + } +}`, + }, + { + Name: "Resource with single destinations attribute", + Input: `resource "cloudflare_zero_trust_access_application" "single_destinations" { + account_id = "1234" + type = "self_hosted" + destinations { + cidr = "10.5.0.0/24" + hostname = "hostname" + l4_protocol = "tcp" + port_range = "80-90" + type = "private" + vnet_id = "vnet_id" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "single_destinations" { + account_id = "1234" + type = "self_hosted" + destinations = [ + { + cidr = "10.5.0.0/24" + hostname = "hostname" + l4_protocol = "tcp" + port_range = "80-90" + type = "private" + vnet_id = "vnet_id" + } + ] +}`, + }, + { + Name: "Resource with multiple destinations attributes", + Input: `resource "cloudflare_zero_trust_access_application" "multiple_destinations" { + account_id = "1234" + type = "self_hosted" + destinations { + type = "public" + uri = "test.example.com/admin" + } + destinations { + cidr = "10.5.0.0/24" + hostname = "hostname" + l4_protocol = "tcp" + port_range = "80-90" + type = "private" + vnet_id = "vnet_id" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "multiple_destinations" { + account_id = "1234" + type = "self_hosted" + destinations = [ + { + type = "public" + uri = "test.example.com/admin" + }, + { + cidr = "10.5.0.0/24" + hostname = "hostname" + l4_protocol = "tcp" + port_range = "80-90" + type = "private" + vnet_id = "vnet_id" + } + ] +}`, + }, + { + Name: "Resource with footer_links", + Input: `resource "cloudflare_zero_trust_access_application" "footer_links" { + account_id = "1234" + type = "app_launcher" + footer_links { + name = "Privacy Policy" + url = "https://example.com/privacy" + } + footer_links { + name = "Terms" + url = "https://example.com/terms" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "footer_links" { + account_id = "1234" + type = "app_launcher" + footer_links = [ + { + name = "Privacy Policy" + url = "https://example.com/privacy" + }, + { + name = "Terms" + url = "https://example.com/terms" + } + ] +}`, + }, + { + Name: "Resource with landing_page_design", + Input: `resource "cloudflare_zero_trust_access_application" "landing_page_design" { + account_id = "1234" + type = "self_hosted" + landing_page_design { + button_color = "#000000" + button_text_color = "#000000" + image_url = "example.com" + message = "message" + title = "title" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "landing_page_design" { + account_id = "1234" + type = "self_hosted" + landing_page_design = { + button_color = "#000000" + button_text_color = "#000000" + image_url = "example.com" + message = "message" + title = "title" + } +}`, + }, + { + Name: "Resource with saas_app", + Input: `resource "cloudflare_zero_trust_access_application" "saas_app" { + account_id = "1234" + type = "saas" + saas_app { + access_token_lifetime = "24h" + allow_pkce_without_client_secret = true + app_launcher_url = "www.example.com" + auth_type = "saml" + consumer_service_url = "www.example.com" + custom_attribute { + source { + name = "name" + name_by_idp = { + "idp1" = "1234" + "idp2" = "5678" + } + } + friendly_name = "friendly_name" + name = "name" + name_format = "name_format" + required = true + } + custom_claim { + source { + name = "name" + name_by_idp = { + "idp1" = "1234" + "idp2" = "5678" + } + } + name = "name" + required = true + scope = "scope" + } + default_relay_state = "default_relay_state" + grant_types = ["grant_1", "grant_2"] + group_filter_regex = "group_filter_regex" + hybrid_and_implicit_options { + return_access_token_from_authorization_endpoint = true + return_id_token_from_authorization_endpoint = true + } + idp_entity_id = "idp_entity_id" + name_id_transform_jsonata = "name_id_transform_jsonata" + redirect_uris = ["uri_1", "uri_2"] + refresh_token_options { + lifetime = "10m" + } + saml_attribute_transform_jsonata = "saml_attribute_transform_jsonata" + scopes = ["scope_1", "scope_2"] + sp_entity_id = "sp_entity_id" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "saas_app" { + account_id = "1234" + type = "saas" + saas_app = { + access_token_lifetime = "24h" + allow_pkce_without_client_secret = true + app_launcher_url = "www.example.com" + auth_type = "saml" + consumer_service_url = "www.example.com" + default_relay_state = "default_relay_state" + grant_types = ["grant_1", "grant_2"] + group_filter_regex = "group_filter_regex" + idp_entity_id = "idp_entity_id" + name_id_transform_jsonata = "name_id_transform_jsonata" + redirect_uris = ["uri_1", "uri_2"] + saml_attribute_transform_jsonata = "saml_attribute_transform_jsonata" + scopes = ["scope_1", "scope_2"] + sp_entity_id = "sp_entity_id" + custom_attributes = [ + { + friendly_name = "friendly_name" + name = "name" + name_format = "name_format" + required = true + source = { + name = "name" + name_by_idp = [ + { + idp_id = "idp1" + source_name = "1234" + }, + { + idp_id = "idp2" + source_name = "5678" + } + ] + } + } + ] + custom_claims = [ + { + name = "name" + required = true + scope = "scope" + source = { + name = "name" + name_by_idp = { + "idp1" = "1234" + "idp2" = "5678" + } + } + } + ] + hybrid_and_implicit_options = { + return_access_token_from_authorization_endpoint = true + return_id_token_from_authorization_endpoint = true + } + refresh_token_options = { + lifetime = "10m" + } + } +}`, + }, + { + Name: "Resource with policies", + Input: `resource "cloudflare_zero_trust_access_application" "policies" { + account_id = "1234" + type = "self_hosted" + policies = ["policy-1", "policy-2"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "policies" { + account_id = "1234" + type = "self_hosted" + policies = [ + { + id = "policy-1" + precedence = 1 + }, + { + id = "policy-2" + precedence = 2 + } + ] +}`, + }, + // TODO scim_config + { + Name: "Resource with scim_config", + Input: `resource "cloudflare_zero_trust_access_application" "scim_config" { + account_id = "1234" + name = "SCIM App" + type = "saas" + + scim_config { + enabled = true + remote_uri = "https://example.com/scim/v2" + idp_uid = "idp-123" + deactivate_on_delete = true + + authentication { + scheme = "oauth2" + client_id = "client-123" + client_secret = "secret-456" + authorization_url = "https://auth.example.com/authorize" + token_url = "https://auth.example.com/token" + scopes = toset(["read", "write"]) + } + + mappings { + schema = "urn:ietf:params:scim:schemas:core:2.0:User" + enabled = true + filter = "userName sw \"test\"" + transform_jsonata = "$" + + operations { + create = true + update = true + delete = false + } + } + + mappings { + schema = "urn:ietf:params:scim:schemas:core:2.0:Group" + enabled = false + strictness = "strict" + } + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "scim_config" { + account_id = "1234" + name = "SCIM App" + type = "saas" + + scim_config = { + enabled = true + remote_uri = "https://example.com/scim/v2" + idp_uid = "idp-123" + deactivate_on_delete = true + authentication = { + scheme = "oauth2" + client_id = "client-123" + client_secret = "secret-456" + authorization_url = "https://auth.example.com/authorize" + token_url = "https://auth.example.com/token" + scopes = ["read", "write"] + } + mappings = [ + { + schema = "urn:ietf:params:scim:schemas:core:2.0:User" + enabled = true + filter = "userName sw \"test\"" + transform_jsonata = "$" + operations = { + create = true + update = true + delete = false + } + }, + { + schema = "urn:ietf:params:scim:schemas:core:2.0:Group" + enabled = false + strictness = "strict" + } + ] + } +}`, + }, + { + Name: "Resource with target_criteria", + Input: `resource "cloudflare_zero_trust_access_application" "target_criteria" { + account_id = "1234" + name = "SSH App" + type = "ssh" + + target_criteria { + port = 22 + protocol = "SSH" + + target_attributes { + name = "hostname" + values = ["server1.example.com", "server2.example.com"] + } + + target_attributes { + name = "username" + values = ["admin", "root"] + } + } + + target_criteria { + port = 3389 + protocol = "RDP" + + target_attributes { + name = "hostname" + values = ["windows-server.example.com"] + } + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "target_criteria" { + account_id = "1234" + name = "SSH App" + type = "ssh" + + target_criteria = [ + { + port = 22 + protocol = "SSH" + target_attributes = { + "hostname" = ["server1.example.com", "server2.example.com"] + "username" = ["admin", "root"] + } + }, + { + port = 3389 + protocol = "RDP" + target_attributes = { + "hostname" = ["windows-server.example.com"] + } + } + ] +}`, + }, + // P0/P1 Gap Tests + { + Name: "destinations without type field - structural transform only", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + destinations { + uri = "https://example.com" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + destinations = [ + { + uri = "https://example.com" + } + ] +}`, + }, + { + Name: "landing_page_design without title field - structural transform only", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + landing_page_design { + message = "Welcome to our app" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + landing_page_design = { + message = "Welcome to our app" + } +}`, + }, + { + Name: "saas_app without auth_type field - structural transform only", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "saas" + + saas_app { + consumer_service_url = "https://example.com/saml/consume" + sp_entity_id = "example-entity" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "saas" + + saas_app = { + consumer_service_url = "https://example.com/saml/consume" + sp_entity_id = "example-entity" + } +}`, + }, + { + Name: "cors_headers with max_age - structural transform only", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + cors_headers { + allowed_methods = ["GET", "POST"] + max_age = 3600 + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + type = "self_hosted" + + cors_headers = { + allowed_methods = ["GET", "POST"] + max_age = 3600 + } +}`, + }, + // Issue #2: Add explicit type when missing but required by type-specific attributes + { + Name: "add type=self_hosted when session_duration present but type missing", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + session_duration = "12h" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + session_duration = "12h" + type = "self_hosted" +}`, + }, + { + Name: "add type=self_hosted when cors_headers present but type missing", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + + cors_headers { + allowed_methods = ["GET", "POST"] + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + + type = "self_hosted" + cors_headers = { + allowed_methods = ["GET", "POST"] + } +}`, + }, + { + Name: "preserve existing type even with type-specific attributes", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + type = "ssh" + session_duration = "8h" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "Test App" + domain = "test.example.com" + type = "ssh" + session_duration = "8h" +}`, + }, + { + Name: "add type=self_hosted when domain present but type missing", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "add type=self_hosted when self_hosted_domains present but type missing", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + self_hosted_domains = ["app1.example.com", "app2.example.com"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "abc123" + name = "Test App" + self_hosted_domains = ["app1.example.com", "app2.example.com"] + type = "self_hosted" +}`, + }, + { + Name: "add type=self_hosted as default when type is not present", + Input: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "My Application" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "test" { + account_id = "1234" + name = "My Application" + type = "self_hosted" +}`, + }, + } + + testhelpers.RunConfigTransformTests(t, tests, migrator) + }) + + t.Run("StateTransformation", func(t *testing.T) { + tests := []testhelpers.StateTestCase{ + // Migration V1 Tests + { + Name: "transforms_cors_headers_from_array_to_object", + Input: `{ + "version": 4, + "terraform_version": "1.12.2", + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "identity_schema_version": 0, + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": [{ + "allowed_methods": ["GET", "POST", "OPTIONS"], + "allowed_origins": ["https://example.com"], + "allow_credentials": true, + "max_age": 600 + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "terraform_version": "1.12.2", + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": { + "allowed_methods": ["GET", "POST", "OPTIONS"], + "allowed_origins": ["https://example.com"], + "allow_credentials": true, + "max_age": 600 + } + }, + "identity_schema_version": 0, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "handles_empty_cors_headers_array", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": [] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": null + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "preserves_cors_headers_when_already_object", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": { + "allowed_methods": ["GET", "POST"], + "allowed_origins": ["https://test.com"] + } + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": { + "allowed_methods": ["GET", "POST"], + "allowed_origins": ["https://test.com"] + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_landing_page_design_from_array_to_object", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "landing_page_design": [{ + "title": "Welcome", + "message": "Please sign in", + "image_url": "https://example.com/logo.png" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "landing_page_design": { + "title": "Welcome", + "message": "Please sign in", + "image_url": "https://example.com/logo.png" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "handles_empty_landing_page_design_array", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "landing_page_design": [] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "landing_page_design": null + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_saas_app_from_array_to_object", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test SAAS App", + "type": "saas", + "saas_app": [{ + "consumer_service_url": "https://example.com/sso/saml/consume", + "sp_entity_id": "example.com", + "name_id_format": "email", + "auth_type": "saml" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test SAAS App", + "type": "saas", + "saas_app": { + "consumer_service_url": "https://example.com/sso/saml/consume", + "sp_entity_id": "example.com", + "name_id_format": "email", + "auth_type": "saml" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "handles_empty_saas_app_array", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "saas", + "saas_app": [] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "saas", + "saas_app": null + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_scim_config_from_array_to_object", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "saas", + "scim_config": [{ + "enabled": true, + "remote_uri": "https://example.com/scim/v2", + "idp_uid": "idp-123", + "deactivate_on_delete": true, + "authentication": [{ + "scheme": "oauth2", + "client_id": "client-123", + "client_secret": "secret-456", + "authorization_url": "https://auth.example.com/authorize", + "token_url": "https://auth.example.com/token", + "scopes": ["read", "write"] + }], + "mappings": [ + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": true, + "filter": "userName sw \"test\"", + "transform_jsonata": "$", + "operations": [{ + "create": true, + "update": true, + "delete": true + }] + }, + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + "enabled": true, + "strictness": "strict" + } + ] + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "saas", + "scim_config": { + "enabled": true, + "remote_uri": "https://example.com/scim/v2", + "idp_uid": "idp-123", + "deactivate_on_delete": true, + "authentication": { + "scheme": "oauth2", + "client_id": "client-123", + "client_secret": "secret-456", + "authorization_url": "https://auth.example.com/authorize", + "token_url": "https://auth.example.com/token", + "scopes": ["read", "write"] + }, + "mappings": [ + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": true, + "filter": "userName sw \"test\"", + "transform_jsonata": "$", + "operations": { + "create": true, + "update": true, + "delete": true + } + }, + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + "enabled": true, + "strictness": "strict" + } + ] + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "handles_empty_scim_config_array", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "scim_config": [] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "scim_config": null + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_empty_hybrid_and_implicit_options_array_to_null", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test SAML App", + "type": "saas", + "saas_app": [{ + "consumer_service_url": "https://saml.example.com/sso/saml", + "sp_entity_id": "saml-app-test", + "name_id_format": "email", + "auth_type": "saml", + "hybrid_and_implicit_options": [] + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test SAML App", + "type": "saas", + "saas_app": { + "consumer_service_url": "https://saml.example.com/sso/saml", + "sp_entity_id": "saml-app-test", + "name_id_format": "email", + "auth_type": "saml", + "hybrid_and_implicit_options": null + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_empty_refresh_token_options_array_to_null", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test OIDC App", + "type": "saas", + "saas_app": [{ + "auth_type": "oidc", + "app_launcher_url": "https://oidc.example.com/launch", + "grant_types": ["authorization_code"], + "scopes": ["openid", "email", "profile"], + "redirect_uris": ["https://oidc.example.com/callback"], + "refresh_token_options": [] + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test OIDC App", + "type": "saas", + "saas_app": { + "auth_type": "oidc", + "app_launcher_url": "https://oidc.example.com/launch", + "grant_types": ["authorization_code"], + "scopes": ["openid", "email", "profile"], + "redirect_uris": ["https://oidc.example.com/callback"], + "refresh_token_options": null + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "transforms_multiple_attributes_together", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": [{ + "allowed_methods": ["GET", "POST"], + "allow_credentials": true + }], + "landing_page_design": [{ + "title": "Welcome", + "message": "Please sign in" + }], + "saas_app": [{ + "consumer_service_url": "https://example.com/callback", + "sp_entity_id": "example.com" + }], + "scim_config": [{ + "enabled": true, + "remote_uri": "https://example.com/scim" + }], + "policies": ["policy-123"], + "allowed_idps": ["idp-1", "idp-2"], + "custom_pages": ["page-1"] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "name": "Test App", + "type": "self_hosted", + "cors_headers": { + "allowed_methods": ["GET", "POST"], + "allow_credentials": true + }, + "landing_page_design": { + "title": "Welcome", + "message": "Please sign in" + }, + "saas_app": { + "consumer_service_url": "https://example.com/callback", + "sp_entity_id": "example.com", + "auth_type": "saml" + }, + "scim_config": { + "enabled": true, + "remote_uri": "https://example.com/scim" + }, + "policies": [{"id": "policy-123", "precedence": 1}], + "allowed_idps": ["idp-1", "idp-2"], + "custom_pages": ["page-1"] + }, + "schema_version": 0 + }] + }] +}`, + }, + // New Tests - State versions of config tests + { + Name: "Minimal resource with basic, unchanged fields", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_access_application", + "name": "basic", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "allow_authenticate_via_warp": true, + "app_launcher_logo_url": true, + "app_launcher_visible": true, + "auto_redirect_to_identity": true, + "bg_color": "#000000", + "custom_deny_message": "message", + "custom_deny_url": "www.example.com", + "custom_non_identity_deny_url": "www.example.com", + "domain": "test.example.com/admin", + "enable_binding_cookie": true, + "header_bg_color": "#000000", + "http_only_cookie_attribute": true, + "logo_url": "www.example.com", + "name": "test", + "options_preflight_bypass": true, + "same_site_cookie_attribute": "strict", + "service_auth_401_redirect": true, + "session_duration": "24h", + "skip_app_launcher_login_page": true, + "skip_interstitial": true, + "type": "self_hosted", + "zone_id": "1234" + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "basic", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "allow_authenticate_via_warp": true, + "app_launcher_logo_url": true, + "app_launcher_visible": true, + "auto_redirect_to_identity": true, + "bg_color": "#000000", + "custom_deny_message": "message", + "custom_deny_url": "www.example.com", + "custom_non_identity_deny_url": "www.example.com", + "domain": "test.example.com/admin", + "enable_binding_cookie": true, + "header_bg_color": "#000000", + "http_only_cookie_attribute": true, + "logo_url": "www.example.com", + "name": "test", + "options_preflight_bypass": true, + "same_site_cookie_attribute": "strict", + "service_auth_401_redirect": true, + "session_duration": "24h", + "skip_app_launcher_login_page": true, + "skip_interstitial": true, + "type": "self_hosted", + "zone_id": "1234" + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Minimal resource with string set, unchanged fields", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "basic_string_set", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "allowed_idps": ["1234", "5678"], + "custom_pages": ["1234", "5678"], + "self_hosted_domains": ["1234", "5678"], + "tags": ["1234", "5678"] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "basic_string_set", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "allowed_idps": ["1234", "5678"], + "custom_pages": ["1234", "5678"], + "self_hosted_domains": ["1234", "5678"], + "tags": ["1234", "5678"] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Remove domain_type attribute", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "remove_domain_type", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "domain_type": "public" + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "remove_domain_type", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted" + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with cors_headers", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "cors_headers", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "cors_headers": [{ + "allow_all_headers": true, + "allow_all_methods": true, + "allow_all_origins": true, + "allow_credentials": true, + "allowed_headers": ["string"], + "allowed_methods": ["GET"], + "allowed_origins": ["https://example.com"], + "max_age": 1 + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "cors_headers", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "cors_headers": { + "allow_all_headers": true, + "allow_all_methods": true, + "allow_all_origins": true, + "allow_credentials": true, + "allowed_headers": ["string"], + "allowed_methods": ["GET"], + "allowed_origins": ["https://example.com"], + "max_age": 1 + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with single destinations attribute", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "single_destinations", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [{ + "cidr": "10.5.0.0/24", + "hostname": "hostname", + "l4_protocol": "tcp", + "port_range": "80-90", + "type": "private", + "vnet_id": "vnet_id" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "single_destinations", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [{ + "cidr": "10.5.0.0/24", + "hostname": "hostname", + "l4_protocol": "tcp", + "port_range": "80-90", + "type": "private", + "vnet_id": "vnet_id" + }] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with multiple destinations attributes", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "multiple_destinations", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [ + { + "type": "public", + "uri": "test.example.com/admin" + }, + { + "cidr": "10.5.0.0/24", + "hostname": "hostname", + "l4_protocol": "tcp", + "port_range": "80-90", + "type": "private", + "vnet_id": "vnet_id" + } + ] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "multiple_destinations", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [ + { + "type": "public", + "uri": "test.example.com/admin" + }, + { + "cidr": "10.5.0.0/24", + "hostname": "hostname", + "l4_protocol": "tcp", + "port_range": "80-90", + "type": "private", + "vnet_id": "vnet_id" + } + ] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with footer_links", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "footer_links", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "footer_links": [ + { + "name": "Privacy Policy", + "url": "https://example.com/privacy" + }, + { + "name": "Terms", + "url": "https://example.com/terms" + } + ] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "footer_links", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "footer_links": [ + { + "name": "Privacy Policy", + "url": "https://example.com/privacy" + }, + { + "name": "Terms", + "url": "https://example.com/terms" + } + ] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with landing_page_design", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "landing_page_design", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "landing_page_design": [{ + "button_color": "#000000", + "button_text_color": "#000000", + "image_url": "example.com", + "message": "message", + "title": "title" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "landing_page_design", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "landing_page_design": { + "button_color": "#000000", + "button_text_color": "#000000", + "image_url": "example.com", + "message": "message", + "title": "title" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with saas_app", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "saas_app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "saas_app": [{ + "access_token_lifetime": "24h", + "allow_pkce_without_client_secret": true, + "app_launcher_url": "www.example.com", + "auth_type": "saml", + "consumer_service_url": "www.example.com", + "custom_attribute": [{ + "source": [{ + "name": "name", + "name_by_idp": { + "idp1": "1234", + "idp2": "5678" + } + }], + "friendly_name": "friendly_name", + "name": "name", + "name_format": "name_format", + "required": true + }], + "custom_claim": [{ + "source": [{ + "name": "name", + "name_by_idp": { + "idp1": "1234", + "idp2": "5678" + } + }], + "name": "name", + "required": true, + "scope": "scope" + }], + "default_relay_state": "default_relay_state", + "grant_types": ["grant_1", "grant_2"], + "group_filter_regex": "group_filter_regex", + "hybrid_and_implicit_options": [{ + "return_access_token_from_authorization_endpoint": true, + "return_id_token_from_authorization_endpoint": true + }], + "idp_entity_id": "idp_entity_id", + "name_id_transform_jsonata": "name_id_transform_jsonata", + "redirect_uris": ["uri_1", "uri_2"], + "refresh_token_options": [{ + "lifetime": "10m" + }], + "saml_attribute_transform_jsonata": "saml_attribute_transform_jsonata", + "scopes": ["scope_1", "scope_2"], + "sp_entity_id": "sp_entity_id" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "saas_app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "saas_app": { + "access_token_lifetime": "24h", + "allow_pkce_without_client_secret": true, + "app_launcher_url": "www.example.com", + "auth_type": "saml", + "consumer_service_url": "www.example.com", + "custom_attributes": [{ + "source": { + "name": "name", + "name_by_idp": [ + { + "idp_id": "idp1", + "source_name": "1234" + }, + { + "idp_id": "idp2", + "source_name": "5678" + } + ] + }, + "friendly_name": "friendly_name", + "name": "name", + "name_format": "name_format", + "required": true + }], + "custom_claims": [{ + "source": { + "name": "name", + "name_by_idp": { + "idp1": "1234", + "idp2": "5678" + } + }, + "name": "name", + "required": true, + "scope": "scope" + }], + "default_relay_state": "default_relay_state", + "grant_types": ["grant_1", "grant_2"], + "group_filter_regex": "group_filter_regex", + "hybrid_and_implicit_options": { + "return_access_token_from_authorization_endpoint": true, + "return_id_token_from_authorization_endpoint": true + }, + "idp_entity_id": "idp_entity_id", + "name_id_transform_jsonata": "name_id_transform_jsonata", + "redirect_uris": ["uri_1", "uri_2"], + "refresh_token_options": { + "lifetime": "10m" + }, + "saml_attribute_transform_jsonata": "saml_attribute_transform_jsonata", + "scopes": ["scope_1", "scope_2"], + "sp_entity_id": "sp_entity_id" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with policies", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "policies", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "policies": ["policy-1", "policy-2"] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "policies", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "policies": [{"id": "policy-1", "precedence": 1}, {"id": "policy-2", "precedence": 2}] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "Resource with target_criteria", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "target_criteria", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "name": "SSH App", + "type": "ssh", + "target_criteria": [ + { + "port": 22, + "protocol": "SSH", + "target_attributes": [ + { + "name": "hostname", + "values": ["server1.example.com", "server2.example.com"] + }, + { + "name": "username", + "values": ["admin", "root"] + } + ] + }, + { + "port": 3389, + "protocol": "RDP", + "target_attributes": [ + { + "name": "hostname", + "values": ["windows-server.example.com"] + } + ] + } + ] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "target_criteria", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "name": "SSH App", + "type": "ssh", + "target_criteria": [ + { + "port": 22, + "protocol": "SSH", + "target_attributes": { + "hostname": ["server1.example.com", "server2.example.com"], + "username": ["admin", "root"] + } + }, + { + "port": 3389, + "protocol": "RDP", + "target_attributes": { + "hostname": ["windows-server.example.com"] + } + } + ] + }, + "schema_version": 0 + }] + }] +}`, + }, + // P0/P1 Gap Tests + { + Name: "destinations without type field - adds default", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [{ + "uri": "https://example.com" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "destinations": [{ + "uri": "https://example.com", + "type": "public" + }] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "landing_page_design without title field - adds default", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "landing_page_design": [{ + "message": "Welcome to our app", + "button_color": "#000000" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "landing_page_design": { + "message": "Welcome to our app", + "button_color": "#000000", + "title": "Welcome!" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "saas_app without auth_type field - adds default", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "saas_app": [{ + "consumer_service_url": "https://example.com/saml/consume", + "sp_entity_id": "example-entity" + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "saas_app": { + "consumer_service_url": "https://example.com/saml/consume", + "sp_entity_id": "example-entity", + "auth_type": "saml" + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "cors_headers with max_age - converts int to float64", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "cors_headers": [{ + "allowed_methods": ["GET", "POST"], + "allowed_origins": ["https://example.com"], + "max_age": 3600 + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "cors_headers": { + "allowed_methods": ["GET", "POST"], + "allowed_origins": ["https://example.com"], + "max_age": 3600.0 + } + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "adds default type when not present", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "name": "My Application" + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "test", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "name": "My Application", + "type": "self_hosted" + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "comprehensive empty values transformation to null test", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "empty_values_test", + "instances": [{ + "attributes": { + "id": "test-id", + "name": "Test Empty Values", + "type": "self_hosted", + "domain": "test.example.com", + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "http_only_cookie_attribute": false, + "service_auth_401_redirect": false, + "skip_interstitial": false, + "cors_headers": [{ + "allow_all_headers": false, + "allow_all_methods": false, + "allow_all_origins": false, + "allow_credentials": false, + "max_age": 0, + "allowed_methods": [], + "allowed_origins": [], + "allowed_headers": [] + }], + "landing_page_design": [{ + "button_color": "", + "button_text_color": "", + "image_url": "", + "message": "" + }], + "saas_app": [], + "scim_config": [{ + "enabled": false, + "deactivate_on_delete": false, + "idp_uid": "test-idp", + "remote_uri": "https://example.com/scim", + "mappings": [{ + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": false, + "filter": "", + "operations": [{ + "create": false, + "update": false, + "delete": false + }] + }] + }], + "policies": [], + "allowed_idps": [], + "custom_pages": [] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "empty_values_test", + "instances": [{ + "attributes": { + "id": "test-id", + "name": "Test Empty Values", + "type": "self_hosted", + "domain": "test.example.com", + "auto_redirect_to_identity": null, + "enable_binding_cookie": null, + "http_only_cookie_attribute": null, + "service_auth_401_redirect": null, + "skip_interstitial": null, + "cors_headers": null, + "landing_page_design": { + "button_color": null, + "button_text_color": null, + "image_url": null, + "message": null, + "title": "Welcome!" + }, + "saas_app": null, + "scim_config": { + "enabled": null, + "deactivate_on_delete": null, + "idp_uid": "test-idp", + "remote_uri": "https://example.com/scim", + "mappings": [{ + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": null, + "filter": null, + "operations": { + "create": null, + "update": null, + "delete": null + } + }] + }, + "policies": null, + "allowed_idps": null, + "custom_pages": null + }, + "schema_version": 0 + }] + }] +}`, + }, + } + + testhelpers.RunStateTransformTests(t, tests, migrator) + }) + + t.Run("EdgeCases", func(t *testing.T) { + tests := []testhelpers.StateTestCase{ + { + Name: "large policies array with 10+ items", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "policies": ["p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9", "p10", "p11", "p12"] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "policies": [ + {"id": "p1", "precedence": 1}, + {"id": "p2", "precedence": 2}, + {"id": "p3", "precedence": 3}, + {"id": "p4", "precedence": 4}, + {"id": "p5", "precedence": 5}, + {"id": "p6", "precedence": 6}, + {"id": "p7", "precedence": 7}, + {"id": "p8", "precedence": 8}, + {"id": "p9", "precedence": 9}, + {"id": "p10", "precedence": 10}, + {"id": "p11", "precedence": 11}, + {"id": "p12", "precedence": 12} + ] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "target_criteria with empty target_attributes - keeps empty array", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "target_criteria": [{ + "port": 22, + "protocol": "SSH", + "target_attributes": [] + }] + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "target_criteria": [{ + "port": 22, + "protocol": "SSH", + "target_attributes": [] + }] + }, + "schema_version": 0 + }] + }] +}`, + }, + { + Name: "null values in optional fields preserved", + Input: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "name": "Test", + "session_duration": null, + "custom_deny_url": null, + "cors_headers": null + } + }] + }] +}`, + Expected: `{ + "version": 4, + "resources": [{ + "type": "cloudflare_zero_trust_access_application", + "name": "app", + "instances": [{ + "attributes": { + "id": "app-id-123", + "account_id": "1234", + "type": "self_hosted", + "name": "Test", + "session_duration": null, + "custom_deny_url": null, + "cors_headers": null + }, + "schema_version": 0 + }] + }] +}`, + }, + } + + testhelpers.RunStateTransformTests(t, tests, migrator) + }) + + t.Run("ConfigEdgeCases", func(t *testing.T) { + tests := []testhelpers.ConfigTestCase{ + { + Name: "resource with count meta-argument", + Input: `resource "cloudflare_access_application" "apps" { + count = 3 + account_id = "abc123" + name = "App ${count.index}" + domain = "app-${count.index}.example.com" + type = "self_hosted" + + policies = ["policy-${count.index}"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "apps" { + count = 3 + account_id = "abc123" + name = "App ${count.index}" + domain = "app-${count.index}.example.com" + type = "self_hosted" + + policies = [ + { + id = "policy-${count.index}" + precedence = 1 + } + ] +}`, + }, + { + Name: "resource with for_each meta-argument - preserves meta-args", + Input: `resource "cloudflare_access_application" "apps" { + for_each = var.applications + account_id = "abc123" + name = each.value.name + domain = each.value.domain + type = "self_hosted" + + policies = ["policy-1", "policy-2"] +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "apps" { + for_each = var.applications + account_id = "abc123" + name = each.value.name + domain = each.value.domain + type = "self_hosted" + + policies = [ + { + id = "policy-1" + precedence = 1 + }, + { + id = "policy-2" + precedence = 2 + } + ] +}`, + }, + { + Name: "dynamic block preserved", + Input: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "Test App" + type = "warp" + + dynamic "destinations" { + for_each = var.destinations + content { + uri = destinations.value.uri + } + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "Test App" + type = "warp" + + dynamic "destinations" { + for_each = var.destinations + content { + uri = destinations.value.uri + } + } +}`, + }, + { + Name: "conditional resource creation with count", + Input: `resource "cloudflare_access_application" "app" { + count = var.create_app ? 1 : 0 + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "app" { + count = var.create_app ? 1 : 0 + account_id = "abc123" + name = "Test App" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "strings with special characters", + Input: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "App with \"quotes\" and 'apostrophes'" + custom_deny_message = "Access denied: contact admin@example.com\nFor help: https://help.example.com" + domain = "test.example.com" + type = "self_hosted" +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "App with \"quotes\" and 'apostrophes'" + custom_deny_message = "Access denied: contact admin@example.com\nFor help: https://help.example.com" + domain = "test.example.com" + type = "self_hosted" +}`, + }, + { + Name: "large destinations array with 10+ items", + Input: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations { + uri = "https://app1.example.com" + } + destinations { + uri = "https://app2.example.com" + } + destinations { + uri = "https://app3.example.com" + } + destinations { + uri = "https://app4.example.com" + } + destinations { + uri = "https://app5.example.com" + } + destinations { + uri = "https://app6.example.com" + } + destinations { + uri = "https://app7.example.com" + } + destinations { + uri = "https://app8.example.com" + } + destinations { + uri = "https://app9.example.com" + } + destinations { + uri = "https://app10.example.com" + } + destinations { + uri = "https://app11.example.com" + } +}`, + Expected: `resource "cloudflare_zero_trust_access_application" "app" { + account_id = "abc123" + name = "Test App" + type = "warp" + + destinations = [ + { + uri = "https://app1.example.com" + }, + { + uri = "https://app2.example.com" + }, + { + uri = "https://app3.example.com" + }, + { + uri = "https://app4.example.com" + }, + { + uri = "https://app5.example.com" + }, + { + uri = "https://app6.example.com" + }, + { + uri = "https://app7.example.com" + }, + { + uri = "https://app8.example.com" + }, + { + uri = "https://app9.example.com" + }, + { + uri = "https://app10.example.com" + }, + { + uri = "https://app11.example.com" + } + ] +}`, + }, + } + + testhelpers.RunConfigTransformTests(t, tests, migrator) + }) +} + +func TestResourceNaming(t *testing.T) { + migrator := NewV4ToV5Migrator() + + t.Run("CanHandle both names", func(t *testing.T) { + if !migrator.CanHandle("cloudflare_access_application") { + t.Error("Should handle cloudflare_access_application") + } + if !migrator.CanHandle("cloudflare_zero_trust_access_application") { + t.Error("Should handle cloudflare_zero_trust_access_application") + } + }) + + t.Run("GetResourceType returns v5 name", func(t *testing.T) { + expected := "cloudflare_zero_trust_access_application" + if migrator.GetResourceType() != expected { + t.Errorf("Expected %s, got %s", expected, migrator.GetResourceType()) + } + }) + + t.Run("GetResourceRename returns correct mapping", func(t *testing.T) { + // GetResourceRename is an optional interface + type resourceRenamer interface { + GetResourceRename() (string, string) + } + + renamer, ok := migrator.(resourceRenamer) + if !ok { + t.Fatal("Migrator should implement GetResourceRename") + } + + old, new := renamer.GetResourceRename() + if old != "cloudflare_access_application" { + t.Errorf("Expected old name cloudflare_access_application, got %s", old) + } + if new != "cloudflare_zero_trust_access_application" { + t.Errorf("Expected new name cloudflare_zero_trust_access_application, got %s", new) + } + }) +} diff --git a/internal/transform/hcl/attributes.go b/internal/transform/hcl/attributes.go index 50c7540..69f0191 100644 --- a/internal/transform/hcl/attributes.go +++ b/internal/transform/hcl/attributes.go @@ -416,6 +416,511 @@ func AttributeValueContainsKey(attr *hclwrite.Attribute, key string) bool { return false } +// MapEntryTransformer is a function that transforms a map entry (key-value pair) into object attributes. +// The function receives the key and value tokens and returns a map of field names to their token values. +// +// Example - Simple key-value transformation: +// +// func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { +// return map[string]hclwrite.Tokens{ +// "idp_id": key, +// "source_name": value, +// } +// } +// +// Example - Transformation with modifications: +// +// func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { +// // Add quotes around the key if not already quoted +// quotedKey := ensureQuoted(key) +// return map[string]hclwrite.Tokens{ +// "name": quotedKey, +// "value": value, +// } +// } +type MapEntryTransformer func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens + +// ConvertMapAttributeToObjectArray converts a map attribute to an array of objects. +// Each map entry is transformed using the provided transformer function. +// +// Parameters: +// - body: The HCL body containing the map attribute +// - attrName: Name of the map attribute to convert +// - transformer: Function that defines how to transform each map entry into object fields +// +// Returns true if the attribute was found and converted, false otherwise. +// +// Example - Converting name_by_idp map to array for SAML: +// +// Before: +// +// source { +// name = "email" +// name_by_idp = { +// "idp1" = "1234" +// "idp2" = "5678" +// } +// } +// +// After calling ConvertMapAttributeToObjectArray(body, "name_by_idp", transformer): +// +// source { +// name = "email" +// name_by_idp = [ +// { +// idp_id = "idp1" +// source_name = "1234" +// }, +// { +// idp_id = "idp2" +// source_name = "5678" +// } +// ] +// } +// +// Where transformer is: +// +// func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { +// return map[string]hclwrite.Tokens{ +// "idp_id": key, +// "source_name": value, +// } +// } +func ConvertMapAttributeToObjectArray(body *hclwrite.Body, attrName string, transformer MapEntryTransformer) bool { + attr := body.GetAttribute(attrName) + if attr == nil { + return false + } + + // Parse the map entries + mapEntries := parseMapAttribute(attr) + if len(mapEntries) == 0 { + return false + } + + // Build array of objects using the transformer + var arrayTokens hclwrite.Tokens + + // Opening bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenOBrack, + Bytes: []byte("["), + }) + + for i, entry := range mapEntries { + // Add comma and newline for all but first element + if i > 0 { + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenComma, + Bytes: []byte(","), + }) + } + + // Add newline before each object + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + + // Transform the map entry into object fields + objectFields := transformer(entry.Key, entry.Value) + + // Build object tokens from the transformed fields + arrayTokens = append(arrayTokens, buildObjectFromFields(objectFields)...) + } + + // Closing newline and bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenCBrack, + Bytes: []byte("]"), + }) + + // Replace the map attribute with the array + body.SetAttributeRaw(attrName, arrayTokens) + + return true +} + +// MapEntry represents a single key-value pair from a map +type MapEntry struct { + Key hclwrite.Tokens + Value hclwrite.Tokens +} + +// parseMapAttribute parses a map attribute and extracts its key-value pairs +func parseMapAttribute(attr *hclwrite.Attribute) []MapEntry { + if attr == nil { + return nil + } + + tokens := attr.Expr().BuildTokens(nil) + var entries []MapEntry + + inMap := false + inQuotedString := false + templateDepth := 0 + currentKey := hclwrite.Tokens{} + currentValue := hclwrite.Tokens{} + inValue := false + + for i := 0; i < len(tokens); i++ { + token := tokens[i] + + // Find the opening brace + if token.Type == hclsyntax.TokenOBrace && !inMap { + inMap = true + continue + } + + // Find the closing brace + if token.Type == hclsyntax.TokenCBrace && inMap && templateDepth == 0 && !inQuotedString { + // Save any pending entry + if len(currentKey) > 0 && len(currentValue) > 0 { + entries = append(entries, MapEntry{ + Key: currentKey, + Value: currentValue, + }) + } + break + } + + if !inMap { + continue + } + + // Track quoted string boundaries + if token.Type == hclsyntax.TokenOQuote { + inQuotedString = true + if inValue { + currentValue = append(currentValue, token) + } else { + currentKey = append(currentKey, token) + } + continue + } + if token.Type == hclsyntax.TokenCQuote && templateDepth == 0 { + inQuotedString = false + if inValue { + currentValue = append(currentValue, token) + } else { + currentKey = append(currentKey, token) + } + continue + } + + // Track template interpolation depth + if token.Type == hclsyntax.TokenTemplateInterp { + templateDepth++ + if inValue { + currentValue = append(currentValue, token) + } else { + currentKey = append(currentKey, token) + } + continue + } + if token.Type == hclsyntax.TokenTemplateSeqEnd { + templateDepth-- + if inValue { + currentValue = append(currentValue, token) + } else { + currentKey = append(currentKey, token) + } + continue + } + + // Handle equals sign (key-value separator) + if token.Type == hclsyntax.TokenEqual && !inQuotedString && templateDepth == 0 { + inValue = true + continue + } + + // Skip newlines between entries + if token.Type == hclsyntax.TokenNewline && !inQuotedString && templateDepth == 0 { + if inValue && len(currentValue) > 0 { + // End of current entry + entries = append(entries, MapEntry{ + Key: currentKey, + Value: currentValue, + }) + currentKey = hclwrite.Tokens{} + currentValue = hclwrite.Tokens{} + inValue = false + } + continue + } + + // Collect key or value tokens + if inValue { + currentValue = append(currentValue, token) + } else { + currentKey = append(currentKey, token) + } + } + + return entries +} + +// buildObjectFromFields builds HCL tokens for an object from a map of field names to tokens +func buildObjectFromFields(fields map[string]hclwrite.Tokens) hclwrite.Tokens { + var tokens hclwrite.Tokens + + // Opening brace + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenOBrace, + Bytes: []byte("{"), + }) + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + + // Add fields in a consistent order (sorted by field name for deterministic output) + // First collect all field names + fieldNames := make([]string, 0, len(fields)) + for fieldName := range fields { + fieldNames = append(fieldNames, fieldName) + } + + // Sort field names for consistent output + // Note: In Go, map iteration order is random, so we need to sort + // We'll use a simple bubble sort since the number of fields is typically small + for i := 0; i < len(fieldNames); i++ { + for j := i + 1; j < len(fieldNames); j++ { + if fieldNames[i] > fieldNames[j] { + fieldNames[i], fieldNames[j] = fieldNames[j], fieldNames[i] + } + } + } + + // Add each field + for _, fieldName := range fieldNames { + fieldTokens := fields[fieldName] + + // Field name + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenIdent, + Bytes: []byte(fieldName), + }) + + // Equals sign + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenEqual, + Bytes: []byte("="), + }) + + // Field value + tokens = append(tokens, fieldTokens...) + + // Newline after field + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + } + + // Closing brace + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenCBrace, + Bytes: []byte("}"), + }) + + return tokens +} + +// ArrayElementTransformer is a function that transforms an array element into object attributes. +// The function receives the element tokens and its index (0-based) and returns a map of field names to their token values. +// +// Example - Converting policy IDs to objects with precedence: +// +// func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { +// return map[string]hclwrite.Tokens{ +// "id": element, +// "precedence": hclwrite.Tokens{&hclwrite.Token{Type: hclsyntax.TokenNumberLit, Bytes: []byte(strconv.Itoa(index + 1))}}, +// } +// } +type ArrayElementTransformer func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens + +// ConvertArrayAttributeToObjectArray converts an array attribute to an array of objects. +// Each array element is transformed using the provided transformer function. +// +// Parameters: +// - body: The HCL body containing the array attribute +// - attrName: Name of the array attribute to convert +// - transformer: Function that defines how to transform each array element (with its index) into object fields +// +// Returns true if the attribute was found and converted, false otherwise. +// +// Example - Converting policies array to object array: +// +// Before: +// +// policies = ["policy-id-1", "policy-id-2"] +// +// After calling ConvertArrayAttributeToObjectArray(body, "policies", transformer): +// +// policies = [ +// { +// id = "policy-id-1" +// precedence = 1 +// }, +// { +// id = "policy-id-2" +// precedence = 2 +// } +// ] +// +// Where transformer is: +// +// func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { +// return map[string]hclwrite.Tokens{ +// "id": element, +// "precedence": hclwrite.Tokens{&hclwrite.Token{Type: hclsyntax.TokenNumberLit, Bytes: []byte(strconv.Itoa(index + 1))}}, +// } +// } +func ConvertArrayAttributeToObjectArray(body *hclwrite.Body, attrName string, transformer ArrayElementTransformer) bool { + attr := body.GetAttribute(attrName) + if attr == nil { + return false + } + + // Parse the array elements + arrayElements := parseArrayAttribute(attr) + if len(arrayElements) == 0 { + return false + } + + // Build array of objects using the transformer + var arrayTokens hclwrite.Tokens + + // Opening bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenOBrack, + Bytes: []byte("["), + }) + + for i, element := range arrayElements { + // Add comma and newline for all but first element + if i > 0 { + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenComma, + Bytes: []byte(","), + }) + } + + // Add newline before each object + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + + // Transform the array element into object fields + objectFields := transformer(element, i) + + // Build object tokens from the transformed fields + arrayTokens = append(arrayTokens, buildObjectFromFields(objectFields)...) + } + + // Closing newline and bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenCBrack, + Bytes: []byte("]"), + }) + + // Replace the array attribute with the transformed array + body.SetAttributeRaw(attrName, arrayTokens) + + return true +} + +// parseArrayAttribute parses an array attribute and extracts its elements +func parseArrayAttribute(attr *hclwrite.Attribute) []hclwrite.Tokens { + if attr == nil { + return nil + } + + tokens := attr.Expr().BuildTokens(nil) + var elements []hclwrite.Tokens + + inArray := false + inQuotedString := false + templateDepth := 0 + currentElement := hclwrite.Tokens{} + + for i := 0; i < len(tokens); i++ { + token := tokens[i] + + // Find the opening bracket + if token.Type == hclsyntax.TokenOBrack && !inArray { + inArray = true + continue + } + + // Find the closing bracket + if token.Type == hclsyntax.TokenCBrack && inArray && templateDepth == 0 && !inQuotedString { + // Save any pending element + if len(currentElement) > 0 { + elements = append(elements, currentElement) + } + break + } + + if !inArray { + continue + } + + // Track quoted string boundaries + if token.Type == hclsyntax.TokenOQuote { + inQuotedString = true + currentElement = append(currentElement, token) + continue + } + if token.Type == hclsyntax.TokenCQuote && templateDepth == 0 { + inQuotedString = false + currentElement = append(currentElement, token) + continue + } + + // Track template interpolation depth + if token.Type == hclsyntax.TokenTemplateInterp { + templateDepth++ + currentElement = append(currentElement, token) + continue + } + if token.Type == hclsyntax.TokenTemplateSeqEnd { + templateDepth-- + currentElement = append(currentElement, token) + continue + } + + // Handle comma (element separator) + if token.Type == hclsyntax.TokenComma && !inQuotedString && templateDepth == 0 { + if len(currentElement) > 0 { + elements = append(elements, currentElement) + currentElement = hclwrite.Tokens{} + } + continue + } + + // Skip newlines between elements + if token.Type == hclsyntax.TokenNewline && !inQuotedString && templateDepth == 0 { + continue + } + + // Collect element tokens + currentElement = append(currentElement, token) + } + + return elements +} + // AttributeInfo holds an attribute name and its corresponding Attribute object type AttributeInfo struct { Name string diff --git a/internal/transform/hcl/attributes_test.go b/internal/transform/hcl/attributes_test.go index 7deb052..550ff81 100644 --- a/internal/transform/hcl/attributes_test.go +++ b/internal/transform/hcl/attributes_test.go @@ -1,9 +1,11 @@ package hcl import ( + "fmt" "testing" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -694,6 +696,401 @@ resource "test" "example" { } } +func TestConvertMapAttributeToObjectArray(t *testing.T) { + tests := []struct { + name string + input string + attrName string + transformer MapEntryTransformer + expected string + }{ + { + name: "Convert simple map to object array", + input: ` +resource "test" "example" { + name_by_idp = { + "idp1" = "value1" + "idp2" = "value2" + } +}`, + attrName: "name_by_idp", + transformer: func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "idp_id": key, + "source_name": value, + } + }, + expected: ` +resource "test" "example" { + name_by_idp = [ + { + idp_id = "idp1" + source_name = "value1" + }, + { + idp_id = "idp2" + source_name = "value2" + } + ] +}`, + }, + { + name: "Convert map with single entry", + input: ` +resource "test" "example" { + mapping = { + "key" = "val" + } +}`, + attrName: "mapping", + transformer: func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "name": key, + "value": value, + } + }, + expected: ` +resource "test" "example" { + mapping = [ + { + name = "key" + value = "val" + } + ] +}`, + }, + { + name: "Return false for non-existent attribute", + input: ` +resource "test" "example" { + other_attr = "value" +}`, + attrName: "missing", + transformer: func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "k": key, + "v": value, + } + }, + expected: ` +resource "test" "example" { + other_attr = "value" +}`, + }, + { + name: "Convert map with three entries", + input: ` +resource "test" "example" { + config = { + "a" = "1" + "b" = "2" + "c" = "3" + } +}`, + attrName: "config", + transformer: func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "key": key, + "value": value, + } + }, + expected: ` +resource "test" "example" { + config = [ + { + key = "a" + value = "1" + }, + { + key = "b" + value = "2" + }, + { + key = "c" + value = "3" + } + ] +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, diags := hclwrite.ParseConfig([]byte(tt.input), "", hcl.InitialPos) + require.False(t, diags.HasErrors()) + + body := file.Body().Blocks()[0].Body() + ConvertMapAttributeToObjectArray(body, tt.attrName, tt.transformer) + + output := string(file.Bytes()) + assert.Equal(t, tt.expected, output) + }) + } +} + +func TestConvertMapAttributeToObjectArray_NestedBlock(t *testing.T) { + input := ` +resource "cloudflare_zero_trust_access_application" "example" { + saas_app { + custom_attribute { + source { + name = "email" + name_by_idp = { + "idp1" = "1234" + "idp2" = "5678" + } + } + } + } +}` + + expected := ` +resource "cloudflare_zero_trust_access_application" "example" { + saas_app { + custom_attribute { + source { + name = "email" + name_by_idp = [ + { + idp_id = "idp1" + source_name = "1234" + }, + { + idp_id = "idp2" + source_name = "5678" + } + ] + } + } + } +}` + + file, diags := hclwrite.ParseConfig([]byte(input), "", hcl.InitialPos) + require.False(t, diags.HasErrors()) + + // Navigate to the nested source block + resourceBlock := file.Body().Blocks()[0] + saasAppBlock := FindBlockByType(resourceBlock.Body(), "saas_app") + require.NotNil(t, saasAppBlock) + + customAttrBlock := FindBlockByType(saasAppBlock.Body(), "custom_attribute") + require.NotNil(t, customAttrBlock) + + sourceBlock := FindBlockByType(customAttrBlock.Body(), "source") + require.NotNil(t, sourceBlock) + + // Apply the transformation + transformer := func(key, value hclwrite.Tokens) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "idp_id": key, + "source_name": value, + } + } + + result := ConvertMapAttributeToObjectArray(sourceBlock.Body(), "name_by_idp", transformer) + assert.True(t, result, "Transformation should succeed") + + output := string(file.Bytes()) + assert.Equal(t, expected, output) +} + +func TestConvertArrayAttributeToObjectArray(t *testing.T) { + tests := []struct { + name string + input string + attrName string + transformer ArrayElementTransformer + expected string + }{ + { + name: "Convert simple string array to object array", + input: ` +resource "test" "example" { + policies = ["policy-id-1", "policy-id-2"] +}`, + attrName: "policies", + transformer: func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "id": element, + "precedence": hclwrite.Tokens{&hclwrite.Token{ + Type: hclsyntax.TokenNumberLit, + Bytes: []byte(fmt.Sprintf("%d", index+1)), + }}, + } + }, + expected: ` +resource "test" "example" { + policies = [ + { + id = "policy-id-1" + precedence = 1 + }, + { + id = "policy-id-2" + precedence = 2 + } + ] +}`, + }, + { + name: "Convert single element array", + input: ` +resource "test" "example" { + policies = ["single-policy"] +}`, + attrName: "policies", + transformer: func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "id": element, + "precedence": hclwrite.Tokens{&hclwrite.Token{ + Type: hclsyntax.TokenNumberLit, + Bytes: []byte(fmt.Sprintf("%d", index+1)), + }}, + } + }, + expected: ` +resource "test" "example" { + policies = [ + { + id = "single-policy" + precedence = 1 + } + ] +}`, + }, + { + name: "Non-existent attribute returns false", + input: ` +resource "test" "example" { + name = "test" +}`, + attrName: "policies", + transformer: func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "id": element, + } + }, + expected: ` +resource "test" "example" { + name = "test" +}`, + }, + { + name: "Multiple elements with different transformer", + input: ` +resource "test" "example" { + items = ["a", "b", "c"] +}`, + attrName: "items", + transformer: func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "value": element, + "index": hclwrite.Tokens{&hclwrite.Token{ + Type: hclsyntax.TokenNumberLit, + Bytes: []byte(fmt.Sprintf("%d", index)), + }}, + "name": hclwrite.Tokens{&hclwrite.Token{ + Type: hclsyntax.TokenIdent, + Bytes: []byte(fmt.Sprintf("\"item_%d\"", index)), + }}, + } + }, + expected: ` +resource "test" "example" { + items = [ + { + index = 0 + name = "item_0" + value = "a" + }, + { + index = 1 + name = "item_1" + value = "b" + }, + { + index = 2 + name = "item_2" + value = "c" + } + ] +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, diags := hclwrite.ParseConfig([]byte(tt.input), "test.tf", hcl.InitialPos) + require.False(t, diags.HasErrors(), "Parse should succeed") + require.NotNil(t, file) + + resourceBlock := file.Body().Blocks()[0] + result := ConvertArrayAttributeToObjectArray(resourceBlock.Body(), tt.attrName, tt.transformer) + + if tt.name == "Non-existent attribute returns false" { + assert.False(t, result, "Transformation should return false for non-existent attribute") + } else { + assert.True(t, result, "Transformation should succeed") + } + + output := string(file.Bytes()) + assert.Equal(t, tt.expected, output) + }) + } +} + +func TestConvertArrayAttributeToObjectArray_RealWorld(t *testing.T) { + input := ` +resource "cloudflare_zero_trust_access_application" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + name = "My App" + policies = ["policy-uuid-1", "policy-uuid-2", "policy-uuid-3"] +}` + + expected := ` +resource "cloudflare_zero_trust_access_application" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + name = "My App" + policies = [ + { + id = "policy-uuid-1" + precedence = 1 + }, + { + id = "policy-uuid-2" + precedence = 2 + }, + { + id = "policy-uuid-3" + precedence = 3 + } + ] +}` + + file, diags := hclwrite.ParseConfig([]byte(input), "test.tf", hcl.InitialPos) + require.False(t, diags.HasErrors(), "Parse should succeed") + require.NotNil(t, file) + + resourceBlock := file.Body().Blocks()[0] + + // Apply the transformation + transformer := func(element hclwrite.Tokens, index int) map[string]hclwrite.Tokens { + return map[string]hclwrite.Tokens{ + "id": element, + "precedence": hclwrite.Tokens{&hclwrite.Token{ + Type: hclsyntax.TokenNumberLit, + Bytes: []byte(fmt.Sprintf("%d", index+1)), + }}, + } + } + + result := ConvertArrayAttributeToObjectArray(resourceBlock.Body(), "policies", transformer) + assert.True(t, result, "Transformation should succeed") + + output := string(file.Bytes()) + assert.Equal(t, expected, output) +} + func TestCreateNestedAttributeFromFields(t *testing.T) { tests := []struct { name string diff --git a/internal/transform/hcl/blocks.go b/internal/transform/hcl/blocks.go index 91fe4fd..c754ab5 100644 --- a/internal/transform/hcl/blocks.go +++ b/internal/transform/hcl/blocks.go @@ -17,20 +17,22 @@ import ( // Example - Renaming legacy resource type: // // Before: -// resource "cloudflare_record" "example" { -// zone_id = "abc123" -// name = "test" -// type = "A" -// value = "192.0.2.1" -// } +// +// resource "cloudflare_record" "example" { +// zone_id = "abc123" +// name = "test" +// type = "A" +// value = "192.0.2.1" +// } // // After calling RenameResourceType(block, "cloudflare_record", "cloudflare_dns_record"): -// resource "cloudflare_dns_record" "example" { -// zone_id = "abc123" -// name = "test" -// type = "A" -// value = "192.0.2.1" -// } +// +// resource "cloudflare_dns_record" "example" { +// zone_id = "abc123" +// name = "test" +// type = "A" +// value = "192.0.2.1" +// } func RenameResourceType(block *hclwrite.Block, oldType, newType string) bool { labels := block.Labels() if len(labels) >= 2 && labels[0] == oldType { @@ -71,48 +73,50 @@ func GetResourceName(block *hclwrite.Block) string { // Example - Converting data blocks to attribute for CAA records: // // Before: -// resource "cloudflare_dns_record" "caa" { -// zone_id = "abc123" -// name = "example.com" -// type = "CAA" -// -// data { -// flags = "0" -// tag = "issue" -// content = "letsencrypt.org" -// } -// } +// +// resource "cloudflare_dns_record" "caa" { +// zone_id = "abc123" +// name = "example.com" +// type = "CAA" +// +// data { +// flags = "0" +// tag = "issue" +// content = "letsencrypt.org" +// } +// } // // After calling ConvertBlocksToAttribute(body, "data", "data", preProcess): -// resource "cloudflare_dns_record" "caa" { -// zone_id = "abc123" -// name = "example.com" -// type = "CAA" -// data = { -// flags = "0" -// tag = "issue" -// value = "letsencrypt.org" # Renamed by preProcess -// } -// } +// +// resource "cloudflare_dns_record" "caa" { +// zone_id = "abc123" +// name = "example.com" +// type = "CAA" +// data = { +// flags = "0" +// tag = "issue" +// value = "letsencrypt.org" # Renamed by preProcess +// } +// } func ConvertBlocksToAttribute(body *hclwrite.Body, blockType, attrName string, preProcess func(*hclwrite.Block)) { var blocksToRemove []*hclwrite.Block - + for _, block := range body.Blocks() { if block.Type() != blockType { continue } - + // Apply preprocessing if provided if preProcess != nil { preProcess(block) } - + // Convert block to object tokens objTokens := BuildObjectFromBlock(block) body.SetAttributeRaw(attrName, objTokens) blocksToRemove = append(blocksToRemove, block) } - + // Remove the converted blocks for _, block := range blocksToRemove { body.RemoveBlock(block) @@ -125,32 +129,34 @@ func ConvertBlocksToAttribute(body *hclwrite.Body, blockType, attrName string, p // Example - Hoisting priority from SRV record data block: // // Before: -// resource "cloudflare_dns_record" "srv" { -// zone_id = "abc123" -// name = "_sip._tcp" -// type = "SRV" -// -// data { -// priority = 10 -// weight = 60 -// port = 5060 -// target = "sipserver.example.com" -// } -// } +// +// resource "cloudflare_dns_record" "srv" { +// zone_id = "abc123" +// name = "_sip._tcp" +// type = "SRV" +// +// data { +// priority = 10 +// weight = 60 +// port = 5060 +// target = "sipserver.example.com" +// } +// } // // After calling HoistAttributeFromBlock(body, "data", "priority"): -// resource "cloudflare_dns_record" "srv" { -// zone_id = "abc123" -// name = "_sip._tcp" -// type = "SRV" -// priority = 10 # Hoisted from data block -// -// data { -// weight = 60 -// port = 5060 -// target = "sipserver.example.com" -// } -// } +// +// resource "cloudflare_dns_record" "srv" { +// zone_id = "abc123" +// name = "_sip._tcp" +// type = "SRV" +// priority = 10 # Hoisted from data block +// +// data { +// weight = 60 +// port = 5060 +// target = "sipserver.example.com" +// } +// } func HoistAttributeFromBlock(parentBody *hclwrite.Body, blockType, attrName string) bool { for _, block := range parentBody.Blocks() { if block.Type() != blockType { @@ -229,10 +235,112 @@ func ConvertSingleBlockToAttribute(body *hclwrite.Body, blockType, attrName stri } objTokens := BuildObjectFromBlock(block) + body.SetAttributeRaw(attrName, objTokens) body.RemoveBlock(block) return true } + +// ConvertBlocksToAttributeList converts multiple blocks of a certain type to an array attribute. +// The preProcess function is called on each block before conversion (can be nil). +// +// Example - Converting destinations blocks to array attribute: +// +// Before: +// +// resource "cloudflare_zero_trust_access_application" "example" { +// name = "App" +// account_id = "abc123" +// +// destinations { +// type = "public" +// uri = "https://app.example.com" +// } +// +// destinations { +// type = "private" +// cidr = "10.0.0.0/24" +// } +// } +// +// After calling ConvertBlocksToAttributeList(body, "destinations", nil): +// +// resource "cloudflare_zero_trust_access_application" "example" { +// name = "App" +// account_id = "abc123" +// +// destinations = [ +// { +// type = "public" +// uri = "https://app.example.com" +// }, +// { +// type = "private" +// cidr = "10.0.0.0/24" +// } +// ] +// } +func ConvertBlocksToAttributeList(body *hclwrite.Body, blockType string, preProcess func(*hclwrite.Block)) bool { + blocks := FindBlocksByType(body, blockType) + if len(blocks) == 0 { + return false + } + + var arrayTokens hclwrite.Tokens + + // Opening bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenOBrack, + Bytes: []byte("["), + }) + + // Process each block + for i, block := range blocks { + // Apply preprocessing if provided + if preProcess != nil { + preProcess(block) + } + + // Add comma and newline for all but first element + if i > 0 { + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenComma, + Bytes: []byte(","), + }) + } + + // Add newline before each object + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + + // Convert block to object tokens + objTokens := BuildObjectFromBlock(block) + arrayTokens = append(arrayTokens, objTokens...) + } + + // Closing newline and bracket + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + arrayTokens = append(arrayTokens, &hclwrite.Token{ + Type: hclsyntax.TokenCBrack, + Bytes: []byte("]"), + }) + + // Set the attribute with the array using the same name as the block type + body.SetAttributeRaw(blockType, arrayTokens) + + // Remove the converted blocks + for _, block := range blocks { + body.RemoveBlock(block) + } + + return true +} + // CreateMovedBlock creates a moved block for resource migration // This is used when resources are renamed or restructured between provider versions func CreateMovedBlock(from, to string) *hclwrite.Block { diff --git a/internal/transform/hcl/blocks_test.go b/internal/transform/hcl/blocks_test.go index 74fafab..a4d2e67 100644 --- a/internal/transform/hcl/blocks_test.go +++ b/internal/transform/hcl/blocks_test.go @@ -455,14 +455,107 @@ resource "test" "example" { }) } -func TestConvertSingleBlockToAttribute(t *testing.T) { +func TestConvertBlocksToAttributeList(t *testing.T) { tests := []struct { - name string - input string + name string + input string blockType string - attrName string - expected bool - contains string + expected string + }{ + { + name: "Convert multiple blocks to array attribute", + input: ` +resource "test" "example" { + name = "test" + + destinations { + type = "public" + uri = "https://app.example.com" + } + + destinations { + type = "private" + cidr = "10.0.0.0/24" + } +}`, + blockType: "destinations", + expected: ` +resource "test" "example" { + name = "test" + + + destinations = [ + { + type = "public" + uri = "https://app.example.com" + }, + { + type = "private" + cidr = "10.0.0.0/24" + } + ] +}`, + }, + { + name: "Convert single block to array attribute", + input: ` +resource "test" "example" { + name = "test" + + destinations { + type = "public" + uri = "https://app.example.com" + } +}`, + blockType: "destinations", + expected: ` +resource "test" "example" { + name = "test" + + destinations = [ + { + type = "public" + uri = "https://app.example.com" + } + ] +}`, + }, + { + name: "Return unchanged for non-existent blocks", + input: ` +resource "test" "example" { + name = "test" +}`, + blockType: "missing", + expected: ` +resource "test" "example" { + name = "test" +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, diags := hclwrite.ParseConfig([]byte(tt.input), "", hcl.InitialPos) + require.False(t, diags.HasErrors()) + + body := file.Body().Blocks()[0].Body() + ConvertBlocksToAttributeList(body, tt.blockType, nil) + + output := string(file.Bytes()) + assert.Equal(t, tt.expected, output) + }) + } +} + +func TestConvertSingleBlockToAttribute(t *testing.T) { + tests := []struct { + name string + input string + blockType string + attrName string + expected bool + contains string notContains string }{ { @@ -476,10 +569,10 @@ resource "test" "example" { value = "test" } }`, - blockType: "config", - attrName: "config", - expected: true, - contains: "config =", + blockType: "config", + attrName: "config", + expected: true, + contains: "config =", notContains: "config {", }, { diff --git a/internal/transform/hcl/expressions.go b/internal/transform/hcl/expressions.go index afcf303..20673b6 100644 --- a/internal/transform/hcl/expressions.go +++ b/internal/transform/hcl/expressions.go @@ -73,3 +73,87 @@ func IsExpressionAttribute(attr *hclwrite.Attribute) bool { return false } + +// RemoveFunctionWrapper removes a function wrapper from an attribute expression. +// This is useful when migrating from v4 to v5 where certain function calls need to be unwrapped. +// +// Common use case: Converting toset() calls to plain lists when v5 changes a set type to a list type. +// +// Example with toset: +// +// Before: allowed_idps = toset(["abc-123", "def-456"]) +// After: allowed_idps = ["abc-123", "def-456"] +// +// The function extracts the argument from funcName(arg) and replaces the entire expression with just arg. +// If the attribute doesn't exist or doesn't contain the specified function, no changes are made. +// +// Parameters: +// - body: The HCL body containing the attribute +// - attrName: The name of the attribute to transform +// - funcName: The name of the function to remove (e.g., "toset", "tonumber") +func RemoveFunctionWrapper(body *hclwrite.Body, attrName string, funcName string) { + attr := body.GetAttribute(attrName) + if attr == nil { + return + } + + tokens := attr.Expr().BuildTokens(nil) + + // Look for the pattern: funcName ( arg ) + // We need to find funcName followed by "(" and extract the content until matching ")" + var result []*hclwrite.Token + inFunction := false + parenDepth := 0 + skipUntilArgStart := false + + for i := 0; i < len(tokens); i++ { + token := tokens[i] + + // Check if this is the start of funcName( + if !inFunction && token.Type == hclsyntax.TokenIdent && string(token.Bytes) == funcName { + inFunction = true + skipUntilArgStart = true + continue + } + + if inFunction { + if skipUntilArgStart { + if token.Type == hclsyntax.TokenOParen { + parenDepth++ + continue + } + // Found the start of the argument (could be a list, object, or other expression) + // For toset specifically, this is typically a list literal [...] + if token.Type == hclsyntax.TokenOBrack { + skipUntilArgStart = false + result = append(result, token) + continue + } + // Skip whitespace and other tokens before the argument + continue + } + + // Track parentheses depth to know when funcName() ends + if token.Type == hclsyntax.TokenOParen { + parenDepth++ + } else if token.Type == hclsyntax.TokenCParen { + parenDepth-- + if parenDepth == 0 { + // End of funcName(), we're done + break + } + } + + // Collect all other tokens (the function argument) + result = append(result, token) + } else { + // Not in function call, keep token as-is + result = append(result, token) + } + } + + // If we found and transformed the function, update the attribute + if inFunction && len(result) > 0 { + body.SetAttributeRaw(attrName, result) + } +} diff --git a/internal/transform/hcl/expressions_test.go b/internal/transform/hcl/expressions_test.go new file mode 100644 index 0000000..ee5d538 --- /dev/null +++ b/internal/transform/hcl/expressions_test.go @@ -0,0 +1,192 @@ +package hcl + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemoveFunctionWrapper(t *testing.T) { + tests := []struct { + name string + input string + attrName string + funcName string + expected string + notContains string + }{ + { + name: "Remove toset from list attribute", + input: ` +resource "test" "example" { + allowed_idps = toset(["abc-123", "def-456"]) +}`, + attrName: "allowed_idps", + funcName: "toset", + expected: `allowed_idps = ["abc-123", "def-456"]`, + notContains: "toset", + }, + { + name: "Remove toset from empty list", + input: ` +resource "test" "example" { + custom_pages = toset([]) +}`, + attrName: "custom_pages", + funcName: "toset", + expected: `custom_pages = []`, + notContains: "toset", + }, + { + name: "Remove toset from single element list", + input: ` +resource "test" "example" { + scopes = toset(["openid"]) +}`, + attrName: "scopes", + funcName: "toset", + expected: `scopes = ["openid"]`, + notContains: "toset", + }, + { + name: "Remove toset with multi-line formatting", + input: ` +resource "test" "example" { + allowed_idps = toset([ + "abc-123", + "def-456", + "ghi-789" + ]) +}`, + attrName: "allowed_idps", + funcName: "toset", + expected: `allowed_idps = [ + "abc-123", + "def-456", + "ghi-789" + ]`, + notContains: "toset", + }, + { + name: "No-op when attribute doesn't exist", + input: ` +resource "test" "example" { + name = "test" +}`, + attrName: "missing_attr", + funcName: "toset", + expected: `name = "test"`, + notContains: "", + }, + { + name: "No-op when function doesn't match", + input: ` +resource "test" "example" { + value = tonumber("123") +}`, + attrName: "value", + funcName: "toset", + expected: `value = tonumber("123")`, + notContains: "", + }, + { + name: "Remove toset with complex list elements", + input: ` +resource "test" "example" { + domains = toset(["example.com", "test.example.com", "api.example.com"]) +}`, + attrName: "domains", + funcName: "toset", + expected: `domains = ["example.com", "test.example.com", "api.example.com"]`, + notContains: "toset", + }, + { + name: "Preserve other attributes unchanged", + input: ` +resource "test" "example" { + name = "test" + allowed_idps = toset(["abc-123"]) + type = "self_hosted" +}`, + attrName: "allowed_idps", + funcName: "toset", + expected: `allowed_idps = ["abc-123"]`, + notContains: "toset", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, diags := hclwrite.ParseConfig([]byte(tt.input), "", hcl.InitialPos) + require.False(t, diags.HasErrors(), "Input HCL should parse without errors") + + body := file.Body().Blocks()[0].Body() + RemoveFunctionWrapper(body, tt.attrName, tt.funcName) + + output := string(file.Bytes()) + assert.Contains(t, output, tt.expected, "Output should contain expected attribute") + + if tt.notContains != "" { + assert.NotContains(t, output, tt.notContains, "Output should not contain unwrapped function") + } + }) + } +} + +func TestRemoveFunctionWrapper_MultipleAttributes(t *testing.T) { + input := ` +resource "test" "example" { + allowed_idps = toset(["abc-123", "def-456"]) + custom_pages = toset(["page-1", "page-2"]) + self_hosted_domains = toset(["app.example.com"]) +}` + + file, diags := hclwrite.ParseConfig([]byte(input), "", hcl.InitialPos) + require.False(t, diags.HasErrors()) + + body := file.Body().Blocks()[0].Body() + + // Remove toset from all three attributes + RemoveFunctionWrapper(body, "allowed_idps", "toset") + RemoveFunctionWrapper(body, "custom_pages", "toset") + RemoveFunctionWrapper(body, "self_hosted_domains", "toset") + + output := string(file.Bytes()) + + // Verify all three attributes were transformed + assert.Contains(t, output, `allowed_idps = ["abc-123", "def-456"]`) + assert.Contains(t, output, `custom_pages = ["page-1", "page-2"]`) + assert.Contains(t, output, `self_hosted_domains = ["app.example.com"]`) + + // Verify no toset remains + assert.NotContains(t, output, "toset") +} + +func TestRemoveFunctionWrapper_NestedBlock(t *testing.T) { + input := ` +resource "test" "example" { + name = "test" + + saas_app { + scopes = toset(["openid", "email", "profile"]) + } +}` + + file, diags := hclwrite.ParseConfig([]byte(input), "", hcl.InitialPos) + require.False(t, diags.HasErrors()) + + // Get the nested block body + resourceBody := file.Body().Blocks()[0].Body() + saasAppBlock := resourceBody.Blocks()[0] + saasAppBody := saasAppBlock.Body() + + RemoveFunctionWrapper(saasAppBody, "scopes", "toset") + + output := string(file.Bytes()) + + assert.Contains(t, output, `scopes = ["openid", "email", "profile"]`) + assert.NotContains(t, output, "toset") +} diff --git a/internal/transform/state/arrays.go b/internal/transform/state/arrays.go index 2326e4c..5971d62 100644 --- a/internal/transform/state/arrays.go +++ b/internal/transform/state/arrays.go @@ -9,11 +9,12 @@ import ( // ArrayToObjectOptions configures how to transform an array to an object type ArrayToObjectOptions struct { - SkipFields []string // Fields to skip when copying - FieldTransforms map[string]func(gjson.Result) interface{} // Custom transformations per field - RenameFields map[string]string // Old field name -> new field name - DefaultFields map[string]interface{} // Fields to add if not present - EnsureObjectExists bool // If true, create empty object even when field is missing/null/empty array + SkipFields []string // Fields to skip when copying + FieldTransforms map[string]func(gjson.Result) interface{} // Custom transformations per field + RenameFields map[string]string // Old field name -> new field name + DefaultFields map[string]interface{} // Fields to add if not present + EnsureObjectExists bool // If true, create empty object even when field is missing/null/empty array + TransformEmptyToNull bool // If true, empty arrays become null instead of being deleted } // TransformArrayToObject transforms the first element of an array to an object. @@ -205,6 +206,9 @@ func TransformFieldArrayToObject( obj[field] = defaultValue } stateJSON, _ = sjson.Set(stateJSON, path+"."+fieldName, obj) + } else if options.TransformEmptyToNull { + // Set field to null instead of deleting + stateJSON, _ = sjson.Set(stateJSON, path+"."+fieldName, nil) } else { // Remove the field stateJSON, _ = sjson.Delete(stateJSON, path+"."+fieldName) diff --git a/internal/transform/state/arrays_test.go b/internal/transform/state/arrays_test.go index 99fa3a4..5208de3 100644 --- a/internal/transform/state/arrays_test.go +++ b/internal/transform/state/arrays_test.go @@ -391,6 +391,147 @@ func TestTransformFieldArrayToObject_EnsureObjectExists(t *testing.T) { } } +func TestTransformFieldArrayToObject_TransformEmptyToNull(t *testing.T) { + tests := []struct { + name string + inputJSON string + path string + fieldName string + options ArrayToObjectOptions + expectedExists bool + expectedIsNull bool + expectedValue map[string]interface{} + }{ + { + name: "TransformEmptyToNull sets empty array to null", + inputJSON: `{ + "attributes": { + "id": "test-id", + "name": "Test", + "cors_headers": [] + } + }`, + path: "attributes", + fieldName: "cors_headers", + options: ArrayToObjectOptions{ + TransformEmptyToNull: true, + }, + expectedExists: true, + expectedIsNull: true, + }, + { + name: "TransformEmptyToNull transforms non-empty array to object", + inputJSON: `{ + "attributes": { + "id": "test-id", + "cors_headers": [ + { + "allowed_methods": ["GET", "POST"], + "max_age": 3600 + } + ] + } + }`, + path: "attributes", + fieldName: "cors_headers", + options: ArrayToObjectOptions{ + TransformEmptyToNull: true, + }, + expectedExists: true, + expectedIsNull: false, + expectedValue: map[string]interface{}{ + "allowed_methods": []interface{}{"GET", "POST"}, + "max_age": int64(3600), + }, + }, + { + name: "Without TransformEmptyToNull, empty array is deleted (default behavior)", + inputJSON: `{ + "attributes": { + "id": "test-id", + "cors_headers": [] + } + }`, + path: "attributes", + fieldName: "cors_headers", + options: ArrayToObjectOptions{ + TransformEmptyToNull: false, + }, + expectedExists: false, + }, + { + name: "TransformEmptyToNull works with missing field", + inputJSON: `{ + "attributes": { + "id": "test-id", + "name": "Test" + } + }`, + path: "attributes", + fieldName: "cors_headers", + options: ArrayToObjectOptions{ + TransformEmptyToNull: true, + }, + expectedExists: false, + }, + { + name: "EnsureObjectExists takes precedence over TransformEmptyToNull", + inputJSON: `{ + "attributes": { + "id": "test-id", + "cors_headers": [] + } + }`, + path: "attributes", + fieldName: "cors_headers", + options: ArrayToObjectOptions{ + EnsureObjectExists: true, + TransformEmptyToNull: true, + }, + expectedExists: true, + expectedIsNull: false, + expectedValue: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse input JSON + instance := gjson.Parse(tt.inputJSON).Get(tt.path) + require.True(t, instance.Exists(), "Input path should exist") + + // Apply transformation + result := TransformFieldArrayToObject(tt.inputJSON, tt.path, instance, tt.fieldName, tt.options) + + // Parse result + resultParsed := gjson.Parse(result) + fieldPath := tt.path + "." + tt.fieldName + fieldValue := resultParsed.Get(fieldPath) + + // Check expectations + if tt.expectedExists { + assert.True(t, fieldValue.Exists(), "Expected %s to exist", fieldPath) + + if tt.expectedIsNull { + assert.Equal(t, gjson.Null, fieldValue.Type, "Expected %s to be null", fieldPath) + } else if tt.expectedValue != nil { + assert.True(t, fieldValue.IsObject(), "Expected %s to be an object", fieldPath) + + // Convert result to map for comparison + actualMap := make(map[string]interface{}) + fieldValue.ForEach(func(key, value gjson.Result) bool { + actualMap[key.String()] = ConvertGjsonValue(value) + return true + }) + assert.Equal(t, tt.expectedValue, actualMap, "Field values don't match") + } + } else { + assert.False(t, fieldValue.Exists(), "Expected %s to not exist", fieldPath) + } + }) + } +} + func TestFlattenArrayField(t *testing.T) { tests := []struct { name string