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"], diff --git a/src/appConfigurationImpl.ts b/src/appConfigurationImpl.ts index e46bd110..896805d4 100644 --- a/src/appConfigurationImpl.ts +++ b/src/appConfigurationImpl.ts @@ -25,6 +25,7 @@ import { DEFAULT_STARTUP_TIMEOUT_IN_MS } from "./startupOptions.js"; import { DEFAULT_REFRESH_INTERVAL_IN_MS, MIN_REFRESH_INTERVAL_IN_MS } from "./refresh/refreshOptions.js"; import { MIN_SECRET_REFRESH_INTERVAL_IN_MS } from "./keyvault/keyVaultOptions.js"; import { Disposable } from "./common/disposable.js"; +import { base64Helper, jsonSorter } from "./common/utils.js"; import { FEATURE_FLAGS_KEY_NAME, FEATURE_MANAGEMENT_KEY_NAME, @@ -34,9 +35,16 @@ import { METADATA_KEY_NAME, ETAG_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"; @@ -82,6 +90,7 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { #featureFlagTracing: FeatureFlagTracingOptions | undefined; #fmVersion: string | undefined; #aiConfigurationTracing: AIConfigurationTracingOptions | undefined; + #useSnapshotReference: boolean = false; // Refresh #refreshInProgress: boolean = false; @@ -213,7 +222,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 +514,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 +594,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. */ @@ -944,9 +975,14 @@ 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_REFERENCE_KEY_NAME]: this.#createFeatureFlagReference(setting), + ...(allocationId !== "" && { [ALLOCATION_ID_KEY_NAME]: allocationId }), ...(metadata || {}) }; } @@ -984,6 +1020,116 @@ export class AzureAppConfigurationImpl implements AzureAppConfiguration { } } } + + 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 getValidSettingSelectors(selectors: SettingSelector[]): SettingSelector[] { @@ -1071,3 +1217,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/common/utils.ts b/src/common/utils.ts index 18667874..0264a72e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,6 +1,28 @@ // 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)); 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); } diff --git a/test/featureFlag.test.ts b/test/featureFlag.test.ts index fb7b3da1..6e9dea0d 100644 --- a/test/featureFlag.test.ts +++ b/test/featureFlag.test.ts @@ -341,6 +341,66 @@ describe("feature flags", function () { 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"); + }); + it("should load feature flags using tag filters", async () => { const connectionString = createMockedConnectionString(); @@ -415,8 +475,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 +});