From 3a28792b9490e9c6dc19afb5609982836143543e Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 11 Apr 2025 10:49:50 +0800 Subject: [PATCH 1/5] revert revert allocation id change --- src/AzureAppConfigurationImpl.ts | 123 +++++++++++++++++++++++++++++++ src/common/utils.ts | 32 ++++++-- test/featureFlag.test.ts | 60 +++++++++++++++ 3 files changed, 210 insertions(+), 5 deletions(-) diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 328d7620..90e283f0 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -9,6 +9,7 @@ import { IKeyValueAdapter } from "./IKeyValueAdapter.js"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./RefreshOptions.js"; import { Disposable } from "./common/disposable.js"; +import { base64Helper, jsonSorter } from "./common/utils.js"; import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, @@ -19,9 +20,16 @@ import { ETAG_KEY_NAME, FEATURE_FLAG_ID_KEY_NAME, FEATURE_FLAG_REFERENCE_KEY_NAME, + ALLOCATION_ID_KEY_NAME, ALLOCATION_KEY_NAME, + DEFAULT_WHEN_ENABLED_KEY_NAME, + PERCENTILE_KEY_NAME, + FROM_KEY_NAME, + TO_KEY_NAME, SEED_KEY_NAME, + VARIANT_KEY_NAME, VARIANTS_KEY_NAME, + CONFIGURATION_VALUE_KEY_NAME, CONDITIONS_KEY_NAME, CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; @@ -669,10 +677,15 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { if (featureFlag[TELEMETRY_KEY_NAME] && featureFlag[TELEMETRY_KEY_NAME][ENABLED_KEY_NAME] === true) { const metadata = featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME]; + let allocationId = ""; + if (featureFlag[ALLOCATION_KEY_NAME] !== undefined) { + allocationId = await this.#generateAllocationId(featureFlag); + } featureFlag[TELEMETRY_KEY_NAME][METADATA_KEY_NAME] = { [ETAG_KEY_NAME]: setting.etag, [FEATURE_FLAG_ID_KEY_NAME]: await this.#calculateFeatureFlagId(setting), [FEATURE_FLAG_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), + ...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }), ...(metadata || {}) }; } @@ -756,6 +769,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } return featureFlagReference; } + + async #generateAllocationId(featureFlag: any): Promise { + let rawAllocationId = ""; + // Only default variant when enabled and variants allocated by percentile involve in the experimentation + // The allocation id is genearted from default variant when enabled and percentile allocation + const variantsForExperimentation: string[] = []; + + rawAllocationId += `seed=${featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] ?? ""}\ndefault_when_enabled=`; + + if (featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]) { + variantsForExperimentation.push(featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]); + rawAllocationId += `${featureFlag[ALLOCATION_KEY_NAME][DEFAULT_WHEN_ENABLED_KEY_NAME]}`; + } + + rawAllocationId += "\npercentiles="; + + const percentileList = featureFlag[ALLOCATION_KEY_NAME][PERCENTILE_KEY_NAME]; + if (percentileList) { + const sortedPercentileList = percentileList + .filter(p => + (p[FROM_KEY_NAME] !== undefined) && + (p[TO_KEY_NAME] !== undefined) && + (p[VARIANT_KEY_NAME] !== undefined) && + (p[FROM_KEY_NAME] !== p[TO_KEY_NAME])) + .sort((a, b) => a[FROM_KEY_NAME] - b[FROM_KEY_NAME]); + + const percentileAllocation: string[] = []; + for (const percentile of sortedPercentileList) { + variantsForExperimentation.push(percentile[VARIANT_KEY_NAME]); + percentileAllocation.push(`${percentile[FROM_KEY_NAME]},${base64Helper(percentile[VARIANT_KEY_NAME])},${percentile[TO_KEY_NAME]}`); + } + rawAllocationId += percentileAllocation.join(";"); + } + + if (variantsForExperimentation.length === 0 && featureFlag[ALLOCATION_KEY_NAME][SEED_KEY_NAME] === undefined) { + // All fields required for generating allocation id are missing, short-circuit and return empty string + return ""; + } + + rawAllocationId += "\nvariants="; + + if (variantsForExperimentation.length !== 0) { + const variantsList = featureFlag[VARIANTS_KEY_NAME]; + if (variantsList) { + const sortedVariantsList = variantsList + .filter(v => + (v[NAME_KEY_NAME] !== undefined) && + variantsForExperimentation.includes(v[NAME_KEY_NAME])) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + + const variantConfiguration: string[] = []; + for (const variant of sortedVariantsList) { + const configurationValue = JSON.stringify(variant[CONFIGURATION_VALUE_KEY_NAME], jsonSorter) ?? ""; + variantConfiguration.push(`${base64Helper(variant[NAME_KEY_NAME])},${configurationValue}`); + } + rawAllocationId += variantConfiguration.join(";"); + } + } + + let crypto; + + // Check for browser environment + if (typeof window !== "undefined" && window.crypto && window.crypto.subtle) { + crypto = window.crypto; + } + // Check for Node.js environment + else if (typeof global !== "undefined" && global.crypto) { + crypto = global.crypto; + } + // Fallback to native Node.js crypto module + else { + try { + if (typeof module !== "undefined" && module.exports) { + crypto = require("crypto"); + } + else { + crypto = await import("crypto"); + } + } catch (error) { + console.error("Failed to load the crypto module:", error.message); + throw error; + } + } + + // Convert to UTF-8 encoded bytes + const data = new TextEncoder().encode(rawAllocationId); + + // In the browser, use crypto.subtle.digest + if (crypto.subtle) { + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + + // Only use the first 15 bytes + const first15Bytes = hashArray.slice(0, 15); + + // btoa/atob is also available in Node.js 18+ + const base64String = btoa(String.fromCharCode(...first15Bytes)); + const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return base64urlString; + } + // In Node.js, use the crypto module's hash function + else { + const hash = crypto.createHash("sha256").update(data).digest(); + + // Only use the first 15 bytes + const first15Bytes = hash.slice(0, 15); + + return first15Bytes.toString("base64url"); + } + } } function getValidSelectors(selectors: SettingSelector[]): SettingSelector[] { diff --git a/src/common/utils.ts b/src/common/utils.ts index 1d790808..5150708c 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,10 +1,32 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +export function base64Helper(str: string): string { + const bytes = new TextEncoder().encode(str); // UTF-8 encoding + let chars = ""; + for (let i = 0; i < bytes.length; i++) { + chars += String.fromCharCode(bytes[i]); + } + return btoa(chars); +} + +export function jsonSorter(key, value) { + if (value === null) { + return null; + } + if (Array.isArray(value)) { + return value; + } + if (typeof value === "object") { + return Object.fromEntries(Object.entries(value).sort()); + } + return value; +} + export function shuffleList(array: T[]): T[] { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; } diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index 906a15ff..2d6a7e02 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -339,4 +339,64 @@ describe("feature flags", function () { expect(featureFlag.telemetry.metadata.FeatureFlagId).equals("Rc8Am7HIGDT7HC5Ovs3wKN_aGaaK_Uz1mH2e11gaK0o"); expect(featureFlag.telemetry.metadata.FeatureFlagReference).equals(`${createMockedEndpoint()}/kv/.appconfig.featureflag/Telemetry_2?label=Test`); }); + + it("should not populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const NoPercentileAndSeed = (featureFlags as any[]).find(item => item.id === "NoPercentileAndSeed"); + expect(NoPercentileAndSeed).not.undefined; + expect(NoPercentileAndSeed?.telemetry.metadata.AllocationId).to.be.undefined; + }); + + it("should populate allocation id", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + featureFlagOptions: { + enabled: true, + selectors: [ { keyFilter: "*" } ] + } + }); + expect(settings).not.undefined; + expect(settings.get("feature_management")).not.undefined; + const featureFlags = settings.get("feature_management").feature_flags; + expect(featureFlags).not.undefined; + + const SeedOnly = (featureFlags as any[]).find(item => item.id === "SeedOnly"); + expect(SeedOnly).not.undefined; + expect(SeedOnly?.telemetry.metadata.AllocationId).equals("qZApcKdfXscxpgn_8CMf"); + + const DefaultWhenEnabledOnly = (featureFlags as any[]).find(item => item.id === "DefaultWhenEnabledOnly"); + expect(DefaultWhenEnabledOnly).not.undefined; + expect(DefaultWhenEnabledOnly?.telemetry.metadata.AllocationId).equals("k486zJjud_HkKaL1C4qB"); + + const PercentileOnly = (featureFlags as any[]).find(item => item.id === "PercentileOnly"); + expect(PercentileOnly).not.undefined; + expect(PercentileOnly?.telemetry.metadata.AllocationId).equals("5YUbmP0P5s47zagO_LvI"); + + const SimpleConfigurationValue = (featureFlags as any[]).find(item => item.id === "SimpleConfigurationValue"); + expect(SimpleConfigurationValue).not.undefined; + expect(SimpleConfigurationValue?.telemetry.metadata.AllocationId).equals("QIOEOTQJr2AXo4dkFFqy"); + + const ComplexConfigurationValue = (featureFlags as any[]).find(item => item.id === "ComplexConfigurationValue"); + expect(ComplexConfigurationValue).not.undefined; + expect(ComplexConfigurationValue?.telemetry.metadata.AllocationId).equals("4Bes0AlwuO8kYX-YkBWs"); + + const TelemetryVariantPercentile = (featureFlags as any[]).find(item => item.id === "TelemetryVariantPercentile"); + expect(TelemetryVariantPercentile).not.undefined; + expect(TelemetryVariantPercentile?.telemetry.metadata.AllocationId).equals("YsdJ4pQpmhYa8KEhRLUn"); + + const Complete = (featureFlags as any[]).find(item => item.id === "Complete"); + expect(Complete).not.undefined; + expect(Complete?.telemetry.metadata.AllocationId).equals("DER2rF-ZYog95c4CBZoi"); + }); }); From f880e53b549a7bbd99f92b7e558cdec23430699e Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sun, 7 Sep 2025 12:14:29 +0800 Subject: [PATCH 2/5] update lint rule --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index f8b227e2..db60a023 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,7 @@ export default defineConfig([globalIgnores([ }], "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-require-imports": "off", "eol-last": ["error", "always"], "no-trailing-spaces": "error", "space-before-blocks": ["error", "always"], From 2b929c23c9ad29f31137520f90ac58e9100626b0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 24 Sep 2025 17:53:40 +0800 Subject: [PATCH 3/5] wip --- src/appConfigurationImpl.ts | 2 +- src/common/contentType.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 691a7a4d..8ccb3d3a 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -40,7 +40,7 @@ import { CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; -import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType, isSnapshotReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/keyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/refreshTimer.js"; import { diff --git a/src/common/contentType.ts b/src/common/contentType.ts index 4891f425..cbd4e35e 100644 --- a/src/common/contentType.ts +++ b/src/common/contentType.ts @@ -60,3 +60,12 @@ export function isSecretReferenceContentType(contentType: ContentType | undefine } return mediaType === secretReferenceContentType; } + +export function isSnapshotReferenceContentType(contentType: ContentType | undefined): boolean { + const mediaType = contentType?.mediaType; + if (!mediaType) { + return false; + } + // TODO: replace with constant when available in Azure SDK + return mediaType === "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; +} From d8e8c6cd30112e5873c18e16f7e7dc9065f13eab Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 2 Oct 2025 15:34:18 +0800 Subject: [PATCH 4/5] support snapshot reference --- src/appConfigurationImpl.ts | 66 ++++++++++++++++++++++++++++----- src/requestTracing/constants.ts | 1 + src/requestTracing/utils.ts | 7 +++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 7f957b72..36b4d0d2 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -82,6 +82,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagTracing: FeatureFlagTracingOptions | undefined; #fmVersion: string | undefined; #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + #useSnapshotReference: boolean = false; // Refresh #refreshInProgress: boolean = false; @@ -213,7 +214,8 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { isFailoverRequest: this.#isFailoverRequest, featureFlagTracing: this.#featureFlagTracing, fmVersion: this.#fmVersion, - aiConfigurationTracing: this.#aiConfigurationTracing + aiConfigurationTracing: this.#aiConfigurationTracing, + useSnapshotReference: this.#useSnapshotReference }; } @@ -504,17 +506,26 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { selector.pageWatchers = pageWatchers; settings = items; } else { // snapshot selector - const snapshot = await this.#getSnapshot(selector.snapshotName); - if (snapshot === undefined) { - throw new InvalidOperationError(`Could not find snapshot with name ${selector.snapshotName}.`); - } - if (snapshot.compositionType != KnownSnapshotComposition.Key) { - throw new InvalidOperationError(`Composition type for the selected snapshot with name ${selector.snapshotName} must be 'key'.`); - } - settings = await this.#listConfigurationSettingsForSnapshot(selector.snapshotName); + settings = await this.#loadConfigurationSettingsFromSnapshot(selector.snapshotName); } for (const setting of settings) { + if (isSnapshotReference(setting) && !loadFeatureFlag) { + this.#useSnapshotReference = true; + + // TODO: When SDK supports snapshot reference, use the helper method from SDK. + const snapshotName = parseSnapshotReference(setting).value.snapshotName; + const settingsFromSnapshot = await this.#loadConfigurationSettingsFromSnapshot(snapshotName); + + for (const snapshotSetting of settingsFromSnapshot) { + if (!isFeatureFlag(snapshotSetting)) { + // Feature flags inside snapshot are ignored. This is consistent the behavior that key value selectors ignore feature flags. + loadedSettings.set(snapshotSetting.key, snapshotSetting); + } + } + continue; + } + if (loadFeatureFlag === isFeatureFlag(setting)) { loadedSettings.set(setting.key, setting); } @@ -575,6 +586,18 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } + async #loadConfigurationSettingsFromSnapshot(snapshotName: string): Promise { + const snapshot = await this.#getSnapshot(snapshotName); + if (snapshot === undefined) { + throw new InvalidOperationError(`Could not find snapshot with name ${snapshotName}.`); + } + if (snapshot.compositionType != KnownSnapshotComposition.Key) { + throw new InvalidOperationError(`Composition type for the selected snapshot with name ${snapshotName} must be 'key'.`); + } + const settings: ConfigurationSetting[] = await this.#listConfigurationSettingsForSnapshot(snapshotName); + return settings; + } + /** * Clears all existing key-values in the local configuration except feature flags. */ @@ -1068,3 +1091,28 @@ function validateTagFilters(tagFilters: string[]): void { } } } + +// TODO: Temporary workaround until SDK supports snapshot reference +const snapshotReferenceContentType = "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; + +interface JsonSnapshotReferenceValue { + snapshot_name: string; +} + +function isSnapshotReference(setting: ConfigurationSetting): + setting is ConfigurationSetting & Required> { + return (setting && setting.contentType === snapshotReferenceContentType && typeof setting.value === "string"); +} + +function parseSnapshotReference(setting: ConfigurationSetting) { + if (!isSnapshotReference(setting)) { + throw new Error(`Invalid snapshot reference: ${setting}`); + } + const jsonSnapshotReferenceValue = JSON.parse(setting.value) as JsonSnapshotReferenceValue; + + const snapshotReference = { + ...setting, + value: { snapshotName: jsonSnapshotReferenceValue.snapshot_name }, + }; + return snapshotReference; +} diff --git a/src/requestTracing/constants.ts b/src/requestTracing/constants.ts index 6f9311b4..2695d0b7 100644 --- a/src/requestTracing/constants.ts +++ b/src/requestTracing/constants.ts @@ -51,6 +51,7 @@ export const REPLICA_COUNT_KEY = "ReplicaCount"; export const KEY_VAULT_CONFIGURED_TAG = "UsesKeyVault"; export const KEY_VAULT_REFRESH_CONFIGURED_TAG = "RefreshesKeyVault"; export const FAILOVER_REQUEST_TAG = "Failover"; +export const SNAPSHOT_REFERENCE_TAG = "SnapshotRef"; // Compact feature tags export const FEATURES_KEY = "Features"; diff --git a/src/requestTracing/utils.ts b/src/requestTracing/utils.ts index e9949505..3b1337e3 100644 --- a/src/requestTracing/utils.ts +++ b/src/requestTracing/utils.ts @@ -41,7 +41,8 @@ import { FM_VERSION_KEY, DELIMITER, AI_CONFIGURATION_TAG, - AI_CHAT_COMPLETION_CONFIGURATION_TAG + AI_CHAT_COMPLETION_CONFIGURATION_TAG, + SNAPSHOT_REFERENCE_TAG } from "./constants.js"; export interface RequestTracingOptions { @@ -53,6 +54,7 @@ export interface RequestTracingOptions { featureFlagTracing: FeatureFlagTracingOptions | undefined; fmVersion: string | undefined; aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + useSnapshotReference: boolean; } // Utils @@ -195,6 +197,9 @@ function createFeaturesString(requestTracingOptions: RequestTracingOptions): str if (requestTracingOptions.aiConfigurationTracing?.usesAIChatCompletionConfiguration) { tags.push(AI_CHAT_COMPLETION_CONFIGURATION_TAG); } + if (requestTracingOptions.useSnapshotReference) { + tags.push(SNAPSHOT_REFERENCE_TAG); + } return tags.join(DELIMITER); } From 43d1730287241ed2178f99523cf5c112f2afece1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 2 Oct 2025 20:58:32 +0800 Subject: [PATCH 5/5] add test --- src/appConfigurationImpl.ts | 2 +- src/common/contentType.ts | 9 --- test/featureFlag.test.ts | 10 +++- test/load.test.ts | 12 ++-- test/snapshotReference.test.ts | 104 +++++++++++++++++++++++++++++++++ test/utils/testHelper.ts | 29 ++++++--- vitest.browser.config.ts | 4 +- 7 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 test/snapshotReference.test.ts diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index 7661d2f0..36b4d0d2 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -41,7 +41,7 @@ import { CLIENT_FILTERS_KEY_NAME } from "./featureManagement/constants.js"; import { FM_PACKAGE_NAME, AI_MIME_PROFILE, AI_CHAT_COMPLETION_MIME_PROFILE } from "./requestTracing/constants.js"; -import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType, isSnapshotReferenceContentType } from "./common/contentType.js"; +import { parseContentType, isJsonContentType, isFeatureFlagContentType, isSecretReferenceContentType } from "./common/contentType.js"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/keyVaultKeyValueAdapter.js"; import { RefreshTimer } from "./refresh/refreshTimer.js"; import { diff --git a/src/common/contentType.ts b/src/common/contentType.ts index cbd4e35e..4891f425 100644 --- a/src/common/contentType.ts +++ b/src/common/contentType.ts @@ -60,12 +60,3 @@ export function isSecretReferenceContentType(contentType: ContentType | undefine } return mediaType === secretReferenceContentType; } - -export function isSnapshotReferenceContentType(contentType: ContentType | undefined): boolean { - const mediaType = contentType?.mediaType; - if (!mediaType) { - return false; - } - // TODO: replace with constant when available in Azure SDK - return mediaType === "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8"; -} diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index fb7b3da1..0a89b745 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -415,8 +415,14 @@ describe("feature flags", function () { it("should load feature flags from snapshot", async () => { const snapshotName = "Test"; - mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); - mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]); + const snapshotResponses = new Map([ + [snapshotName, { compositionType: "key" }] + ]); + const snapshotKVs = new Map([ + [snapshotName, [[createMockedFeatureFlag("TestFeature", { enabled: true })]]] + ]); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { featureFlagOptions: { diff --git a/test/load.test.ts b/test/load.test.ts index 91a74990..d1f109e1 100644 --- a/test/load.test.ts +++ b/test/load.test.ts @@ -581,8 +581,14 @@ describe("load", function () { it("should load key values from snapshot", async () => { const snapshotName = "Test"; - mockAppConfigurationClientGetSnapshot(snapshotName, {compositionType: "key"}); - mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]); + const snapshotResponses = new Map([ + [snapshotName, { compositionType: "key" }] + ]); + const snapshotKVs = new Map([ + [snapshotName, [[{key: "TestKey", value: "TestValue"}].map(createMockedKeyValue)]]] + ); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); const connectionString = createMockedConnectionString(); const settings = await load(connectionString, { selectors: [{ @@ -590,9 +596,7 @@ describe("load", function () { }] }); expect(settings).not.undefined; - expect(settings).not.undefined; expect(settings.get("TestKey")).eq("TestValue"); - restoreMocks(); }); }); /* eslint-enable @typescript-eslint/no-unused-expressions */ diff --git a/test/snapshotReference.test.ts b/test/snapshotReference.test.ts new file mode 100644 index 00000000..38d7442f --- /dev/null +++ b/test/snapshotReference.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; +import { load } from "../src/index.js"; +import { + mockAppConfigurationClientListConfigurationSettings, + mockAppConfigurationClientGetSnapshot, + mockAppConfigurationClientListConfigurationSettingsForSnapshot, + restoreMocks, + createMockedConnectionString, + createMockedKeyValue, + createMockedSnapshotReference, + createMockedFeatureFlag, + sleepInMs +} from "./utils/testHelper.js"; +import * as uuid from "uuid"; + +const mockedKVs = [{ + key: "TestKey1", + value: "Value1", +}, { + key: "TestKey2", + value: "Value2", +} +].map(createMockedKeyValue); + +mockedKVs.push(createMockedSnapshotReference("TestSnapshotRef", "TestSnapshot1")); + +// TestSnapshot1 +const snapshot1 = [{ + key: "TestKey1", + value: "Value1 in snapshot1", +} +].map(createMockedKeyValue); +const testFeatureFlag = createMockedFeatureFlag("TestFeatureFlag"); +snapshot1.push(testFeatureFlag); + +// TestSnapshot2 +const snapshot2 = [{ + key: "TestKey1", + value: "Value1 in snapshot2", +} +].map(createMockedKeyValue); + +describe("snapshot reference", function () { + + beforeEach(() => { + const snapshotResponses = new Map([ + ["TestSnapshot1", { compositionType: "key" }], + ["TestSnapshot2", { compositionType: "key" }]] + ); + const snapshotKVs = new Map([ + ["TestSnapshot1", [snapshot1]], + ["TestSnapshot2", [snapshot2]]] + ); + mockAppConfigurationClientGetSnapshot(snapshotResponses); + mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotKVs); + mockAppConfigurationClientListConfigurationSettings([mockedKVs]); + }); + + afterEach(() => { + restoreMocks(); + }); + + it("should resolve snapshot reference", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString); + expect(settings.get("TestKey1")).eq("Value1 in snapshot1"); + + // it should ignore feature flags in snapshot + expect(settings.get(testFeatureFlag.key)).to.be.undefined; + expect(settings.get("feature_management")).to.be.undefined; + + // it should not load the snapshot reference key + expect(settings.get("TestSnapshotRef")).to.be.undefined; + }); + + it("should refresh when snapshot reference changes", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + refreshOptions: { + enabled: true, + refreshIntervalInMs: 2000 + } + }); + expect(settings.get("TestKey1")).eq("Value1 in snapshot1"); + + const setting = mockedKVs.find(kv => kv.key === "TestSnapshotRef"); + setting!.value = "{\"snapshot_name\":\"TestSnapshot2\"}"; + setting!.etag = uuid.v4(); + + await sleepInMs(2 * 1000 + 1); + + await settings.refresh(); + + expect(settings.get("TestKey1")).eq("Value1 in snapshot2"); + }); + +}); diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts index b4f06dd3..2ab07d04 100644 --- a/test/utils/testHelper.ts +++ b/test/utils/testHelper.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as sinon from "sinon"; -import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType } from "@azure/app-configuration"; +import { AppConfigurationClient, ConfigurationSetting, featureFlagContentType, secretReferenceContentType } from "@azure/app-configuration"; import { ClientSecretCredential } from "@azure/identity"; import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; import * as uuid from "uuid"; @@ -186,29 +186,29 @@ function mockAppConfigurationClientGetConfigurationSetting(kvList: any[], custom }); } -function mockAppConfigurationClientGetSnapshot(snapshotName: string, mockedResponse: any, customCallback?: (options) => any) { +function mockAppConfigurationClientGetSnapshot(snapshotResponses: Map, customCallback?: (options) => any) { sinon.stub(AppConfigurationClient.prototype, "getSnapshot").callsFake((name, options) => { if (customCallback) { customCallback(options); } - if (name === snapshotName) { - return mockedResponse; + if (snapshotResponses.has(name)) { + return snapshotResponses.get(name); } else { throw new RestError("", { statusCode: 404 }); } }); } -function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotName: string, pages: ConfigurationSetting[][], customCallback?: (options) => any) { +function mockAppConfigurationClientListConfigurationSettingsForSnapshot(snapshotResponses: Map, customCallback?: (options) => any) { sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettingsForSnapshot").callsFake((name, listOptions) => { if (customCallback) { customCallback(listOptions); } - if (name === snapshotName) { - const kvs = _filterKVs(pages.flat(), listOptions); - return getMockedIterator(pages, kvs, listOptions); + if (snapshotResponses.has(name)) { + const kvs = _filterKVs(snapshotResponses.get(name)!.flat(), listOptions); + return getMockedIterator(snapshotResponses.get(name)!, kvs, listOptions); } else { throw new RestError("", { statusCode: 404 }); } @@ -253,7 +253,7 @@ const createMockedKeyVaultReference = (key: string, vaultUri: string): Configura // https://${vaultName}.vault.azure.net/secrets/${secretName} value: `{"uri":"${vaultUri}"}`, key, - contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + contentType: secretReferenceContentType, lastModified: new Date(), tags: {}, etag: uuid.v4(), @@ -297,6 +297,16 @@ const createMockedFeatureFlag = (name: string, flagProps?: any, props?: any) => isReadOnly: false }, props)); +const createMockedSnapshotReference = (key: string, snapshotName: string): ConfigurationSetting => ({ + value: `{"snapshot_name":"${snapshotName}"}`, + key, + contentType: "application/json; profile=\"https://azconfig.io/mime-profiles/snapshot-ref\"; charset=utf-8", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false, +}); + class HttpRequestHeadersPolicy { headers: any; name: string; @@ -329,6 +339,7 @@ export { createMockedJsonKeyValue, createMockedKeyValue, createMockedFeatureFlag, + createMockedSnapshotReference, sleepInMs, HttpRequestHeadersPolicy diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 011e0f93..7c51088a 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ { browser: "chromium" }, ], }, - include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js"], + include: ["out/esm/test/load.test.js", "out/esm/test/refresh.test.js", "out/esm/test/featureFlag.test.js", "out/esm/test/json.test.js", "out/esm/test/startup.test.js", "out/esm/test/snapshotReference.test.js"], testTimeout: 200_000, hookTimeout: 200_000, reporters: "default", @@ -18,4 +18,4 @@ export default defineConfig({ // Provide Mocha-style hooks as globals setupFiles: ["./vitest.setup.mjs"], }, -}); \ No newline at end of file +});