diff --git a/api/src/auth/apiRequest/AzureResourcesApiRequestContext.ts b/api/src/auth/apiRequest/AzureResourcesApiRequestContext.ts index a346bf63..4cc8739d 100644 --- a/api/src/auth/apiRequest/AzureResourcesApiRequestContext.ts +++ b/api/src/auth/apiRequest/AzureResourcesApiRequestContext.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureResourcesExtensionApi } from "../../extensionApi"; +import { AzureResourcesExtensionApi, AzureResourcesExtensionAuthApi } from "../../extensionApi"; import { AzureExtensionApi } from "../../utils/apiUtils"; +import { AzExtCredentialManager } from "../credentialManager/AzExtCredentialManager"; import { AzureResourcesApiRequestError } from "./apiRequestErrors"; export interface AzureResourcesApiRequestContext { @@ -30,3 +31,19 @@ export interface AzureResourcesApiRequestContext { */ onApiRequestError?: (error: AzureResourcesApiRequestError) => void | Promise; } + +// NOTE: Dependency injection options for tests; skip publically exporting this in the index +export interface CustomRequestDependenciesContext extends AzureResourcesApiRequestContext { + /** + * An optional credential manager used for issuing and verifying the client extensions credentials. If none are supplied, a simple UUID credential manager is used. + * @test Use this to more easily mock and inspect the behavior of the underlying credential manager. + */ + credentialManager?: AzExtCredentialManager; + + /** + * An optional API provider to be used in lieu of the VS Code extension provider `vscode.extension.getExtension()`. + * This should _NOT_ be used in production environments. + * @test Use this to more easily mock and inject custom host extension API exports. + */ + hostApiProvider?: { getApi(): AzureResourcesExtensionAuthApi }; +} diff --git a/api/src/auth/apiRequest/apiRequest.ts b/api/src/auth/apiRequest/apiRequest.ts index 71e84920..46fd6967 100644 --- a/api/src/auth/apiRequest/apiRequest.ts +++ b/api/src/auth/apiRequest/apiRequest.ts @@ -8,7 +8,7 @@ import { AzureResourcesExtensionAuthApi } from "../../extensionApi"; import { apiUtils, AzureExtensionApi } from "../../utils/apiUtils"; import { AzExtCredentialManager } from "../credentialManager/AzExtCredentialManager"; import { AzExtUUIDCredentialManager } from "../credentialManager/AzExtUUIDCredentialManager"; -import { AzureResourcesApiRequestContext } from "./AzureResourcesApiRequestContext"; +import { AzureResourcesApiRequestContext, CustomRequestDependenciesContext } from "./AzureResourcesApiRequestContext"; import { AzureResourcesApiRequestErrorCode } from "./apiRequestErrors"; const azureResourcesAuthApiVersion: string = '^4.0.0'; @@ -38,7 +38,7 @@ export function prepareAzureResourcesApiRequest(con throw new Error('You must specify at least one Azure Resources API version.'); } - const clientCredentialManager: AzExtCredentialManager = new AzExtUUIDCredentialManager(); + const clientCredentialManager: AzExtCredentialManager = (context as CustomRequestDependenciesContext).credentialManager ?? new AzExtUUIDCredentialManager(); if (!clientExtensionApi.receiveAzureResourcesApiSession) { clientExtensionApi.receiveAzureResourcesApiSession = createReceiveAzureResourcesApiSession(context, clientCredentialManager); @@ -56,13 +56,13 @@ async function requestAzureResourcesSession(context: AzureResourcesApiRequestCon clientCredential = await clientCredentialManager.createCredential(context.clientExtensionId); } catch (err) { if (err instanceof Error) { - void context.onApiRequestError?.({ code: AzureResourcesApiRequestErrorCode.ClientFailedCreateCredential, message: clientCredentialManager.maskCredentials(err.message) }) + void context.onApiRequestError?.({ code: AzureResourcesApiRequestErrorCode.ClientFailedCreateCredential, message: clientCredentialManager.maskCredentials(err.message) }); } return; } try { - const resourcesAuthApi = await getExtensionApi(azureResourcesExtId, azureResourcesAuthApiVersion); + const resourcesAuthApi = (context as CustomRequestDependenciesContext).hostApiProvider?.getApi() ?? await getExtensionApi(azureResourcesExtId, azureResourcesAuthApiVersion); await resourcesAuthApi.createAzureResourcesApiSession(context.clientExtensionId, clientApiVersion, clientCredential); } catch (err) { if (err instanceof Error) { @@ -92,7 +92,7 @@ function createReceiveAzureResourcesApiSession(context: AzureResourcesApiRequest } try { - const resourcesAuthApi = await getExtensionApi(azureResourcesExtId, azureResourcesAuthApiVersion); + const resourcesAuthApi = (context as CustomRequestDependenciesContext).hostApiProvider?.getApi() ?? await getExtensionApi(azureResourcesExtId, azureResourcesAuthApiVersion); const resourcesApis = await resourcesAuthApi.getAzureResourcesApis(context.clientExtensionId, azureResourcesCredential, context.azureResourcesApiVersions); void context.onDidReceiveAzureResourcesApis(resourcesApis); } catch (err) { @@ -101,7 +101,7 @@ function createReceiveAzureResourcesApiSession(context: AzureResourcesApiRequest } return; } - } + }; } async function getExtensionApi(extensionId: string, extensionVersion: string): Promise { diff --git a/api/src/index.ts b/api/src/index.ts index 83e9eb44..6ea6cf08 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,7 +5,7 @@ export * from './auth/apiRequest/apiRequest'; export * from './auth/apiRequest/apiRequestErrors'; -export * from './auth/apiRequest/AzureResourcesApiRequestContext'; +export { AzureResourcesApiRequestContext } from './auth/apiRequest/AzureResourcesApiRequestContext'; export * from './AzExtResourceType'; export * from './extensionApi'; export * from './getAzExtResourceType'; diff --git a/test/api/auth/MockUUIDCredentialManager.ts b/test/api/auth/MockUUIDCredentialManager.ts index 0c3e745d..f0014a1b 100644 --- a/test/api/auth/MockUUIDCredentialManager.ts +++ b/test/api/auth/MockUUIDCredentialManager.ts @@ -11,7 +11,7 @@ import { maskValue } from "../../../api/src/utils/maskValue"; * but with a public getter to inspect the UUIDs during test. */ export class MockUUIDCredentialManager implements AzExtCredentialManager { - #uuidMap: Map = new Map(); + #uuidMap = new Map(); get uuidMap() { return this.#uuidMap; diff --git a/test/api/auth/v4.client.test.ts b/test/api/auth/v4.client.test.ts new file mode 100644 index 00000000..a2e849b3 --- /dev/null +++ b/test/api/auth/v4.client.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import { AzureExtensionApi, AzureResourcesApiRequestContext, AzureResourcesApiRequestError, AzureResourcesApiRequestErrorCode, AzureResourcesExtensionApi, prepareAzureResourcesApiRequest } from "../../../api/src"; +import { CustomRequestDependenciesContext } from "../../../api/src/auth/apiRequest/AzureResourcesApiRequestContext"; +import { AzExtUUIDCredentialManager } from "../../../api/src/auth/credentialManager/AzExtUUIDCredentialManager"; +import { createMockAuthApi } from "./mockAuthApi"; + +const clientExtensionId: string = 'ms-azuretools.vscode-azurecontainerapps'; + +suite('Azure Resources API client-side request tests', async () => { + test('prepareAzureResourcesApiRequest should successfully enable the handshake & return available APIs if on the allow list', async () => { + let receivedResourcesApis: (AzureExtensionApi | AzureResourcesExtensionApi | undefined)[] = []; + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 5000); + + const requestContext: AzureResourcesApiRequestContext = { + clientExtensionId, + azureResourcesApiVersions: ['0.0.1', '^2.0.0'], + onDidReceiveAzureResourcesApis: (azureResourcesApis: (AzureExtensionApi | AzureResourcesExtensionApi | undefined)[]) => { + clearTimeout(timeout); + receivedResourcesApis = azureResourcesApis; + resolve(); + }, + onApiRequestError: () => { + clearTimeout(timeout); + resolve(); + }, + }; + + const coreClientExtensionApi: AzureExtensionApi = { + apiVersion: '1.0.0', + }; + + // Inject an external manager so the two preparation calls that follow will point to the same one + (requestContext as CustomRequestDependenciesContext).credentialManager = new AzExtUUIDCredentialManager(); + + // For testing, it is necessary to wire up both the client and host api provider to represent the parties on each side of the handshake. + // The prepare call needs to happen twice in order to set this scenario up - once to generate the client API for the host to point to, + // and then once more to pass that client to the host API for the final handshake method to point to. + // + // NOTE: This is not normally necessary since VS Code's API normally manages extension exports; however, this is not something we can rely on + // during tests because we need to be able to point to our own mocked APIs (which requires us to swap out the native VS Code extension provider). + + const { clientApi } = prepareAzureResourcesApiRequest(requestContext, coreClientExtensionApi); + const hostApi = createMockAuthApi({ clientApiProvider: { getApi: () => clientApi } }); + (requestContext as CustomRequestDependenciesContext).hostApiProvider = { getApi: () => hostApi }; + + const { requestResourcesApis } = prepareAzureResourcesApiRequest(requestContext, clientApi); + requestResourcesApis(); + }); + + assert.equal(receivedResourcesApis[0]?.apiVersion, '0.0.1'); + assert.match(receivedResourcesApis[1]?.apiVersion ?? '', /^2\./); + }); + + test('prepareAzureResourcesApiRequest should return an error if not on the allow list', async () => { + let receivedError: AzureResourcesApiRequestError | undefined; + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 5000); + + const requestContext: AzureResourcesApiRequestContext = { + clientExtensionId: 'extension1', + azureResourcesApiVersions: ['0.0.1', '^2.0.0'], + onDidReceiveAzureResourcesApis: () => { + clearTimeout(timeout); + resolve(); + }, + onApiRequestError: (error: AzureResourcesApiRequestError) => { + clearTimeout(timeout); + receivedError = error; + resolve(); + } + }; + + const coreClientExtensionApi: AzureExtensionApi = { + apiVersion: '1.0.0', + }; + + // We don't need to wire up the custom test api providers as we expect the initial call to fail before the host ever tries to reach back out to the client + const { requestResourcesApis } = prepareAzureResourcesApiRequest(requestContext, coreClientExtensionApi); + requestResourcesApis(); + }); + + assert.equal(receivedError?.code, AzureResourcesApiRequestErrorCode.HostCreateSessionFailed); + }); +});