Most Recently Used support in VSCode extension (#77)

* MRU support in ConnectionStore

- Core functionality including tests to support most recently used list
- Configuration option to let users define the size of the MRU list

* Recent Connection added on Connect

- ConnectionManager.Connect will save a connection to the recently used list
- Unit Tests added to cover this
- Default value from the user settings is now filtered out as this change caused it to be shown (and we do not want the sample value visible)
This commit is contained in:
Kevin Cunnane 2016-09-13 18:09:38 -07:00 коммит произвёл GitHub
Родитель 4e78839713
Коммит ee9a4933cb
10 изменённых файлов: 560 добавлений и 150 удалений

Просмотреть файл

@ -167,6 +167,11 @@
"default": false,
"description": "[Optional] Log debug output to the VS Code console (Help -> Toggle Developer Tools)"
},
"vscode-mssql.maxRecentConnections": {
"type": "number",
"default": 5,
"description": "The maximum number of recently used connections to store in the connection list"
},
"vscode-mssql.connections": {
"type": "array",
"default": [

Просмотреть файл

@ -40,7 +40,8 @@ export default class ConnectionManager {
statusView: StatusView,
prompter: IPrompter,
private _client?: SqlToolsServerClient,
private _vscodeWrapper?: VscodeWrapper) {
private _vscodeWrapper?: VscodeWrapper,
private _connectionStore?: ConnectionStore) {
this._context = context;
this._statusView = statusView;
this._prompter = prompter;
@ -53,7 +54,11 @@ export default class ConnectionManager {
this.vscodeWrapper = new VscodeWrapper();
}
this._connectionUI = new ConnectionUI(new ConnectionStore(context), prompter, this.vscodeWrapper);
if (!this._connectionStore) {
this._connectionStore = new ConnectionStore(context);
}
this._connectionUI = new ConnectionUI(this._connectionStore, prompter, this.vscodeWrapper);
this.vscodeWrapper.onDidCloseTextDocument(params => this.onDidCloseTextDocument(params));
this.vscodeWrapper.onDidSaveTextDocument(params => this.onDidSaveTextDocument(params));
@ -287,16 +292,28 @@ export default class ConnectionManager {
extensionConnectionTime: extensionTimer.getDuration() - serviceTimer.getDuration(),
serviceConnectionTime: serviceTimer.getDuration()
});
resolve(true);
return newCredentials;
} else {
Utils.showErrorMsg(Constants.msgError + Constants.msgConnectionError);
self.statusView.connectError(fileUri, connectionCreds, result.messages);
self.connectionUI.showConnectionErrors(result.messages);
// We've logged the failure so no need to throw
return undefined;
}
}).then( (newConnection: Interfaces.IConnectionCredentials) => {
if (newConnection) {
let connectionToSave: Interfaces.IConnectionCredentials = Object.assign({}, newConnection);
self._connectionStore.addRecentlyUsed(connectionToSave)
.then(() => {
resolve(true);
}, err => {
reject(err);
});
} else {
resolve(false);
}
}, err => {
// Catch unexpected errors and return over the Promise reject callback
reject(err);
});
});
}

Просмотреть файл

@ -47,7 +47,7 @@ export default class VscodeWrapper {
* @param extensionName The string name of the extension to get the configuration for
*/
public getConfiguration(extensionName: string): vscode.WorkspaceConfiguration {
return undefined;
return vscode.workspace.getConfiguration(extensionName);
}
/**

Просмотреть файл

@ -4,6 +4,7 @@ import Constants = require('./constants');
import ConnInfo = require('./connectionInfo');
import Utils = require('../models/utils');
import ValidationException from '../utils/validationException';
import VscodeWrapper from '../controllers/vscodeWrapper';
import { ConnectionCredentials } from '../models/connectionCredentials';
import { IConnectionCredentials, IConnectionProfile, IConnectionCredentialsQuickPickItem, CredentialsQuickPickItemType } from '../models/interfaces';
import { ICredentialStore } from '../credentialstore/icredentialstore';
@ -17,16 +18,17 @@ import { CredentialStore } from '../credentialstore/credentialstore';
*/
export class ConnectionStore {
private _context: vscode.ExtensionContext;
private _credentialStore: ICredentialStore;
constructor(
private _context: vscode.ExtensionContext,
private _credentialStore?: ICredentialStore,
private _vscodeWrapper?: VscodeWrapper) {
constructor(context: vscode.ExtensionContext, credentialStore?: ICredentialStore) {
this._context = context;
if (credentialStore) {
this._credentialStore = credentialStore;
} else {
if (!this._credentialStore) {
this._credentialStore = new CredentialStore();
}
if (!this._vscodeWrapper) {
this._vscodeWrapper = new VscodeWrapper();
}
}
public static get CRED_PREFIX(): string { return 'Microsoft.SqlTools'; }
@ -87,34 +89,24 @@ export class ConnectionStore {
*
* @returns {Promise<IConnectionCredentialsQuickPickItem[]>}
*/
public getPickListItems(): Promise<IConnectionCredentialsQuickPickItem[]> {
const self = this;
return new Promise<IConnectionCredentialsQuickPickItem[]>((resolve, reject) => {
self.loadAllConnections()
.then((pickListItems: IConnectionCredentialsQuickPickItem[]) => {
// Always add an "Add New Connection" quickpick item
pickListItems.push(<IConnectionCredentialsQuickPickItem> {
label: Constants.CreateProfileLabel,
connectionCreds: undefined,
quickPickItemType: CredentialsQuickPickItemType.NewConnection
});
resolve(pickListItems);
});
public getPickListItems(): IConnectionCredentialsQuickPickItem[] {
let pickListItems: IConnectionCredentialsQuickPickItem[] = this.loadAllConnections();
pickListItems.push(<IConnectionCredentialsQuickPickItem> {
label: Constants.CreateProfileLabel,
connectionCreds: undefined,
quickPickItemType: CredentialsQuickPickItemType.NewConnection
});
return pickListItems;
}
/**
* Gets all connection profiles stored in the user settings
* Note: connections will not include password value
*
* @returns {Promise<IConnectionCredentialsQuickPickItem[]>}
* @returns {IConnectionCredentialsQuickPickItem[]}
*/
public getProfilePickListItems(): Promise<IConnectionCredentialsQuickPickItem[]> {
const self = this;
return self.loadProfiles().then(items => {
// TODO add MRU list here
return items;
});
public getProfilePickListItems(): IConnectionCredentialsQuickPickItem[] {
return this.loadProfiles();
}
public addSavedPassword(credentialsItem: IConnectionCredentialsQuickPickItem): Promise<IConnectionCredentialsQuickPickItem> {
@ -164,7 +156,7 @@ export class ConnectionStore {
self._context.globalState.update(Constants.configMyConnections, configValues)
.then(() => {
// Only save if we successfully added the profile
return self.savePasswordIfNeeded(profile);
return self.saveProfilePasswordIfNeeded(profile);
// And resolve / reject at the end of the process
}, err => {
reject(err);
@ -179,12 +171,72 @@ export class ConnectionStore {
});
}
private savePasswordIfNeeded(profile: IConnectionProfile): Promise<boolean> {
/**
* Gets the list of recently used connections. These will not include the password - a separate call to
* {addSavedPassword} is needed to fill that before connecting
*
* @returns {IConnectionCredentials[]} the array of connections, empty if none are found
*/
public getRecentlyUsedConnections(): IConnectionCredentials[] {
let configValues = this._context.globalState.get<IConnectionCredentials[]>(Constants.configRecentConnections);
if (!configValues) {
configValues = [];
}
return configValues;
}
/**
* Adds a connection to the recently used list.
* Password values are stored to a separate credential store if the "savePassword" option is true
*
* @param {IConnectionCredentials} conn the connection to add
* @returns {Promise<void>} a Promise that returns when the connection was saved
*/
public addRecentlyUsed(conn: IConnectionCredentials): Promise<void> {
const self = this;
return new Promise<void>((resolve, reject) => {
// Get all profiles
let configValues = self.getRecentlyUsedConnections();
let maxConnections = self.getMaxRecentConnectionsCount();
// Remove the connection from the list if it already exists
configValues = configValues.filter(value => !Utils.isSameConnection(value, conn));
// Add the connection to the front of the list, taking care to clear out the password field
let savedConn: IConnectionCredentials = Object.assign({}, conn, { password: '' });
configValues.unshift(savedConn);
// Remove last element if needed
if (configValues.length > maxConnections) {
configValues = configValues.slice(0, maxConnections);
}
self._context.globalState.update(Constants.configRecentConnections, configValues)
.then(() => {
// Only save if we successfully added the profile
self.doSavePassword(conn, CredentialsQuickPickItemType.Mru);
// And resolve / reject at the end of the process
resolve(undefined);
}, err => {
reject(err);
});
});
}
private saveProfilePasswordIfNeeded(profile: IConnectionProfile): Promise<boolean> {
if (!profile.savePassword) {
return Promise.resolve(true);
}
return this.doSavePassword(profile, CredentialsQuickPickItemType.Profile);
}
private doSavePassword(conn: IConnectionCredentials, type: CredentialsQuickPickItemType): Promise<boolean> {
let self = this;
return new Promise<boolean>((resolve, reject) => {
if (profile.savePassword === true && Utils.isNotEmpty(profile.password)) {
let credentialId = ConnectionStore.formatCredentialId(profile.server, profile.database, profile.user, ConnectionStore.CRED_PROFILE_USER);
self._credentialStore.saveCredential(credentialId, profile.password)
if (Utils.isNotEmpty(conn.password)) {
let credType: string = type === CredentialsQuickPickItemType.Mru ? ConnectionStore.CRED_MRU_USER : ConnectionStore.CRED_PROFILE_USER;
let credentialId = ConnectionStore.formatCredentialId(conn.server, conn.database, conn.user, credType);
self._credentialStore.saveCredential(credentialId, conn.password)
.then((result) => {
resolve(result);
}).catch(err => {
@ -192,7 +244,6 @@ export class ConnectionStore {
reject(err);
});
} else {
// Operation successful as didn't need to save
resolve(true);
}
});
@ -252,40 +303,60 @@ export class ConnectionStore {
}
// Load connections from user preferences
private loadAllConnections(): Promise<IConnectionCredentialsQuickPickItem[]> {
let self = this;
return new Promise<IConnectionCredentialsQuickPickItem[]>(resolve => {
// Load connections from user preferences
// Per this https://code.visualstudio.com/Docs/customization/userandworkspace
// Settings defined in workspace scope overwrite the settings defined in user scope
let connections: IConnectionCredentials[] = [];
let config = vscode.workspace.getConfiguration(Constants.extensionName);
private loadAllConnections(): IConnectionCredentialsQuickPickItem[] {
let quickPickItems: IConnectionCredentialsQuickPickItem[] = [];
// first read from the user settings
let configValues = config[Constants.configMyConnections];
self.addConnections(connections, configValues);
let quickPickItems = connections.map(c => self.createQuickPickItem(c, CredentialsQuickPickItemType.Profile));
resolve(quickPickItems);
}).then(quickPickItems => {
// next read from the global state
let allQuickPickItems = self.loadProfiles().then(items => {
return quickPickItems.concat(items);
});
// Read recently used items from a memento
let recentConnections = this.getConnectionsFromGlobalState(Constants.configRecentConnections);
quickPickItems = quickPickItems.concat(this.mapToQuickPickItems(recentConnections, CredentialsQuickPickItemType.Mru));
return allQuickPickItems;
});
// Load connections from user preferences
// Per this https://code.visualstudio.com/Docs/customization/userandworkspace
// Settings defined in workspace scope overwrite the settings defined in user scope
let profilesInConfiguration = this.getConnectionsFromConfig<IConnectionCredentials>(Constants.configMyConnections);
quickPickItems = quickPickItems.concat(this.mapToQuickPickItems(profilesInConfiguration, CredentialsQuickPickItemType.Profile));
// next read from the profiles saved in our own memento
// TODO remove once user settings are editable programmatically
let profiles = this.loadProfiles();
quickPickItems = quickPickItems.concat(profiles);
// Return all connections
return quickPickItems;
}
private loadProfiles(): Promise<IConnectionCredentialsQuickPickItem[]> {
let self = this;
return new Promise<IConnectionCredentialsQuickPickItem[]>((resolve, reject) => {
let connections: IConnectionProfile[] = [];
// read from the global state
let configValues = self._context.globalState.get<IConnectionProfile[]>(Constants.configMyConnections);
self.addConnections(connections, configValues);
let quickPickItems = connections.map(c => self.createQuickPickItem(c, CredentialsQuickPickItemType.Profile));
resolve(quickPickItems);
});
private getConnectionsFromGlobalState<T extends IConnectionCredentials>(configName: string): T[] {
let connections: T[] = [];
// read from the global state
let configValues = this._context.globalState.get<T[]>(configName);
this.addConnections(connections, configValues);
return connections;
}
private getConnectionsFromConfig<T extends IConnectionCredentials>(configName: string): T[] {
let config = this._vscodeWrapper.getConfiguration(Constants.extensionName);
// we do not want the default value returned since it's used for helping users only
let configValues = config.get(configName, undefined);
if (configValues) {
configValues = configValues.filter(conn => {
// filter any connection missing a server name or the sample that's shown by default
return !!(conn.server) && conn.server !== Constants.SampleServerName;
});
} else {
configValues = [];
}
return configValues;
}
private mapToQuickPickItems(connections: IConnectionCredentials[], itemType: CredentialsQuickPickItemType): IConnectionCredentialsQuickPickItem[] {
return connections.map(c => this.createQuickPickItem(c, itemType));
}
private loadProfiles(): IConnectionCredentialsQuickPickItem[] {
let connections: IConnectionProfile[] = this.getConnectionsFromGlobalState<IConnectionProfile>(Constants.configMyConnections);
let quickPickItems = connections.map(c => this.createQuickPickItem(c, CredentialsQuickPickItemType.Profile));
return quickPickItems;
}
private addConnections(connections: IConnectionCredentials[], configValues: IConnectionCredentials[]): void {
@ -301,4 +372,14 @@ export class ConnectionStore {
}
}
}
private getMaxRecentConnectionsCount(): number {
let config = this._vscodeWrapper.getConfiguration(Constants.extensionName);
let maxConnections: number = config[Constants.configMaxRecentConnections];
if (typeof(maxConnections) !== 'number' || maxConnections <= 0) {
maxConnections = 5;
}
return maxConnections;
}
}

Просмотреть файл

@ -28,6 +28,8 @@ export const msgContentProviderSqlOutputHtml = 'sqlOutput.ejs';
export const configLogDebugInfo = 'logDebugInfo';
export const configMyConnections = 'connections';
export const configSaveAsCsv = 'saveAsCsv';
export const configRecentConnections = 'recentConnections';
export const configMaxRecentConnections = 'maxRecentConnections';
// localizable strings
export const configMyConnectionsNoServerName = 'Missing server name in user preferences connection: ';
@ -72,6 +74,7 @@ export const labelOpenGlobalSettings = 'Open Global Settings';
export const labelOpenWorkspaceSettings = 'Open Workspace Settings';
export const CreateProfileLabel = 'Create Connection Profile';
export const RemoveProfileLabel = 'Remove Connection Profile';
export const SampleServerName = '{{put-server-name-here}}';
export const serverPrompt = 'Server name';
export const serverPlaceholder = 'hostname\\instance or <server>.database.windows.net';

Просмотреть файл

@ -195,6 +195,23 @@ export function isSameProfile(currentProfile: interfaces.IConnectionProfile, exp
&& expectedProfile.user === currentProfile.user;
}
/**
* Compares 2 connections to see if they match. Logic for matching:
* match on all key properties (server, db, auth type, user) being identical.
* Other properties are ignored for this purpose
*
* @param {IConnectionCredentials} conn the connection to check
* @param {IConnectionCredentials} expectedConn the connection to try to match
* @returns boolean that is true if the connections match
*/
export function isSameConnection(conn: interfaces.IConnectionCredentials, expectedConn: interfaces.IConnectionCredentials): boolean {
return expectedConn.server === conn.server
&& expectedConn.database === conn.database
&& expectedConn.authenticationType === conn.authenticationType
&& expectedConn.user === conn.user;
}
// One-time use timer for performance testing
export class Timer {
private _startTime: number[];

Просмотреть файл

@ -40,27 +40,25 @@ export class ConnectionUI {
public showConnections(): Promise<IConnectionCredentials> {
const self = this;
return new Promise<IConnectionCredentials>((resolve, reject) => {
self._connectionStore.getPickListItems()
.then((picklist: IConnectionCredentialsQuickPickItem[]) => {
if (picklist.length === 0) {
// No recent connections - prompt to open user settings or workspace settings to add a connection
self.openUserOrWorkspaceSettings();
resolve(undefined);
} else {
// We have recent connections - show them in a picklist
self.promptItemChoice({
placeHolder: Constants.recentConnectionsPlaceholder,
matchOnDescription: true
}, picklist)
.then(selection => {
if (selection) {
resolve(self.handleSelectedConnection(selection));
} else {
resolve(undefined);
}
});
}
});
let picklist: IConnectionCredentialsQuickPickItem[] = self._connectionStore.getPickListItems();
if (picklist.length === 0) {
// No recent connections - prompt to open user settings or workspace settings to add a connection
self.openUserOrWorkspaceSettings();
resolve(undefined);
} else {
// We have recent connections - show them in a picklist
self.promptItemChoice({
placeHolder: Constants.recentConnectionsPlaceholder,
matchOnDescription: true
}, picklist)
.then(selection => {
if (selection) {
resolve(self.handleSelectedConnection(selection));
} else {
resolve(undefined);
}
});
}
});
}
@ -239,20 +237,20 @@ export class ConnectionUI {
let self = this;
// Flow: Select profile to remove, confirm removal, remove, notify
return self._connectionStore.getProfilePickListItems()
.then(profiles => self.selectProfileForRemoval(profiles))
.then(profile => {
if (profile) {
return self._connectionStore.removeProfile(profile);
}
return false;
}).then(result => {
if (result) {
// TODO again consider moving information prompts to the prompt package
vscode.window.showInformationMessage(Constants.msgProfileRemoved);
}
return result;
});
let profiles = self._connectionStore.getProfilePickListItems();
return self.selectProfileForRemoval(profiles)
.then(profile => {
if (profile) {
return self._connectionStore.removeProfile(profile);
}
return false;
}).then(result => {
if (result) {
// TODO again consider moving information prompts to the prompt package
vscode.window.showInformationMessage(Constants.msgProfileRemoved);
}
return result;
});
}
private selectProfileForRemoval(profiles: IConnectionCredentialsQuickPickItem[]): Promise<IConnectionProfile> {

Просмотреть файл

@ -5,44 +5,59 @@ import * as TypeMoq from 'typemoq';
import vscode = require('vscode');
import * as utils from '../src/models/utils';
import * as connectionInfo from '../src/models/connectionInfo';
import { TestExtensionContext, TestMemento } from './stubs';
import { IConnectionProfile, CredentialsQuickPickItemType, AuthenticationTypes } from '../src/models/interfaces';
import * as Constants from '../src/models/constants';
import * as stubs from './stubs';
import * as interfaces from '../src/models/interfaces';
import { CredentialStore } from '../src/credentialstore/credentialstore';
import { ConnectionProfile } from '../src/models/connectionProfile';
import { ConnectionStore } from '../src/models/connectionStore';
import VscodeWrapper from '../src/controllers/vscodeWrapper';
import assert = require('assert');
suite('ConnectionStore tests', () => {
let defaultNamedProfile: IConnectionProfile;
let defaultUnnamedProfile: IConnectionProfile;
let defaultNamedProfile: interfaces.IConnectionProfile;
let defaultUnnamedProfile: interfaces.IConnectionProfile;
let context: TypeMoq.Mock<vscode.ExtensionContext>;
let globalstate: TypeMoq.Mock<vscode.Memento>;
let credentialStore: TypeMoq.Mock<CredentialStore>;
let vscodeWrapper: TypeMoq.Mock<VscodeWrapper>;
setup(() => {
defaultNamedProfile = Object.assign(new ConnectionProfile(), {
profileName: 'abc-bcd-cde',
server: 'abc',
profileName: 'defaultNamedProfile',
server: 'namedServer',
database: 'bcd',
authenticationType: utils.authTypeToString(AuthenticationTypes.SqlLogin),
authenticationType: utils.authTypeToString(interfaces.AuthenticationTypes.SqlLogin),
user: 'cde',
password: 'asdf!@#$'
});
defaultUnnamedProfile = Object.assign(new ConnectionProfile(), {
profileName: undefined,
server: 'namedServer',
server: 'unnamedServer',
database: undefined,
authenticationType: utils.authTypeToString(AuthenticationTypes.SqlLogin),
authenticationType: utils.authTypeToString(interfaces.AuthenticationTypes.SqlLogin),
user: 'aUser',
password: 'asdf!@#$'
});
context = TypeMoq.Mock.ofType(TestExtensionContext);
globalstate = TypeMoq.Mock.ofType(TestMemento);
context = TypeMoq.Mock.ofType(stubs.TestExtensionContext);
globalstate = TypeMoq.Mock.ofType(stubs.TestMemento);
context.object.globalState = globalstate.object;
credentialStore = TypeMoq.Mock.ofType(CredentialStore);
vscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper);
// setup default behavior for vscodeWrapper
// setup configuration to return maxRecent for the #MRU items
let maxRecent = 5;
let configResult: {[key: string]: any} = {};
configResult[Constants.configMaxRecentConnections] = maxRecent;
let config = stubs.createWorkspaceConfiguration(configResult);
vscodeWrapper.setup(x => x.getConfiguration(TypeMoq.It.isAny()))
.returns(x => {
return config;
});
});
test('formatCredentialId should handle server, DB and username correctly', () => {
@ -76,7 +91,7 @@ suite('ConnectionStore tests', () => {
user: 'cde',
password: 'asdf!@#$'
});
let label = connectionInfo.getPicklistLabel(unnamedProfile, CredentialsQuickPickItemType.Profile);
let label = connectionInfo.getPicklistLabel(unnamedProfile, interfaces.CredentialsQuickPickItemType.Profile);
assert.ok(label.endsWith(unnamedProfile.server));
});
@ -88,13 +103,13 @@ suite('ConnectionStore tests', () => {
user: 'cde',
password: 'asdf!@#$'
});
let label = connectionInfo.getPicklistLabel(namedProfile, CredentialsQuickPickItemType.Profile);
let label = connectionInfo.getPicklistLabel(namedProfile, interfaces.CredentialsQuickPickItemType.Profile);
assert.ok(label.endsWith(namedProfile.profileName));
});
test('getPickListLabel has different symbols for Profiles vs Recently Used', () => {
let profileLabel: string = connectionInfo.getPicklistLabel(defaultNamedProfile, CredentialsQuickPickItemType.Profile);
let mruLabel: string = connectionInfo.getPicklistLabel(defaultNamedProfile, CredentialsQuickPickItemType.Mru);
let profileLabel: string = connectionInfo.getPicklistLabel(defaultNamedProfile, interfaces.CredentialsQuickPickItemType.Profile);
let mruLabel: string = connectionInfo.getPicklistLabel(defaultNamedProfile, interfaces.CredentialsQuickPickItemType.Mru);
assert.ok(mruLabel, 'expect value for label');
assert.ok(profileLabel, 'expect value for label');
@ -105,9 +120,9 @@ suite('ConnectionStore tests', () => {
// Given
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
let credsToSave: IConnectionProfile[];
let credsToSave: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: IConnectionProfile[]) => {
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
credsToSave = profiles;
return Promise.resolve();
});
@ -115,7 +130,7 @@ suite('ConnectionStore tests', () => {
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
// When SaveProfile is called with savePassword false
let profile: IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: false });
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: false });
return connectionStore.saveProfile(profile)
.then(savedProfile => {
// Then expect password not saved in either the context object or the credential store
@ -131,29 +146,29 @@ suite('ConnectionStore tests', () => {
// Given
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
let credsToSave: IConnectionProfile[];
let credsToSave: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: IConnectionProfile[]) => {
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
credsToSave = profiles;
return Promise.resolve();
});
let capturedCreds: any;
credentialStore.setup(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.callback((cred: string, pass: any) => {
capturedCreds = {
'credentialId': cred,
'password': pass
};
})
.returns(() => Promise.resolve(true));
.callback((cred: string, pass: any) => {
capturedCreds = {
'credentialId': cred,
'password': pass
};
})
.returns(() => Promise.resolve(true));
let expectedCredFormat: string = ConnectionStore.formatCredentialId(defaultNamedProfile.server, defaultNamedProfile.database, defaultNamedProfile.user);
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
// When SaveProfile is called with savePassword true
let profile: IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: true });
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: true });
connectionStore.saveProfile(profile)
.then(savedProfile => {
@ -178,9 +193,9 @@ suite('ConnectionStore tests', () => {
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, profile]);
let updatedCredentials: IConnectionProfile[];
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: IConnectionProfile[]) => {
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -226,9 +241,9 @@ suite('ConnectionStore tests', () => {
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
let updatedCredentials: IConnectionProfile[];
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: IConnectionProfile[]) => {
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -261,9 +276,9 @@ suite('ConnectionStore tests', () => {
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
let updatedCredentials: IConnectionProfile[];
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: IConnectionProfile[]) => {
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -283,5 +298,194 @@ suite('ConnectionStore tests', () => {
done();
}).catch(err => done(new Error(err)));
});
test('addRecentlyUsed should limit saves to the MaxRecentConnections amount ', (done) => {
// Given 3 is the max # creds
let numCreds = 4;
let maxRecent = 3;
// setup configuration to return maxRecent for the #MRU items - must override vscodeWrapper in this test
vscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper);
let configResult: {[key: string]: any} = {};
configResult[Constants.configMaxRecentConnections] = maxRecent;
let config = stubs.createWorkspaceConfiguration(configResult);
vscodeWrapper.setup(x => x.getConfiguration(TypeMoq.It.isAny()))
.returns(x => {
return config;
});
// setup memento for MRU to return a list we have access to
let creds: interfaces.IConnectionCredentials[] = [];
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => creds.slice(0, creds.length));
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, credsToSave: interfaces.IConnectionCredentials[]) => {
creds = credsToSave;
return Promise.resolve();
});
// When saving 4 connections
// Then expect the only the 3 most recently saved connections to be returned as size is limited to 3
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
let promise = Promise.resolve();
for (let i = 0; i < numCreds; i++) {
let cred = Object.assign({}, defaultNamedProfile, { server: defaultNamedProfile.server + i});
promise = promise.then(() => {
return connectionStore.addRecentlyUsed(cred);
}).then(() => {
if (i < maxRecent) {
assert.equal(creds.length, i + 1, 'expect all credentials to be saved when limit not reached');
} else {
assert.equal(creds.length, maxRecent, `expect only top ${maxRecent} creds to be saved`);
}
assert.equal(creds[0].server, cred.server, 'Expect most recently saved item to be first in list');
assert.ok(utils.isEmpty(creds[0].password));
});
}
promise.then(() => {
credentialStore.verify(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.exactly(numCreds));
let recentConnections = connectionStore.getRecentlyUsedConnections();
assert.equal(maxRecent, recentConnections.length);
done();
}, err => {
// Must call done here so test indicates it's finished if errors occur
done(err);
});
});
test('addRecentlyUsed should add same connection exactly once', (done) => {
// setup memento for MRU to return a list we have access to
let creds: interfaces.IConnectionCredentials[] = [];
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => creds.slice(0, creds.length));
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, credsToSave: interfaces.IConnectionCredentials[]) => {
creds = credsToSave;
return Promise.resolve();
});
// Given we save the same connection twice
// Then expect the only 1 instance of that connection to be listed in the MRU
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
let promise = Promise.resolve();
let cred = Object.assign({}, defaultNamedProfile, { server: defaultNamedProfile.server + 1});
promise = promise.then(() => {
return connectionStore.addRecentlyUsed(defaultNamedProfile);
}).then(() => {
return connectionStore.addRecentlyUsed(cred);
}).then(() => {
return connectionStore.addRecentlyUsed(cred);
}).then(() => {
assert.equal(creds.length, 2, 'expect 2 unique credentials to have been added');
assert.equal(creds[0].server, cred.server, 'Expect most recently saved item to be first in list');
assert.ok(utils.isEmpty(creds[0].password));
}).then(() => done(), err => done(err));
});
test('addRecentlyUsed should save password to credential store', (done) => {
// setup memento for MRU to return a list we have access to
let creds: interfaces.IConnectionCredentials[] = [];
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => creds.slice(0, creds.length));
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, credsToSave: interfaces.IConnectionCredentials[]) => {
creds = credsToSave;
return Promise.resolve();
});
// Setup credential store to capture credentials sent to it
let capturedCreds: any;
credentialStore.setup(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.callback((cred: string, pass: any) => {
capturedCreds = {
'credentialId': cred,
'password': pass
};
})
.returns(() => Promise.resolve(true));
// Given we save 1 connection with password and multiple other connections without
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
let integratedCred = Object.assign({}, defaultNamedProfile, {
server: defaultNamedProfile.server + 'Integrated',
authenticationType: interfaces.AuthenticationTypes[interfaces.AuthenticationTypes.Integrated],
user: '',
password: ''
});
let noPwdCred = Object.assign({}, defaultNamedProfile, {
server: defaultNamedProfile.server + 'NoPwd',
password: ''
});
let expectedCredCount = 0;
let promise = Promise.resolve();
promise = promise.then(() => {
expectedCredCount++;
return connectionStore.addRecentlyUsed(defaultNamedProfile);
}).then(() => {
// Then verify that since its password based we save the password
credentialStore.verify(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.once());
assert.strictEqual(capturedCreds.password, defaultNamedProfile.password);
let credId: string = capturedCreds.credentialId;
assert.ok(credId.includes(ConnectionStore.CRED_MRU_USER), 'Expect credential to be marked as an MRU cred');
assert.ok(utils.isEmpty(creds[0].password));
}).then(() => {
// When add integrated auth connection
expectedCredCount++;
return connectionStore.addRecentlyUsed(integratedCred);
}).then(() => {
// then expect no to have credential store called, but MRU count upped to 2
credentialStore.verify(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.once());
assert.equal(creds.length, expectedCredCount, `expect ${expectedCredCount} unique credentials to have been added`);
}).then(() => {
// When add connection without password
expectedCredCount++;
return connectionStore.addRecentlyUsed(noPwdCred);
}).then(() => {
// then expect no to have credential store called, but MRU count upped to 3
credentialStore.verify(x => x.saveCredential(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.once());
assert.equal(creds.length, expectedCredCount, `expect ${expectedCredCount} unique credentials to have been added`);
}).then(() => done(), err => done(err));
});
test('getPickListItems should display Recently Used then Profiles then New Connection', (done) => {
// Given 3 items in MRU and 2 in Profile list
let recentlyUsed: interfaces.IConnectionCredentials[] = [];
for (let i = 0; i < 3; i++) {
recentlyUsed.push( Object.assign({}, defaultNamedProfile, { server: defaultNamedProfile.server + i}) );
}
globalstate.setup(x => x.get(Constants.configRecentConnections)).returns(key => recentlyUsed);
let profiles: interfaces.IConnectionProfile[] = [defaultNamedProfile, defaultUnnamedProfile];
globalstate.setup(x => x.get(Constants.configMyConnections)).returns(key => profiles);
// When we get the list of available connection items
// Then expect MRU items first, then profile items, then a new connection item
let connectionStore = new ConnectionStore(context.object, credentialStore.object, vscodeWrapper.object);
let items: interfaces.IConnectionCredentialsQuickPickItem[] = connectionStore.getPickListItems();
let expectedCount = recentlyUsed.length + profiles.length + 1;
assert.equal(items.length, expectedCount);
// Then expect recent items first
let i = 0;
for (let recentItem of recentlyUsed) {
assert.equal(items[i].connectionCreds, recentItem);
assert.equal(items[i].quickPickItemType, interfaces.CredentialsQuickPickItemType.Mru);
i++;
}
// Then profile items
for (let profile of profiles) {
assert.equal(items[i].connectionCreds, profile);
assert.equal(items[i].quickPickItemType, interfaces.CredentialsQuickPickItemType.Profile);
i++;
}
// then new connection
assert.equal(items[i].quickPickItemType, interfaces.CredentialsQuickPickItemType.NewConnection);
// Then test is complete
done();
});
});

Просмотреть файл

@ -10,6 +10,7 @@ import { IConnectionCredentials, AuthenticationTypes } from '../src/models/inter
import * as ConnectionContracts from '../src/models/contracts/connection';
import MainController from '../src/controllers/controller';
import * as Interfaces from '../src/models/interfaces';
import { ConnectionStore } from '../src/models/connectionStore';
import StatusView from '../src/views/statusView';
import Telemetry from '../src/models/telemetry';
import * as Utils from '../src/models/utils';
@ -59,14 +60,23 @@ function createTestCredentials(): IConnectionCredentials {
function createTestConnectionManager(
serviceClient: SqlToolsServiceClient,
wrapper?: VscodeWrapper,
statusView?: StatusView): ConnectionManager {
statusView?: StatusView,
connectionStore?: ConnectionStore): ConnectionManager {
let contextMock: TypeMoq.Mock<ExtensionContext> = TypeMoq.Mock.ofType(TestExtensionContext);
let prompterMock: TypeMoq.Mock<IPrompter> = TypeMoq.Mock.ofType(TestPrompter);
if (!statusView) {
statusView = TypeMoq.Mock.ofType(StatusView).object;
}
return new ConnectionManager(contextMock.object, statusView, prompterMock.object, serviceClient, wrapper);
if (!connectionStore) {
let connectionStoreMock = TypeMoq.Mock.ofType(ConnectionStore);
// disable saving recently used by default
connectionStoreMock.setup(x => x.addRecentlyUsed(TypeMoq.It.isAny())).returns(() => {
return Promise.resolve();
});
connectionStore = connectionStoreMock.object;
}
return new ConnectionManager(contextMock.object, statusView, prompterMock.object, serviceClient, wrapper, connectionStore);
}
function createTestListDatabasesResult(): ConnectionContracts.ListDatabasesResult {
@ -354,14 +364,7 @@ suite('Per File Connection Tests', () => {
connectionCreds.database = '';
// When the result will return 'master' as the database connected to
let myResult = new ConnectionContracts.ConnectionResult();
myResult.connectionId = Utils.generateGuid();
myResult.messages = '';
myResult.connectionSummary = {
serverName: connectionCreds.server,
databaseName: expectedDbName,
userName: connectionCreds.user
};
let myResult = createConnectionResultForCreds(connectionCreds, expectedDbName);
let serviceClientMock: TypeMoq.Mock<SqlToolsServiceClient> = TypeMoq.Mock.ofType(SqlToolsServiceClient);
serviceClientMock.setup(x => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
@ -387,4 +390,64 @@ suite('Per File Connection Tests', () => {
done(err);
});
});
function createConnectionResultForCreds(connectionCreds: IConnectionCredentials, dbName?: string): ConnectionContracts.ConnectionResult {
let myResult = new ConnectionContracts.ConnectionResult();
if (!dbName) {
dbName = connectionCreds.database;
}
myResult.connectionId = Utils.generateGuid();
myResult.messages = '';
myResult.connectionSummary = {
serverName: connectionCreds.server,
databaseName: dbName,
userName: connectionCreds.user
};
return myResult;
}
test('Should save new connections to recently used list', done => {
const testFile = 'file:///my/test/file.sql';
const expectedDbName = 'master';
// Given a connection to default database
let connectionCreds = createTestCredentials();
connectionCreds.database = '';
// When the result will return 'master' as the database connected to
let myResult = createConnectionResultForCreds(connectionCreds, expectedDbName);
let serviceClientMock: TypeMoq.Mock<SqlToolsServiceClient> = TypeMoq.Mock.ofType(SqlToolsServiceClient);
serviceClientMock.setup(x => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.returns(() => Promise.resolve(myResult));
let statusViewMock: TypeMoq.Mock<StatusView> = TypeMoq.Mock.ofType(StatusView);
let actualDbName = undefined;
statusViewMock.setup(x => x.connectSuccess(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.callback((fileUri, creds: IConnectionCredentials) => {
actualDbName = creds.database;
});
// And we store any DBs saved to recent connections
let savedConnection: IConnectionCredentials = undefined;
let connectionStoreMock = TypeMoq.Mock.ofType(ConnectionStore);
connectionStoreMock.setup(x => x.addRecentlyUsed(TypeMoq.It.isAny())).returns( conn => {
savedConnection = conn;
return Promise.resolve();
});
let manager: ConnectionManager = createTestConnectionManager(serviceClientMock.object, undefined, statusViewMock.object, connectionStoreMock.object);
// Then on connecting expect 'master' to be the database used in status view and URI mapping
manager.connect(testFile, connectionCreds).then( result => {
assert.equal(result, true);
connectionStoreMock.verify(x => x.addRecentlyUsed(TypeMoq.It.isAny()), TypeMoq.Times.once());
assert.equal(savedConnection.database, expectedDbName, 'Expect actual DB name returned from connection to be saved');
assert.equal(savedConnection.password, connectionCreds.password, 'Expect password to be saved');
done();
}).catch(err => {
done(err);
});
});
});

Просмотреть файл

@ -39,4 +39,26 @@ class TestMemento implements vscode.Memento {
}
}
export { TestPrompter, TestExtensionContext, TestMemento };
function createWorkspaceConfiguration(items: {[key: string]: any}): vscode.WorkspaceConfiguration {
const result: vscode.WorkspaceConfiguration = {
has(key: string): boolean {
return items[key] !== 'undefined';
},
get<T>(key: string, defaultValue?: T): T {
let val = items[key];
if (typeof val === 'undefined') {
val = defaultValue;
}
return val;
}
};
// Copy properties across so that indexer works as expected
Object.keys(items).forEach((key) => {
result[key] = items[key];
});
return Object.freeze(result);
}
export { TestPrompter, TestExtensionContext, TestMemento, createWorkspaceConfiguration };