diff --git a/extensions/sql-bindings/package.json b/extensions/sql-bindings/package.json index 7753f5e08a..0ef7e61f8d 100644 --- a/extensions/sql-bindings/package.json +++ b/extensions/sql-bindings/package.json @@ -51,7 +51,7 @@ }, { "command": "sqlBindings.createAzureFunction", - "when": "view == objectExplorer && viewItem == Table && !azdataAvailable", + "when": "!azdataAvailable", "group": "zAzure_Function@1" } ], diff --git a/extensions/sql-bindings/src/common/azureFunctionsUtils.ts b/extensions/sql-bindings/src/common/azureFunctionsUtils.ts index df0896c6aa..86f6d607ae 100644 --- a/extensions/sql-bindings/src/common/azureFunctionsUtils.ts +++ b/extensions/sql-bindings/src/common/azureFunctionsUtils.ts @@ -44,7 +44,6 @@ export async function getLocalSettingsJson(localSettingsPath: string): Promise { +export async function promptAndUpdateConnectionStringSetting(projectUri: vscode.Uri | undefined, connectionInfo?: IConnectionInfo): Promise { let connectionStringSettingName: string | undefined; const vscodeMssqlApi = await utils.getVscodeMssqlApi(); @@ -390,6 +390,7 @@ export async function promptAndUpdateConnectionStringSetting(projectUri: vscode. 'WEBSITE_VNET_ROUTE_ALL' ]; + // setup connetion string setting quickpick let connectionStringSettings: (vscode.QuickPickItem)[] = []; if (settings?.Values) { connectionStringSettings = Object.keys(settings.Values).filter(setting => !knownSettings.includes(setting)).map(setting => { return { label: setting }; }); @@ -426,104 +427,65 @@ export async function promptAndUpdateConnectionStringSetting(projectUri: vscode. // show the connection string methods (user input and connection profile options) const listOfConnectionStringMethods = [constants.connectionProfile, constants.userConnectionString]; + let selectedConnectionStringMethod: string | undefined; + let connectionString: string | undefined = ''; while (true) { - const selectedConnectionStringMethod = await vscode.window.showQuickPick(listOfConnectionStringMethods, { - canPickMany: false, - title: constants.selectConnectionString, - ignoreFocusOut: true - }); - if (!selectedConnectionStringMethod) { - // User cancelled - return; - } + try { + const projectFolder: string = path.dirname(projectUri.fsPath); + const localSettingsPath: string = path.join(projectFolder, constants.azureFunctionLocalSettingsFileName); - let connectionString: string = ''; - let includePassword: string | undefined; - let connectionInfo: IConnectionInfo | undefined; - let connectionDetails: ConnectionDetails; - if (selectedConnectionStringMethod === constants.userConnectionString) { - // User chooses to enter connection string manually - connectionString = await vscode.window.showInputBox( - { - title: constants.enterConnectionString, - ignoreFocusOut: true, - value: 'Server=localhost;Initial Catalog={db_name};User ID=sa;Password={your_password};Persist Security Info=False', - validateInput: input => input ? undefined : constants.valueMustNotBeEmpty - } - ) ?? ''; - } else { - // Let user choose from existing connections to create connection string from - connectionInfo = await vscodeMssqlApi.promptForConnection(true); if (!connectionInfo) { - // User cancelled return to selectedConnectionStringMethod prompt - continue; - } - connectionDetails = { options: connectionInfo }; - try { - // Prompt to include password in connection string if authentication type is SqlLogin and connection has password saved - if (connectionInfo.authenticationType === 'SqlLogin' && connectionInfo.password) { - includePassword = await vscode.window.showQuickPick([constants.yesString, constants.noString], { - title: constants.includePassword, - canPickMany: false, - ignoreFocusOut: true - }); - if (includePassword === constants.yesString) { - // set connection string to include password - connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, true, false); - } + // show the connection string methods (user input and connection profile options) + selectedConnectionStringMethod = await vscode.window.showQuickPick(listOfConnectionStringMethods, { + canPickMany: false, + title: constants.selectConnectionString, + ignoreFocusOut: true + }); + if (!selectedConnectionStringMethod) { + // User cancelled + return; } - // set connection string to not include the password if connection info does not include password, or user chooses to not include password, or authentication type is not sql login - if (includePassword !== constants.yesString) { - connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, false, false); - } - } catch (e) { - // failed to get connection string for selected connection and will go back to prompt for connection string methods - console.warn(e); - void vscode.window.showErrorMessage(constants.failedToGetConnectionString); - continue; - } - } - if (connectionString) { - try { - const projectFolder: string = path.dirname(projectUri.fsPath); - const localSettingsPath: string = path.join(projectFolder, constants.azureFunctionLocalSettingsFileName); - let userPassword: string | undefined; - // Ask user to enter password if auth type is sql login and password is not saved - if (connectionInfo?.authenticationType === 'SqlLogin' && !connectionInfo?.password) { - userPassword = await vscode.window.showInputBox({ - prompt: constants.enterPasswordPrompt, - placeHolder: constants.enterPasswordManually, - ignoreFocusOut: true, - password: true, - validateInput: input => input ? undefined : constants.valueMustNotBeEmpty - }); - if (userPassword) { - // if user enters password replace password placeholder with user entered password - connectionString = connectionString.replace(constants.passwordPlaceholder, userPassword); - } - } - if (includePassword !== constants.yesString && !userPassword && connectionInfo?.authenticationType === 'SqlLogin') { - // if user does not want to include password or user does not enter password, show warning message that they will have to enter it manually later in local.settings.json - void vscode.window.showWarningMessage(constants.userPasswordLater, constants.openFile, constants.closeButton).then(async (result) => { - if (result === constants.openFile) { - // open local.settings.json file - void vscode.commands.executeCommand(constants.vscodeOpenCommand, vscode.Uri.file(localSettingsPath)); + if (selectedConnectionStringMethod === constants.userConnectionString) { + // User chooses to enter connection string manually + connectionString = await vscode.window.showInputBox( + { + title: constants.enterConnectionString, + ignoreFocusOut: true, + value: 'Server=localhost;Initial Catalog={db_name};User ID=sa;Password={your_password};Persist Security Info=False', + validateInput: input => input ? undefined : constants.valueMustNotBeEmpty } - }); - } - const success = await setLocalAppSetting(projectFolder, newConnectionStringSettingName, connectionString); - if (success) { - // exit both loops and insert binding - connectionStringSettingName = newConnectionStringSettingName; - break; + ) ?? ''; } else { - void vscode.window.showErrorMessage(constants.selectConnectionError()); + // Let user choose from existing connections to create connection string from + connectionInfo = await vscodeMssqlApi.promptForConnection(true); } - } catch (e) { - // display error message and show select setting quickpick again - void vscode.window.showErrorMessage(constants.selectConnectionError(e)); - continue; } + if (selectedConnectionStringMethod !== constants.userConnectionString) { + if (!connectionInfo) { + // User cancelled return to selectedConnectionStringMethod prompt + continue; + } + // get the connection string including prompts for password if needed + connectionString = await promptConnectionStringPasswordAndUpdateConnectionString(connectionInfo, localSettingsPath); + } + if (!connectionString) { + // user cancelled the prompts + return; + } + + const success = await setLocalAppSetting(projectFolder, newConnectionStringSettingName, connectionString); + if (success) { + // exit both loops and insert binding + connectionStringSettingName = newConnectionStringSettingName; + break; + } else { + void vscode.window.showErrorMessage(constants.selectConnectionError()); + } + + } catch (e) { + // display error message and show select setting quickpick again + void vscode.window.showErrorMessage(constants.selectConnectionError(e)); + continue; } } } else { @@ -545,3 +507,70 @@ export async function promptAndUpdateConnectionStringSetting(projectUri: vscode. } return connectionStringSettingName; } + +/** + * Prompts the user to include password in the connection string and updates the connection string based on user input + * @param connectionInfo connection info from the connection profile user selected + * @param localSettingsPath path to the local.settings.json file + * @returns the updated connection string based on password prompts + */ +export async function promptConnectionStringPasswordAndUpdateConnectionString(connectionInfo: IConnectionInfo, localSettingsPath: string): Promise { + let includePassword: string | undefined; + let connectionString: string = ''; + let connectionDetails: ConnectionDetails; + const vscodeMssqlApi = await utils.getVscodeMssqlApi(); + connectionDetails = { options: connectionInfo }; + + try { + // Prompt to include password in connection string if authentication type is SqlLogin and connection has password saved + if (connectionInfo.authenticationType === 'SqlLogin' && connectionInfo.password) { + includePassword = await vscode.window.showQuickPick([constants.yesString, constants.noString], { + title: constants.includePassword, + canPickMany: false, + ignoreFocusOut: true + }); + if (includePassword === constants.yesString) { + // set connection string to include password + connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, true, false); + } + } + // set connection string to not include the password if connection info does not include password, or user chooses to not include password, or authentication type is not sql login + let userPassword: string | undefined; + if (includePassword !== constants.yesString) { + connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, false, false); + + // Ask user to enter password if auth type is sql login and password is not saved + if (connectionInfo.authenticationType === 'SqlLogin' && connectionInfo.password) { + userPassword = await vscode.window.showInputBox({ + prompt: constants.enterPasswordPrompt, + placeHolder: constants.enterPasswordManually, + ignoreFocusOut: true, + password: true, + validateInput: input => input ? undefined : constants.valueMustNotBeEmpty + }); + if (userPassword) { + // if user enters password replace password placeholder with user entered password + connectionString = connectionString.replace(constants.passwordPlaceholder, userPassword); + } + } + } + + if (includePassword !== constants.yesString && !userPassword && connectionInfo?.authenticationType === 'SqlLogin') { + // if user does not want to include password or user does not enter password, show warning message that they will have to enter it manually later in local.settings.json + void vscode.window.showWarningMessage(constants.userPasswordLater, constants.openFile, constants.closeButton).then(async (result) => { + if (result === constants.openFile) { + // open local.settings.json file + void vscode.commands.executeCommand(constants.vscodeOpenCommand, vscode.Uri.file(localSettingsPath)); + } + }); + } + + return connectionString; + + } catch (e) { + // failed to get connection string for selected connection and will go back to prompt for connection string methods + console.warn(e); + void vscode.window.showErrorMessage(constants.failedToGetConnectionString); + return undefined; + } +} diff --git a/extensions/sql-bindings/src/services/azureFunctionsService.ts b/extensions/sql-bindings/src/services/azureFunctionsService.ts index 9a187b6b8e..04aab68542 100644 --- a/extensions/sql-bindings/src/services/azureFunctionsService.ts +++ b/extensions/sql-bindings/src/services/azureFunctionsService.ts @@ -114,8 +114,6 @@ export async function createAzureFunction(node?: ITreeNodeInfo): Promise { objectName = utils.generateQuotedFullName(node.metadata.schema, node.metadata.name); } - const connectionDetails = vscodeMssqlApi.createConnectionDetails(connectionInfo); - const connectionString = await vscodeMssqlApi.getConnectionString(connectionDetails, false, false); TelemetryReporter.createActionEvent(TelemetryViews.CreateAzureFunctionWithSqlBinding, TelemetryActions.startCreateAzureFunctionWithSqlBinding) .withConnectionInfo(connectionInfo).send(); @@ -222,13 +220,18 @@ export async function createAzureFunction(node?: ITreeNodeInfo): Promise { // issue https://github.com/microsoft/azuredatastudio/issues/18780 await azureFunctionsUtils.setLocalAppSetting(path.dirname(projectFile), constants.azureWebJobsStorageSetting, constants.azureWebJobsStoragePlaceholder); + // prompt for connection string setting name and set connection string in local.settings.json + quickPickStep = 'getConnectionStringSettingName'; + let connectionStringSettingName = await azureFunctionsUtils.promptAndUpdateConnectionStringSetting(vscode.Uri.parse(projectFile), connectionInfo); + // create C# Azure Function with SQL Binding await azureFunctionApi.createFunction({ language: 'C#', templateId: templateId, functionName: functionName, + targetFramework: 'netcoreapp3.1', functionSettings: { - connectionStringSetting: constants.sqlConnectionStringSetting, + connectionStringSetting: connectionStringSettingName, ...(selectedBindingType === BindingType.input && { object: objectName }), ...(selectedBindingType === BindingType.output && { table: objectName }) }, @@ -267,7 +270,6 @@ export async function createAzureFunction(node?: ITreeNodeInfo): Promise { .withAdditionalProperties(propertyBag).send(); newFunctionFileObject.watcherDisposable.dispose(); } - await azureFunctionsUtils.addConnectionStringToConfig(connectionString, projectFile); } else { TelemetryReporter.sendErrorEvent(TelemetryViews.CreateAzureFunctionWithSqlBinding, TelemetryActions.finishCreateAzureFunctionWithSqlBinding); } diff --git a/extensions/sql-bindings/src/test/common/azureFunctionsUtils.test.ts b/extensions/sql-bindings/src/test/common/azureFunctionsUtils.test.ts new file mode 100644 index 0000000000..ae79285e23 --- /dev/null +++ b/extensions/sql-bindings/src/test/common/azureFunctionsUtils.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as should from 'should'; +import * as sinon from 'sinon'; +import * as constants from '../../common/constants'; +import * as azureFunctionsUtils from '../../common/azureFunctionsUtils'; +import { EOL } from 'os'; + +let rootFolderPath = 'test'; +let localSettingsPath: string = `${rootFolderPath}/local.settings.json`; +let projectFilePath: string = `${rootFolderPath}//projectFilePath.csproj`; + +describe('Tests to verify Azure Functions Utils functions', function (): void { + + it('Should correctly parse local.settings.json', async () => { + sinon.stub(fs, 'existsSync').withArgs(localSettingsPath).returns(true); + sinon.stub(fs, 'readFileSync').withArgs(localSettingsPath).returns( + `{"IsEncrypted": false, + "Values": {"test1": "test1", "test2": "test2", "test3":"test3"}}` + ); + let settings = await azureFunctionsUtils.getLocalSettingsJson(localSettingsPath); + should(settings.IsEncrypted).equals(false); + should(Object.keys(settings.Values!).length).equals(3); + }); + + it('setLocalAppSetting can update settings.json with new setting value', async () => { + sinon.stub(fs, 'existsSync').withArgs(localSettingsPath).returns(true); + sinon.stub(fs, 'readFileSync').withArgs(localSettingsPath).returns( + `{"IsEncrypted": false, + "Values": {"test1": "test1", "test2": "test2", "test3":"test3"}}` + ); + + let writeFileStub = sinon.stub(fs.promises, 'writeFile'); + await azureFunctionsUtils.setLocalAppSetting(path.dirname(localSettingsPath), 'test4', 'test4'); + should(writeFileStub.calledWithExactly(localSettingsPath, `{${EOL} "IsEncrypted": false,${EOL} "Values": {${EOL} "test1": "test1",${EOL} "test2": "test2",${EOL} "test3": "test3",${EOL} "test4": "test4"${EOL} }${EOL}}`)).equals(true); + }); + + it('Should not overwrite setting if value already exists in local.settings.json', async () => { + sinon.stub(fs, 'existsSync').withArgs(localSettingsPath).returns(true); + sinon.stub(fs, 'readFileSync').withArgs(localSettingsPath).returns( + `{"IsEncrypted": false, + "Values": {"test1": "test1", "test2": "test2", "test3":"test3"}}` + ); + + let warningMsg = constants.settingAlreadyExists('test1'); + const spy = sinon.stub(vscode.window, 'showWarningMessage').resolves({ title: constants.settingAlreadyExists('test1') }); + + await azureFunctionsUtils.setLocalAppSetting(path.dirname(localSettingsPath), 'test1', 'newValue'); + should(spy.calledOnce).be.true('showWarningMessage should have been called exactly once'); + should(spy.calledWith(warningMsg)).be.true(`showWarningMessage not called with expected message '${warningMsg}' Actual '${spy.getCall(0).args[0]}'`); + }); + + it('Should get settings file given project file', async () => { + const settingsFile = await azureFunctionsUtils.getSettingsFile(projectFilePath); + should(settingsFile).equals(localSettingsPath); + }); + + it('Should add connection string to local.settings.json', async () => { + sinon.stub(fs, 'existsSync').withArgs(localSettingsPath).returns(true); + sinon.stub(fs, 'readFileSync').withArgs(localSettingsPath).returns( + `{"IsEncrypted": false, + "Values": {"test1": "test1", "test2": "test2", "test3":"test3"}}` + ); + const connectionString = 'testConnectionString'; + + let writeFileStub = sinon.stub(fs.promises, 'writeFile'); + await azureFunctionsUtils.addConnectionStringToConfig(connectionString, projectFilePath); + should(writeFileStub.calledWithExactly(localSettingsPath, `{${EOL} "IsEncrypted": false,${EOL} "Values": {${EOL} "test1": "test1",${EOL} "test2": "test2",${EOL} "test3": "test3",${EOL} "SqlConnectionString": "testConnectionString"${EOL} }${EOL}}`)).equals(true); + }); + + afterEach(async function (): Promise { + sinon.restore(); + }); +}); diff --git a/extensions/sql-bindings/src/test/dialog/addSqlBindingQuickpick.test.ts b/extensions/sql-bindings/src/test/dialog/addSqlBindingQuickpick.test.ts index df7dcbe805..ace2269552 100644 --- a/extensions/sql-bindings/src/test/dialog/addSqlBindingQuickpick.test.ts +++ b/extensions/sql-bindings/src/test/dialog/addSqlBindingQuickpick.test.ts @@ -117,8 +117,8 @@ describe('Add SQL Binding quick pick', () => { await launchAddSqlBindingQuickpick(vscode.Uri.file('testUri')); // should go back to the select connection string methods - should(quickpickStub.callCount === 5); - should(quickpickStub.getCall(4).args).deepEqual([ + should(quickpickStub.callCount === 4); + should(quickpickStub.getCall(3).args).deepEqual([ [constants.connectionProfile, constants.userConnectionString], { canPickMany: false,