diff --git a/.eslintrc b/.eslintrc index 754a9f38..8b96047e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -40,5 +40,6 @@ "avoidEscape": true } ], - }, + "@typescript-eslint/no-explicit-any": "off" + } } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 613b189e..81490d40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index d946d028..1ccd7b97 100644 --- a/.gitignore +++ b/.gitignore @@ -400,6 +400,7 @@ FodyWeavers.xsd # bundled folder dist/ dist-esm/ +out/ types/ # dotenv @@ -409,4 +410,4 @@ types/ *.tgz # examples -examples/package-lock.json \ No newline at end of file +examples/package-lock.json diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 53ac8f1e..f1925cb2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -12,7 +12,7 @@ pool: steps: - task: NodeTool@0 inputs: - versionSpec: '16.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: | diff --git a/package-lock.json b/package-lock.json index 71d02854..398cff78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "1.0.0-preview", "license": "MIT", "dependencies": { - "@azure/app-configuration": "^1.4.1", + "@azure/app-configuration": "^1.5.0", "@azure/identity": "^3.3.2", "@azure/keyvault-secrets": "^4.7.0" }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", + "@types/mocha": "^10.0.4", "@types/node": "^20.5.7", + "@types/sinon": "^17.0.1", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "chai": "^4.3.7", @@ -29,7 +32,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": { @@ -53,14 +57,15 @@ } }, "node_modules/@azure/app-configuration": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.4.1.tgz", - "integrity": "sha512-9vLmveQR3U5DmMGkXU6fQzG7vf+GWMM+1g6zf/5Ro81dOP0R2fPDdBOd4Edg2pZpuswsf2t6QE+wo/oCeaVLZA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.5.0.tgz", + "integrity": "sha512-YlXwWc/weDFCk12arPkfskXDGxDaSyAA7JaztSVQ0y/IS7GFYqmIj3RTKbsNUSSuGLrKqcxwJ7y3vY9UmHgsdA==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", - "@azure/core-http-compat": "^1.2.0", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.5.1", "@azure/core-paging": "^1.4.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/core-tracing": "^1.0.0", @@ -68,6 +73,19 @@ "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, + "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", + "integrity": "sha512-xpQZz/q7E0jSW4rckrTo2mDFDQgo6I69hBU4voMQi7REi6JRW5a+KfVkbJCFCWnkFmP6cAJ0IbuudTdf/MEBOQ==", + "dependencies": { + "@azure/abort-controller": "^1.0.4", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + }, "engines": { "node": ">=14.0.0" } @@ -141,9 +159,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.1.tgz", - "integrity": "sha512-SsyWQ+T5MFQRX+M8H/66AlaI6HyCbQStGfFngx2fuiW+vKI2DkhtOvbYodPyf9fOe/ARLWWc3ohX54lQ5Kmaog==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.2.tgz", + "integrity": "sha512-wLLJQdL4v1yoqYtEtjKNjf8pJ/G/BqVomAWxcKOR1KbZJyCEnCv04yks7Y1NhJ3JzxbDs307W67uX0JzklFdCg==", "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.4.0", @@ -156,7 +174,7 @@ "tslib": "^2.2.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" } }, "node_modules/@azure/core-tracing": { @@ -208,6 +226,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 +298,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", @@ -815,6 +849,12 @@ "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, + "node_modules/@types/mocha": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.4.tgz", + "integrity": "sha512-xKU7bUjiFTIttpWaIZ9qvgg+22O1nmbA+HRxdlR+u6TWsGfmFdXrheJoK4fFxrHNVIOBDvDNKZG+LYBpMHpX3w==", + "dev": true + }, "node_modules/@types/node": { "version": "20.8.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", @@ -830,6 +870,27 @@ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, + "node_modules/@types/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-Q2Go6TJetYn5Za1+RJA1Aik61Oa2FS8SuJ0juIqUuJ5dZR4wvhKfmSdIqWtQ3P6gljKWjW0/R7FZkA4oXVL6OA==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", @@ -3379,9 +3440,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" } @@ -3533,20 +3599,33 @@ } }, "@azure/app-configuration": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.4.1.tgz", - "integrity": "sha512-9vLmveQR3U5DmMGkXU6fQzG7vf+GWMM+1g6zf/5Ro81dOP0R2fPDdBOd4Edg2pZpuswsf2t6QE+wo/oCeaVLZA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/app-configuration/-/app-configuration-1.5.0.tgz", + "integrity": "sha512-YlXwWc/weDFCk12arPkfskXDGxDaSyAA7JaztSVQ0y/IS7GFYqmIj3RTKbsNUSSuGLrKqcxwJ7y3vY9UmHgsdA==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", - "@azure/core-http-compat": "^1.2.0", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.5.1", "@azure/core-paging": "^1.4.0", "@azure/core-rest-pipeline": "^1.6.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.1.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" + }, + "dependencies": { + "@azure/core-http-compat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.0.1.tgz", + "integrity": "sha512-xpQZz/q7E0jSW4rckrTo2mDFDQgo6I69hBU4voMQi7REi6JRW5a+KfVkbJCFCWnkFmP6cAJ0IbuudTdf/MEBOQ==", + "requires": { + "@azure/abort-controller": "^1.0.4", + "@azure/core-client": "^1.3.0", + "@azure/core-rest-pipeline": "^1.3.0" + } + } } }, "@azure/core-auth": { @@ -3603,9 +3682,9 @@ } }, "@azure/core-rest-pipeline": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.1.tgz", - "integrity": "sha512-SsyWQ+T5MFQRX+M8H/66AlaI6HyCbQStGfFngx2fuiW+vKI2DkhtOvbYodPyf9fOe/ARLWWc3ohX54lQ5Kmaog==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.12.2.tgz", + "integrity": "sha512-wLLJQdL4v1yoqYtEtjKNjf8pJ/G/BqVomAWxcKOR1KbZJyCEnCv04yks7Y1NhJ3JzxbDs307W67uX0JzklFdCg==", "requires": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.4.0", @@ -3656,6 +3735,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 +3791,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": { @@ -4116,6 +4209,12 @@ "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true }, + "@types/mocha": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.4.tgz", + "integrity": "sha512-xKU7bUjiFTIttpWaIZ9qvgg+22O1nmbA+HRxdlR+u6TWsGfmFdXrheJoK4fFxrHNVIOBDvDNKZG+LYBpMHpX3w==", + "dev": true + }, "@types/node": { "version": "20.8.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.7.tgz", @@ -4131,6 +4230,27 @@ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, + "@types/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-Q2Go6TJetYn5Za1+RJA1Aik61Oa2FS8SuJ0juIqUuJ5dZR4wvhKfmSdIqWtQ3P6gljKWjW0/R7FZkA4oXVL6OA==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, + "@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", @@ -5947,9 +6067,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..74eca211 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ "README.md" ], "scripts": { - "build": "npm run clean && npm run build-cjs && npm run build-esm", + "build": "npm run clean && npm run build-cjs && npm run build-esm && npm run build-test", "build-cjs": "rollup --config", - "build-esm": "tsc -p ./", - "clean": "rimraf dist dist-esm types", + "build-esm": "tsc -p ./tsconfig.json", + "build-test": "tsc -p ./tsconfig.test.json", + "clean": "rimraf dist dist-esm out types", "dev": "rollup --config --watch", "lint": "eslint src/ test/", "fix-lint": "eslint src/ test/ --fix", - "test": "mocha --timeout 10000" + "test": "mocha out/test/*.test.{js,cjs,mjs} --timeout 10000" }, "repository": { "type": "git", @@ -33,7 +34,10 @@ "license": "MIT", "devDependencies": { "@rollup/plugin-typescript": "^11.1.2", + "@types/mocha": "^10.0.4", "@types/node": "^20.5.7", + "@types/sinon": "^17.0.1", + "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", "chai": "^4.3.7", @@ -47,10 +51,11 @@ "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", + "@azure/app-configuration": "^1.5.0", "@azure/identity": "^3.3.2", "@azure/keyvault-secrets": "^4.7.0" } diff --git a/src/AzureAppConfiguration.ts b/src/AzureAppConfiguration.ts index cd4cdc2a..a4af8746 100644 --- a/src/AzureAppConfiguration.ts +++ b/src/AzureAppConfiguration.ts @@ -3,4 +3,4 @@ export type AzureAppConfiguration = { // methods for advanced features, e.g. refresh() -} & ReadonlyMap; +} & ReadonlyMap; diff --git a/src/AzureAppConfigurationImpl.ts b/src/AzureAppConfigurationImpl.ts index 323ac2a8..3c682502 100644 --- a/src/AzureAppConfigurationImpl.ts +++ b/src/AzureAppConfigurationImpl.ts @@ -6,11 +6,11 @@ import { AzureAppConfiguration } from "./AzureAppConfiguration"; import { AzureAppConfigurationOptions } from "./AzureAppConfigurationOptions"; import { IKeyValueAdapter } from "./IKeyValueAdapter"; import { JsonKeyValueAdapter } from "./JsonKeyValueAdapter"; -import { KeyFilter } from "./KeyFilter"; -import { LabelFilter } from "./LabelFilter"; +import { KeyFilter, LabelFilter } from "./types"; import { AzureKeyVaultKeyValueAdapter } from "./keyvault/AzureKeyVaultKeyValueAdapter"; import { CorrelationContextHeaderName } from "./requestTracing/constants"; import { createCorrelationContextHeader, requestTracingEnabled } from "./requestTracing/utils"; +import { SettingSelector } from "./types"; export class AzureAppConfigurationImpl extends Map implements AzureAppConfiguration { private adapters: IKeyValueAdapter[] = []; @@ -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,34 @@ export class AzureAppConfigurationImpl extends Map implements A return headers; } } + +function getValidSelectors(selectors?: SettingSelector[]) { + if (!selectors || selectors.length === 0) { + // Default selector: key: *, label: \0 + return [{ keyFilter: KeyFilter.Any, labelFilter: LabelFilter.Null }]; + } + + // below code dedupes selectors by keyFilter and labelFilter, the latter selector wins + const dedupedSelectors: SettingSelector[] = []; + for (const selector of selectors) { + const existingSelectorIndex = dedupedSelectors.findIndex(s => s.keyFilter === selector.keyFilter && s.labelFilter === selector.labelFilter); + if (existingSelectorIndex >= 0) { + dedupedSelectors.splice(existingSelectorIndex, 1); + } + dedupedSelectors.push(selector); + } + + return dedupedSelectors.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..5da5fd18 100644 --- a/src/AzureAppConfigurationOptions.ts +++ b/src/AzureAppConfigurationOptions.ts @@ -3,13 +3,32 @@ import { AppConfigurationClientOptions } from "@azure/app-configuration"; import { AzureAppConfigurationKeyVaultOptions } from "./keyvault/AzureAppConfigurationKeyVaultOptions"; +import { SettingSelector } from "./types"; 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. + * If no selectors are specified then all key-values with no label will be included. + */ + selectors?: SettingSelector[]; + + /** + * Specifies prefixes to be trimmed from the keys of all key-values retrieved from Azure App Configuration. + * This is useful when you want to remove a common prefix from all keys to avoid repetition. + * The provided prefixes will be sorted in descending order and the longest matching prefix will be trimmed first. + */ trimKeyPrefixes?: string[]; + + /** + * Specifies custom options to be used when creating the AppConfigurationClient. + */ clientOptions?: AppConfigurationClientOptions; + + /** + * Specifies options used to resolve Vey Vault references. + */ keyVaultOptions?: AzureAppConfigurationKeyVaultOptions; } \ No newline at end of file diff --git a/src/LabelFilter.ts b/src/LabelFilter.ts deleted file mode 100644 index ebb8361a..00000000 --- a/src/LabelFilter.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -export enum LabelFilter { - Null = "\0" -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b2dc467b..524dbec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,4 @@ export { load } from "./load"; export { AzureAppConfiguration } from "./AzureAppConfiguration"; -export { KeyFilter } from "./KeyFilter"; -export { LabelFilter } from "./LabelFilter"; \ No newline at end of file +export { KeyFilter, LabelFilter } from "./types"; \ No newline at end of file diff --git a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts b/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts index 9b85beef..2b5c7d87 100644 --- a/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts +++ b/src/keyvault/AzureAppConfigurationKeyVaultOptions.ts @@ -4,8 +4,24 @@ import { TokenCredential } from "@azure/identity"; import { SecretClient } from "@azure/keyvault-secrets"; +/** + * Options used to resolve Key Vault references. + */ export interface AzureAppConfigurationKeyVaultOptions { + /** + * Specifies the Key Vault secret client used for resolving Key Vault references. + */ secretClients?: SecretClient[]; + + /** + * Specifies the credentials used to authenticate to key vaults that have no applied SecretClient. + */ credential?: TokenCredential; + + /** + * Specifies the callback used to resolve key vault references that have no applied SecretClient. + * @param keyVaultReference The Key Vault reference to resolve. + * @returns The secret value. + */ secretResolver?: (keyVaultReference: URL) => string | Promise; } \ No newline at end of file diff --git a/src/load.ts b/src/load.ts index dbb64196..a44d4042 100644 --- a/src/load.ts +++ b/src/load.ts @@ -8,7 +8,18 @@ import { AzureAppConfigurationImpl } from "./AzureAppConfigurationImpl"; import { AzureAppConfigurationOptions, MaxRetries, MaxRetryDelayInMs } from "./AzureAppConfigurationOptions"; import * as RequestTracing from "./requestTracing/constants"; +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param connectionString The connection string for the App Configuration store. + * @param options Optional parameters. + */ export async function load(connectionString: string, options?: AzureAppConfigurationOptions): Promise; +/** + * Loads the data from Azure App Configuration service and returns an instance of AzureAppConfiguration. + * @param endpoint The URL to the App Configuration store. + * @param credential The credential to use to connect to the App Configuration store. + * @param options Optional parameters. + */ export async function load(endpoint: URL | string, credential: TokenCredential, options?: AzureAppConfigurationOptions): Promise; export async function load( connectionStringOrEndpoint: string | URL, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..0b51f905 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * SettingSelector is used to select key-values from Azure App Configuration. + * It is used to filter key-values based on keys and labels. + * + * @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 `\`. + */ +export type SettingSelector = { keyFilter: string, labelFilter?: string }; + +/** + * KeyFilter is used to filter key-values based on keys. + * + * @property Any: + * Matches all key-values. + */ +export enum KeyFilter { + Any = "*" +} + +/** + * LabelFilter is used to filter key-values based on labels. + * + * @property Null: + * Matches key-values without a label. + */ +export enum LabelFilter { + Null = "\0" +} diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index a9331162..00000000 --- a/test/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "@typescript-eslint/no-var-requires": "off" - } -} \ No newline at end of file diff --git a/test/clientOptions.test.js b/test/clientOptions.test.ts similarity index 65% rename from test/clientOptions.test.js rename to test/clientOptions.test.ts index ebd615b2..2bf146f1 100644 --- a/test/clientOptions.test.js +++ b/test/clientOptions.test.ts @@ -1,15 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { createMockedConnectionString } = require("./utils/testHelper"); -const nock = require("nock"); +import { load } from "./exportedApi"; +import { createMockedConnectionString } from "./utils/testHelper"; +import * as nock from "nock"; class HttpRequestCountPolicy { + count: number; + name: string; + constructor() { this.count = 0; this.name = "HttpRequestCountPolicy"; @@ -25,12 +28,12 @@ class HttpRequestCountPolicy { describe("custom client options", function () { const fakeEndpoint = "https://azure.azconfig.io"; - before(() => { + beforeEach(() => { // Thus here mock it to reply 500, in which case the retry mechanism works. nock(fakeEndpoint).persist().get(() => true).reply(500); }); - after(() => { + afterEach(() => { nock.restore(); }) @@ -58,7 +61,7 @@ describe("custom client options", function () { it("should override default retry options", async () => { const countPolicy = new HttpRequestCountPolicy(); - const loadWithMaxRetries = (maxRetries) => { + const loadWithMaxRetries = (maxRetries: number) => { return load(createMockedConnectionString(fakeEndpoint), { clientOptions: { additionalPolicies: [{ @@ -93,9 +96,26 @@ describe("custom client options", function () { expect(countPolicy.count).eq(2); }); - // Note: - // core-rest-pipeline skips the retry throwing `RestError: getaddrinfo ENOTFOUND azure.azconfig.io` - // Probably would be fixed in upstream libs. - // See https://github.com/Azure/azure-sdk-for-js/issues/27037 - it("should retry on DNS failure"); + it("should retry on DNS failure", async () => { + nock.restore(); // stop mocking with 500 error but sending real requests which will fail with ENOTFOUND + const countPolicy = new HttpRequestCountPolicy(); + const loadPromise = () => { + return load(createMockedConnectionString(fakeEndpoint), { + clientOptions: { + additionalPolicies: [{ + policy: countPolicy, + position: "perRetry" + }] + } + }) + }; + let error; + try { + await loadPromise(); + } catch (e) { + error = e; + } + expect(error).not.undefined; + expect(countPolicy.count).eq(3); + }); }) diff --git a/src/KeyFilter.ts b/test/exportedApi.ts similarity index 63% rename from src/KeyFilter.ts rename to test/exportedApi.ts index ff7366ea..7e6feba1 100644 --- a/src/KeyFilter.ts +++ b/test/exportedApi.ts @@ -1,6 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export enum KeyFilter { - Any = "*" -} \ No newline at end of file +export { load } from "../src"; \ No newline at end of file diff --git a/test/json.test.js b/test/json.test.ts similarity index 90% rename from test/json.test.js rename to test/json.test.ts index 24e18de5..4818e9b2 100644 --- a/test/json.test.js +++ b/test/json.test.ts @@ -1,18 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { - mockAppConfigurationClientListConfigurationSettings, - restoreMocks, - createMockedConnectionString, - createMockedKeyVaultReference, - createMockedJsonKeyValue -} = require("./utils/testHelper"); +import { load } from "./exportedApi"; +import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedKeyVaultReference, createMockedJsonKeyValue } from "./utils/testHelper"; const jsonKeyValue = createMockedJsonKeyValue("json.settings.logging", '{"Test":{"Level":"Debug"},"Prod":{"Level":"Warning"}}'); const keyVaultKeyValue = createMockedKeyVaultReference("TestKey", "https://fake-vault-name.vault.azure.net/secrets/fakeSecretName"); diff --git a/test/keyvault.test.js b/test/keyvault.test.ts similarity index 84% rename from test/keyvault.test.js rename to test/keyvault.test.ts index 02d676d7..c5ac332e 100644 --- a/test/keyvault.test.js +++ b/test/keyvault.test.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { sinon, - createMockedConnectionString, - createMockedTokenCredential, - mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } = require("./utils/testHelper"); -const { SecretClient } = require("@azure/keyvault-secrets"); +import { load } from "./exportedApi"; +import { sinon, createMockedConnectionString, createMockedTokenCredential, mockAppConfigurationClientListConfigurationSettings, mockSecretClientGetSecret, restoreMocks, createMockedKeyVaultReference } from "./utils/testHelper"; +import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; const mockedData = [ // key, secretUri, value @@ -72,9 +69,9 @@ describe("key vault reference", function () { // mock specific behavior per secret client const client1 = new SecretClient("https://fake-vault-name.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client1, "getSecret").returns({ value: "SecretValueViaClient1" }); + sinon.stub(client1, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient1" } as KeyVaultSecret)); const client2 = new SecretClient("https://fake-vault-name2.vault.azure.net", createMockedTokenCredential()); - sinon.stub(client2, "getSecret").returns({ value: "SecretValueViaClient2" }); + sinon.stub(client2, "getSecret").returns(Promise.resolve({value: "SecretValueViaClient2" } as KeyVaultSecret)); const settings = await load(createMockedConnectionString(), { keyVaultOptions: { secretClients: [ diff --git a/test/load.test.js b/test/load.test.ts similarity index 60% rename from test/load.test.js rename to test/load.test.ts index 5a8682a9..a0b89691 100644 --- a/test/load.test.js +++ b/test/load.test.ts @@ -1,65 +1,34 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { - mockAppConfigurationClientListConfigurationSettings, - restoreMocks, - createMockedConnectionString, - createMockedEnpoint, - createMockedTokenCredential, -} = require("./utils/testHelper"); +import { load } from "./exportedApi"; +import { mockAppConfigurationClientListConfigurationSettings, restoreMocks, createMockedConnectionString, createMockedEnpoint, createMockedTokenCredential, createMockedKeyValue } from "./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 +87,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 +104,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 +116,59 @@ 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"); + }); + + it("should dedup exact same selectors but keeping the precedence", async () => { + const connectionString = createMockedConnectionString(); + const settings = await load(connectionString, { + selectors: [{ + keyFilter: "Test*", + labelFilter: "Prod" + }, { + 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/requestTracing.test.js b/test/requestTracing.test.ts similarity index 86% rename from test/requestTracing.test.js rename to test/requestTracing.test.ts index 533cd7a3..aaedae20 100644 --- a/test/requestTracing.test.js +++ b/test/requestTracing.test.ts @@ -1,14 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -const { load } = require("../dist/index"); -const { createMockedConnectionString, createMockedTokenCredential } = require("./utils/testHelper"); - +import { createMockedConnectionString, createMockedTokenCredential } from "./utils/testHelper"; +import { load } from "./exportedApi"; class HttpRequestHeadersPolicy { + headers: any; + name: string; + constructor() { this.headers = {}; this.name = "HttpRequestHeadersPolicy"; @@ -22,13 +24,14 @@ class HttpRequestHeadersPolicy { describe("request tracing", function () { const fakeEndpoint = "https://127.0.0.1"; // sufficient to test the request it sends out const headerPolicy = new HttpRequestHeadersPolicy(); + const position: "perCall" | "perRetry" = "perCall"; const clientOptions = { retryOptions: { maxRetries: 0 // save time }, additionalPolicies: [{ policy: headerPolicy, - position: "perCall" + position }] }; @@ -43,7 +46,7 @@ describe("request tracing", function () { await load(createMockedConnectionString(fakeEndpoint), { clientOptions }); } catch (e) { /* empty */ } expect(headerPolicy.headers).not.undefined; - expect(headerPolicy.headers.get("User-Agent")).satisfy(ua => ua.startsWith("javascript-appconfiguration-provider")); + expect(headerPolicy.headers.get("User-Agent")).satisfy((ua: string) => ua.startsWith("javascript-appconfiguration-provider")); }); it("should have request type in correlation-context header", async () => { @@ -99,7 +102,7 @@ describe("request tracing", function () { delete process.env.WEBSITE_SITE_NAME; }); - it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async() => { + it("should disable request tracing when AZURE_APP_CONFIGURATION_TRACING_DISABLED is true", async () => { for (const indicator of ["TRUE", "true"]) { process.env.AZURE_APP_CONFIGURATION_TRACING_DISABLED = indicator; try { diff --git a/test/utils/testHelper.js b/test/utils/testHelper.js deleted file mode 100644 index 11e43f71..00000000 --- a/test/utils/testHelper.js +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -const sinon = require("sinon"); -const { AppConfigurationClient } = require("@azure/app-configuration"); -const { ClientSecretCredential } = require("@azure/identity"); -const { SecretClient } = require("@azure/keyvault-secrets"); - -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; - } - sinon.stub(AppConfigurationClient.prototype, "listConfigurationSettings").callsFake(() => testKvSetGnerator()); -} - -// uriValueList: [["", "value"], ...] -function mockSecretClientGetSecret(uriValueList) { - const dict = new Map(); - for (const [uri, value] of uriValueList) { - dict.set(uri, value); - } - - sinon.stub(SecretClient.prototype, "getSecret").callsFake(function (secretName, options) { - const url = new URL(this.vaultUrl); - url.pathname = `/secrets/${secretName}`; - if (options?.version) { - url.pathname += `/${options.version}`; - } - return { value: dict.get(url.toString()) }; - }) -} - -function restoreMocks() { - sinon.restore(); -} - -const createMockedEnpoint = (name = "azure") => `https://${name}.azconfig.io`; - -const createMockedConnectionString = (endpoint = createMockedEnpoint(), secret = "secret", id = "b1d9b31") => { - const toEncodeAsBytes = Buffer.from(secret); - const returnValue = toEncodeAsBytes.toString("base64"); - return `Endpoint=${endpoint};Id=${id};Secret=${returnValue}`; -} - -const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_CLIENT_ID, clientSecret = TEST_CLIENT_SECRET) => { - return new ClientSecretCredential(tenantId, clientId, clientSecret); -} - -const createMockedKeyVaultReference = (key, vaultUri) => ({ - // https://${vaultName}.vault.azure.net/secrets/${secretName} - value: `{"uri":"${vaultUri}"}`, - key, - label: null, - contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", - lastModified: "2023-05-09T08:51:11.000Z", - tags: { - }, - etag: "SPJSMnJ2ph4BAjftWfdIctV2VIyQxtcIzRbh1oxTBkM", - isReadOnly: false, -}); - -const createMockedJsonKeyValue = (key, value) => ({ - value: value, - key: key, - label: null, - contentType: "application/json", - lastModified: "2023-05-04T04:32:56.000Z", - tags: {}, - etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", - isReadOnly: false -}); - -module.exports = { - sinon, - mockAppConfigurationClientListConfigurationSettings, - mockSecretClientGetSecret, - restoreMocks, - - createMockedEnpoint, - createMockedConnectionString, - createMockedTokenCredential, - createMockedKeyVaultReference, - createMockedJsonKeyValue -} \ No newline at end of file diff --git a/test/utils/testHelper.ts b/test/utils/testHelper.ts new file mode 100644 index 00000000..679069c4 --- /dev/null +++ b/test/utils/testHelper.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as sinon from "sinon"; +import { AppConfigurationClient, ConfigurationSetting } from "@azure/app-configuration"; +import { ClientSecretCredential } from "@azure/identity"; +import { KeyVaultSecret, SecretClient } from "@azure/keyvault-secrets"; +import * as uuid from "uuid"; + +const TEST_CLIENT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000000"; +const TEST_CLIENT_SECRET = "0000000000000000000000000000000000000000"; + +function mockAppConfigurationClientListConfigurationSettings(kvList: ConfigurationSetting[]) { + function* testKvSetGnerator(kvs: any[]) { + yield* kvs; + } + 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, -1)) : kv.key === keyFilter; + + let labelMatched = false; + if (labelFilter === "*") { + labelMatched = true; + } else if (labelFilter === "\0") { + labelMatched = kv.label === undefined; + } else if (labelFilter.endsWith("*")) { + labelMatched = kv.label !== undefined && kv.label.startsWith(labelFilter.slice(0, -1)); + } else { + labelMatched = kv.label === labelFilter; + } + return keyMatched && labelMatched; + }) + return testKvSetGnerator(kvs) as any; + }); +} + +// uriValueList: [["", "value"], ...] +function mockSecretClientGetSecret(uriValueList: [string, string][]) { + const dict = new Map(); + for (const [uri, value] of uriValueList) { + dict.set(uri, value); + } + + sinon.stub(SecretClient.prototype, "getSecret").callsFake(async function (secretName, options) { + const url = new URL(this.vaultUrl); + url.pathname = `/secrets/${secretName}`; + if (options?.version) { + url.pathname += `/${options.version}`; + } + return { + name: secretName, + value: dict.get(url.toString()) + } as KeyVaultSecret; + }) +} + +function restoreMocks() { + sinon.restore(); +} + +const createMockedEnpoint = (name = "azure") => `https://${name}.azconfig.io`; + +const createMockedConnectionString = (endpoint = createMockedEnpoint(), secret = "secret", id = "b1d9b31") => { + const toEncodeAsBytes = Buffer.from(secret); + const returnValue = toEncodeAsBytes.toString("base64"); + return `Endpoint=${endpoint};Id=${id};Secret=${returnValue}`; +} + +const createMockedTokenCredential = (tenantId = TEST_TENANT_ID, clientId = TEST_CLIENT_ID, clientSecret = TEST_CLIENT_SECRET) => { + return new ClientSecretCredential(tenantId, clientId, clientSecret); +} + +const createMockedKeyVaultReference = (key: string, vaultUri: string): ConfigurationSetting => ({ + // https://${vaultName}.vault.azure.net/secrets/${secretName} + value: `{"uri":"${vaultUri}"}`, + key, + contentType: "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8", + lastModified: new Date(), + tags: { + }, + etag: "SPJSMnJ2ph4BAjftWfdIctV2VIyQxtcIzRbh1oxTBkM", + isReadOnly: false, +}); + +const createMockedJsonKeyValue = (key: string, value: any): ConfigurationSetting => ({ + value: value, + key: key, + contentType: "application/json", + lastModified: new Date(), + tags: {}, + etag: "GdmsLWq3mFjFodVEXUYRmvFr3l_qRiKAW_KdpFbxZKk", + isReadOnly: false +}); + +const createMockedKeyValue = (props: {[key: string]: any}): ConfigurationSetting => (Object.assign({ + value: "TestValue", + key: "TestKey", + contentType: "", + lastModified: new Date(), + tags: {}, + etag: uuid.v4(), + isReadOnly: false +}, props)); + +export { + sinon, + mockAppConfigurationClientListConfigurationSettings, + mockSecretClientGetSecret, + restoreMocks, + + createMockedEnpoint, + createMockedConnectionString, + createMockedTokenCredential, + createMockedKeyVaultReference, + createMockedJsonKeyValue, + createMockedKeyValue +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..0f96a196 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": [ + "DOM", + "WebWorker", + "ESNext" + ], + "skipDefaultLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2022", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true, + "inlineSources": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 395c4d54..f2fc0e9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,10 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": [ - "DOM", - "WebWorker", - "ESNext" - ], - "skipDefaultLibCheck": true, "module": "ESNext", - "moduleResolution": "Node", - "target": "ES2022", - "strictNullChecks": true, - "strictFunctionTypes": true, - "sourceMap": true, - "inlineSources": true, "outDir": "./dist-esm" }, "include": [ "src/**/*" - ], - "exclude": [ - "node_modules", - "**/node_modules/*" ] } \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..3cbd3c0a --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./out" + }, + "include": [ + "test/**/*" + ] +} \ No newline at end of file