feat(cdn): add geoblocking (#906)

relates to STACKITCDN-841
This commit is contained in:
Christian Hamm 2025-07-15 15:00:53 +02:00 committed by GitHub
parent 6f33262e8c
commit bf9b225cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 287 additions and 44 deletions

View file

@ -43,6 +43,10 @@ data "stackit_cdn_distribution" "example" {
<a id="nestedatt--config"></a> <a id="nestedatt--config"></a>
### Nested Schema for `config` ### Nested Schema for `config`
Optional:
- `blocked_countries` (List of String) The configured countries where distribution of content is blocked
Read-Only: Read-Only:
- `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend)) - `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend))

View file

@ -21,7 +21,8 @@ This guide outlines the process of creating a STACKIT CDN distribution and confi
type = "http" type = "http"
origin_url = "mybackend.onstackit.cloud" origin_url = "mybackend.onstackit.cloud"
} }
regions = ["EU", "US", "ASIA", "AF", "SA"] regions = ["EU", "US", "ASIA", "AF", "SA"]
blocked_countries = ["DE", "AT", "CH"]
} }
} }

View file

@ -23,7 +23,9 @@ resource "stackit_cdn_distribution" "example_distribution" {
type = "http" type = "http"
origin_url = "mybackend.onstackit.cloud" origin_url = "mybackend.onstackit.cloud"
} }
regions = ["EU", "US", "ASIA", "AF", "SA"] regions = ["EU", "US", "ASIA", "AF", "SA"]
blocked_countries = ["DE", "AT", "CH"]
optimizer = { optimizer = {
enabled = true enabled = true
} }
@ -59,6 +61,7 @@ Required:
Optional: Optional:
- `blocked_countries` (List of String) The configured countries where distribution of content is blocked
- `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer))
<a id="nestedatt--config--backend"></a> <a id="nestedatt--config--backend"></a>

View file

@ -5,7 +5,9 @@ resource "stackit_cdn_distribution" "example_distribution" {
type = "http" type = "http"
origin_url = "mybackend.onstackit.cloud" origin_url = "mybackend.onstackit.cloud"
} }
regions = ["EU", "US", "ASIA", "AF", "SA"] regions = ["EU", "US", "ASIA", "AF", "SA"]
blocked_countries = ["DE", "AT", "CH"]
optimizer = { optimizer = {
enabled = true enabled = true
} }

View file

@ -24,6 +24,7 @@ var instanceResource = map[string]string{
"config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud", "config_backend_origin_url": "https://test-backend-1.cdn-dev.runs.onstackit.cloud",
"config_regions": "\"EU\", \"US\"", "config_regions": "\"EU\", \"US\"",
"config_regions_updated": "\"EU\", \"US\", \"ASIA\"", "config_regions_updated": "\"EU\", \"US\", \"ASIA\"",
"blocked_countries": "\"CU\", \"AQ\"", // Do NOT use DE or AT here, because the request might be blocked by bunny at the time of creation - don't lock yourself out
"custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs "custom_domain_prefix": uuid.NewString(), // we use a different domain prefix each test run due to inconsistent upstream release of domains, which might impair consecutive test runs
} }
@ -38,7 +39,9 @@ func configResources(regions string) string {
type = "http" type = "http"
origin_url = "%s" origin_url = "%s"
} }
regions = [%s] regions = [%s]
blocked_countries = [%s]
optimizer = { optimizer = {
enabled = true enabled = true
} }
@ -60,7 +63,9 @@ func configResources(regions string) string {
type = "CNAME" type = "CNAME"
records = ["${stackit_cdn_distribution.distribution.domains[0].name}."] records = ["${stackit_cdn_distribution.distribution.domains[0].name}."]
} }
`, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"], regions, testutil.ProjectId, testutil.ProjectId, instanceResource["custom_domain_prefix"]) `, testutil.CdnProviderConfig(), testutil.ProjectId, instanceResource["config_backend_origin_url"],
regions, instanceResource["blocked_countries"], testutil.ProjectId,
testutil.ProjectId, instanceResource["custom_domain_prefix"])
} }
func configCustomDomainResources(regions string) string { func configCustomDomainResources(regions string) string {
@ -111,6 +116,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),
@ -191,6 +199,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.#", "2"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("data.stackit_cdn_distribution.distribution", "status", "ACTIVE"),
@ -217,6 +228,9 @@ func TestAccCDNDistributionResource(t *testing.T) {
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.0", "EU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.1", "US"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.2", "ASIA"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.regions.2", "ASIA"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.#", "2"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.0", "CU"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.blocked_countries.1", "AQ"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "config.optimizer.enabled", "true"),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "project_id", testutil.ProjectId),
resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"), resource.TestCheckResourceAttr("stackit_cdn_distribution.distribution", "status", "ACTIVE"),

View file

@ -149,6 +149,11 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe
Description: schemaDescriptions["config_regions"], Description: schemaDescriptions["config_regions"],
ElementType: types.StringType, ElementType: types.StringType,
}, },
"blocked_countries": schema.ListAttribute{
Optional: true,
Description: schemaDescriptions["config_blocked_countries"],
ElementType: types.StringType,
},
"optimizer": schema.SingleNestedAttribute{ "optimizer": schema.SingleNestedAttribute{
Description: schemaDescriptions["config_optimizer"], Description: schemaDescriptions["config_optimizer"],
Computed: true, Computed: true,

View file

@ -57,6 +57,7 @@ var schemaDescriptions = map[string]string{
"config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.",
"config_backend_origin_url": "The configured backend type for the distribution", "config_backend_origin_url": "The configured backend type for the distribution",
"config_backend_origin_request_headers": "The configured origin request headers for the backend", "config_backend_origin_request_headers": "The configured origin request headers for the backend",
"config_blocked_countries": "The configured countries where distribution of content is blocked",
"domain_name": "The name of the domain", "domain_name": "The name of the domain",
"domain_status": "The status of the domain", "domain_status": "The status of the domain",
"domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user", "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user",
@ -76,13 +77,16 @@ type Model struct {
} }
type distributionConfig struct { type distributionConfig struct {
Backend backend `tfsdk:"backend"` // The backend associated with the distribution Backend backend `tfsdk:"backend"` // The backend associated with the distribution
Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached
Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked
Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration
} }
type optimizerConfig struct { type optimizerConfig struct {
Enabled types.Bool `tfsdk:"enabled"` Enabled types.Bool `tfsdk:"enabled"`
} }
type backend struct { type backend struct {
Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported
OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend OriginURL string `tfsdk:"origin_url"` // The origin URL of the backend
@ -90,8 +94,9 @@ type backend struct {
} }
var configTypes = map[string]attr.Type{ var configTypes = map[string]attr.Type{
"backend": types.ObjectType{AttrTypes: backendTypes}, "backend": types.ObjectType{AttrTypes: backendTypes},
"regions": types.ListType{ElemType: types.StringType}, "regions": types.ListType{ElemType: types.StringType},
"blocked_countries": types.ListType{ElemType: types.StringType},
"optimizer": types.ObjectType{ "optimizer": types.ObjectType{
AttrTypes: optimizerTypes, AttrTypes: optimizerTypes,
}, },
@ -258,6 +263,11 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques
Description: schemaDescriptions["config_regions"], Description: schemaDescriptions["config_regions"],
ElementType: types.StringType, ElementType: types.StringType,
}, },
"blocked_countries": schema.ListAttribute{
Optional: true,
Description: schemaDescriptions["config_blocked_countries"],
ElementType: types.StringType,
},
}, },
}, },
}, },
@ -378,6 +388,26 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
regions = append(regions, *regionEnum) regions = append(regions, *regionEnum)
} }
// blockedCountries
// Use a pointer to a slice to distinguish between an empty list (unblock all) and nil (no change).
var blockedCountries *[]string
if configModel.BlockedCountries != nil {
// Use a temporary slice
tempBlockedCountries := []string{}
for _, blockedCountry := range *configModel.BlockedCountries {
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Blocked countries: %v", err))
return
}
tempBlockedCountries = append(tempBlockedCountries, validatedBlockedCountry)
}
// Point to the populated slice
blockedCountries = &tempBlockedCountries
}
configPatch := &cdn.ConfigPatch{ configPatch := &cdn.ConfigPatch{
Backend: &cdn.ConfigPatchBackend{ Backend: &cdn.ConfigPatchBackend{
HttpBackendPatch: &cdn.HttpBackendPatch{ HttpBackendPatch: &cdn.HttpBackendPatch{
@ -386,7 +416,8 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
Type: &configModel.Backend.Type, Type: &configModel.Backend.Type,
}, },
}, },
Regions: &regions, Regions: &regions,
BlockedCountries: blockedCountries,
} }
if !utils.IsUndefined(configModel.Optimizer) { if !utils.IsUndefined(configModel.Optimizer) {
@ -411,7 +442,9 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
}).Execute() }).Execute()
if err != nil { if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err)) core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Patch distribution: %v", err))
return
} }
waitResp, err := wait.UpdateDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx) waitResp, err := wait.UpdateDistributionWaitHandler(ctx, r.client, projectId, distributionId).WaitWithContext(ctx)
if err != nil { if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Waiting for update: %v", err)) core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Waiting for update: %v", err))
@ -423,6 +456,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe
core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err)) core.LogAndAddError(ctx, &resp.Diagnostics, "Update CDN distribution", fmt.Sprintf("Processing API payload: %v", err))
return return
} }
diags = resp.State.Set(ctx, model) diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...) resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() { if resp.Diagnostics.HasError() {
@ -501,6 +535,7 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
model.CreatedAt = types.StringValue(distribution.CreatedAt.String()) model.CreatedAt = types.StringValue(distribution.CreatedAt.String())
model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String()) model.UpdatedAt = types.StringValue(distribution.UpdatedAt.String())
// distributionErrors
distributionErrors := []attr.Value{} distributionErrors := []attr.Value{}
if distribution.Errors != nil { if distribution.Errors != nil {
for _, e := range *distribution.Errors { for _, e := range *distribution.Errors {
@ -513,6 +548,7 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
} }
model.Errors = modelErrors model.Errors = modelErrors
// regions
regions := []attr.Value{} regions := []attr.Value{}
for _, r := range *distribution.Config.Regions { for _, r := range *distribution.Config.Regions {
regions = append(regions, types.StringValue(string(r))) regions = append(regions, types.StringValue(string(r)))
@ -521,6 +557,21 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
if diags.HasError() { if diags.HasError() {
return core.DiagsToError(diags) return core.DiagsToError(diags)
} }
// blockedCountries
var blockedCountries []attr.Value
if distribution.Config != nil && distribution.Config.BlockedCountries != nil {
for _, c := range *distribution.Config.BlockedCountries {
blockedCountries = append(blockedCountries, types.StringValue(string(c)))
}
}
modelBlockedCountries, diags := types.ListValue(types.StringType, blockedCountries)
if diags.HasError() {
return core.DiagsToError(diags)
}
// originRequestHeaders
originRequestHeaders := types.MapNull(types.StringType) originRequestHeaders := types.MapNull(types.StringType)
if origHeaders := distribution.Config.Backend.HttpBackend.OriginRequestHeaders; origHeaders != nil && len(*origHeaders) > 0 { if origHeaders := distribution.Config.Backend.HttpBackend.OriginRequestHeaders; origHeaders != nil && len(*origHeaders) > 0 {
headers := map[string]attr.Value{} headers := map[string]attr.Value{}
@ -557,9 +608,10 @@ func mapFields(distribution *cdn.Distribution, model *Model) error {
} }
} }
cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{ cfg, diags := types.ObjectValue(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": modelRegions, "regions": modelRegions,
"optimizer": optimizerVal, "blocked_countries": modelBlockedCountries,
"optimizer": optimizerVal,
}) })
if diags.HasError() { if diags.HasError() {
return core.DiagsToError(diags) return core.DiagsToError(diags)
@ -624,6 +676,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution
IntentId: cdn.PtrString(uuid.NewString()), IntentId: cdn.PtrString(uuid.NewString()),
OriginUrl: cfg.Backend.HttpBackend.OriginUrl, OriginUrl: cfg.Backend.HttpBackend.OriginUrl,
Regions: cfg.Regions, Regions: cfg.Regions,
BlockedCountries: cfg.BlockedCountries,
OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders, OriginRequestHeaders: cfg.Backend.HttpBackend.OriginRequestHeaders,
Optimizer: optimizer, Optimizer: optimizer,
} }
@ -646,6 +699,8 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
if diags.HasError() { if diags.HasError() {
return nil, core.DiagsToError(diags) return nil, core.DiagsToError(diags)
} }
// regions
regions := []cdn.Region{} regions := []cdn.Region{}
for _, r := range *configModel.Regions { for _, r := range *configModel.Regions {
regionEnum, err := cdn.NewRegionFromValue(r) regionEnum, err := cdn.NewRegionFromValue(r)
@ -655,6 +710,19 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
regions = append(regions, *regionEnum) regions = append(regions, *regionEnum)
} }
// blockedCountries
var blockedCountries []string
if configModel.BlockedCountries != nil {
for _, blockedCountry := range *configModel.BlockedCountries {
validatedBlockedCountry, err := validateCountryCode(blockedCountry)
if err != nil {
return nil, err
}
blockedCountries = append(blockedCountries, validatedBlockedCountry)
}
}
// originRequestHeaders
originRequestHeaders := map[string]string{} originRequestHeaders := map[string]string{}
if configModel.Backend.OriginRequestHeaders != nil { if configModel.Backend.OriginRequestHeaders != nil {
for k, v := range *configModel.Backend.OriginRequestHeaders { for k, v := range *configModel.Backend.OriginRequestHeaders {
@ -670,7 +738,8 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
Type: &configModel.Backend.Type, Type: &configModel.Backend.Type,
}, },
}, },
Regions: &regions, Regions: &regions,
BlockedCountries: &blockedCountries,
} }
if !utils.IsUndefined(configModel.Optimizer) { if !utils.IsUndefined(configModel.Optimizer) {
@ -687,3 +756,25 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) {
return cdnConfig, nil return cdnConfig, nil
} }
// validateCountryCode checks for a valid country user input. This is just a quick check
// since the API already does a more thorough check.
func validateCountryCode(country string) (string, error) {
if len(country) != 2 {
return "", errors.New("country code must be exactly 2 characters long")
}
upperCountry := strings.ToUpper(country)
// Check if both characters are alphabetical letters within the ASCII range A-Z.
// Yes, we could use the unicode package, but we are only targeting ASCII letters specifically, so
// let's omit this dependency.
char1 := upperCountry[0]
char2 := upperCountry[1]
if !((char1 >= 'A' && char1 <= 'Z') && (char2 >= 'A' && char2 <= 'Z')) {
return "", fmt.Errorf("country code '%s' must consist of two alphabetical letters (A-Z or a-z)", country)
}
return upperCountry, nil
}

View file

@ -24,13 +24,16 @@ func TestToCreatePayload(t *testing.T) {
}) })
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions) regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{
"enabled": types.BoolValue(true), "enabled": types.BoolValue(true),
}) })
config := types.ObjectValueMust(configTypes, map[string]attr.Value{ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture,
"optimizer": types.ObjectNull(optimizerTypes),
}) })
modelFixture := func(mods ...func(*Model)) *Model { modelFixture := func(mods ...func(*Model)) *Model {
model := &Model{ model := &Model{
@ -55,17 +58,19 @@ func TestToCreatePayload(t *testing.T) {
"testHeader0": "testHeaderValue0", "testHeader0": "testHeaderValue0",
"testHeader1": "testHeaderValue1", "testHeader1": "testHeaderValue1",
}, },
OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
Regions: &[]cdn.Region{"EU", "US"}, Regions: &[]cdn.Region{"EU", "US"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
}, },
IsValid: true, IsValid: true,
}, },
"happy_path_with_optimizer": { "happy_path_with_optimizer": {
Input: modelFixture(func(m *Model) { Input: modelFixture(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": optimizer, "optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
}) })
}), }),
Expected: &cdn.CreateDistributionPayload{ Expected: &cdn.CreateDistributionPayload{
@ -73,9 +78,10 @@ func TestToCreatePayload(t *testing.T) {
"testHeader0": "testHeaderValue0", "testHeader0": "testHeaderValue0",
"testHeader1": "testHeaderValue1", "testHeader1": "testHeaderValue1",
}, },
OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), OriginUrl: cdn.PtrString("https://www.mycoolapp.com"),
Regions: &[]cdn.Region{"EU", "US"}, Regions: &[]cdn.Region{"EU", "US"},
Optimizer: cdn.NewOptimizer(true), Optimizer: cdn.NewOptimizer(true),
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
}, },
IsValid: true, IsValid: true,
}, },
@ -127,11 +133,14 @@ func TestConvertConfig(t *testing.T) {
}) })
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions) regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)}) optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)})
config := types.ObjectValueMust(configTypes, map[string]attr.Value{ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": types.ObjectNull(optimizerTypes), "optimizer": types.ObjectNull(optimizerTypes),
"blocked_countries": blockedCountriesFixture,
}) })
modelFixture := func(mods ...func(*Model)) *Model { modelFixture := func(mods ...func(*Model)) *Model {
model := &Model{ model := &Model{
@ -162,16 +171,18 @@ func TestConvertConfig(t *testing.T) {
Type: cdn.PtrString("http"), Type: cdn.PtrString("http"),
}, },
}, },
Regions: &[]cdn.Region{"EU", "US"}, Regions: &[]cdn.Region{"EU", "US"},
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
}, },
IsValid: true, IsValid: true,
}, },
"happy_path_with_optimizer": { "happy_path_with_optimizer": {
Input: modelFixture(func(m *Model) { Input: modelFixture(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": optimizer, "optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
}) })
}), }),
Expected: &cdn.Config{ Expected: &cdn.Config{
@ -185,8 +196,9 @@ func TestConvertConfig(t *testing.T) {
Type: cdn.PtrString("http"), Type: cdn.PtrString("http"),
}, },
}, },
Regions: &[]cdn.Region{"EU", "US"}, Regions: &[]cdn.Region{"EU", "US"},
Optimizer: cdn.NewOptimizer(true), Optimizer: cdn.NewOptimizer(true),
BlockedCountries: &[]string{"XX", "YY", "ZZ"},
}, },
IsValid: true, IsValid: true,
}, },
@ -237,13 +249,16 @@ func TestMapFields(t *testing.T) {
}) })
regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")} regions := []attr.Value{types.StringValue("EU"), types.StringValue("US")}
regionsFixture := types.ListValueMust(types.StringType, regions) regionsFixture := types.ListValueMust(types.StringType, regions)
blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")}
blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries)
optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{
"enabled": types.BoolValue(true), "enabled": types.BoolValue(true),
}) })
config := types.ObjectValueMust(configTypes, map[string]attr.Value{ config := types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture,
"optimizer": types.ObjectNull(optimizerTypes),
}) })
emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{})
@ -284,8 +299,9 @@ func TestMapFields(t *testing.T) {
Type: cdn.PtrString("http"), Type: cdn.PtrString("http"),
}, },
}, },
Regions: &[]cdn.Region{"EU", "US"}, Regions: &[]cdn.Region{"EU", "US"},
Optimizer: nil, BlockedCountries: &[]string{"XX", "YY", "ZZ"},
Optimizer: nil,
}, },
CreatedAt: &createdAt, CreatedAt: &createdAt,
Domains: &[]cdn.Domain{ Domains: &[]cdn.Domain{
@ -318,9 +334,10 @@ func TestMapFields(t *testing.T) {
"happy_path_with_optimizer": { "happy_path_with_optimizer": {
Expected: expectedModel(func(m *Model) { Expected: expectedModel(func(m *Model) {
m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{
"backend": backend, "backend": backend,
"regions": regionsFixture, "regions": regionsFixture,
"optimizer": optimizer, "optimizer": optimizer,
"blocked_countries": blockedCountriesFixture,
}) })
}), }),
Input: distributionFixture(func(d *cdn.Distribution) { Input: distributionFixture(func(d *cdn.Distribution) {
@ -411,3 +428,108 @@ func TestMapFields(t *testing.T) {
}) })
} }
} }
// TestValidateCountryCode tests the validateCountryCode function with a variety of inputs.
func TestValidateCountryCode(t *testing.T) {
testCases := []struct {
name string
inputCountry string
wantOutput string
expectError bool
expectedError string
}{
// Happy Path
{
name: "Valid lowercase",
inputCountry: "us",
wantOutput: "US",
expectError: false,
},
{
name: "Valid uppercase",
inputCountry: "DE",
wantOutput: "DE",
expectError: false,
},
{
name: "Valid mixed case",
inputCountry: "cA",
wantOutput: "CA",
expectError: false,
},
{
name: "Valid country code FR",
inputCountry: "fr",
wantOutput: "FR",
expectError: false,
},
// Error Scenarios
{
name: "Invalid length - too short",
inputCountry: "a",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
{
name: "Invalid length - too long",
inputCountry: "USA",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
{
name: "Invalid characters - contains number",
inputCountry: "U1",
wantOutput: "",
expectError: true,
expectedError: "country code 'U1' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Invalid characters - contains symbol",
inputCountry: "D!",
wantOutput: "",
expectError: true,
expectedError: "country code 'D!' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Invalid characters - both are numbers",
inputCountry: "42",
wantOutput: "",
expectError: true,
expectedError: "country code '42' must consist of two alphabetical letters (A-Z or a-z)",
},
{
name: "Empty string",
inputCountry: "",
wantOutput: "",
expectError: true,
expectedError: "country code must be exactly 2 characters long",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotOutput, err := validateCountryCode(tc.inputCountry)
if tc.expectError {
if err == nil {
t.Errorf("expected an error for input '%s', but got none", tc.inputCountry)
} else if err.Error() != tc.expectedError {
t.Errorf("for input '%s', expected error '%s', but got '%s'", tc.inputCountry, tc.expectedError, err.Error())
}
if gotOutput != "" {
t.Errorf("expected empty string on error, but got '%s'", gotOutput)
}
} else {
if err != nil {
t.Errorf("did not expect an error for input '%s', but got: %v", tc.inputCountry, err)
}
if gotOutput != tc.wantOutput {
t.Errorf("for input '%s', expected output '%s', but got '%s'", tc.inputCountry, tc.wantOutput, gotOutput)
}
}
})
}
}

View file

@ -21,7 +21,8 @@ This guide outlines the process of creating a STACKIT CDN distribution and confi
type = "http" type = "http"
origin_url = "mybackend.onstackit.cloud" origin_url = "mybackend.onstackit.cloud"
} }
regions = ["EU", "US", "ASIA", "AF", "SA"] regions = ["EU", "US", "ASIA", "AF", "SA"]
blocked_countries = ["DE", "AT", "CH"]
} }
} }