diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 02c9580d1..8ce076511 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,10 +9,8 @@ trigger: # https://docs.microsoft.com/en-us/azure/devops/pipelines/agents/hosted?view=azure-devops strategy: matrix: - # # Linux testing is currently disabled because of issues with - # # headless linux & keytar. Tracked: VSCODE-110 - # linux: - # imageName: 'ubuntu-latest' + linux: + imageName: 'ubuntu-latest' mac: imageName: 'macos-latest' windows: diff --git a/src/connectionController.ts b/src/connectionController.ts index 22f5a05a0..eae051be8 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as vscode from 'vscode'; import Connection = require('mongodb-connection-model/lib/model'); import DataService = require('mongodb-data-service'); -import * as keytarType from 'keytar'; + import { ConnectionModelType } from './connectionModelType'; import { DataServiceType } from './dataServiceType'; import { createLogger } from './logging'; @@ -10,25 +10,23 @@ import { StatusView } from './views'; import { EventEmitter } from 'events'; import { StorageController, StorageVariables } from './storage'; import { SavedConnection, StorageScope } from './storage/storageController'; -import { getNodeModule } from './utils/getNodeModule'; import TelemetryController from './telemetry/telemetryController'; +import { ext } from './extensionConstants'; const { name, version } = require('../package.json'); const log = createLogger('connection controller'); const MAX_CONNECTION_NAME_LENGTH = 512; -type KeyTar = typeof keytarType; - export enum DataServiceEventTypes { CONNECTIONS_DID_CHANGE = 'CONNECTIONS_DID_CHANGE', ACTIVE_CONNECTION_CHANGED = 'ACTIVE_CONNECTION_CHANGED', - ACTIVE_CONNECTION_CHANGING = 'ACTIVE_CONNECTION_CHANGING' + ACTIVE_CONNECTION_CHANGING = 'ACTIVE_CONNECTION_CHANGING', } export enum ConnectionTypes { CONNECTION_FORM = 'CONNECTION_FORM', CONNECTION_STRING = 'CONNECTION_STRING', - CONNECTION_ID = 'CONNECTION_ID' + CONNECTION_ID = 'CONNECTION_ID', } export type SavedConnectionInformation = { @@ -48,8 +46,6 @@ export default class ConnectionController { } = {}; private readonly _serviceName = 'mdb.vscode.savedConnections'; - private _keytar: KeyTar | undefined; - _activeDataService: null | DataServiceType = null; _activeConnectionModel: null | ConnectionModelType = null; private _currentConnectionId: null | string = null; @@ -73,33 +69,20 @@ export default class ConnectionController { this._statusView = _statusView; this._storageController = storageController; this._telemetryController = telemetryController; - - try { - // We load keytar in two different ways. This is because when the - // extension is webpacked it requires the vscode external keytar dependency - // differently then our testing development environment. - this._keytar = require('keytar'); - - if (!this._keytar) { - this._keytar = getNodeModule('keytar'); - } - } catch (err) { - // Couldn't load keytar, proceed without storing & loading connections. - } } _loadSavedConnection = async ( connectionId: string, savedConnection: SavedConnection ): Promise => { - if (!this._keytar) { + if (!ext.keytarModule) { return; } let loadedSavedConnection: LoadedConnection; try { - const unparsedConnectionInformation = await this._keytar.getPassword( + const unparsedConnectionInformation = await ext.keytarModule.getPassword( this._serviceName, connectionId ); @@ -138,7 +121,7 @@ export default class ConnectionController { }; loadSavedConnections = async (): Promise => { - if (!this._keytar) { + if (!ext.keytarModule) { return; } @@ -297,10 +280,10 @@ export default class ConnectionController { this._connections[connectionId] = newLoadedConnection; - if (this._keytar) { + if (ext.keytarModule) { const connectionInfoAsString = JSON.stringify(connectionInformation); - await this._keytar.setPassword( + await ext.keytarModule.setPassword( this._serviceName, connectionId, connectionInfoAsString @@ -496,8 +479,8 @@ export default class ConnectionController { ): Promise => { delete this._connections[connectionId]; - if (this._keytar) { - await this._keytar.deletePassword(this._serviceName, connectionId); + if (ext.keytarModule) { + await ext.keytarModule.deletePassword(this._serviceName, connectionId); // We only remove the connection from the saved connections if we // have deleted the connection information with keytar. this._storageController.removeConnection(connectionId); @@ -595,13 +578,13 @@ export default class ConnectionController { const connectionNameToRemove: | string | undefined = await vscode.window.showQuickPick( - connectionIds.map( - (id, index) => `${index + 1}: ${this._connections[id].name}` - ), - { - placeHolder: 'Choose a connection to remove...' - } - ); + connectionIds.map( + (id, index) => `${index + 1}: ${this._connections[id].name}` + ), + { + placeHolder: 'Choose a connection to remove...' + } + ); if (!connectionNameToRemove) { return Promise.resolve(false); diff --git a/src/extension.ts b/src/extension.ts index 2e842937a..d15e5d1ef 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { ext } from './extensionConstants'; +import { createKeytar } from './utils/keytar'; import { createLogger } from './logging'; const log = createLogger('extension.ts'); @@ -26,6 +27,13 @@ export function activate(context: vscode.ExtensionContext): void { log.info('activate extension called'); ext.context = context; + + try { + ext.keytarModule = createKeytar(); + } catch (err) { + // Couldn't load keytar, proceed without storing & loading connections. + } + mdbExtension = new MDBExtensionController(context); mdbExtension.activate(); diff --git a/src/extensionConstants.ts b/src/extensionConstants.ts index 68fe4e2c1..031b1ee9b 100644 --- a/src/extensionConstants.ts +++ b/src/extensionConstants.ts @@ -1,8 +1,10 @@ import { ExtensionContext } from 'vscode'; +import { KeytarInterface } from './utils/keytar'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace ext { export let context: ExtensionContext; + export let keytarModule: KeytarInterface | undefined; } export function getImagesPath(): string { diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index f7a8b4d48..2db916e28 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -5,9 +5,13 @@ import path = require('path'); import * as keytarType from 'keytar'; import MDBExtensionController from '../../mdbExtensionController'; +import { ext } from '../../extensionConstants'; +import KeytarStub from './keytarStub'; import { TestExtensionContext } from './stubs'; import { mdbTestExtension } from './stubbableMdbExtension'; +type KeyTar = typeof keytarType; + export function run(): Promise { const reporterOptions = { spec: '-', @@ -37,6 +41,12 @@ export function run(): Promise { ); mdbTestExtension.testExtensionController.activate(); + // We avoid using the user's credential store when running tests + // in order to ensure we're not polluting the credential store + // and because its tough to get the credential store running on + // headless linux. + ext.keytarModule = new KeytarStub(); + // Disable metrics. vscode.workspace.getConfiguration('mdb').update('sendTelemetry', false); @@ -44,39 +54,12 @@ export function run(): Promise { vscode.workspace .getConfiguration('mdb.connectionSaving') .update('hideOptionToChooseWhereToSaveNewConnections', true) - .then(async () => { - // We require keytar in runtime because it is a vscode provided - // native node module. - const keytar: typeof keytarType = require('keytar'); - const existingCredentials = await keytar.findCredentials( - 'mdb.vscode.savedConnections' - ); - + .then(() => { // Add files to the test suite. files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); try { // Run the mocha test. - mocha.run(async (failures) => { - // After tests are run we clear any passwords added - // to local secure storage. - const postRunCredentials = await keytar.findCredentials( - 'mdb.vscode.savedConnections' - ); - postRunCredentials.forEach((credential) => { - if ( - !existingCredentials.find( - (existingCredential) => - existingCredential.account === credential.account - ) - ) { - // If the credential is newly added, we remove it. - keytar.deletePassword( - 'mdb.vscode.savedConnections', - credential.account - ); - } - }); - + mocha.run((failures) => { if (failures > 0) { e(new Error(`${failures} tests failed.`)); } else { diff --git a/src/test/suite/keytarStub.ts b/src/test/suite/keytarStub.ts new file mode 100644 index 000000000..ad505f028 --- /dev/null +++ b/src/test/suite/keytarStub.ts @@ -0,0 +1,63 @@ +import { KeytarInterface } from '../../utils/keytar'; + +const retrievalDelay = 1; // ms simulated delay on keytar methods. + +export default class KeytarStub implements KeytarInterface { + private _services: Map> = new Map>(); + + public async findCredentials(service: string): Promise | undefined> { + await this.delay(); + const savedServices = this._services.get(service); + if (savedServices) { + return savedServices; + } + + return undefined; + } + + public async getPassword(service: string, account: string): Promise { + await this.delay(); + const savedService = this._services.get(service); + if (savedService) { + const savedAccount = savedService.get(account); + + if (savedAccount !== undefined) { + return savedAccount; + } + } + + return null; + } + + public async setPassword(service: string, account: string, password: string): Promise { + await this.delay(); + let savedService = this._services.get(service); + if (!savedService) { + savedService = new Map(); + this._services.set(service, savedService); + } + + savedService.set(account, password); + } + + public async deletePassword(service: string, account: string): Promise { + await this.delay(); + const savedService = this._services.get(service); + if (savedService) { + if (savedService.has(account)) { + savedService.delete(account); + return true; + } + } + + return false; + } + + private async delay(): Promise { + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, retrievalDelay); + }); + } +} diff --git a/src/utils/keytar.ts b/src/utils/keytar.ts new file mode 100644 index 000000000..1c4ce7053 --- /dev/null +++ b/src/utils/keytar.ts @@ -0,0 +1,53 @@ +import * as keytarType from 'keytar'; + +import { getNodeModule } from './getNodeModule'; + +export interface KeytarInterface { + /** + * Get the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the password string. + */ + getPassword(service: string, account: string): Promise; + + /** + * Add the password for the service and account to the keychain. + * + * @param service The string service name. + * @param account The string account name. + * @param password The string password. + * + * @returns A promise for the set password completion. + */ + setPassword( + service: string, + account: string, + password: string + ): Promise; + + /** + * Delete the stored password for the service and account. + * + * @param service The string service name. + * @param account The string account name. + * + * @returns A promise for the deletion status. True on success. + */ + deletePassword(service: string, account: string): Promise; +} + +export const createKeytar = (): KeytarInterface | undefined => { + // We load keytar in two different ways. This is because when the + // extension is webpacked it requires the vscode external keytar dependency + // differently then our development environment. + let keytarModule: KeytarInterface | undefined = require('keytar'); + + if (!keytarModule) { + keytarModule = getNodeModule('keytar'); + } + + return keytarModule; +};