* Initial commit of profile file saving support

* Fix path used on mac/linux

* Fixed issue with loading profiles from settings file

* Refactored out file logic to separate class to fix tests

* Fixed all existing tests

* Fixed unhandled exceptions leaking in tests

* Added unit tests for connection config

* Minor fix for edge case

* Addressing feedback
This commit is contained in:
Mitchell Sternke 2016-09-19 18:07:13 -07:00 коммит произвёл GitHub
Родитель 3a4625f072
Коммит 1b1b7dc920
11 изменённых файлов: 668 добавлений и 38 удалений

169
connections.schema.json Normal file
Просмотреть файл

@ -0,0 +1,169 @@
{
"type": "object",
"properties": {
// Note: this is a copy of 'vscode-mssql.connections' schema located in package.json. Ensure that both are identical when making changes.
"vscode-mssql.connections": {
"type": "array",
"default": [{
"server": "{{put-server-name-here}}",
"database": "{{put-database-name-here}}",
"user": "{{put-username-here}}",
"password": "{{put-password-here}}"
}],
"description": "Connections placed here are shown in the connections picklist across VS Code sessions.",
"items": {
"type": "object",
"properties": {
"server": {
"type": "string",
"default": "{{put-server-name-here}}",
"description": "[Required] Server to connect to. Use 'hostname\\instance' or '<server>.database.windows.net'."
},
"database": {
"type": "string",
"default": "{{put-database-name-here}}",
"description": "[Optional] Database to connect to. If this is empty, default depends on server configuration, typically 'master'."
},
"user": {
"type": "string",
"default": "{{put-username-here}}",
"description": "[Optional] User name for SQL authentication. If this is empty, you are prompted when you connect."
},
"password": {
"type": "string",
"default": "{{put-password-here}}",
"description": "[Optional] Password for SQL authentication. If this is empty, you are prompted when you connect."
},
"authenticationType": {
"type": "string",
"default": "SqlLogin",
"enum": [
"Integrated",
"SqlLogin"
],
"description": "[Optional] Specifies the method of authenticating with SQL Server."
},
"port": {
"type": "number",
"default": 1433,
"description": "[Optional] Specify the port number to connect to."
},
"encrypt": {
"type": "boolean",
"default": false,
"description": "[Optional] Specify if the connection will be encrypted. Always set to 'true' for Azure SQL DB and loaded from here otherwise."
},
"trustServerCertificate": {
"type": "boolean",
"description": "[Optional]"
},
"persistSecurityInfo": {
"type": "boolean",
"default": false,
"description": "[Optional]"
},
"connectTimeout": {
"type": "number",
"default": 15,
"description": "[Optional] The length of time in seconds to wait for a connection to the server before terminating the attempt and generating error."
},
"connectRetryCount": {
"type": "number",
"default": 1,
"description": "[Optional] Number of attempts to restore connection."
},
"connectRetryInterval": {
"type": "number",
"default": 10,
"description": "[Optional] Delay between attempts to restore connection."
},
"applicationName": {
"type": "string",
"default": "vscode-mssql",
"description": "[Optional] Application name used for SQL server logging (default: 'vscode-mssql')."
},
"workstationId": {
"type": "string",
"default": "",
"description": "[Optional] The name of the workstation connecting to SQL Server."
},
"applicationIntent": {
"type": "string",
"default": "ReadWrite",
"enum": [
"ReadWrite",
"ReadOnly"
],
"description": "[Optional]"
},
"currentLanguage": {
"type": "string",
"default": "",
"description": "[Optional]"
},
"pooling": {
"type": "boolean",
"default": false,
"description": "[Optional]"
},
"maxPoolSize": {
"type": "number",
"default": 100,
"description": "[Optional]"
},
"minPoolSize": {
"type": "number",
"default": 0,
"description": "[Optional]"
},
"loadBalanceTimeout": {
"type": "number",
"default": 0,
"description": "[Optional]"
},
"replication": {
"type": "boolean",
"default": true,
"description": "[Optional]"
},
"attachDbFilename": {
"type": "string",
"default": "",
"description": "[Optional]"
},
"failoverPartner": {
"type": "string",
"default": "",
"description": "[Optional]"
},
"multiSubnetFailover": {
"type": "boolean",
"default": true,
"description": "[Optional]"
},
"multipleActiveResultSets": {
"type": "boolean",
"default": false,
"description": "[Optional]"
},
"packetSize": {
"type": "number",
"default": 8192,
"description": "[Optional]"
},
"typeSystemVersion": {
"type": "string",
"enum": [
"Latest"
],
"description": "[Optional]"
},
"profileName": {
"type": "string",
"description": "[Optional]"
}
}
}
}
}
}

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

@ -133,6 +133,11 @@
"command": "extension.chooseDatabase",
"title": "Switch database on current server",
"category": "MSSQL"
},
{
"command": "extension.openConnectionSettingsFile",
"title": "Open Connection Profile Settings",
"category": "MSSQL"
}
],
"keybindings": [
@ -161,6 +166,12 @@
"when": "editorTextFocus && editorLangId == 'sql'"
}
],
"jsonValidation": [
{
"fileMatch" : "connections.json",
"url": "./connections.schema.json"
}
],
"configuration": {
"type": "object",
"title": "MSSQL configuration",
@ -332,6 +343,10 @@
"Latest"
],
"description": "[Optional]"
},
"profileName": {
"type": "string",
"description": "[Optional]"
}
}
}

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

@ -0,0 +1,131 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import fs = require('fs');
import os = require('os');
import * as Constants from '../models/constants';
import * as Utils from '../models/utils';
import { IConnectionCredentials, IConnectionProfile } from '../models/interfaces';
import { IConnectionConfig } from './iconnectionconfig';
import VscodeWrapper from '../controllers/vscodeWrapper';
/**
* Implements connection profile file storage.
*/
export class ConnectionConfig implements IConnectionConfig {
/**
* Constructor.
*/
public constructor(private _fs?: any, private _vscodeWrapper?: VscodeWrapper) {
if (!this._fs) {
this._fs = fs;
}
if (!this.vscodeWrapper) {
this.vscodeWrapper = new VscodeWrapper();
}
}
private get vscodeWrapper(): VscodeWrapper {
return this._vscodeWrapper;
}
private set vscodeWrapper(value: VscodeWrapper) {
this._vscodeWrapper = value;
}
/**
* Read connection profiles stored in connection json file, if it exists.
*/
public readConnectionsFromConfigFile(): IConnectionProfile[] {
let profiles: IConnectionProfile[] = [];
try {
let fileBuffer: Buffer = this._fs.readFileSync(ConnectionConfig.configFilePath);
if (fileBuffer) {
let fileContents: string = fileBuffer.toString();
if (!Utils.isEmpty(fileContents)) {
try {
let json: any = JSON.parse(fileContents);
if (json && json.hasOwnProperty(Constants.connectionsArrayName)) {
profiles = json[Constants.connectionsArrayName];
} else {
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
}
} catch (e) { // Error parsing JSON
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
}
}
}
} catch (e) { // Error reading the file
if (e.code !== 'ENOENT') { // Ignore error if the file doesn't exist
this.vscodeWrapper.showErrorMessage(Utils.formatString(Constants.msgErrorReadingConfigFile, ConnectionConfig.configFilePath));
}
}
return profiles;
}
/**
* Write connection profiles to the configuration json file.
*/
public writeConnectionsToConfigFile(connections: IConnectionCredentials[]): Promise<void> {
const self = this;
return new Promise<void>((resolve, reject) => {
self.createConfigFileDirectory().then(() => {
let connectionsObject = {};
connectionsObject[Constants.connectionsArrayName] = connections;
// Format the file using 4 spaces as indentation
self._fs.writeFile(ConnectionConfig.configFilePath, JSON.stringify(connectionsObject, undefined, 4), err => {
if (err) {
reject(err);
}
resolve();
});
}).catch(err => {
reject(err);
});
});
}
/**
* Get the directory containing the connection config file.
*/
private static get configFileDirectory(): string {
if (os.platform() === 'win32') {
// On Windows, we store connection configurations in %APPDATA%\<extension name>\
return process.env['APPDATA'] + '\\' + Constants.extensionName + '\\';
} else {
// On OSX/Linux, we store connection configurations in ~/.config/<extension name>/
return process.env['HOME'] + '/.config/' + Constants.extensionName + '/';
}
}
/**
* Get the full path of the connection config filename.
*/
public static get configFilePath(): string {
return this.configFileDirectory + Constants.connectionConfigFilename;
}
/**
* Public for testing purposes.
*/
public createConfigFileDirectory(): Promise<void> {
const self = this;
const configFileDir: string = ConnectionConfig.configFileDirectory;
return new Promise<void>((resolve, reject) => {
self._fs.mkdir(configFileDir, err => {
// If the directory already exists, ignore the error
if (err && err.code !== 'EEXIST') {
reject(err);
}
resolve();
});
});
}
}

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

@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import { IConnectionCredentials, IConnectionProfile } from '../models/interfaces';
/**
* Interface for a configuration file that stores connection profiles.
*
* @export
* @interface IConnectionConfig
*/
export interface IConnectionConfig {
readConnectionsFromConfigFile(): IConnectionProfile[];
writeConnectionsToConfigFile(connections: IConnectionCredentials[]): Promise<void>;
}

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

@ -70,6 +70,8 @@ export default class MainController implements vscode.Disposable {
this._event.on(Constants.cmdRemoveProfile, () => { self.runAndLogErrors(self.onRemoveProfile()); });
this.registerCommand(Constants.cmdChooseDatabase);
this._event.on(Constants.cmdChooseDatabase, () => { self.onChooseDatabase(); } );
this.registerCommand(Constants.cmdOpenConnectionSettings);
this._event.on(Constants.cmdOpenConnectionSettings, () => { self.onOpenConnectionSettings(); } );
this._vscodeWrapper = new VscodeWrapper();
@ -174,6 +176,13 @@ export default class MainController implements vscode.Disposable {
return this._connectionMgr.onRemoveProfile();
}
/**
* Opens the settings file where connection profiles are stored.
*/
public onOpenConnectionSettings(): void {
this._connectionMgr.connectionUI.openConnectionProfileConfigFile();
}
private runAndLogErrors<T>(promise: Promise<T>): Promise<T> {
let self = this;
return promise.catch(err => {

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

@ -78,6 +78,25 @@ export default class VscodeWrapper {
return vscode.workspace.onDidSaveTextDocument;
}
/**
* Opens the denoted document from disk. Will return early if the
* document is already open, otherwise the document is loaded and the
* [open document](#workspace.onDidOpenTextDocument)-event fires.
* The document to open is denoted by the [uri](#Uri). Two schemes are supported:
*
* file: A file on disk, will be rejected if the file does not exist or cannot be loaded, e.g. `file:///Users/frodo/r.ini`.
* untitled: A new file that should be saved on disk, e.g. `untitled:c:\frodo\new.js`. The language will be derived from the file name.
*
* Uris with other schemes will make this method return a rejected promise.
*
* @param uri Identifies the resource to open.
* @return A promise that resolves to a [document](#TextDocument).
* @see vscode.workspace.openTextDocument
*/
public openTextDocument(uri: vscode.Uri): Thenable<vscode.TextDocument> {
return vscode.workspace.openTextDocument(uri);
}
/**
* Helper to log messages to "MSSQL" output channel.
*/
@ -125,10 +144,48 @@ export default class VscodeWrapper {
return vscode.window.showQuickPick<T>(items, options);
}
/**
* Show the given document in a text editor. A [column](#ViewColumn) can be provided
* to control where the editor is being shown. Might change the [active editor](#window.activeTextEditor).
*
* @param document A text document to be shown.
* @param column A view column in which the editor should be shown. The default is the [one](#ViewColumn.One), other values
* are adjusted to be __Min(column, columnCount + 1)__.
* @param preserveFocus When `true` the editor will not take focus.
* @return A promise that resolves to an [editor](#TextEditor).
*/
public showTextDocument(document: vscode.TextDocument, column?: vscode.ViewColumn, preserveFocus?: boolean): Thenable<vscode.TextEditor> {
return vscode.window.showTextDocument(document, column, preserveFocus);
}
/**
* Formats and shows a vscode warning message
*/
public showWarningMessage(msg: string): Thenable<string> {
return vscode.window.showWarningMessage(Constants.extensionName + ': ' + msg );
}
/**
* Create an URI from a file system path. The [scheme](#Uri.scheme)
* will be `file`.
*
* @param path A file system or UNC path.
* @return A new Uri instance.
* @see vscode.Uri.file
*/
public uriFile(path: string): vscode.Uri {
return vscode.Uri.file(path);
}
/**
* Create an URI from a string. Will throw if the given value is not
* valid.
*
* @param value The string value of an Uri.
* @return A new Uri instance.
* @see vscode.Uri.parse
*/
public uriParse(value: string): vscode.Uri {
return vscode.Uri.parse(value);
}
}

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

@ -8,6 +8,8 @@ import { ConnectionCredentials } from '../models/connectionCredentials';
import { IConnectionCredentials, IConnectionProfile, IConnectionCredentialsQuickPickItem, CredentialsQuickPickItemType } from '../models/interfaces';
import { ICredentialStore } from '../credentialstore/icredentialstore';
import { CredentialStore } from '../credentialstore/credentialstore';
import { IConnectionConfig } from '../connectionconfig/iconnectionconfig';
import { ConnectionConfig } from '../connectionconfig/connectionconfig';
import VscodeWrapper from '../controllers/vscodeWrapper';
/**
@ -21,12 +23,16 @@ export class ConnectionStore {
constructor(
private _context: vscode.ExtensionContext,
private _credentialStore?: ICredentialStore,
private _connectionConfig?: IConnectionConfig,
private _vscodeWrapper?: VscodeWrapper) {
if (!this._credentialStore) {
this._credentialStore = new CredentialStore();
}
if (!this._vscodeWrapper) {
this._vscodeWrapper = new VscodeWrapper();
if (!this.vscodeWrapper) {
this.vscodeWrapper = new VscodeWrapper();
}
if (!this._connectionConfig) {
this._connectionConfig = new ConnectionConfig();
}
}
@ -149,10 +155,7 @@ export class ConnectionStore {
const self = this;
return new Promise<IConnectionProfile>((resolve, reject) => {
// Get all profiles
let configValues = self._context.globalState.get<IConnectionProfile[]>(Constants.configMyConnections);
if (!configValues) {
configValues = [];
}
let configValues = self.getConnectionsFromConfigFile();
// Remove the profile if already set
configValues = configValues.filter(value => !Utils.isSameProfile(value, profile));
@ -160,7 +163,7 @@ export class ConnectionStore {
// Add the profile to the saved list, taking care to clear out the password field
let savedProfile: IConnectionProfile = Object.assign({}, profile, { password: '' });
configValues.push(savedProfile);
self._context.globalState.update(Constants.configMyConnections, configValues)
self._connectionConfig.writeConnectionsToConfigFile(configValues)
.then(() => {
// Only save if we successfully added the profile
return self.saveProfilePasswordIfNeeded(profile);
@ -268,10 +271,7 @@ export class ConnectionStore {
const self = this;
return new Promise<boolean>((resolve, reject) => {
// Get all profiles
let configValues = self._context.globalState.get<IConnectionProfile[]>(Constants.configMyConnections);
if (!configValues) {
configValues = [];
}
let configValues = self.getConnectionsFromConfigFile();
// Remove the profile if already set
let found: boolean = false;
@ -285,7 +285,7 @@ export class ConnectionStore {
}
});
self._context.globalState.update(Constants.configMyConnections, configValues).then(() => {
self._connectionConfig.writeConnectionsToConfigFile(configValues).then(() => {
resolve(found);
}, err => reject(err));
}).then(profileFound => {
@ -324,8 +324,7 @@ export class ConnectionStore {
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
// next read from the profiles saved in the user-editable config file
let profiles = this.loadProfiles();
quickPickItems = quickPickItems.concat(profiles);
@ -341,6 +340,14 @@ export class ConnectionStore {
return connections;
}
private getConnectionsFromConfigFile<T extends IConnectionProfile>(): T[] {
let connections: T[] = [];
// read from the config file
let configValues = this._connectionConfig.readConnectionsFromConfigFile();
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
@ -362,7 +369,7 @@ export class ConnectionStore {
}
private loadProfiles(): IConnectionCredentialsQuickPickItem[] {
let connections: IConnectionProfile[] = this.getConnectionsFromGlobalState<IConnectionProfile>(Constants.configMyConnections);
let connections: IConnectionProfile[] = this.getConnectionsFromConfigFile<IConnectionProfile>();
let quickPickItems = connections.map(c => this.createQuickPickItem(c, CredentialsQuickPickItemType.Profile));
return quickPickItems;
}

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

@ -3,12 +3,26 @@ export const languageId = 'sql';
export const extensionName = 'vscode-mssql';
export const outputChannelName = 'MSSQL';
export const connectionConfigFilename = 'connections.json';
export const connectionsArrayName = 'vscode-mssql.connections';
export const defaultConnectionSettingsFileJson = {
'vscode-mssql.connections': [
{
'server': '{{put-server-name-here}}',
'database': '{{put-database-name-here}}',
'user': '{{put-username-here}}',
'password': '{{put-password-here}}'
}
]
};
export const cmdRunQuery = 'extension.runQuery';
export const cmdConnect = 'extension.connect';
export const cmdDisconnect = 'extension.disconnect';
export const cmdCreateProfile = 'extension.createprofile';
export const cmdRemoveProfile = 'extension.removeprofile';
export const cmdChooseDatabase = 'extension.chooseDatabase';
export const cmdOpenConnectionSettings = 'extension.openConnectionSettingsFile';
export const sqlDbPrefix = '.database.windows.net';
export const defaultConnectionTimeout = 15;
@ -147,3 +161,7 @@ export const msgChangedDatabaseContext = 'Changed database context to \"{0}\" fo
export const msgPromptRetryCreateProfile = 'Error: Unable to connect using the profile information provided. Retry profile creation?';
export const msgConnectedServerInfo = 'Connected to server \"{0}\" on document \"{1}\". Server information: {2}';
export const msgErrorReadingConfigFile = 'Error: Unable to load connection profiles from [{0}]. Check that the file is formatted correctly.';
export const msgNewConfigFileHelpInfo = 'Save connections.json to enable autocomplete while editing connection profile settings.';
export const msgErrorOpeningConfigFile = 'Error: Unable to open connection profile settings file.';

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

@ -1,6 +1,8 @@
'use strict';
import vscode = require('vscode');
import fs = require('fs');
import Constants = require('../models/constants');
import { ConnectionConfig } from '../connectionconfig/connectionconfig';
import { ConnectionCredentials } from '../models/connectionCredentials';
import ConnectionManager from '../controllers/connectionManager';
import { ConnectionStore } from '../models/connectionStore';
@ -9,6 +11,7 @@ import { IConnectionCredentials, IConnectionProfile, IConnectionCredentialsQuick
import { IQuestion, IPrompter, QuestionTypes } from '../prompts/question';
import Interfaces = require('../models/interfaces');
import { Timer } from '../models/utils';
import * as Utils from '../models/utils';
import VscodeWrapper from '../controllers/vscodeWrapper';
export class ConnectionUI {
@ -345,4 +348,48 @@ export class ConnectionUI {
}
});
}
/**
* Open the configuration file that stores the connection profiles.
*/
public openConnectionProfileConfigFile(): void {
const self = this;
this.vscodeWrapper.openTextDocument(this.vscodeWrapper.uriFile(ConnectionConfig.configFilePath))
.then(doc => {
self.vscodeWrapper.showTextDocument(doc);
}, error => {
// Check if the file doesn't exist
let fileExists = true;
try {
fs.accessSync(ConnectionConfig.configFilePath);
} catch (err) {
if (err.code === 'ENOENT') {
fileExists = false;
}
}
if (!fileExists) {
// Open an untitled file to be saved at the proper path if it doesn't exist
self.vscodeWrapper.openTextDocument(self.vscodeWrapper.uriParse(Constants.untitledScheme + ':' + ConnectionConfig.configFilePath))
.then(doc => {
self.vscodeWrapper.showTextDocument(doc).then(editor => {
if (Utils.isEmpty(editor.document.getText())) {
// Insert the template for a new connection into the file
editor.edit(builder => {
let position: vscode.Position = new vscode.Position(0, 0);
builder.insert(position, JSON.stringify(Constants.defaultConnectionSettingsFileJson, undefined, 4));
});
}
// Remind the user to save if they would like auto-completion enabled while editing the new file
self._vscodeWrapper.showInformationMessage(Constants.msgNewConfigFileHelpInfo);
});
}, err => {
self._vscodeWrapper.showErrorMessage(Constants.msgErrorOpeningConfigFile);
});
} else { // Unable to access the file
self._vscodeWrapper.showErrorMessage(Constants.msgErrorOpeningConfigFile);
}
});
}
}

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

@ -11,6 +11,7 @@ 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 { ConnectionConfig } from '../src/connectionconfig/connectionconfig';
import VscodeWrapper from '../src/controllers/vscodeWrapper';
import assert = require('assert');
@ -22,6 +23,7 @@ suite('ConnectionStore tests', () => {
let globalstate: TypeMoq.Mock<vscode.Memento>;
let credentialStore: TypeMoq.Mock<CredentialStore>;
let vscodeWrapper: TypeMoq.Mock<VscodeWrapper>;
let connectionConfig: TypeMoq.Mock<ConnectionConfig>;
setup(() => {
defaultNamedProfile = Object.assign(new ConnectionProfile(), {
@ -47,6 +49,7 @@ suite('ConnectionStore tests', () => {
context.object.globalState = globalstate.object;
credentialStore = TypeMoq.Mock.ofType(CredentialStore);
vscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper);
connectionConfig = TypeMoq.Mock.ofType(ConnectionConfig);
// setup default behavior for vscodeWrapper
// setup configuration to return maxRecent for the #MRU items
@ -121,13 +124,13 @@ suite('ConnectionStore tests', () => {
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
let credsToSave: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
.returns((profiles: interfaces.IConnectionProfile[]) => {
credsToSave = profiles;
return Promise.resolve();
});
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
// When SaveProfile is called with savePassword false
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: false });
@ -147,8 +150,8 @@ suite('ConnectionStore tests', () => {
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => []);
let credsToSave: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
.returns((profiles: interfaces.IConnectionProfile[]) => {
credsToSave = profiles;
return Promise.resolve();
});
@ -165,7 +168,7 @@ suite('ConnectionStore tests', () => {
let expectedCredFormat: string = ConnectionStore.formatCredentialId(defaultNamedProfile.server, defaultNamedProfile.database, defaultNamedProfile.user);
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
// When SaveProfile is called with savePassword true
let profile: interfaces.IConnectionProfile = Object.assign(new ConnectionProfile(), defaultNamedProfile, { savePassword: true });
@ -191,11 +194,11 @@ suite('ConnectionStore tests', () => {
server: 'otherServer',
savePassword: true
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, profile]);
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, profile]);
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
.returns((profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -211,7 +214,7 @@ suite('ConnectionStore tests', () => {
let expectedCredFormat: string = ConnectionStore.formatCredentialId(profile.server, profile.database, profile.user);
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
// When RemoveProfile is called for once profile
@ -239,11 +242,11 @@ suite('ConnectionStore tests', () => {
let namedProfile = Object.assign(new ConnectionProfile(), unnamedProfile, {
profileName: 'named'
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, unnamedProfile, namedProfile]);
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
.returns((profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -251,7 +254,7 @@ suite('ConnectionStore tests', () => {
credentialStore.setup(x => x.deleteCredential(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(true));
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
// When RemoveProfile is called for the profile
connectionStore.removeProfile(unnamedProfile)
.then(success => {
@ -274,11 +277,11 @@ suite('ConnectionStore tests', () => {
let namedProfile = Object.assign(new ConnectionProfile(), unnamedProfile, {
profileName: 'named'
});
globalstate.setup(x => x.get(TypeMoq.It.isAny())).returns(key => [defaultNamedProfile, unnamedProfile, namedProfile]);
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => [defaultNamedProfile, unnamedProfile, namedProfile]);
let updatedCredentials: interfaces.IConnectionProfile[];
globalstate.setup(x => x.update(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyObject(Array)))
.returns((id: string, profiles: interfaces.IConnectionProfile[]) => {
connectionConfig.setup(x => x.writeConnectionsToConfigFile(TypeMoq.It.isAnyObject(Array)))
.returns((profiles: interfaces.IConnectionProfile[]) => {
updatedCredentials = profiles;
return Promise.resolve();
});
@ -286,7 +289,7 @@ suite('ConnectionStore tests', () => {
credentialStore.setup(x => x.deleteCredential(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(true));
let connectionStore = new ConnectionStore(context.object, credentialStore.object);
let connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object);
// When RemoveProfile is called for the profile
connectionStore.removeProfile(namedProfile)
.then(success => {
@ -328,7 +331,7 @@ suite('ConnectionStore tests', () => {
// 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 connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
let promise = Promise.resolve();
for (let i = 0; i < numCreds; i++) {
@ -371,7 +374,7 @@ suite('ConnectionStore tests', () => {
// 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 connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
let promise = Promise.resolve();
let cred = Object.assign({}, defaultNamedProfile, { server: defaultNamedProfile.server + 1});
@ -410,7 +413,7 @@ suite('ConnectionStore tests', () => {
.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 connectionStore = new ConnectionStore(context.object, credentialStore.object, undefined, vscodeWrapper.object);
let integratedCred = Object.assign({}, defaultNamedProfile, {
server: defaultNamedProfile.server + 'Integrated',
authenticationType: interfaces.AuthenticationTypes[interfaces.AuthenticationTypes.Integrated],
@ -462,12 +465,12 @@ suite('ConnectionStore tests', () => {
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);
connectionConfig.setup(x => x.readConnectionsFromConfigFile()).returns(() => 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 connectionStore = new ConnectionStore(context.object, credentialStore.object, connectionConfig.object, vscodeWrapper.object);
let items: interfaces.IConnectionCredentialsQuickPickItem[] = connectionStore.getPickListItems();
let expectedCount = recentlyUsed.length + profiles.length + 1;

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

@ -0,0 +1,156 @@
import * as TypeMoq from 'typemoq';
import { ConnectionConfig } from '../src/connectionconfig/connectionconfig';
import { IConnectionProfile } from '../src/models/interfaces';
import VscodeWrapper from '../src/controllers/vscodeWrapper';
import assert = require('assert');
import fs = require('fs');
const corruptJson =
`{
vscode-mssql.connections: [
{}
corrupt!@#$%
]
}`;
const validJson =
`
{
"vscode-mssql.connections": [
{
"server": "my-server",
"database": "my_db",
"user": "sa",
"password": "12345678"
},
{
"server": "my-other-server",
"database": "my_other_db",
"user": "sa",
"password": "qwertyuiop"
}
]
}
`;
const arrayTitleMissingJson =
`
[
{
"server": "my-server",
"database": "my_db",
"user": "sa",
"password": "12345678"
}
]
`;
suite('ConnectionConfig tests', () => {
test('error message is shown when reading corrupt config file', () => {
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
bufferMock.setup(x => x.toString()).returns(() => corruptJson);
let fsMock = TypeMoq.Mock.ofInstance(fs);
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
// Given a connection config object that reads a corrupt json file
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
config.readConnectionsFromConfigFile();
// Verify that an error message was displayed to the user
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.once());
});
test('no error message is shown when reading valid config file', () => {
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
bufferMock.setup(x => x.toString()).returns(() => validJson);
let fsMock = TypeMoq.Mock.ofInstance(fs);
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
// Given a connection config object that reads a valid json file
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
let profiles: IConnectionProfile[] = config.readConnectionsFromConfigFile();
// Verify that the profiles were read correctly
assert.strictEqual(profiles.length, 2);
assert.strictEqual(profiles[0].server, 'my-server');
assert.strictEqual(profiles[0].database, 'my_db');
assert.strictEqual(profiles[0].user, 'sa');
assert.strictEqual(profiles[0].password, '12345678');
assert.strictEqual(profiles[1].server, 'my-other-server');
assert.strictEqual(profiles[1].database, 'my_other_db');
assert.strictEqual(profiles[1].user, 'sa');
assert.strictEqual(profiles[1].password, 'qwertyuiop');
// Verify that no error message was displayed to the user
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.never());
});
test('error message is shown when config file is missing array title', () => {
let bufferMock = TypeMoq.Mock.ofType(Buffer, TypeMoq.MockBehavior.Loose, 0);
bufferMock.setup(x => x.toString()).returns(() => arrayTitleMissingJson);
let fsMock = TypeMoq.Mock.ofInstance(fs);
fsMock.setup(x => x.readFileSync(TypeMoq.It.isAny())).returns(() => bufferMock.object);
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
// Given a connection config object that reads a json file with the array title missing
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
config.readConnectionsFromConfigFile();
// Verify that an error message was shown to the user
vscodeWrapperMock.verify(x => x.showErrorMessage(TypeMoq.It.isAny()), TypeMoq.Times.once());
});
test('error is thrown when config directory cannot be created', done => {
let fsMock = TypeMoq.Mock.ofInstance(fs);
fsMock.setup(x => x.mkdir(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.returns((path, errorHandler) => {
let error = {
code: 'EACCES'
};
errorHandler(error);
});
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
// Given a connection config object that tries to create a config directory without appropriate permissions
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
config.createConfigFileDirectory().then(() => {
done('Promise should not resolve successfully');
}).catch(err => {
// Expect an error to be thrown
done();
});
});
test('error is not thrown when config directory already exists', done => {
let fsMock = TypeMoq.Mock.ofInstance(fs);
fsMock.setup(x => x.mkdir(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.returns((path, errorHandler) => {
let error = {
code: 'EEXIST'
};
errorHandler(error);
});
let vscodeWrapperMock = TypeMoq.Mock.ofType(VscodeWrapper);
// Given a connection config object that tries to create a config directory when it already exists
let config = new ConnectionConfig(fsMock.object, vscodeWrapperMock.object);
config.createConfigFileDirectory().then(() => {
// Expect no error to be thrown
done();
}).catch(err => {
done(err);
});
});
});