diff --git a/package-lock.json b/package-lock.json index 71d02854..ada9f0d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -208,6 +209,14 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/identity/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/keyvault-secrets": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.7.0.tgz", @@ -272,6 +281,14 @@ "node": "10 || 12 || 14 || 16 || 18" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -3379,9 +3396,14 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -3656,6 +3678,13 @@ "stoppable": "^1.1.0", "tslib": "^2.2.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@azure/keyvault-secrets": { @@ -3705,6 +3734,13 @@ "@azure/msal-common": "13.3.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@babel/code-frame": { @@ -5947,9 +5983,10 @@ } }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true }, "which": { "version": "2.0.2", diff --git a/package.json b/package.json index 148f1718..b354ca12 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "rollup-plugin-dts": "^5.3.0", "sinon": "^15.2.0", "tslib": "^2.6.0", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "uuid": "^9.0.1" }, "dependencies": { "@azure/app-configuration": "^1.4.1", diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 323ac2a8..faaf2524 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -44,7 +44,10 @@ export class AzureAppConfigurationImpl extends Map implements A public async load() { const keyValues: [key: string, value: unknown][] = []; - const selectors = this.options?.selectors ?? [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + + // validate selectors + const selectors = getValidSelectors(this.options?.selectors); + for (const selector of selectors) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, @@ -105,3 +108,23 @@ export class AzureAppConfigurationImpl extends Map implements A return headers; } } + +function getValidSelectors(selectors?: { keyFilter: string, labelFilter?: string }[]) { + if (!selectors || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + } + return selectors.map(selectorCandidate => { + const selector = { ...selectorCandidate }; + if (!selector.keyFilter) { + throw new Error("Key filter cannot be null or empty."); + } + if (!selector.labelFilter) { + selector.labelFilter = LabelFilter.Null; + } + if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { + throw new Error("The characters '*' and ',' are not supported in label filters."); + } + return selector; + }); +} \ No newline at end of file diff --git a/src/AzureAppConfigurationOptions.ts b/src/AzureAppConfigurationOptions.ts index 975cb838..11a9ba5b 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -8,7 +8,26 @@ export const MaxRetries = 2; export const MaxRetryDelayInMs = 60000; export interface AzureAppConfigurationOptions { - selectors?: { keyFilter: string, labelFilter: string }[]; + /** + * Specify what key-values to include in the configuration provider. include multiple sets of key-values + * + * @property keyFilter: + * The key filter to apply when querying Azure App Configuration for key-values. + * 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 (\). + * e.g. the key filter `a\\b\,\*c*` returns all key-values whose key starts with `a\b,*c`. + * + * @property labelFilter: + * The label filter to apply when querying Azure App Configuration for key-values. + * By default, the "null label" will be used, matching key-values without a label. + * The characters asterisk `*` and comma `,` are not supported. + * Backslash `\` character is reserved and must be escaped using another backslash `\`. + */ + selectors?: { keyFilter: string, labelFilter?: string }[]; trimKeyPrefixes?: string[]; clientOptions?: AppConfigurationClientOptions; keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; diff --git a/test/load.test.js b/test/load.test.js index 5a8682a9..36d9d8bf 100644 --- a/test/load.test.js +++ b/test/load.test.js @@ -12,54 +12,30 @@ const { createMockedConnectionString, createMockedEnpoint, createMockedTokenCredential, + createMockedKeyValue, } = require("./utils/testHelper"); const mockedKVs = [{ - value: "red", key: "app.settings.fontColor", - label: null, - contentType: "", - lastModified: "2023-05-04T04:34:24.000Z", - tags: {}, - etag: "210fjkPIWZMjFTi_qyEEmmsJjtUjj0YQl-Y3s1m6GLw", - isReadOnly: false + value: "red", }, { - value: "40", key: "app.settings.fontSize", - label: null, - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: "40", }, { - value: "TestValue", key: "TestKey", label: "Test", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: "TestValue", +}, { + key: "TestKey", + label: "Prod", + value: "TestValueForProd", }, { - value: null, key: "KeyForNullValue", - label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false + value: null, }, { - value: "", key: "KeyForEmptyValue", - label: "", - contentType: "", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false -}]; + value: "", +}].map(createMockedKeyValue); describe("load", function () { before(() => { @@ -118,8 +94,7 @@ describe("load", function () { expect(settings.get("fontColor")).eq("red"); expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("TestKey")).eq(true); - expect(settings.get("TestKey")).eq("TestValue"); + expect(settings.has("TestKey")).eq(false); }); it("should trim longest key prefix first", async () => { @@ -136,8 +111,7 @@ describe("load", function () { expect(settings.get("fontColor")).eq("red"); expect(settings.has("fontSize")).eq(true); expect(settings.get("fontSize")).eq("40"); - expect(settings.has("Key")).eq(true); - expect(settings.get("Key")).eq("TestValue"); + expect(settings.has("TestKey")).eq(false); }); it("should support null/empty value", async () => { @@ -149,4 +123,39 @@ describe("load", function () { expect(settings.has("KeyForEmptyValue")).eq(true); expect(settings.get("KeyForEmptyValue")).eq(""); }); + + it("should not support * or , in label filters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithWildcardLabelFilter = load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "*" + }] + }); + expect(loadWithWildcardLabelFilter).to.eventually.rejected; + + const loadWithMultipleLabelFilter = load(connectionString, { + selectors: [{ + keyFilter: "app.*", + labelFilter: "labelA,labelB" + }] + }); + expect(loadWithMultipleLabelFilter).to.eventually.rejected; + }); + + it("should override config settings with same key but different label", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "Test*", + labelFilter: "Test" + }, { + keyFilter: "Test*", + labelFilter: "Prod" + }] + }); + expect(settings).not.undefined; + expect(settings.has("TestKey")).eq(true); + expect(settings.get("TestKey")).eq("TestValueForProd"); + }); }) diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js index 11e43f71..b58aa859 100644 --- a/test/utils/testHelper.js +++ b/test/utils/testHelper.js @@ -5,16 +5,27 @@ const sinon = require("sinon"); const { AppConfigurationClient } = require("@azure/app-configuration"); const { ClientSecretCredential } = require("@azure/identity"); const { SecretClient } = require("@azure/keyvault-secrets"); +const uuid = require("uuid"); const TEST_CLIENT_ID = "62e76eb5-218e-4f90-8261-000000000000"; const TEST_TENANT_ID = "72f988bf-86f1-41af-91ab-000000000000"; const TEST_CLIENT_SECRET = "Q158Q~2JtUwVbuq0Mzm9ocH2umTB000000000000"; function mockAppConfigurationClientListConfigurationSettings(kvList) { - function* testKvSetGnerator() { - yield* kvList; + function* testKvSetGnerator(kvs) { + yield* kvs; } - sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake(() => testKvSetGnerator()); + sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake((listOptions) => { + const keyFilter = listOptions.keyFilter ?? "*"; + const labelFilter = listOptions.labelFilter ?? "*"; + const kvs = kvList.filter(kv => { + const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, keyFilter.length - 1)) : kv.key === keyFilter; + const labelMatched = labelFilter.endsWith("*") ? kv.label.startsWith(labelFilter.slice(0, labelFilter.length - 1)) + : (labelFilter === "\0" ? kv.label === null : kv.label === labelFilter); // '\0' in labelFilter, null in config setting. + return keyMatched && labelMatched; + }) + return testKvSetGnerator(kvs); + }); } // uriValueList: [["", "value"], ...] @@ -74,6 +85,17 @@ const createMockedJsonKeyValue = (key, value) => ({ isReadOnly: false }); +const createMockedKeyValue = (props) => (Object.assign({ + value: "TestValue", + key: "TestKey", + label: null, + contentType: "", + lastModified: new Date().toISOString(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + module.exports = { sinon, mockAppConfigurationClientListConfigurationSettings, @@ -84,5 +106,6 @@ module.exports = { createMockedConnectionString, createMockedTokenCredential, createMockedKeyVaultReference, - createMockedJsonKeyValue + createMockedJsonKeyValue, + createMockedKeyValue } \ No newline at end of file