diff --git a/package-lock.json b/package-lock.json index 037ba765..1fcd84ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@azure/arm-resources-profile-2020-09-01-hybrid": "^2.1.0", "@microsoft/vscode-azext-azureauth": "^5.1.1", "@microsoft/vscode-azext-azureutils": "^4.0.0", - "@microsoft/vscode-azext-utils": "^4.0.2", + "@microsoft/vscode-azext-utils": "^4.0.3", "form-data": "^4.0.4", "fs-extra": "^11.3.0", "jsonc-parser": "^2.2.1", @@ -1650,9 +1650,9 @@ } }, "node_modules/@microsoft/vscode-azext-utils": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-4.0.2.tgz", - "integrity": "sha512-RpHKn4hcDTtJpMaif0jb8ulfDEFPT/C/U3wcx93eLxgQhDVFQ5aC49KybxodNp89dVUXhNUHhu6gdNDPfXt6Pg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-utils/-/vscode-azext-utils-4.0.3.tgz", + "integrity": "sha512-Qli06TAOPqW91cRtHI17jIW344ew487FF+YydZ4pM0o0zomi9VR9yJjZ+1vVnhHmTZuFTUCrI6w+I8JByWxM5w==", "license": "MIT", "dependencies": { "@microsoft/vscode-azureresources-api": "^3.0.0", diff --git a/package.json b/package.json index dd2d3634..b415da48 100644 --- a/package.json +++ b/package.json @@ -923,7 +923,7 @@ "@azure/arm-resources-profile-2020-09-01-hybrid": "^2.1.0", "@microsoft/vscode-azext-azureauth": "^5.1.1", "@microsoft/vscode-azext-azureutils": "^4.0.0", - "@microsoft/vscode-azext-utils": "^4.0.2", + "@microsoft/vscode-azext-utils": "^4.0.3", "form-data": "^4.0.4", "fs-extra": "^11.3.0", "jsonc-parser": "^2.2.1", diff --git a/test/api/auth/MockUUIDCredentialManager.ts b/test/api/auth/MockUUIDCredentialManager.ts new file mode 100644 index 00000000..0c3e745d --- /dev/null +++ b/test/api/auth/MockUUIDCredentialManager.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { AzExtCredentialManager } from "../../../api/src/auth/credentialManager/AzExtCredentialManager"; +import { maskValue } from "../../../api/src/utils/maskValue"; + +/** + * A mock credential manager with the same implementation as `AzExtUUIDCredentialManager`, + * but with a public getter to inspect the UUIDs during test. + */ +export class MockUUIDCredentialManager implements AzExtCredentialManager { + #uuidMap: Map = new Map(); + + get uuidMap() { + return this.#uuidMap; + } + + createCredential(extensionId: string): string { + const uuid: string = crypto.randomUUID(); + this.#uuidMap.set(extensionId, uuid); + return uuid; + } + + verifyCredential(credential: string, extensionId: string): boolean { + if (!credential || !extensionId) { + return false; + } + return credential === this.#uuidMap.get(extensionId); + } + + maskCredentials(data: string): string { + for (const uuid of this.#uuidMap.values()) { + data = maskValue(data, uuid); + } + return data; + } +} diff --git a/test/api/auth/mockAuthApi.ts b/test/api/auth/mockAuthApi.ts new file mode 100644 index 00000000..ac8c0d6b --- /dev/null +++ b/test/api/auth/mockAuthApi.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { AzureExtensionApiFactory, createApiProvider } from "@microsoft/vscode-azext-utils"; +import { apiUtils, AzureExtensionApi, AzureResourcesExtensionAuthApi, GetApiOptions } from "../../../api/src"; +import { AuthApiFactoryDependencies, createAuthApiFactory } from "../../../src/api/auth/createAuthApiFactory"; + +/** + * Creates a mock API provider with API factories matching the versions provided. + * Only the values required by the interface will be implemented. + */ +function createMockApiProvider(versions: string[]): apiUtils.AzureExtensionApiProvider { + const apiFactories: AzureExtensionApiFactory[] = versions.map(version => { + return { + apiVersion: version, + createApi: (_options?: GetApiOptions) => { + return { + apiVersion: version, + }; + }, + }; + }); + + return createApiProvider(apiFactories); +} + +/** + * Creates a mock auth API protecting core API versions: ['0.0.1', '2.0.0', '3.0.0'] + */ +export function createMockAuthApi(customDependencies?: AuthApiFactoryDependencies): AzureResourcesExtensionAuthApi { + const coreApiVersions: string[] = ['0.0.1', '2.0.0', '3.0.0']; + const coreApiProvider = createMockApiProvider(coreApiVersions); + const authApiProvider = createAuthApiFactory(coreApiProvider, customDependencies); + return authApiProvider.createApi({ extensionId: 'ms-azuretools.vscode-azureresourcegroups-tests' }); +} diff --git a/test/api/auth/v4.host.test.ts b/test/api/auth/v4.host.test.ts new file mode 100644 index 00000000..557c1350 --- /dev/null +++ b/test/api/auth/v4.host.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { nonNullValue, parseError } from "@microsoft/vscode-azext-utils"; +import * as assert from "assert"; +import { apiUtils, AzureExtensionApi, AzureResourcesExtensionAuthApi } from "../../../api/src"; +import { assertThrowsAsync } from "../../wrapFunctionsInTelemetry.test"; +import { MockUUIDCredentialManager } from "./MockUUIDCredentialManager"; +import { createMockAuthApi } from "./mockAuthApi"; + +const clientExtensionId: string = 'ms-azuretools.vscode-azurecontainerapps'; +const clientExtensionVersion: string = '1.0.0'; + +suite('v4 internal API auth tests', async () => { + test('v4 API should be defined', async () => { + const apiProvider = await apiUtils.getExtensionExports('ms-azuretools.vscode-azureresourcegroups'); + assert.ok(apiProvider, 'API provider is undefined'); + + const v4Api = apiProvider.getApi('^4.0.0', { extensionId: 'ms-azuretools.vscode-azureresourcegroups-tests' }); + assert.ok(v4Api); + }); + + test('createAzureResourcesApiSession should provide a valid credential but not return it directly', async () => { + let apiSession: unknown; + let receivedHostCredential: string = ''; + let receivedClientCredential: string = ''; + + const credentialManager = new MockUUIDCredentialManager(); + const generatedClientCredential: string = crypto.randomUUID(); + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 5000); + + const mockClientExtensionApi: AzureExtensionApi = { + apiVersion: clientExtensionVersion, + receiveAzureResourcesApiSession: (hostCredential: string, clientCredential: string) => { + clearTimeout(timeout); + receivedHostCredential = hostCredential; + receivedClientCredential = clientCredential; + resolve(); + }, + }; + + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi({ credentialManager, clientApiProvider: { getApi: () => mockClientExtensionApi } }); + authApi.createAzureResourcesApiSession(clientExtensionId, clientExtensionVersion, generatedClientCredential) + .then(session => apiSession = session) + .catch(() => { clearTimeout(timeout); resolve(); }); + }); + + assert.equal(apiSession, undefined); + assert.equal(receivedClientCredential, generatedClientCredential); + + const generatedHostCredential: string = nonNullValue(credentialManager.uuidMap.get(clientExtensionId)); + assert.equal(receivedHostCredential, generatedHostCredential); + }); + + test('createAzureResourcesApiSession should throw if an unallowed extension id is provided', async () => { + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi(); + await assertThrowsAsync(async () => await authApi.createAzureResourcesApiSession('extension1', clientExtensionVersion, crypto.randomUUID())); + }); + + test('createAzureResourcesApiSession should not spill sensitive extension credentials in errors', async () => { + const credentialManager = new MockUUIDCredentialManager(); + credentialManager.createCredential('extension1'); + credentialManager.createCredential = () => { + throw new Error(credentialManager.uuidMap.get('extension1')); + }; + + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi({ credentialManager }); + + try { + await authApi.createAzureResourcesApiSession(clientExtensionId, clientExtensionVersion, crypto.randomUUID()); + assert.fail('We expect the credential manager to throw in this test.'); + } catch (err) { + const perr = parseError(err); + assert.doesNotMatch(perr.message, new RegExp(nonNullValue(credentialManager.uuidMap.get('extension1')), 'i')); + } + }); + + test('getAzureResourcesApis should return matching APIs if provided a valid credential', async () => { + const credentialManager = new MockUUIDCredentialManager(); + const generatedHostCredential: string = credentialManager.createCredential(clientExtensionId); + + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi({ credentialManager }); + const resourcesApis = await authApi.getAzureResourcesApis(clientExtensionId, generatedHostCredential, ['0.0.1', '^2.0.0']); + + assert.equal(resourcesApis[0]?.apiVersion, '0.0.1'); + assert.match(resourcesApis[1]?.apiVersion ?? '', /^2\./); + }); + + test('getAzureResourcesApis should throw if provided an invalid credential', async () => { + const credentialManager = new MockUUIDCredentialManager(); + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi({ credentialManager }); + await assertThrowsAsync(async () => await authApi.getAzureResourcesApis(clientExtensionId, crypto.randomUUID(), ['^2.0.0'])); + + credentialManager.createCredential(clientExtensionId); + await assertThrowsAsync(async () => await authApi.getAzureResourcesApis(clientExtensionId, crypto.randomUUID(), ['^2.0.0'])); + }); + + test('getAzureResourcesApis should not spill sensitive extension credentials in errors', async () => { + const credentialManager = new MockUUIDCredentialManager(); + const authApi: AzureResourcesExtensionAuthApi = createMockAuthApi({ credentialManager }); + + credentialManager.createCredential('extension1'); + credentialManager.createCredential('extension2'); + credentialManager.createCredential('extension3'); + + try { + await authApi.getAzureResourcesApis(clientExtensionId, crypto.randomUUID(), ['^2.0.0']); + assert.fail('Should throw if requesting Azure Resources APIs without a valid credential.'); + } catch (err) { + const perr = parseError(err); + for (const credential of credentialManager.uuidMap.values()) { + assert.doesNotMatch(perr.message, new RegExp(credential, 'i')); + } + } + }); +}); diff --git a/test/global.test.ts b/test/global.test.ts index 7d66ce04..991ace0f 100644 --- a/test/global.test.ts +++ b/test/global.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { registerOnActionStartHandler, TestUserInput } from '@microsoft/vscode-azext-utils'; +import { registerOnActionStartHandler, testGlobalSetup, TestUserInput } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { settingUtils } from '../src/utils/settingUtils'; import { getTestApi } from './utils/testApiAccess'; @@ -20,6 +20,7 @@ suiteSetup(async function (this: Mocha.Context): Promise { // Initialize test API - this caches it for use throughout tests await getTestApi(); + testGlobalSetup(); await vscode.commands.executeCommand('azureResourceGroups.refresh'); // activate the extension before tests begin diff --git a/test/utils/testApiAccess.ts b/test/utils/testApiAccess.ts index 1811606c..12fdb7d9 100644 --- a/test/utils/testApiAccess.ts +++ b/test/utils/testApiAccess.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { TestApi } from '../../src/testApi'; import { apiUtils } from '../../api/src/utils/apiUtils'; +import { TestApi } from '../../src/testApi'; let cachedTestApi: TestApi | undefined;