diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index f34886e..82cdc03 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -33,7 +33,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tree" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) @@ -55,8 +55,8 @@ type AzureAppConfiguration struct { // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - kvETags map[Selector][]*azcore.ETag - ffETags map[Selector][]*azcore.ETag + kvETags map[comparableSelector][]*azcore.ETag + ffETags map[comparableSelector][]*azcore.ETag keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition @@ -121,7 +121,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.kvETags = make(map[Selector][]*azcore.ETag) + azappcfg.kvETags = make(map[comparableSelector][]*azcore.ETag) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -137,7 +137,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.ffSelectors = getFeatureFlagSelectors(deduplicateSelectors(options.FeatureFlagOptions.Selectors)) if options.FeatureFlagOptions.RefreshOptions.Enabled { azappcfg.ffRefreshTimer = refresh.NewTimer(options.FeatureFlagOptions.RefreshOptions.Interval) - azappcfg.ffETags = make(map[Selector][]*azcore.ETag) + azappcfg.ffETags = make(map[comparableSelector][]*azcore.ETag) } } @@ -759,7 +759,7 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Create a map to track unique selectors - seen := make(map[Selector]struct{}) + seen := make(map[comparableSelector]struct{}) var result []Selector // Process the selectors in reverse order to maintain the behavior @@ -771,8 +771,9 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Check if we've seen this selector before - if _, exists := seen[selectors[i]]; !exists { - seen[selectors[i]] = struct{}{} + key := selectors[i].comparableKey() + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} result = append(result, selectors[i]) } } diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index f68d0ba..681b622 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -16,7 +16,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/fm" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -128,7 +128,7 @@ func TestLoadFeatureFlags_Success(t *testing.T) { {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, {Key: toPtr(".appconfig.featureflag/Alpha"), Value: &value2, ContentType: toPtr(featureFlagContentType)}, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1545,7 +1545,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { ContentType: toPtr(featureFlagContentType), }, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1601,3 +1601,203 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { // Verify max variants is included assert.Contains(t, correlationCtx, tracing.FFMaxVariantsKey+"=3") } + +func TestLoadKeyValues_WithTagFilter(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + // Create mock settings with different tags + value1 := "value1" + value3 := "value3" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key3"), + Value: &value3, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("frontend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[comparableSelector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with single tag filter + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilters: []string{"env=production"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load keys with env=production tag (key1, key3, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value3, azappcfg.keyValues["app:key3"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) + assert.NotContains(t, azappcfg.keyValues, "app:key2") // staging env, should be filtered out +} + +func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + value1 := "value1" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[comparableSelector][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with multiple tag filters (must match ALL) + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilters: []string{"env=production", "team=backend"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load only keys that match BOTH env=production AND team=backend (key1, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) +} + +func TestSelectorComparableKey_WithTagFilter(t *testing.T) { + // Test that selectors with same TagFilter (but different order) produce the same comparable key + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"env=production", "team=backend"}, + } + + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"team=backend", "env=production"}, // Different order + } + + key1 := selector1.comparableKey() + key2 := selector2.comparableKey() + + // Should produce the same comparable key due to sorting + assert.Equal(t, key1, key2) + assert.Equal(t, `["env=production","team=backend"]`, key1.TagFilters) + assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilters) +} + +func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { + // Test that selectors handle special characters in tag values correctly + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{ + `env=prod,staging`, // Comma in value + `description="test,with,quotes"`, // Quotes and commas + `path=c:\windows\system32`, // Backslashes + `json={"key":"value"}`, // JSON in value + }, + } + + key := selector.comparableKey() + + // Verify JSON encoding handles all special characters properly + expected := `["description=\"test,with,quotes\"","env=prod,staging","json={\"key\":\"value\"}","path=c:\\windows\\system32"]` + assert.Equal(t, expected, key.TagFilters) +} + +func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { + // Test empty TagFilter + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{}, + } + + key1 := selector1.comparableKey() + assert.Equal(t, "", key1.TagFilters) + + // Test nil TagFilter (should be handled the same as empty) + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: nil, + } + + key2 := selector2.comparableKey() + assert.Equal(t, "", key2.TagFilters) +} + +func TestSelectorComparableKey_Deterministic(t *testing.T) { + // Test that the same selector always produces the same key + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilters: []string{"z=last", "a=first", "m=middle"}, + } + + key1 := selector.comparableKey() + key2 := selector.comparableKey() + + assert.Equal(t, key1, key2) + assert.Equal(t, `["a=first","m=middle","z=last"]`, key1.TagFilters) // Should be sorted +} diff --git a/azureappconfiguration/client_manager.go b/azureappconfiguration/client_manager.go index 1b125fc..4ffdb97 100644 --- a/azureappconfiguration/client_manager.go +++ b/azureappconfiguration/client_manager.go @@ -17,7 +17,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // configurationClientManager handles creation and management of app configuration clients diff --git a/azureappconfiguration/failover_test.go b/azureappconfiguration/failover_test.go index 2b06e05..c389cc5 100644 --- a/azureappconfiguration/failover_test.go +++ b/azureappconfiguration/failover_test.go @@ -14,7 +14,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 260945b..def7c87 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -2,7 +2,7 @@ module github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration go 1.24.0 -require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 +require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index af0f2ef..292ddcf 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -2,8 +2,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HR github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 h1:uU4FujKFQAz31AbWOO3INV9qfIanHeIUSsGhRlcJJmg= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0/go.mod h1:qr3M3Oy6V98VR0c5tCHKUpaeJTRQh6KYzJewRtFWqfc= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 h1:K7LqZL3VW+DElZhW+5tY/cp2RRFrB3W45WUG/9fhhls= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0/go.mod h1:4IPby+BYf0rPMnMur/mNtowysFd4NoEW5U1vhrkhARA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index ff4cb75..2c30ccf 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -45,8 +45,8 @@ const ( LoadBalancingEnabledTag = "LB" // Feature flag usage tracing - FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" - FMGoVerKey = "FMGoVer" + FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" + FMGoVerKey = "FMGoVer" FeatureFilterTypeKey = "Filter" CustomFilterKey = "CSTM" TimeWindowFilterKey = "TIME" diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index d1f0533..937a72c 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -5,11 +5,13 @@ package azureappconfiguration import ( "context" + "encoding/json" "net/url" + "sort" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // Options contains optional parameters to configure the behavior of an Azure App Configuration provider. @@ -79,6 +81,49 @@ type Selector struct { // SnapshotName specifies the name of the snapshot to retrieve. // If SnapshotName is used in a selector, no key and label filter should be used for it. Otherwise, an error will be returned. SnapshotName string + + // TagFilters specifies which tags to retrieve from Azure App Configuration. + // Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + // Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + TagFilters []string +} + +// comparableKey returns a comparable representation of the Selector that can be used as a map key. +// This method creates a deterministic string representation by sorting the TagFilter slice. +func (s Selector) comparableKey() comparableSelector { + cs := comparableSelector{ + KeyFilter: s.KeyFilter, + LabelFilter: s.LabelFilter, + SnapshotName: s.SnapshotName, + } + + if len(s.TagFilters) > 0 { + // Deduplicate TagFilter + unique := make(map[string]struct{}, len(s.TagFilters)) + var tagFilter []string + for _, tag := range s.TagFilters { + if _, exists := unique[tag]; !exists { + unique[tag] = struct{}{} + tagFilter = append(tagFilter, tag) + } + } + sort.Strings(tagFilter) + + // Use JSON encoding for robust serialization that handles all special characters + tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail + cs.TagFilters = string(tagFilterJSON) + } + + return cs +} + +// comparableSelector is a comparable version of Selector that can be used as a map key. +// It represents the same selector information but with TagFilter as a sorted, JSON-encoded string. +type comparableSelector struct { + KeyFilter string + LabelFilter string + SnapshotName string + TagFilters string // Sorted, JSON-encoded representation of the original TagFilter slice } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index b0fe243..76274a0 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -14,7 +14,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/refresh" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index ca3bbc9..d269322 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -5,6 +5,7 @@ package azureappconfiguration import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -13,13 +14,13 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag - pageETags map[Selector][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag } type selectorSettingsClient struct { @@ -36,7 +37,7 @@ type watchedSettingClient struct { } type pageETagsClient struct { - pageETags map[Selector][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag client *azappconfig.Client tracingOptions tracing.Options } @@ -61,12 +62,13 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } settings := make([]azappconfig.Setting, 0) - pageETags := make(map[Selector][]*azcore.ETag) + pageETags := make(map[comparableSelector][]*azcore.ETag) for _, filter := range s.selectors { if filter.SnapshotName == "" { selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), LabelFilter: to.Ptr(filter.LabelFilter), + TagsFilter: filter.TagFilters, Fields: azappconfig.AllSettingFields(), } @@ -82,7 +84,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } } - pageETags[filter] = eTags + pageETags[filter.comparableKey()] = eTags } else { snapshot, err := s.client.GetSnapshot(ctx, filter.SnapshotName, nil) if err != nil { @@ -167,10 +169,6 @@ func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, er } func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) { - if c.tracingOptions.Enabled { - ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, c.tracingOptions)) - } - for selector, pageETags := range c.pageETags { s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), @@ -178,6 +176,12 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) Fields: azappconfig.AllSettingFields(), } + tagFilters := make([]string, 0) + if selector.TagFilters != "" { + json.Unmarshal([]byte(selector.TagFilters), &tagFilters) + s.TagsFilter = tagFilters + } + conditions := make([]azcore.MatchConditions, 0) for _, eTag := range pageETags { conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: eTag}) diff --git a/azureappconfiguration/snapshot_test.go b/azureappconfiguration/snapshot_test.go index 4506e91..d8ed62e 100644 --- a/azureappconfiguration/snapshot_test.go +++ b/azureappconfiguration/snapshot_test.go @@ -9,7 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 6bee613..aede1ba 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -77,8 +77,8 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { if selector.SnapshotName != "" { - if selector.KeyFilter != "" || selector.LabelFilter != "" { - return fmt.Errorf("key and label filters should not be used if snapshot name is provided") + if selector.KeyFilter != "" || selector.LabelFilter != "" || len(selector.TagFilters) > 0 { + return fmt.Errorf("key, label and tag filters should not be used if snapshot name is provided") } } else { if selector.KeyFilter == "" { @@ -88,6 +88,31 @@ func verifySelectors(selectors []Selector) error { if strings.Contains(selector.LabelFilter, "*") || strings.Contains(selector.LabelFilter, ",") { return fmt.Errorf("label filter cannot contain '*' or ','") } + + if err := validateTagFilters(selector.TagFilters); err != nil { + return err + } + } + } + + return nil +} + +// validateTagFilters validates that each tag filter follows the required format "tagName=tagValue" +// and ensures no more than 5 tag filters are provided. +func validateTagFilters(tagFilters []string) error { + if len(tagFilters) > 5 { + return fmt.Errorf("up to 5 tag filters can be provided, got %d", len(tagFilters)) + } + + for _, tagFilter := range tagFilters { + if tagFilter == "" { + return fmt.Errorf("tag filter cannot be empty") + } + + parts := strings.Split(tagFilter, "=") + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid tag filter: %s. Tag filter must follow the format \"tagName=tagValue\"", tagFilter) } } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 160b7f7..dc1c560 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -186,6 +186,34 @@ func TestVerifySelectors(t *testing.T) { }, expectedError: true, }, + { + name: "valid tag filter", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"environment=production", "team=backend"}}, + }, + expectedError: false, + }, + { + name: "invalid tag filter format", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"invalid_format"}}, + }, + expectedError: true, + }, + { + name: "too many tag filters", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"tag1=val1", "tag2=val2", "tag3=val3", "tag4=val4", "tag5=val5", "tag6=val6"}}, + }, + expectedError: true, + }, + { + name: "tag filter with snapshot (should fail)", + selectors: []Selector{ + {SnapshotName: "my-snapshot", TagFilters: []string{"environment=production"}}, + }, + expectedError: true, + }, } for _, test := range tests { @@ -200,6 +228,86 @@ func TestVerifySelectors(t *testing.T) { } } +func TestValidateTagFilters(t *testing.T) { + tests := []struct { + name string + tagFilters []string + expectedError bool + errorContains string + }{ + { + name: "empty tag filters", + tagFilters: []string{}, + expectedError: false, + }, + { + name: "valid single tag filter", + tagFilters: []string{"environment=production"}, + expectedError: false, + }, + { + name: "valid multiple tag filters", + tagFilters: []string{"environment=production", "team=backend", "version=1.0"}, + expectedError: false, + }, + { + name: "valid tag filter with numbers and special chars", + tagFilters: []string{"tag=value\\,with\\,commas"}, + expectedError: false, + }, + { + name: "too many tag filters (more than 5)", + tagFilters: []string{"tag1=value1", "tag2=value2", "tag3=value3", "tag4=value4", "tag5=value5", "tag6=value6"}, + expectedError: true, + errorContains: "up to 5 tag filters can be provided", + }, + { + name: "empty tag filter string", + tagFilters: []string{""}, + expectedError: true, + errorContains: "tag filter cannot be empty", + }, + { + name: "invalid format - no equals sign", + tagFilters: []string{"environmentproduction"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - empty tag name", + tagFilters: []string{"=production"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - only equals sign", + tagFilters: []string{"="}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "mixed valid and invalid tag filters", + tagFilters: []string{"environment=production", "invalid_format"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTagFilters(test.tagFilters) + if test.expectedError { + assert.Error(t, err) + if test.errorContains != "" { + assert.Contains(t, err.Error(), test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + func TestReverse(t *testing.T) { tests := []struct { name string