diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index a25d941..84f3ed0 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "log" + "maps" "os" "strconv" "strings" @@ -27,21 +28,25 @@ 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" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) // An AzureAppConfiguration is a configuration provider that stores and manages settings sourced from Azure App Configuration. type AzureAppConfiguration struct { - keyValues map[string]any + keyValues map[string]any + kvSelectors []Selector trimPrefixes []string watchedSettings []WatchedSetting - sentinelETags map[WatchedSetting]*azcore.ETag - kvRefreshTimer refresh.Condition - onRefreshSuccess []func() - tracingOptions tracing.Options + sentinelETags map[WatchedSetting]*azcore.ETag + keyVaultRefs map[string]string // unversioned Key Vault references + kvRefreshTimer refresh.Condition + secretRefreshTimer refresh.Condition + onRefreshSuccess []func() + tracingOptions tracing.Options clientManager *configurationClientManager resolver *keyVaultReferenceResolver @@ -96,6 +101,12 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) } + if options.KeyVaultOptions.RefreshOptions.Enabled { + azappcfg.secretRefreshTimer = refresh.NewTimer(options.KeyVaultOptions.RefreshOptions.Interval) + azappcfg.keyVaultRefs = make(map[string]string) + azappcfg.tracingOptions.KeyVaultRefreshConfigured = true + } + if err := azappcfg.load(ctx); err != nil { return nil, err } @@ -187,8 +198,8 @@ func (azappcfg *AzureAppConfiguration) GetBytes(options *ConstructionOptions) ([ // Returns: // - An error if refresh is not configured, or if the refresh operation fails func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { - if azappcfg.kvRefreshTimer == nil { - return fmt.Errorf("refresh is not configured") + if azappcfg.kvRefreshTimer == nil && azappcfg.secretRefreshTimer == nil { + return fmt.Errorf("refresh is not enabled for either key values or Key Vault secrets") } // Try to set refreshInProgress to true, returning false if it was already true @@ -199,19 +210,24 @@ func (azappcfg *AzureAppConfiguration) Refresh(ctx context.Context) error { // Reset the flag when we're done defer azappcfg.refreshInProgress.Store(false) - // Check if it's time to perform a refresh based on the timer interval - if !azappcfg.kvRefreshTimer.ShouldRefresh() { - return nil - } - // Attempt to refresh and check if any values were actually updated - refreshed, err := azappcfg.refreshKeyValues(ctx, azappcfg.newKeyValueRefreshClient()) + keyValueRefreshed, err := azappcfg.refreshKeyValues(ctx, azappcfg.newKeyValueRefreshClient()) if err != nil { return fmt.Errorf("failed to refresh configuration: %w", err) } + // Attempt to reload Key Vault secrets and check if any values were actually updated + // No need to reload Key Vault secrets if key values are refreshed + secretRefreshed := false + if !keyValueRefreshed { + secretRefreshed, err = azappcfg.refreshKeyVaultSecrets(ctx) + if err != nil { + return fmt.Errorf("failed to reload Key Vault secrets: %w", err) + } + } + // Only execute callbacks if actual changes were applied - if refreshed { + if keyValueRefreshed || secretRefreshed { for _, callback := range azappcfg.onRefreshSuccess { if callback != nil { callback() @@ -280,9 +296,8 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin return err } - var useAIConfiguration, useAIChatCompletionConfiguration bool - kvSettings := make(map[string]any, len(settingsResponse.settings)) - keyVaultRefs := make(map[string]string) + // de-duplicate settings + rawSettings := make(map[string]azappconfig.Setting, len(settingsResponse.settings)) for _, setting := range settingsResponse.settings { if setting.Key == nil { continue @@ -292,7 +307,13 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin log.Printf("Key of the setting '%s' is trimmed to the empty string, just ignore it", *setting.Key) continue } + rawSettings[trimmedKey] = setting + } + var useAIConfiguration, useAIChatCompletionConfiguration bool + kvSettings := make(map[string]any, len(settingsResponse.settings)) + keyVaultRefs := make(map[string]string) + for trimmedKey, setting := range rawSettings { if setting.ContentType == nil || setting.Value == nil { kvSettings[trimmedKey] = setting.Value continue @@ -326,43 +347,63 @@ func (azappcfg *AzureAppConfiguration) loadKeyValues(ctx context.Context, settin azappcfg.tracingOptions.UseAIConfiguration = useAIConfiguration azappcfg.tracingOptions.UseAIChatCompletionConfiguration = useAIChatCompletionConfiguration - var eg errgroup.Group - resolvedSecrets := sync.Map{} - if len(keyVaultRefs) > 0 { - if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil { - return fmt.Errorf("no Key Vault credential or SecretResolver configured") - } + secrets, err := azappcfg.loadKeyVaultSecrets(ctx, keyVaultRefs) + if err != nil { + return fmt.Errorf("failed to load Key Vault secrets: %w", err) + } - for key, kvRef := range keyVaultRefs { - key, kvRef := key, kvRef - eg.Go(func() error { - resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef) - if err != nil { - return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error()) - } - resolvedSecrets.Store(key, resolvedSecret) - return nil - }) - } + maps.Copy(kvSettings, secrets) + azappcfg.keyValues = kvSettings + azappcfg.keyVaultRefs = getUnversionedKeyVaultRefs(keyVaultRefs) - if err := eg.Wait(); err != nil { - return err - } + return nil +} + +func (azappcfg *AzureAppConfiguration) loadKeyVaultSecrets(ctx context.Context, keyVaultRefs map[string]string) (map[string]any, error) { + secrets := make(map[string]any) + if len(keyVaultRefs) == 0 { + return secrets, nil + } + + if azappcfg.resolver.credential == nil && azappcfg.resolver.secretResolver == nil { + return secrets, fmt.Errorf("no Key Vault credential or SecretResolver was configured in KeyVaultOptions") + } + + resolvedSecrets := sync.Map{} + var eg errgroup.Group + for key, kvRef := range keyVaultRefs { + key, kvRef := key, kvRef + eg.Go(func() error { + resolvedSecret, err := azappcfg.resolver.resolveSecret(ctx, kvRef) + if err != nil { + return fmt.Errorf("fail to resolve the Key Vault reference '%s': %s", key, err.Error()) + } + resolvedSecrets.Store(key, resolvedSecret) + return nil + }) + } + + if err := eg.Wait(); err != nil { + return secrets, fmt.Errorf("failed to resolve Key Vault references: %w", err) } resolvedSecrets.Range(func(key, value interface{}) bool { - kvSettings[key.(string)] = value.(string) + secrets[key.(string)] = value.(string) return true }) - azappcfg.keyValues = kvSettings - - return nil + return secrets, nil } // refreshKeyValues checks if any watched settings have changed and reloads configuration if needed // Returns true if configuration was actually refreshed, false otherwise func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, refreshClient refreshClient) (bool, error) { + if azappcfg.kvRefreshTimer == nil || + !azappcfg.kvRefreshTimer.ShouldRefresh() { + // Timer not expired, no need to refresh + return false, nil + } + // Check if any ETags have changed eTagChanged, err := refreshClient.monitor.checkIfETagChanged(ctx) if err != nil { @@ -402,6 +443,40 @@ func (azappcfg *AzureAppConfiguration) refreshKeyValues(ctx context.Context, ref return true, nil } +func (azappcfg *AzureAppConfiguration) refreshKeyVaultSecrets(ctx context.Context) (bool, error) { + if azappcfg.secretRefreshTimer == nil || + !azappcfg.secretRefreshTimer.ShouldRefresh() { + // Timer not expired, no need to refresh + return false, nil + } + + if len(azappcfg.keyVaultRefs) == 0 { + azappcfg.secretRefreshTimer.Reset() + return false, nil + } + + unversionedSecrets, err := azappcfg.loadKeyVaultSecrets(ctx, azappcfg.keyVaultRefs) + if err != nil { + return false, fmt.Errorf("failed to reload Key Vault secrets: %w", err) + } + + // Check if any secrets have changed + changed := false + keyValues := make(map[string]any) + maps.Copy(keyValues, azappcfg.keyValues) + for key, newSecret := range unversionedSecrets { + if oldSecret, exists := keyValues[key]; !exists || oldSecret != newSecret { + changed = true + keyValues[key] = newSecret + } + } + + // Reset the timer only after successful refresh + azappcfg.keyValues = keyValues + azappcfg.secretRefreshTimer.Reset() + return changed, nil +} + func (azappcfg *AzureAppConfiguration) trimPrefix(key string) string { result := key for _, prefix := range azappcfg.trimPrefixes { diff --git a/azureappconfiguration/constants.go b/azureappconfiguration/constants.go index 028387d..204be8a 100644 --- a/azureappconfiguration/constants.go +++ b/azureappconfiguration/constants.go @@ -25,4 +25,6 @@ const ( const ( // minimalRefreshInterval is the minimum allowed refresh interval for key-value settings minimalRefreshInterval time.Duration = time.Second + // minimalKeyVaultRefreshInterval is the minimum allowed refresh interval for Key Vault references + minimalKeyVaultRefreshInterval time.Duration = 1 * time.Minute ) diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index fdecf93..f677eb2 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -35,6 +35,7 @@ const ( RequestTypeKey = "RequestType" HostTypeKey = "Host" KeyVaultConfiguredTag = "UsesKeyVault" + KeyVaultRefreshConfiguredTag = "RefreshesKeyVault" FeaturesKey = "Features" AIConfigurationTag = "AI" AIChatCompletionConfigurationTag = "AICC" @@ -52,6 +53,7 @@ type Options struct { InitialLoadFinished bool Host HostType KeyVaultConfigured bool + KeyVaultRefreshConfigured bool UseAIConfiguration bool UseAIChatCompletionConfiguration bool } @@ -87,7 +89,10 @@ func CreateCorrelationContextHeader(ctx context.Context, options Options) http.H if options.KeyVaultConfigured { output = append(output, KeyVaultConfiguredTag) + } + if options.KeyVaultRefreshConfigured { + output = append(output, KeyVaultRefreshConfiguredTag) } features := make([]string, 0) diff --git a/azureappconfiguration/internal/tracing/tracing_test.go b/azureappconfiguration/internal/tracing/tracing_test.go index 86336b0..4729901 100644 --- a/azureappconfiguration/internal/tracing/tracing_test.go +++ b/azureappconfiguration/internal/tracing/tracing_test.go @@ -71,6 +71,20 @@ func TestCreateCorrelationContextHeader(t *testing.T) { assert.Contains(t, corrContext, KeyVaultConfiguredTag) }) + t.Run("with KeyVaultRefresh configured", func(t *testing.T) { + ctx := context.Background() + options := Options{ + KeyVaultConfigured: true, + KeyVaultRefreshConfigured: true, + } + + header := CreateCorrelationContextHeader(ctx, options) + + // Should contain KeyVaultRefreshConfiguredTag + corrContext := header.Get(CorrelationContextHeader) + assert.Contains(t, corrContext, KeyVaultRefreshConfiguredTag) + }) + t.Run("with AI configuration", func(t *testing.T) { ctx := context.Background() options := Options{ diff --git a/azureappconfiguration/keyvault.go b/azureappconfiguration/keyvault.go index 5e607ab..b224fed 100644 --- a/azureappconfiguration/keyvault.go +++ b/azureappconfiguration/keyvault.go @@ -140,3 +140,19 @@ func parse(reference string) (*secretMetadata, error) { version: secretVersion, }, nil } + +func getUnversionedKeyVaultRefs(refs map[string]string) map[string]string { + unversionedRefs := make(map[string]string) + for key, value := range refs { + var kvRef keyVaultReference + // If it is an invalid key vault reference, error will be returned when resolveSecret is called + json.Unmarshal([]byte(value), &kvRef) + + // Parse the URI to get metadata (host, secret name, version) + if secretMeta, _ := parse(kvRef.URI); secretMeta != nil && secretMeta.version == "" { + unversionedRefs[key] = value + } + } + + return unversionedRefs +} diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index a8e0d81..9716786 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -104,6 +104,19 @@ type KeyVaultOptions struct { // SecretResolver specifies a custom implementation for resolving Key Vault references. // When provided, this takes precedence over using the default resolver with Credential. SecretResolver SecretResolver + + // RefreshOptions specifies the behavior of Key Vault secrets refresh. + // Sets the refresh interval for periodically reloading secrets from Key Vault, must be greater than 1 minute. + RefreshOptions RefreshOptions +} + +// RefreshOptions contains optional parameters to configure the behavior of refresh +type RefreshOptions struct { + // Interval specifies the minimum time interval between consecutive refresh operations + Interval time.Duration + + // Enabled specifies whether the provider should automatically refresh when data is changed. + Enabled bool } // ConstructionOptions contains parameters for parsing keys with hierarchical structure. diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index c2ed49d..d8a3ef4 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -5,7 +5,10 @@ package azureappconfiguration import ( "context" + "encoding/json" "fmt" + "net/url" + "sync" "testing" "time" @@ -54,23 +57,7 @@ func TestRefresh_NotConfigured(t *testing.T) { // Verify that an error is returned require.Error(t, err) - assert.Contains(t, err.Error(), "refresh is not configured") -} - -func TestRefresh_NotTimeToRefresh(t *testing.T) { - // Setup a provider with a timer that indicates it's not time to refresh - mockTimer := &mockRefreshCondition{shouldRefresh: false} - azappcfg := &AzureAppConfiguration{ - kvRefreshTimer: mockTimer, - } - - // Attempt to refresh - err := azappcfg.Refresh(context.Background()) - - // Verify no error and that we returned early - assert.NoError(t, err) - // Timer should not be reset if we're not refreshing - assert.False(t, mockTimer.resetCalled) + assert.Contains(t, err.Error(), "refresh is not enabled for either key values or Key Vault secrets") } func TestRefreshEnabled_EmptyWatchedSettings(t *testing.T) { @@ -231,7 +218,7 @@ func (m *mockKvRefreshClient) getSettings(ctx context.Context) (*settingsRespons // TestRefreshKeyValues_NoChanges tests when no ETags change is detected func TestRefreshKeyValues_NoChanges(t *testing.T) { // Setup mocks - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: false} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -262,7 +249,7 @@ func TestRefreshKeyValues_NoChanges(t *testing.T) { // TestRefreshKeyValues_ChangesDetected tests when ETags changed and reload succeeds func TestRefreshKeyValues_ChangesDetected(t *testing.T) { // Setup mocks for successful refresh - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -294,7 +281,7 @@ func TestRefreshKeyValues_ChangesDetected(t *testing.T) { // TestRefreshKeyValues_LoaderError tests when loader client returns an error func TestRefreshKeyValues_LoaderError(t *testing.T) { // Setup mocks with loader error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{err: fmt.Errorf("loader error")} mockSentinels := &mockKvRefreshClient{} @@ -325,7 +312,7 @@ func TestRefreshKeyValues_LoaderError(t *testing.T) { // TestRefreshKeyValues_SentinelError tests when sentinel client returns an error func TestRefreshKeyValues_SentinelError(t *testing.T) { // Setup mocks with sentinel error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{changed: true} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{err: fmt.Errorf("sentinel error")} @@ -358,7 +345,7 @@ func TestRefreshKeyValues_SentinelError(t *testing.T) { // TestRefreshKeyValues_MonitorError tests when monitor client returns an error func TestRefreshKeyValues_MonitorError(t *testing.T) { // Setup mocks with monitor error - mockTimer := &mockRefreshCondition{} + mockTimer := &mockRefreshCondition{shouldRefresh: true} mockMonitor := &mockETagsClient{err: fmt.Errorf("monitor error")} mockLoader := &mockKvRefreshClient{} mockSentinels := &mockKvRefreshClient{} @@ -403,3 +390,176 @@ func TestRefresh_AlreadyInProgress(t *testing.T) { // Verify no error and that we returned early assert.NoError(t, err) } + +func TestRefreshKeyVaultSecrets_WithMockResolver_Scenarios(t *testing.T) { + // resolutionInstruction defines how a specific Key Vault URI should be resolved by the mock. + type resolutionInstruction struct { + Value string + Err error + } + + tests := []struct { + name string + description string // Optional: for more clarity + + // Initial state for AzureAppConfiguration + initialTimer refresh.Condition + initialKeyVaultRefs map[string]string // map[appConfigKey]jsonURIString -> e.g., {"secretAppKey": `{"uri":"https://mykv.vault.azure.net/secrets/mysecret"}`} + initialKeyValues map[string]any // map[appConfigKey]currentValue + + // Configuration for the mockSecretResolver + // map[actualURIString]resolutionInstruction -> e.g., {"https://mykv.vault.azure.net/secrets/mysecret": {Value: "resolvedValue", Err: nil}} + secretResolutionConfig map[string]resolutionInstruction + + // Expected outcomes + expectedChanged bool + expectedErrSubstring string // Substring of the error expected from refreshKeyVaultSecrets + expectedTimerReset bool + expectedFinalKeyValues map[string]any + }{ + { + name: "Timer is nil", + initialTimer: nil, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://kv.com/s/s1/"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1"}, + expectedChanged: false, + expectedTimerReset: false, + expectedFinalKeyValues: map[string]any{"appSecret1": "oldVal1"}, + }, + { + name: "Timer not expired", + initialTimer: &mockRefreshCondition{shouldRefresh: false}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://kv.com/s/s1/"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1"}, + expectedChanged: false, + expectedTimerReset: false, + expectedFinalKeyValues: map[string]any{"appSecret1": "oldVal1"}, + }, + { + name: "No keyVaultRefs, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{}, + initialKeyValues: map[string]any{"appKey": "appVal"}, + expectedChanged: false, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{"appKey": "appVal"}, + }, + { + name: "Secrets not changed, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`}, + initialKeyValues: map[string]any{"appSecret1": "currentVal", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "currentVal"}, + }, + expectedChanged: false, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{"appSecret1": "currentVal", "appKey": "appVal"}, + }, + { + name: "Secrets changed - existing secret updated, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"appSecret1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`}, + initialKeyValues: map[string]any{"appSecret1": "oldVal1", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "newVal1"}, + }, + expectedChanged: true, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{ + "appSecret1": "newVal1", + "appKey": "appVal", + }, + }, + { + name: "Secrets changed - mix of updated, unchanged, timer ready", + initialTimer: &mockRefreshCondition{shouldRefresh: true}, + initialKeyVaultRefs: map[string]string{"s1": `{"uri":"https://myvault.vault.azure.net/secrets/s1"}`, "s3": `{"uri":"https://myvault.vault.azure.net/secrets/s3"}`}, + initialKeyValues: map[string]any{"s1": "oldVal1", "s3": "val3Unchanged", "appKey": "appVal"}, + secretResolutionConfig: map[string]resolutionInstruction{ + "https://myvault.vault.azure.net/secrets/s1": {Value: "newVal1"}, + "https://myvault.vault.azure.net/secrets/s3": {Value: "val3Unchanged"}, + }, + expectedChanged: true, + expectedTimerReset: true, + expectedFinalKeyValues: map[string]any{ + "s1": "newVal1", + "s3": "val3Unchanged", + "appKey": "appVal", + }, + }, + } + + ctx := context.Background() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup + currentKeyValues := make(map[string]any) + if tc.initialKeyValues != nil { + for k, v := range tc.initialKeyValues { + currentKeyValues[k] = v + } + } + + mockResolver := new(mockSecretResolver) + azappcfg := &AzureAppConfiguration{ + secretRefreshTimer: tc.initialTimer, + keyVaultRefs: tc.initialKeyVaultRefs, + keyValues: currentKeyValues, + resolver: &keyVaultReferenceResolver{ + clients: sync.Map{}, + secretResolver: mockResolver, + }, + } + + if tc.initialKeyVaultRefs != nil && tc.secretResolutionConfig != nil { + for _, jsonRefString := range tc.initialKeyVaultRefs { + var kvRefInternal struct { // Re-declare locally or use the actual keyVaultReference type if accessible + URI string `json:"uri"` + } + err := json.Unmarshal([]byte(jsonRefString), &kvRefInternal) + if err != nil { + continue + } + actualURIString := kvRefInternal.URI + if actualURIString == "" { + continue + } + + if instruction, ok := tc.secretResolutionConfig[actualURIString]; ok { + parsedURL, parseErr := url.Parse(actualURIString) + require.NoError(t, parseErr, "Test setup: Failed to parse URI for mock expectation: %s", actualURIString) + mockResolver.On("ResolveSecret", ctx, *parsedURL).Return(instruction.Value, instruction.Err).Once() + } + } + } + + // Execute + changed, err := azappcfg.refreshKeyVaultSecrets(context.Background()) + + // Assert Error + if tc.expectedErrSubstring != "" { + require.Error(t, err, "Expected an error but got nil") + assert.Contains(t, err.Error(), tc.expectedErrSubstring, "Error message mismatch") + } else { + require.NoError(t, err, "Expected no error but got: %v", err) + } + + // Assert Changed Flag + assert.Equal(t, tc.expectedChanged, changed, "Changed flag mismatch") + + // Assert Timer Reset + if mockTimer, ok := tc.initialTimer.(*mockRefreshCondition); ok { + assert.Equal(t, tc.expectedTimerReset, mockTimer.resetCalled, "Timer reset state mismatch") + } else if tc.initialTimer == nil { + assert.False(t, tc.expectedTimerReset, "Timer was nil, reset should not be expected") + } + + // Assert Final KeyValues + assert.Equal(t, tc.expectedFinalKeyValues, azappcfg.keyValues, "Final keyValues mismatch") + + // Verify mock expectations + mockResolver.AssertExpectations(t) + }) + } +} diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index f567360..6bb473a 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -55,6 +55,13 @@ func verifyOptions(options *Options) error { } } + if options.KeyVaultOptions.RefreshOptions.Enabled { + if options.KeyVaultOptions.RefreshOptions.Interval != 0 && + options.KeyVaultOptions.RefreshOptions.Interval < minimalRefreshInterval { + return fmt.Errorf("refresh interval of Key Vault secrets cannot be less than %s", minimalKeyVaultRefreshInterval) + } + } + return nil }