diff --git a/examples/console-app/package-lock.json b/examples/console-app/package-lock.json index 810a755f..fb1b22be 100644 --- a/examples/console-app/package-lock.json +++ b/examples/console-app/package-lock.json @@ -1029,9 +1029,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/examples/web-app/package-lock.json b/examples/web-app/package-lock.json index dcd64cc1..9e249f98 100644 --- a/examples/web-app/package-lock.json +++ b/examples/web-app/package-lock.json @@ -8,7 +8,7 @@ "@azure/app-configuration-provider": "*", "@azure/identity": "^4.1.0", "dotenv": "^16.3.1", - "express": "^4.21.2" + "express": "^4.22.1" } }, "node_modules/@azure/abort-controller": { @@ -404,28 +404,58 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -450,6 +480,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -667,38 +698,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -905,6 +937,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -1223,11 +1256,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -1245,19 +1279,49 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -1291,7 +1355,8 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, "node_modules/semver": { "version": "7.7.1", diff --git a/examples/web-app/package.json b/examples/web-app/package.json index 9358ff07..275cafce 100644 --- a/examples/web-app/package.json +++ b/examples/web-app/package.json @@ -3,6 +3,6 @@ "@azure/app-configuration-provider": "latest", "@azure/identity": "^4.1.0", "dotenv": "^16.3.1", - "express": "^4.21.2" + "express": "^4.22.1" } } diff --git a/package-lock.json b/package-lock.json index 7a72793b..f6ac125c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/app-configuration-provider", - "version": "2.4.0-preview", + "version": "2.4.1-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure/app-configuration-provider", - "version": "2.4.0-preview", + "version": "2.4.1-preview", "license": "MIT", "dependencies": { "@azure/app-configuration": "^1.10.0", diff --git a/package.json b/package.json index d3f708a5..a13976b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/app-configuration-provider", - "version": "2.4.0-preview", + "version": "2.4.1-preview", "description": "The JavaScript configuration provider for Azure App Configuration", "files": [ "dist/", diff --git a/src/keyvault/keyVaultSecretProvider.ts b/src/keyvault/keyVaultSecretProvider.ts index 47b21812..2d5e113b 100644 --- a/src/keyvault/keyVaultSecretProvider.ts +++ b/src/keyvault/keyVaultSecretProvider.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { KeyVaultOptions } from "./keyVaultOptions.js"; +import { KeyVaultOptions, MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyVaultOptions.js"; import { RefreshTimer } from "../refresh/refreshTimer.js"; import { ArgumentError } from "../common/errors.js"; import { SecretClient, KeyVaultSecretIdentifier } from "@azure/keyvault-secrets"; @@ -10,6 +10,7 @@ import { KeyVaultReferenceErrorMessages } from "../common/errorMessages.js"; export class AzureKeyVaultSecretProvider { #keyVaultOptions: KeyVaultOptions | undefined; #secretRefreshTimer: RefreshTimer | undefined; + #minSecretRefreshTimer: RefreshTimer; #secretClients: Map; // map key vault hostname to corresponding secret client #cachedSecretValues: Map = new Map(); // map secret identifier to secret value @@ -24,6 +25,7 @@ export class AzureKeyVaultSecretProvider { } this.#keyVaultOptions = keyVaultOptions; this.#secretRefreshTimer = refreshTimer; + this.#minSecretRefreshTimer = new RefreshTimer(MIN_SECRET_REFRESH_INTERVAL_IN_MS); this.#secretClients = new Map(); for (const client of this.#keyVaultOptions?.secretClients ?? []) { const clientUrl = new URL(client.vaultUrl); @@ -47,7 +49,10 @@ export class AzureKeyVaultSecretProvider { } clearCache(): void { - this.#cachedSecretValues.clear(); + if (this.#minSecretRefreshTimer.canRefresh()) { + this.#cachedSecretValues.clear(); + this.#minSecretRefreshTimer.reset(); + } } async #getSecretValueFromKeyVault(secretIdentifier: KeyVaultSecretIdentifier): Promise { diff --git a/src/types.ts b/src/types.ts index d32c93a5..144d93bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,10 +11,7 @@ export type SettingSelector = { * @remarks * An asterisk `*` can be added to the end to return all key-values whose key begins with the key filter. * e.g. key filter `abc*` returns all key-values whose key starts with `abc`. - * A comma `,` can be used to select multiple key-values. Comma separated filters must exactly match a key to select it. - * Using asterisk to select key-values that begin with a key filter while simultaneously using comma separated key filters is not supported. - * E.g. the key filter `abc*,def` is not supported. The key filters `abc*` and `abc,def` are supported. - * For all other cases the characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). + * Characters: asterisk `*`, comma `,`, and backslash `\` are reserved. Reserved characters must be escaped using a backslash (\). * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. */ keyFilter?: string, diff --git a/src/version.ts b/src/version.ts index 07db82c1..bae4a2d7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const VERSION = "2.4.0-preview"; +export const VERSION = "2.4.1-preview"; diff --git a/test/keyvault.test.ts b/test/keyvault.test.ts index 53140077..feeb2941 100644 --- a/test/keyvault.test.ts +++ b/test/keyvault.test.ts @@ -7,7 +7,7 @@ import chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; import { load } from "../src/index.js"; -import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, sleepInMs } from "./utils/testHelper.js"; +import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockAppConfigurationClientGetConfigurationSetting, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference, createMockedKeyValue, sleepInMs } from "./utils/testHelper.js"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import { ErrorMessages, KeyVaultReferenceErrorMessages } from "../src/common/errorMessages.js"; @@ -199,4 +199,151 @@ describe("key vault secret refresh", function () { expect(settings.get("TestKey")).eq("SecretValue - Updated"); }); }); + +describe("min secret refresh interval during key-value refresh", function () { + let getSecretCallCount = 0; + let sentinelEtag = "initial-etag"; + + afterEach(() => { + restoreMocks(); + getSecretCallCount = 0; + }); + + /** + * This test verifies the enforcement of the minimum secret refresh interval during key-value refresh. + * When key-value refresh is triggered (by a watched setting change), the provider calls clearCache() + * on the KeyVaultSecretProvider. However, clearCache() only clears the cache if the minimum secret + * refresh interval (60 seconds) has passed. This prevents overwhelming Key Vaults with too many requests. + */ + it("should not re-fetch secrets when key-value refresh happens within min secret refresh interval", async () => { + // Setup: key vault reference + sentinel key for watching + const kvWithSentinel = [ + createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"), + createMockedKeyValue({ key: "sentinel", value: "initialValue", etag: sentinelEtag }) + ]; + mockAppConfigurationClientListConfigurationSettings([kvWithSentinel]); + mockAppConfigurationClientGetConfigurationSetting(kvWithSentinel); + + // Mock SecretClient with call counting + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client, "getSecret").callsFake(async () => { + getSecretCallCount++; + return { value: "SecretValue" } as KeyVaultSecret; + }); + + // Load with key-value refresh enabled (watching sentinel) + const settings = await load(createMockedConnectionString(), { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000, // 1 second refresh interval for key-values + watchedSettings: [{ key: "sentinel" }] + }, + keyVaultOptions: { + secretClients: [client] + } + }); + + expect(settings.get("TestKey")).eq("SecretValue"); + expect(getSecretCallCount).eq(1); // Initial load fetched the secret + + // Simulate sentinel change to trigger key-value refresh + sentinelEtag = "changed-etag-1"; + const updatedKvs = [ + createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"), + createMockedKeyValue({ key: "sentinel", value: "changedValue1", etag: sentinelEtag }) + ]; + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings([updatedKvs]); + mockAppConfigurationClientGetConfigurationSetting(updatedKvs); + sinon.stub(client, "getSecret").callsFake(async () => { + getSecretCallCount++; + return { value: "SecretValue" } as KeyVaultSecret; + }); + + // Wait for refresh interval and trigger refresh + await sleepInMs(1000 + 100); + await settings.refresh(); + + // Key-value refresh happened, but secret should NOT be re-fetched + // because min secret refresh interval (60s) hasn't passed + expect(getSecretCallCount).eq(1); // Still 1, no additional getSecret call + + // Trigger another key-value refresh + sentinelEtag = "changed-etag-2"; + const updatedKvs2 = [ + createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"), + createMockedKeyValue({ key: "sentinel", value: "changedValue2", etag: sentinelEtag }) + ]; + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings([updatedKvs2]); + mockAppConfigurationClientGetConfigurationSetting(updatedKvs2); + sinon.stub(client, "getSecret").callsFake(async () => { + getSecretCallCount++; + return { value: "SecretValue" } as KeyVaultSecret; + }); + + await sleepInMs(1000 + 100); + await settings.refresh(); + + // Still no additional getSecret call due to min interval enforcement + expect(getSecretCallCount).eq(1); + }); + + it("should re-fetch secrets after min secret refresh interval passes during key-value refresh", async () => { + // Setup: key vault reference + sentinel key for watching + let currentSentinelValue = "initialValue"; + sentinelEtag = "initial-etag"; + + const getKvs = () => [ + createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"), + createMockedKeyValue({ key: "sentinel", value: currentSentinelValue, etag: sentinelEtag }) + ]; + + mockAppConfigurationClientListConfigurationSettings([getKvs()]); + mockAppConfigurationClientGetConfigurationSetting(getKvs()); + + // Mock SecretClient with call counting + const client = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); + sinon.stub(client, "getSecret").callsFake(async () => { + getSecretCallCount++; + return { value: `SecretValue-${getSecretCallCount}` } as KeyVaultSecret; + }); + + // Load with key-value refresh enabled + const settings = await load(createMockedConnectionString(), { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 1000, + watchedSettings: [{ key: "sentinel" }] + }, + keyVaultOptions: { + secretClients: [client] + } + }); + + expect(settings.get("TestKey")).eq("SecretValue-1"); + expect(getSecretCallCount).eq(1); + + // Wait for min secret refresh interval (60 seconds) to pass + await sleepInMs(60_000 + 100); + + // Now change sentinel to trigger key-value refresh + currentSentinelValue = "changedValue"; + sentinelEtag = "changed-etag"; + restoreMocks(); + mockAppConfigurationClientListConfigurationSettings([getKvs()]); + mockAppConfigurationClientGetConfigurationSetting(getKvs()); + sinon.stub(client, "getSecret").callsFake(async () => { + getSecretCallCount++; + return { value: `SecretValue-${getSecretCallCount}` } as KeyVaultSecret; + }); + + await sleepInMs(1000 + 100); // Wait for kv refresh interval + await settings.refresh(); + + // Now getSecret SHOULD be called again because min interval has passed + expect(getSecretCallCount).eq(2); + expect(settings.get("TestKey")).eq("SecretValue-2"); + }); +}); /* eslint-enable @typescript-eslint/no-unused-expressions */