diff --git a/package-lock.json b/package-lock.json index 9aa77a9d..393863eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.1.0", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", "jsonc-parser": "^3.3.1" @@ -58,11 +58,11 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.6.1.tgz", - "integrity": "sha512-pk8zyG/8Nc6VN7uDA9QY19UFhTXneUbnB+5IcW9uuPyVDXU17TcXBI4xY1ZBm7hmhn0yh3CeZK4kOxa/tjsMqQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.9.0.tgz", + "integrity": "sha512-X0AVDQygL4AGLtplLYW+W0QakJpJ417sQldOacqwcBQ882tAPdUVs6V3mZ4jUjwVsgr+dV1v9zMmijvsp6XBxA==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-http-compat": "^2.0.0", @@ -78,6 +78,17 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", @@ -91,6 +102,17 @@ "node": ">=14.0.0" } }, + "node_modules/@azure/app-configuration/node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@azure/core-auth": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", @@ -1234,6 +1256,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3231,6 +3254,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } diff --git a/package.json b/package.json index 57e76127..bb2afc37 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "uuid": "^9.0.1" }, "dependencies": { - "@azure/app-configuration": "^1.6.1", + "@azure/app-configuration": "^1.8.0", "@azure/identity": "^4.2.1", "@azure/keyvault-secrets": "^4.7.0", "jsonc-parser": "^3.3.1" diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 7c1263cd..011c2017 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -491,7 +491,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (selector.snapshotName === undefined) { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, - labelFilter: selector.labelFilter + labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters }; const pageEtags: string[] = []; const pageIterator = listConfigurationSettingsWithTrace( @@ -727,6 +728,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { const listOptions: ListConfigurationSettingsOptions = { keyFilter: selector.keyFilter, labelFilter: selector.labelFilter, + tagsFilter: selector.tagFilters, pageEtags: selector.pageEtags }; @@ -966,7 +968,11 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector // below code deduplicates selectors, the latter selector wins const uniqueSelectors: SettingSelector[] = []; for (const selector of selectors) { - const existingSelectorIndex = uniqueSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter && s.snapshotName === selector.snapshotName); + const existingSelectorIndex = uniqueSelectors.findIndex( + s => s.keyFilter === selector.keyFilter && + s.labelFilter === selector.labelFilter && + s.snapshotName === selector.snapshotName && + areTagFiltersEqual(s.tagFilters, selector.tagFilters)); if (existingSelectorIndex >= 0) { uniqueSelectors.splice(existingSelectorIndex, 1); } @@ -976,8 +982,8 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector return uniqueSelectors.map(selectorCandidate => { const selector = { ...selectorCandidate }; if (selector.snapshotName) { - if (selector.keyFilter || selector.labelFilter) { - throw new ArgumentError("Key or label filter should not be used for a snapshot."); + if (selector.keyFilter || selector.labelFilter || selector.tagFilters) { + throw new ArgumentError("Key, label or tag filters should not be specified while selecting a snapshot."); } } else { if (!selector.keyFilter) { @@ -989,11 +995,31 @@ function getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector if (selector.labelFilter.includes("*") || selector.labelFilter.includes(",")) { throw new ArgumentError("The characters '*' and ',' are not supported in label filters."); } + if (selector.tagFilters) { + validateTagFilters(selector.tagFilters); + } } return selector; }); } +function areTagFiltersEqual(tagsA?: string[], tagsB?: string[]): boolean { + if (!tagsA && !tagsB) { + return true; + } + if (!tagsA || !tagsB) { + return false; + } + if (tagsA.length !== tagsB.length) { + return false; + } + + const sortedStringA = [...tagsA].sort().join("\n"); + const sortedStringB = [...tagsB].sort().join("\n"); + + return sortedStringA === sortedStringB; +} + function getValidKeyValueSelectors(selectors?: SettingSelector[]): SettingSelector[] { if (selectors === undefined || selectors.length === 0) { // Default selector: key: *, label: \0 @@ -1014,3 +1040,12 @@ function getValidFeatureFlagSelectors(selectors?: SettingSelector[]): SettingSel }); return getValidSettingSelectors(selectors); } + +function validateTagFilters(tagFilters: string[]): void { + for (const tagFilter of tagFilters) { + const res = tagFilter.split("="); + if (res[0] === "" || res.length !== 2) { + throw new Error(`Invalid tag filter: ${tagFilter}. Tag filter must follow the format "tagName=tagValue".`); + } + } +} diff --git a/src/types.ts b/src/types.ts index bef8b6b9..21ce23f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,16 @@ export type SettingSelector = { */ labelFilter?: string + /** + * The tag filter to apply when querying Azure App Configuration for key-values. + * + * @remarks + * Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + * Built in tag filter value is `TagFilter.Null`, which indicates the tag has no value. For example, `tagName=${TagFilter.Null}` will match all key-values with the tag "tagName" that has no value. + * 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[] + /** * The name of snapshot to load from App Configuration. * @@ -59,3 +69,13 @@ export enum LabelFilter { */ Null = "\0" } + +/** + * TagFilter is used to filter key-values based on tags. + */ +export enum TagFilter { + /** + * Represents empty tag value. + */ + Null = "" +} diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 14586cf7..3c42007c 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -55,6 +55,9 @@ const mockedKVs = [{ createMockedFeatureFlag("FlagWithTestLabel", { enabled: true }, {label: "Test"}), createMockedFeatureFlag("Alpha_1", { enabled: true }), createMockedFeatureFlag("Alpha_2", { enabled: false }), + createMockedFeatureFlag("DevFeatureFlag", { enabled: true }, { tags: { "environment": "dev" } }), + createMockedFeatureFlag("ProdFeatureFlag", { enabled: false }, { tags: { "environment": "prod" } }), + createMockedFeatureFlag("TaggedFeature", { enabled: true }, { tags: { "team": "backend", "priority": "high" } }), createMockedFeatureFlag("Telemetry_1", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag"}), createMockedFeatureFlag("Telemetry_2", { enabled: true, telemetry: { enabled: true } }, { etag: "ETag", label: "Test"}), createMockedFeatureFlag("NoPercentileAndSeed", { @@ -338,6 +341,78 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + it("should load feature flags using tag filters", async () => { + const connectionString = createMockedConnectionString(); + + // Test filtering by environment=dev tag + const settingsWithDevTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=dev"] + }] + } + }); + + expect(settingsWithDevTag).not.undefined; + expect(settingsWithDevTag.get("feature_management")).not.undefined; + let featureFlags = settingsWithDevTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("DevFeatureFlag"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by environment=prod tag + const settingsWithProdTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["environment=prod"] + }] + } + }); + + featureFlags = settingsWithProdTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("ProdFeatureFlag"); + expect(featureFlags[0].enabled).equals(false); + + // Test filtering by multiple tags (team=backend AND priority=high) + const settingsWithMultipleTags = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["team=backend", "priority=high"] + }] + } + }); + + featureFlags = settingsWithMultipleTags.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(1); + expect(featureFlags[0].id).equals("TaggedFeature"); + expect(featureFlags[0].enabled).equals(true); + + // Test filtering by non-existent tag + const settingsWithNonExistentTag = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["nonexistent=tag"] + }] + } + }); + + featureFlags = settingsWithNonExistentTag.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + expect((featureFlags as []).length).equals(0); + }); + it("should load feature flags from snapshot", async () => { const snapshotName = "Test"; mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); diff --git a/test/load.test.ts b/test/load.test.ts index 7806789d..244782e9 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -29,10 +29,12 @@ const mockedKVs = [{ }, { key: "TestKey", label: "Test", + tags: {"testTag": ""}, value: "TestValue", }, { key: "TestKey", label: "Prod", + tags: {"testTag": ""}, value: "TestValueForProd", }, { key: "KeyForNullValue", @@ -73,6 +75,30 @@ const mockedKVs = [{ } }), contentType: "application/vnd.microsoft.appconfig.ff+json;charset=utf-8" +}, { + key: "keyWithMultipleTags", + value: "someValue", + tags: {"tag1": "someValue", "tag2": "someValue"} +}, { + key: "keyWithTag1", + value: "someValue1", + tags: {"tag1": "someValue"} +}, { + key: "keyWithTag2", + value: "someValue2", + tags: {"tag2": "someValue"} +}, { + key: "keyWithNullTag", + value: "valueWithNullTag", + tags: {"nullTag": null} +}, { + key: "keyWithEscapedComma", + value: "valueWithEscapedComma", + tags: {"tag": "value\\,with\\,commas"} +}, { + key: "keyWithEmptyTag", + value: "valueWithEmptyTag", + tags: {"emptyTag": ""} } ].map(createMockedKeyValue); @@ -138,7 +164,7 @@ describe("load", function () { snapshotName: "Test", labelFilter: "\0" }] - })).eventually.rejectedWith("Key or label filter should not be used for a snapshot."); + })).eventually.rejectedWith("Key, label or tag filters should not be specified while selecting a snapshot."); }); it("should not include feature flags directly in the settings", async () => { @@ -165,6 +191,79 @@ describe("load", function () { expect(settings.get("app.settings.fontFamily")).undefined; }); + it("should filter by tags, has(key) and get(key) should work", async () => { + const connectionString = createMockedConnectionString(); + const loadWithTag1 = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag1=someValue"] + }] + }); + expect(loadWithTag1.has("keyWithTag1")).true; + expect(loadWithTag1.get("keyWithTag1")).eq("someValue1"); + expect(loadWithTag1.has("keyWithTag2")).false; + expect(loadWithTag1.has("keyWithMultipleTags")).true; + expect(loadWithTag1.get("keyWithMultipleTags")).eq("someValue"); + + const loadWithMultipleTags = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag1=someValue", "tag2=someValue"] + }] + }); + expect(loadWithMultipleTags.has("keyWithTag1")).false; + expect(loadWithMultipleTags.has("keyWithTag2")).false; + expect(loadWithMultipleTags.has("keyWithMultipleTags")).true; + expect(loadWithMultipleTags.get("keyWithMultipleTags")).eq("someValue"); + }); + + it("should filter by nullTag to load key values with null tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithNullTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["nullTag=\0"] + }] + }); + + // Should include only key values with nullTag=\0 + expect(loadWithNullTag.has("keyWithNullTag")).true; + expect(loadWithNullTag.get("keyWithNullTag")).eq("valueWithNullTag"); + + // Should exclude key values with other tags + expect(loadWithNullTag.has("keyWithEmptyTag")).false; + }); + + it("should filter by tags with escaped comma characters", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEscapedComma = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["tag=value\\,with\\,commas"] + }] + }); + + expect(loadWithEscapedComma.has("keyWithEscapedComma")).true; + expect(loadWithEscapedComma.get("keyWithEscapedComma")).eq("valueWithEscapedComma"); + }); + + it("should filter by empty tag value to load key values with empty tag", async () => { + const connectionString = createMockedConnectionString(); + const loadWithEmptyTag = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["emptyTag="] + }] + }); + + // Should include key values with emptyTag="" + expect(loadWithEmptyTag.has("keyWithEmptyTag")).true; + expect(loadWithEmptyTag.get("keyWithEmptyTag")).eq("valueWithEmptyTag"); + + // Should exclude key values with other tags + expect(loadWithEmptyTag.has("keyWithNullTag")).false; + }); + it("should also work with other ReadonlyMap APIs", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -274,6 +373,45 @@ describe("load", function () { return expect(loadWithMultipleLabelFilter).to.eventually.rejectedWith("The characters '*' and ',' are not supported in label filters."); }); + it("should throw exception when there is any invalid tag filter", async () => { + const connectionString = createMockedConnectionString(); + const loadWithInvalidTagFilter = load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["emptyTag"] + }] + }); + return expect(loadWithInvalidTagFilter).to.eventually.rejectedWith("Tag filter must follow the format \"tagName=tagValue\""); + }); + + it("should throw exception when too many tag filters are provided", async () => { + const connectionString = createMockedConnectionString(); + + // Create a list with more than the maximum allowed tag filters (assuming max is 5) + const tooManyTagFilters = [ + "Environment=Development", + "Team=Backend", + "Priority=High", + "Version=1.0", + "Stage=Testing", + "Region=EastUS" // This should exceed the limit + ]; + try { + await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: tooManyTagFilters + }] + }); + } catch (error) { + expect(error.message).eq("Failed to load."); + expect(error.cause.message).eq("Invalid request parameter 'tags'. Maximum number of tag filters is 5."); + return; + } + // we should never reach here, load should throw an error + throw new Error("Expected load to throw."); + }); + it("should override config settings with same key but different label", async () => { const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { @@ -294,13 +432,15 @@ describe("load", function () { const settings = await load(connectionString, { selectors: [{ keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }, { keyFilter: "Test*", labelFilter: "Test" }, { keyFilter: "Test*", - labelFilter: "Prod" + labelFilter: "Prod", + tagFilters: ["testTag="] }] }); expect(settings).not.undefined; diff --git a/test/refresh.test.ts b/test/refresh.test.ts index 704d6c21..0468475d 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -39,7 +39,8 @@ describe("dynamic refresh", function () { mockedKVs = [ { value: "red", key: "app.settings.fontColor" }, { value: "40", key: "app.settings.fontSize" }, - { value: "30", key: "app.settings.fontSize", label: "prod" } + { value: "30", key: "app.settings.fontSize", label: "prod" }, + { value: "someValue", key: "TestTagKey", tags: { "env": "dev" } } ].map(createMockedKeyValue); mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); @@ -435,6 +436,34 @@ describe("dynamic refresh", function () { expect(getKvRequestCount).eq(1); expect(settings.get("app.settings.fontColor")).eq("blue"); }); + + it("should refresh key values using tag filters", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + + expect(settings).not.undefined; + + // Verify only dev-tagged items are loaded + expect(settings.get("TestTagKey")).eq("someValue"); + + // Change the dev-tagged key value + updateSetting("TestTagKey", "newValue"); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + // Verify changes are reflected + expect(settings.get("TestTagKey")).eq("newValue"); + }); }); describe("dynamic refresh feature flags", function () { @@ -549,4 +578,50 @@ describe("dynamic refresh feature flags", function () { expect(getKvRequestCount).eq(0); expect(refreshSuccessfulCount).eq(1); // change in feature flags, because page etags are different. }); + + it("should refresh feature flags using tag filters", async () => { + mockedKVs = [ + createMockedFeatureFlag("DevFeature", { enabled: true }, { tags: { "env": "dev" } }), + createMockedFeatureFlag("ProdFeature", { enabled: false }, { tags: { "env": "prod" } }) + ]; + mockAppConfigurationClientListConfigurationSettings([mockedKVs], listKvCallback); + mockAppConfigurationClientGetConfigurationSetting(mockedKVs, getKvCallback); + + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [{ + keyFilter: "*", + tagFilters: ["env=dev"] + }], + refresh: { + enabled: true, + refreshIntervalInMs: 2000 + } + } + }); + + expect(settings).not.undefined; + + const featureManagement = settings.get("feature_management"); + expect(featureManagement).not.undefined; + expect(featureManagement.feature_flags).not.undefined; + expect(featureManagement.feature_flags.length).eq(1); + expect(featureManagement.feature_flags[0].id).eq("DevFeature"); + expect(featureManagement.feature_flags[0].enabled).eq(true); + + // Change the dev-tagged feature flag + updateSetting(".appconfig.featureflag/DevFeature", JSON.stringify({ + "id": "DevFeature", + "enabled": false + })); + + await sleepInMs(2 * 1000 + 1); + await settings.refresh(); + + const updatedFeatureManagement = settings.get("feature_management"); + expect(updatedFeatureManagement.feature_flags[0].id).eq("DevFeature"); + expect(updatedFeatureManagement.feature_flags[0].enabled).eq(false); + }); }); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index 60f629fc..de0d2470 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -26,6 +26,12 @@ function _sha256(input) { function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { const keyFilter = listOptions?.keyFilter ?? "*"; const labelFilter = listOptions?.labelFilter ?? "*"; + const tagsFilter = listOptions?.tagsFilter ?? []; + + if (tagsFilter.length > 5) { + throw new RestError("Invalid request parameter 'tags'. Maximum number of tag filters is 5.", { statusCode: 400 }); + } + return unfilteredKvs.filter(kv => { const keyMatched = keyFilter.endsWith("*") ? kv.key.startsWith(keyFilter.slice(0, -1)) : kv.key === keyFilter; let labelMatched = false; @@ -38,7 +44,17 @@ function _filterKVs(unfilteredKvs: ConfigurationSetting[], listOptions: any) { } else { labelMatched = kv.label === labelFilter; } - return keyMatched && labelMatched; + let tagsMatched = true; + if (tagsFilter.length > 0) { + tagsMatched = tagsFilter.every(tag => { + const [tagName, tagValue] = tag.split("="); + if (tagValue === "\0") { + return kv.tags && kv.tags[tagName] === null; + } + return kv.tags && kv.tags[tagName] === tagValue; + }); + } + return keyMatched && labelMatched && tagsMatched; }); } @@ -233,8 +249,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura key, contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", lastModified: new Date(), - tags: { - }, + tags: {}, etag: uuid.v4(), isReadOnly: false, });