diff --git a/extensions/azdata/src/azdata.ts b/extensions/azdata/src/azdata.ts index 9477057f87..c9a3f86b33 100644 --- a/extensions/azdata/src/azdata.ts +++ b/extensions/azdata/src/azdata.ts @@ -17,7 +17,7 @@ import { getErrorMessage, NoAzdataError, searchForCmd } from './common/utils'; import { azdataAcceptEulaKey, azdataConfigSection, azdataFound, azdataInstallKey, azdataUpdateKey, debugConfigKey, eulaAccepted, eulaUrl, microsoftPrivacyStatementUrl } from './constants'; import * as loc from './localizedConstants'; -const enum AzdataDeployOption { +export const enum AzdataDeployOption { dontPrompt = 'dontPrompt', prompt = 'prompt' } diff --git a/extensions/azdata/src/test/api.test.ts b/extensions/azdata/src/test/api.test.ts new file mode 100644 index 0000000000..8fd231cdaa --- /dev/null +++ b/extensions/azdata/src/test/api.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as TypeMoq from 'typemoq'; +import { getExtensionApi } from '../api'; +import { AzdataToolService } from '../services/azdataToolService'; +import { assertRejected } from './testUtils'; + +describe('api', function (): void { + afterEach(function (): void { + sinon.restore(); + }); + describe('getExtensionApi', function (): void { + it('throws when no azdata', async function(): Promise { + const mementoMock = TypeMoq.Mock.ofType(); + const azdataToolService = new AzdataToolService(); + const api = getExtensionApi(mementoMock.object, azdataToolService, Promise.resolve(undefined)); + await assertRejected(api.isEulaAccepted(), 'isEulaAccepted'); + + await assertRejected(api.azdata.getPath(), 'getPath'); + await assertRejected(api.azdata.getSemVersion(), 'getSemVersion'); + await assertRejected(api.azdata.login('', '', ''), 'login'); + await assertRejected(api.azdata.version(), 'version'); + + await assertRejected(api.azdata.arc.dc.create('', '', '', '', '', ''), 'arc dc create'); + + await assertRejected(api.azdata.arc.dc.config.list(), 'arc dc config list'); + await assertRejected(api.azdata.arc.dc.config.show(), 'arc dc config show'); + + await assertRejected(api.azdata.arc.dc.endpoint.list(), 'arc dc endpoint list'); + + await assertRejected(api.azdata.arc.sql.mi.list(), 'arc sql mi list'); + await assertRejected(api.azdata.arc.sql.mi.delete(''), 'arc sql mi delete'); + await assertRejected(api.azdata.arc.sql.mi.show(''), 'arc sql mi show'); + + await assertRejected(api.azdata.arc.postgres.server.list(), 'arc sql postgres server list'); + await assertRejected(api.azdata.arc.postgres.server.delete(''), 'arc sql postgres server delete'); + await assertRejected(api.azdata.arc.postgres.server.show(''), 'arc sql postgres server show'); + await assertRejected(api.azdata.arc.postgres.server.edit('', { }), 'arc sql postgres server edit'); + }); + }); +}); + diff --git a/extensions/azdata/src/test/azdata.test.ts b/extensions/azdata/src/test/azdata.test.ts index e3b9950556..25abd7d500 100644 --- a/extensions/azdata/src/test/azdata.test.ts +++ b/extensions/azdata/src/test/azdata.test.ts @@ -14,8 +14,11 @@ import * as loc from '../localizedConstants'; import * as os from 'os'; import * as fs from 'fs'; import { AzdataReleaseInfo } from '../azdataReleaseInfo'; +import * as TypeMoq from 'typemoq'; +import { eulaAccepted } from '../constants'; const oldAzdataMock = new azdata.AzdataTool('/path/to/azdata', '0.0.0'); +const currentAzdataMock = new azdata.AzdataTool('/path/to/azdata', '9999.999.999'); /** * This matches the schema of the JSON file used to determine the current version of @@ -131,6 +134,24 @@ describe('azdata', function () { '--replace-engine-settings', args.workers.toString()]); }); + it('edit no optional args', async function (): Promise { + await azdataTool.arc.postgres.server.edit(name, {}); + verifyExecuteCommandCalledWithArgs([ + 'arc', 'postgres', 'server', 'edit', + name]); + verifyExecuteCommandCalledWithoutArgs([ + '--admin-password', + '--cores-limit', + '--cores-request', + '--engine-settings', + '--extensions', + '--memory-limit', + '--memory-request', + '--no-wait', + '--port', + '--replace-engine-settings', + '--workers']); + }); }); }); describe('sql', function (): void { @@ -207,10 +228,23 @@ describe('azdata', function () { }); }); + /** + * Verifies that the specified args were included in the call to executeCommand + * @param args The args to check were included in the execute command call + */ function verifyExecuteCommandCalledWithArgs(args: string[]): void { const commandArgs = executeCommandStub.args[0][1] as string[]; args.forEach(arg => should(commandArgs).containEql(arg)); } + + /** + * Verifies that the specified args weren't included in the call to executeCommand + * @param args The args to check weren't included in the execute command call + */ + function verifyExecuteCommandCalledWithoutArgs(args: string[]): void { + const commandArgs = executeCommandStub.args[0][1] as string[]; + args.forEach(arg => should(commandArgs).not.containEql(arg)); + } }); describe('findAzdata', function (): void { @@ -235,8 +269,9 @@ describe('azdata', function () { describe('installAzdata', function (): void { + let errorMessageStub: sinon.SinonStub; beforeEach(function (): void { - sinon.stub(vscode.window, 'showErrorMessage').returns(Promise.resolve(loc.yes)); + errorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').returns(Promise.resolve(loc.yes)); sinon.stub(utils, 'searchForCmd').returns(Promise.resolve('/path/to/azdata')); executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); }); @@ -255,6 +290,42 @@ describe('azdata', function () { } }); + it('skipped install - dont prompt config', async function (): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => 'dontPrompt'); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + switch (process.platform) { + case 'win32': + await testWin32SkippedInstall(); + break; + case 'darwin': + await testDarwinSkippedInstall(); + break; + case 'linux': + await testLinuxSkippedInstall(); + break; + } + }); + + it('skipped install - user chose not to prompt', async function (): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + errorMessageStub.resolves(loc.doNotAskAgain); + switch (process.platform) { + case 'win32': + await testWin32SkippedInstall(); + break; + case 'darwin': + await testDarwinSkippedInstall(); + break; + case 'linux': + await testLinuxSkippedInstall(); + break; + } + configMock.verify(x => x.update(TypeMoq.It.isAny(), azdata.AzdataDeployOption.dontPrompt, TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + if (process.platform === 'win32') { it('unsuccessful download - win32', async function (): Promise { sinon.stub(HttpClient, 'downloadFile').rejects(); @@ -283,8 +354,10 @@ describe('azdata', function () { }); describe('updateAzdata', function (): void { + let showInformationMessageStub: sinon.SinonStub; + beforeEach(function (): void { - sinon.stub(vscode.window, 'showInformationMessage').returns(Promise.resolve(loc.yes)); + showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').returns(Promise.resolve(loc.yes)); executeSudoCommandStub = sinon.stub(childProcess, 'executeSudoCommand').returns(Promise.resolve({ stdout: '', stderr: '' })); }); @@ -302,6 +375,78 @@ describe('azdata', function () { } }); + it('successful update - always prompt if user requested', async function (): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + switch (process.platform) { + case 'win32': + await testWin32SuccessfulUpdate(true); + break; + case 'darwin': + await testDarwinSuccessfulUpdate(true); + break; + case 'linux': + await testLinuxSuccessfulUpdate(true); + break; + } + }); + + it('skipped update - config set not to prompt', async function (): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + switch (process.platform) { + case 'win32': + await testWin32SkippedUpdateDontPrompt(); + break; + case 'darwin': + await testDarwinSkippedUpdateDontPrompt(); + break; + case 'linux': + await testLinuxSkippedUpdateDontPrompt(); + break; + } + }); + + it('skipped update - user chose to never prompt again', async function (): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + showInformationMessageStub.resolves(loc.doNotAskAgain); + switch (process.platform) { + case 'win32': + await testWin32SkippedUpdateDontPrompt(); + break; + case 'darwin': + await testDarwinSkippedUpdateDontPrompt(); + break; + case 'linux': + await testLinuxSkippedUpdateDontPrompt(); + break; + } + // Config should have been updated since user chose never to prompt again + configMock.verify(x => x.update(TypeMoq.It.isAny(), azdata.AzdataDeployOption.dontPrompt, TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + + it('skipped update - no new version', async function (): Promise { + switch (process.platform) { + case 'win32': + await testWin32SkippedUpdate(); + break; + case 'darwin': + await testDarwinSkippedUpdate(); + break; + case 'linux': + await testLinuxSkippedUpdate(); + break; + } + }); + + it('skipped update - no azdata', async function (): Promise { + const result = await azdata.checkAndUpdateAzdata(); + should(result).be.false(); + }); it('unsuccessful update', async function (): Promise { switch (process.platform) { @@ -323,6 +468,81 @@ describe('azdata', function () { }); }); }); + + describe('promptForEula', function(): void { + it('skipped because of config', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const result = await azdata.promptForEula(mementoMock.object); + should(result).be.false(); + }); + + it('always prompt if user requested', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const showInformationMessage = sinon.stub(vscode.window, 'showInformationMessage'); + const result = await azdata.promptForEula(mementoMock.object, true); + should(result).be.false(); + should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); + }); + + it('prompt if config set to do so', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const showInformationMessage = sinon.stub(vscode.window, 'showInformationMessage'); + const result = await azdata.promptForEula(mementoMock.object); + should(result).be.false(); + should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); + }); + + it('update config if user chooses not to prompt', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const showInformationMessage = sinon.stub(vscode.window, 'showInformationMessage').resolves(loc.doNotAskAgain); + const result = await azdata.promptForEula(mementoMock.object); + configMock.verify(x => x.update(TypeMoq.It.isAny(), azdata.AzdataDeployOption.dontPrompt, TypeMoq.It.isAny()), TypeMoq.Times.once()); + should(result).be.false('EULA should not have been accepted'); + should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); + }); + + it('user accepted EULA', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const showInformationMessage = sinon.stub(vscode.window, 'showInformationMessage').resolves(loc.accept); + const result = await azdata.promptForEula(mementoMock.object); + mementoMock.verify(x => x.update(eulaAccepted, true), TypeMoq.Times.once()); + should(result).be.true('EULA should have been accepted'); + should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); + }); + + it('user accepted EULA - require user action', async function(): Promise { + const configMock = TypeMoq.Mock.ofType(); + configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); + sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); + const mementoMock = TypeMoq.Mock.ofType(); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(loc.accept); + const result = await azdata.promptForEula(mementoMock.object, true, true); + mementoMock.verify(x => x.update(eulaAccepted, true), TypeMoq.Times.once()); + should(result).be.true('EULA should have been accepted'); + should(showErrorMessage.calledOnce).be.true('showErrorMessage should have been called to prompt user'); + }); + }); + + describe('isEulaAccepted', function(): void { + const mementoMock = TypeMoq.Mock.ofType(); + mementoMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => true); + should(azdata.isEulaAccepted(mementoMock.object)).be.true(); + }); }); async function testLinuxUnsuccessfulUpdate() { @@ -371,16 +591,56 @@ async function testWin32UnsuccessfulUpdate() { should(executeSudoCommandStub.calledOnce).be.true(); } -async function testLinuxSuccessfulUpdate() { +async function testLinuxSuccessfulUpdate(userRequested = false) { sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); const executeCommandStub = sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: '0.0.0', stderr: '' })); executeSudoCommandStub.resolves({ stdout: '0.0.0', stderr: '' }); - await azdata.checkAndUpdateAzdata(oldAzdataMock); + await azdata.checkAndUpdateAzdata(oldAzdataMock, userRequested); should(executeSudoCommandStub.callCount).be.equal(6); should(executeCommandStub.calledOnce).be.true(); } -async function testDarwinSuccessfulUpdate() { +async function testDarwinSuccessfulUpdate(userRequested = false) { + const brewInfoOutput = [{ + name: 'azdata-cli', + full_name: 'microsoft/azdata-cli-release/azdata-cli', + versions: { + 'stable': '9999.999.999', + 'devel': null, + 'head': null, + 'bottle': true + } + }]; + const executeCommandStub = sinon.stub(childProcess, 'executeCommand') + .onThirdCall() //third call is brew info azdata-cli --json which needs to return json of new available azdata versions. + .resolves({ + stderr: '', + stdout: JSON.stringify(brewInfoOutput) + }) + .resolves({ stdout: '0.0.0', stderr: '' }); + await azdata.checkAndUpdateAzdata(oldAzdataMock, userRequested); + should(executeCommandStub.callCount).be.equal(6); + should(executeCommandStub.getCall(2).args[0]).be.equal('brew', '3rd call should have been to brew'); + should(executeCommandStub.getCall(2).args[1]).deepEqual(['info', 'azdata-cli', '--json'], '3rd call did not have expected arguments'); +} + + +async function testWin32SuccessfulUpdate(userRequested = false) { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + await azdata.checkAndUpdateAzdata(oldAzdataMock, userRequested); + should(executeSudoCommandStub.calledOnce).be.true('executeSudoCommand should have been called once'); + should(executeSudoCommandStub.getCall(0).args[0]).startWith('msiexec /qn /i'); +} + +async function testLinuxSkippedUpdate() { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + executeSudoCommandStub.resolves({ stdout: '0.0.0', stderr: '' }); + await azdata.checkAndUpdateAzdata(currentAzdataMock); + should(executeSudoCommandStub.callCount).be.equal(0, 'executeSudoCommand was not expected to be called'); +} + +async function testDarwinSkippedUpdateDontPrompt() { const brewInfoOutput = [{ name: 'azdata-cli', full_name: 'microsoft/azdata-cli-release/azdata-cli', @@ -400,17 +660,92 @@ async function testDarwinSuccessfulUpdate() { .resolves({ stdout: '0.0.0', stderr: '' }); await azdata.checkAndUpdateAzdata(oldAzdataMock); should(executeCommandStub.callCount).be.equal(6); - should(executeCommandStub.getCall(2).args[0]).be.equal('brew', '3rd call should have been to brew'); - should(executeCommandStub.getCall(2).args[1]).deepEqual(['info', 'azdata-cli', '--json'], '3rd call did not have expected arguments'); + should(executeCommandStub.notCalledWith(sinon.match.any, sinon.match.array.contains(['upgrade', 'azdata-cli']))); } - -async function testWin32SuccessfulUpdate() { +async function testWin32SkippedUpdateDontPrompt() { sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); await azdata.checkAndUpdateAzdata(oldAzdataMock); - should(executeSudoCommandStub.calledOnce).be.true('executeSudoCommand should have been called once'); - should(executeSudoCommandStub.getCall(0).args[0]).startWith('msiexec /qn /i'); + should(executeSudoCommandStub.notCalled).be.true('executeSudoCommand should not have been called'); +} + +async function testLinuxSkippedUpdateDontPrompt() { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + sinon.stub(childProcess, 'executeCommand').returns(Promise.resolve({ stdout: '0.0.0', stderr: '' })); + executeSudoCommandStub.resolves({ stdout: '0.0.0', stderr: '' }); + await azdata.checkAndUpdateAzdata(oldAzdataMock); + should(executeSudoCommandStub.callCount).be.equal(0, 'executeSudoCommand was not expected to be called'); +} + +async function testDarwinSkippedUpdate() { + const brewInfoOutput = [{ + name: 'azdata-cli', + full_name: 'microsoft/azdata-cli-release/azdata-cli', + versions: { + 'stable': '9999.999.999', + 'devel': null, + 'head': null, + 'bottle': true + } + }]; + const executeCommandStub = sinon.stub(childProcess, 'executeCommand') + .onThirdCall() //third call is brew info azdata-cli --json which needs to return json of new available azdata versions. + .resolves({ + stderr: '', + stdout: JSON.stringify(brewInfoOutput) + }) + .resolves({ stdout: '0.0.0', stderr: '' }); + await azdata.checkAndUpdateAzdata(currentAzdataMock); + should(executeCommandStub.callCount).be.equal(6); + should(executeCommandStub.notCalledWith(sinon.match.any, sinon.match.array.contains(['upgrade', 'azdata-cli']))); +} + +async function testWin32SkippedUpdate() { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + await azdata.checkAndUpdateAzdata(currentAzdataMock); + should(executeSudoCommandStub.notCalled).be.true('executeSudoCommand should not have been called'); +} + +async function testDarwinSkippedInstall() { + const executeCommandStub = sinon.stub(childProcess, 'executeCommand') + .onFirstCall() + .callsFake(async (_command: string, _args: string[]) => { + return Promise.reject(new Error('not Found')); + }) + .callsFake(async (_command: string, _args: string[]) => { + return Promise.resolve({ stdout: '0.0.0', stderr: '' }); + }); + const result = await azdata.checkAndInstallAzdata(); + should(result).equal(undefined, 'result should be undefined'); + should(executeCommandStub.callCount).be.equal(0); +} + +async function testLinuxSkippedInstall() { + sinon.stub(childProcess, 'executeCommand') + .onFirstCall() + .rejects(new Error('not Found')) + .resolves({ stdout: '0.0.0', stderr: '' }); + executeSudoCommandStub + .resolves({ stdout: 'success', stderr: '' }); + const result = await azdata.checkAndInstallAzdata(); + should(result).equal(undefined, 'result should be undefined'); + should(executeSudoCommandStub.callCount).be.equal(0); +} + +async function testWin32SkippedInstall() { + sinon.stub(HttpClient, 'getTextContent').returns(Promise.resolve(JSON.stringify(releaseJson))); + sinon.stub(HttpClient, 'downloadFile').returns(Promise.resolve(__filename)); + sinon.stub(childProcess, 'executeCommand') + .onFirstCall() + .rejects(new Error('not Found')) // First call mock the tool not being found + .resolves({ stdout: '1.0.0', stderr: '' }); + executeSudoCommandStub + .returns({ stdout: '', stderr: '' }); + const result = await azdata.checkAndInstallAzdata(); + should(result).equal(undefined, 'result should be undefined'); + should(executeSudoCommandStub.notCalled).be.true('executeSudoCommand should not have been called'); } async function testWin32SuccessfulInstall() { diff --git a/extensions/azdata/src/test/testUtils.ts b/extensions/azdata/src/test/testUtils.ts new file mode 100644 index 0000000000..6176dc2e78 --- /dev/null +++ b/extensions/azdata/src/test/testUtils.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Asserts that the specified promise was rejected. This is similar to should(..).be.rejected but + * allows specifying a message in the thrown Error to add more information to the failure. + * @param promise The promise to verify was rejected + * @param message The message to include in the error if the promise isn't rejected + */ +export async function assertRejected(promise: Promise, message: string): Promise { + try { + await promise; + } catch { + return; + } + throw new Error(message); +} +