diff --git a/app/actions.js b/app/actions.js index 3971f93..1313aba 100644 --- a/app/actions.js +++ b/app/actions.js @@ -1,5 +1,4 @@ import co from 'co'; -import keytar from 'keytar'; import RethinkDbService from './services/rethinkdb.service'; import ReQLEval from './services/reql-eval.service'; import { convertStringsToDates } from './services/date-type.service' @@ -163,7 +162,7 @@ export function saveInlineEdit(originalRow, row) { export function saveRow(conn, selectedTable, row) { return dispatch => { return new Promise((resolve, reject) => { - ReQLEval(row).then(async(rowObj) => { + ReQLEval(row).then(async (rowObj) => { row = convertStringsToDates(selectedTable.editingRecord, rowObj); selectedTable.codeBodyError = null; @@ -286,15 +285,3 @@ export function writeConfigFile() { }); } } - -export function addKey(service, account, password) { - return (dispatch, getState) => { - keytar.addPassword(service, account, password); - // const state = getState(); - // return configService.writeConfigFile({ - // email: state.main.email, - // created: state.main.created, - // connections: state.connections - // }); - } -} diff --git a/app/components/Sidebar/Connections/connections.actions.js b/app/components/Sidebar/Connections/connections.actions.js index d3b2731..ff4b295 100644 --- a/app/components/Sidebar/Connections/connections.actions.js +++ b/app/components/Sidebar/Connections/connections.actions.js @@ -1,4 +1,5 @@ -import { writeConfigFile, addKey } from '../../../actions'; +import { writeConfigFile } from '../../../actions'; +import { updateKeysForConnection } from '../../../services/keychain.service'; import * as types from '../../../action-types'; import { selectConnection } from './selectedConnection.actions'; @@ -9,8 +10,10 @@ export function addConnection(connection) { connection: connection }); dispatch(writeConfigFile()); - dispatch(addKey('ReQLPro', connection.user, connection.password)); - dispatch(addKey('ReQLPro', connection.host, connection.ca)); + + // Add password and cert to system keychain + updateKeysForConnection(connection); + // Grab the connection from the updated array of connections, which will have the index property const conns = getState().connections; dispatch(selectConnection(conns[conns.length - 1])); @@ -25,6 +28,9 @@ export function updateConnection(connection) { }); dispatch(writeConfigFile()); + // Add password and cert to system keychain + updateKeysForConnection(connection); + dispatch(selectConnection(connection)); } } @@ -43,7 +49,7 @@ export function deleteConnection(connection) { if (shouldSelectNew && getState().connections[0]) { // Grab the updated array of connections, and select the first one dispatch(selectConnection(getState().connections[0])); - }else{ + } else { //if there are no connections to select, clear connection error dispatch({ type: 'SET_DB_CONNECTION_ERROR', diff --git a/app/components/Sidebar/Connections/connections.spec.js b/app/components/Sidebar/Connections/connections.spec.js index 28abcdc..3186936 100644 --- a/app/components/Sidebar/Connections/connections.spec.js +++ b/app/components/Sidebar/Connections/connections.spec.js @@ -5,6 +5,7 @@ import * as reducer from './connections.reducer'; import * as types from '../../../action-types'; let RethinkDbService; +let KeychainService; let dispatch; let getState; @@ -42,6 +43,9 @@ describe('connections', () => { const actions = sinon.stub(); actions.writeConfigFile = sinon.stub().returns('testingWriteConfigFileCall'); mockery.registerMock('../../../actions', actions); + + const KeychainService = sinon.stub(); + mockery.registerMock('../../../services/keychain.service', KeychainService); }); describe('deleteConnection', () => { diff --git a/app/main.js b/app/main.js index 30bd88a..4180d61 100644 --- a/app/main.js +++ b/app/main.js @@ -1,21 +1,21 @@ -// Require our sass files -require("../node_modules/bootstrap-sass/assets/stylesheets/_bootstrap.scss"); -require("../node_modules/font-awesome/scss/font-awesome.scss"); -require("./styles/index.scss"); - -// // Module needed to access global values from main process to any renderer process +// Module needed to access global values from main process to any renderer process import { remote, ipcRenderer } from 'electron'; -// Segment -import Segment from './services/segment.service'; -import ConfigService from './services/config.service'; -// React Specific libs/components import React from 'react'; import ReactDOM from 'react-dom'; import App from './components/App/App'; import { Provider } from 'react-redux'; + +import Segment from './services/segment.service'; +import ConfigService from './services/config.service'; +import { getKeysForConnection } from './services/keychain.service'; import store from './store'; import { getDbConnection } from './components/Sidebar/Connections/selectedConnection.actions'; +// Require our sass files +require("../node_modules/bootstrap-sass/assets/stylesheets/_bootstrap.scss"); +require("../node_modules/font-awesome/scss/font-awesome.scss"); +require("./styles/index.scss"); + export function initApp() { // Set Up Renderer (Browser-side) Events Listeners @@ -58,39 +58,52 @@ export function initApp() { }); }; - const initialState = createInitialState(userConfig); + // const initialState = createInitialState(userConfig); + createInitialState(userConfig) + .then((initialState) => { - // Set Initial State - store.dispatch({ - type: 'SET_STATE', - state: initialState - }); + // Set Initial State + store.dispatch({ + type: 'SET_STATE', + state: initialState + }); - // If a connection exists, connect to it - if (initialState.connection.selected) { - store.dispatch(getDbConnection(initialState.connection.selected)); - } + // If a connection exists, connect to it + if (initialState.connection.selected) { + store.dispatch(getDbConnection(initialState.connection.selected)); + } - // Render App Component - ReactDOM.render( - - - , - document.getElementById('app') - ); + // Render App Component + ReactDOM.render( + + + , + document.getElementById('app') + ); + }); }); } export function createInitialState(config) { - let state = { - main: { email: config.email || null }, - connections: config.connections || [], - connection: {} - }; - if (config.connections && config.connections[0]) { - state.connection.selected = config.connections[0]; - } - return state; -}; + return new Promise((resolve, reject) => { + if (config.connections && config.connections.length > 0) { + config.connections.forEach(async (conn) => { + const keys = await getKeysForConnection(conn); + conn.pass = keys.pass; + conn.ca = keys.ca; + }); + } + + let state = { + main: { email: config.email || null }, + connections: config.connections || [], + connection: {} + }; + if (config.connections && config.connections[0]) { + state.connection.selected = config.connections[0]; + } + resolve(state); + }); +} initApp(); diff --git a/app/main.spec.js b/app/main.spec.js index d0824c5..3419f2c 100644 --- a/app/main.spec.js +++ b/app/main.spec.js @@ -2,10 +2,10 @@ import * as core from './core'; import { main } from './main.reducer'; import store from './store'; - let ConfigService, Segment, electron, HS, _store; // let remote; -require.context = function(){}; +require.context = function() { +}; describe('main', () => { beforeEach(() => { @@ -18,7 +18,8 @@ describe('main', () => { // Mock dependencies ConfigService = sinon.stub(); mockery.registerMock('./services/config.service', ConfigService); - ConfigService.readConfigFile = sinon.stub().returns(new Promise(function(){})); + ConfigService.readConfigFile = sinon.stub().returns(new Promise(function() { + })); Segment = { identify: sinon.spy() @@ -35,13 +36,14 @@ describe('main', () => { mockery.registerMock("../node_modules/bootstrap-sass/assets/stylesheets/_bootstrap.scss", sinon.stub()); mockery.registerMock("../node_modules/font-awesome/scss/font-awesome.scss", sinon.stub()); mockery.registerMock("./styles/index.scss", sinon.stub()); + mockery.registerMock("./services/keychain.service", sinon.stub()); electron = sinon.stub(); electron.ipcRenderer = { - on: function(message, callback){ + on: function(message, callback) { this.callback = callback; }, - send: function(message){ + send: function(message) { this.callback(); } }; @@ -52,7 +54,7 @@ describe('main', () => { describe('initApp', () => { - beforeEach(function(){ + beforeEach(function() { _store = sinon.stub(); _store.dispatch = sinon.stub(); mockery.registerMock('./store', _store); @@ -70,36 +72,53 @@ describe('main', () => { }); describe('createInitialState', () => { - beforeEach(function(){ + beforeEach(function() { _store = sinon.stub(); _store.dispatch = sinon.stub(); mockery.registerMock('./store', _store); }); - it('should take the user config file and return initial app state object', () => { - + it('should take empty user config file and return initial app state object', (done) => { const { createInitialState } = require('./main'); - let fakeConfigFile, fakeState, actual; - - ////// test 1 no config file - fakeConfigFile ={}; - fakeState = { + const fakeConfigFile = {}; + const fakeState = { connection: {}, connections: [], main: { email: null } }; - actual = createInitialState(fakeConfigFile); - expect(actual).to.eql(fakeState); - ////// test 2 config with connections - fakeConfigFile = { + createInitialState(fakeConfigFile) + .then((actual) => { + expect(actual).to.eql(fakeState); + done(); + }); + }); + it('should take user config file with email and no connections and return initial app state object', (done) => { + const { createInitialState } = require('./main'); + const fakeConfigFile = { + email: 'cassie@codehangar.io', + }; + const fakeState = { + main: { email: 'cassie@codehangar.io' }, + connections: [], + connection: {} + }; + createInitialState(fakeConfigFile) + .then((actual) => { + expect(actual).to.eql(fakeState); + done(); + }); + }); + it('should take user config file with email and connections and return initial app state object', (done) => { + const { createInitialState } = require('./main'); + const fakeConfigFile = { email: 'cassie@codehangar.io', connections: [ 'connection1', 'connection2' ] }; - fakeState = { + const fakeState = { main: { email: 'cassie@codehangar.io' }, connections: [ 'connection1', 'connection2' @@ -108,35 +127,25 @@ describe('main', () => { selected: 'connection1' } }; - actual = createInitialState(fakeConfigFile); - expect(actual).to.eql(fakeState); - - ////// test 3 config without connections - fakeConfigFile = { - email: 'cassie@codehangar.io', - }; - fakeState = { - main: { email: 'cassie@codehangar.io' }, - connections: [], - connection: {} - }; - actual = createInitialState(fakeConfigFile); - expect(actual).to.eql(fakeState); - + createInitialState(fakeConfigFile) + .then((actual) => { + expect(actual).to.eql(fakeState); + done(); + }); }); }); // describe('main.reducer', () => { - // + // it('should call core.setEmail for dispatch type SET_EMAIL', () => { - // core.setEmail = sinon.spy(); - // console.log(store.getState()); - // store.dispatch({ - // type: 'SET_EMAIL', - // email: 'cassie@codehangar.io', - // created: '1/1/17' - // }); - // expect(core.setEmail.callCount).to.equal(1); + // core.setEmail = sinon.spy(); + // console.log(store.getState()); + // store.dispatch({ + // type: 'SET_EMAIL', + // email: 'cassie@codehangar.io', + // created: '1/1/17' + // }); + // expect(core.setEmail.callCount).to.equal(1); }); }); diff --git a/app/services/keychain-service.spec.js b/app/services/keychain-service.spec.js new file mode 100644 index 0000000..3e57e4a --- /dev/null +++ b/app/services/keychain-service.spec.js @@ -0,0 +1,251 @@ +import freeze from 'deep-freeze-node'; +import * as types from '../action-types'; + +let keytar; +const SERVICE_NAME = 'ReQLPro'; + +describe('keychain', () => { + + afterEach(function() { + mockery.deregisterAll(); + mockery.resetCache(); + }); + + + beforeEach(function() { + + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true + }); + + // Mock the rethinkdb service + keytar = sinon.stub(); + keytar.setPassword = sinon.stub().returns(Promise.resolve()); + keytar.deletePassword = sinon.stub().returns(Promise.resolve()); + keytar.getPassword = sinon.stub().returns(Promise.resolve('testKey')); + mockery.registerMock('keytar', keytar); + mockery.registerMock('keytar.node', keytar); + }); + + describe('SERVICE_NAME', () => { + it('should be ReQLPro', () => { + const { SERVICE_NAME } = require('./keychain.service'); + expect(SERVICE_NAME).to.equal('ReQLPro'); + }); + }); + + describe('getAccountNameForPassword', () => { + it('should throw an error if host is missing', () => { + const { getAccountNameForPassword } = require('./keychain.service'); + const host = ''; + const port = '28015'; + const user = 'bob'; + + const test = () => getAccountNameForPassword(host, port, user); + expect(test).to.throw('host is required'); + }); + it('should throw an error if port is missing', () => { + const { getAccountNameForPassword } = require('./keychain.service'); + const host = 'localhost'; + const port = ''; + const user = 'bob'; + + const test = () => getAccountNameForPassword(host, port, user); + expect(test).to.throw('port is required'); + }); + it('should throw an error if user is missing', () => { + const { getAccountNameForPassword } = require('./keychain.service'); + const host = 'localhost'; + const port = '28015'; + const user = ''; + + const test = () => getAccountNameForPassword(host, port, user); + expect(test).to.throw('user is required'); + }); + it('should properly format the keychain account name for storing a connection password', () => { + const { getAccountNameForPassword } = require('./keychain.service'); + const host = 'localhost'; + const port = '28015'; + const user = 'bob'; + + const actual = getAccountNameForPassword(host, port, user); + const expected = 'bob@localhost:28015'; + expect(actual).to.equal(expected); + }); + }); + + describe('getAccountNameForCert', () => { + it('should throw an error if host is missing', () => { + const { getAccountNameForCert } = require('./keychain.service'); + const host = ''; + const port = '28015'; + + const test = () => getAccountNameForCert(host, port); + expect(test).to.throw('host is required'); + }); + it('should throw an error if port is missing', () => { + const { getAccountNameForCert } = require('./keychain.service'); + const host = 'localhost'; + const port = ''; + + const test = () => getAccountNameForCert(host, port); + expect(test).to.throw('port is required'); + }); + it('should properly format the keychain account name for storing a connection cert', () => { + const { getAccountNameForCert } = require('./keychain.service'); + const host = 'localhost'; + const port = '28015'; + + const actual = getAccountNameForCert(host, port); + const expected = 'localhost:28015_cert'; + expect(actual).to.equal(expected); + }); + }); + + describe('updatePassword', () => { + it('should add a connection password to the keychain if password is given', (done) => { + const { updatePassword } = require('./keychain.service'); + + const host = 'localhost'; + const port = '28015'; + const user = 'bob'; + const pass = 'secret'; + + updatePassword(host, port, user, pass).then(() => { + expect(keytar.setPassword.callCount).to.equal(1); + + const account = 'bob@localhost:28015'; + expect(keytar.setPassword.calledWith( + SERVICE_NAME, account, pass + )).to.equal(true); + + done(); + }); + }); + + it('should delete a connection password to the keychain if password is not given', (done) => { + const { updatePassword } = require('./keychain.service'); + + const host = 'localhost'; + const port = '28015'; + const user = 'bob'; + const pass = ''; + + updatePassword(host, port, user, pass).then(() => { + expect(keytar.deletePassword.callCount).to.equal(1); + + const account = 'bob@localhost:28015'; + expect(keytar.deletePassword.calledWith( + SERVICE_NAME, account + )).to.equal(true); + + done(); + }); + }); + }); + + describe('updateCert', () => { + it('should add a connection cert to the keychain if cert is given', (done) => { + const { updateCert } = require('./keychain.service'); + + const host = 'localhost'; + const port = '28015'; + const cert = 'random-cert-string'; + + updateCert(host, port, cert).then(() => { + expect(keytar.setPassword.callCount).to.equal(1); + + const account = 'localhost:28015_cert'; + expect(keytar.setPassword.calledWith( + SERVICE_NAME, account, cert + )).to.equal(true); + + done(); + }); + }); + + it('should delete a connection cert to the keychain if cert is not given', (done) => { + const { updateCert } = require('./keychain.service'); + + const host = 'localhost'; + const port = '28015'; + const cert = ''; + + updateCert(host, port, cert).then(() => { + expect(keytar.deletePassword.callCount).to.equal(1); + + const account = 'localhost:28015_cert'; + expect(keytar.deletePassword.calledWith( + SERVICE_NAME, account + )).to.equal(true); + + done(); + }); + }); + }); + + describe('updateKeysForConnection', () => { + it('should add a connection password and cert to the keychain if given', (done) => { + const { updateKeysForConnection } = require('./keychain.service'); + + const connection = { + host: 'localhost', + port: '28015', + user: 'bob', + pass: 'secret', + ca: 'random-cert-string' + }; + + updateKeysForConnection(connection).then(() => { + expect(keytar.setPassword.callCount).to.equal(2); + + const accountForPass = 'bob@localhost:28015'; + expect(keytar.setPassword.calledWith( + SERVICE_NAME, accountForPass, connection.pass + )).to.equal(true); + + const accountForCert = 'localhost:28015_cert'; + expect(keytar.setPassword.calledWith( + SERVICE_NAME, accountForCert, connection.ca + )).to.equal(true); + + done(); + }); + }); + }); + + describe('getKeysForConnection', () => { + it('should add a connection password and cert to the keychain if given', (done) => { + const { getKeysForConnection } = require('./keychain.service'); + + const connection = { + host: 'localhost', + port: '28015', + user: 'bob' + }; + + getKeysForConnection(connection).then((keys) => { + expect(keytar.getPassword.callCount).to.equal(2); + + const accountForPass = 'bob@localhost:28015'; + expect(keytar.getPassword.calledWith( + SERVICE_NAME, accountForPass + )).to.equal(true); + + const accountForCert = 'localhost:28015_cert'; + expect(keytar.getPassword.calledWith( + SERVICE_NAME, accountForCert + )).to.equal(true); + + expect(keys).to.deep.equal({ + pass: 'testKey', + ca: 'testKey' + }); + + done(); + }); + }); + }); +}); diff --git a/app/services/keychain.service.js b/app/services/keychain.service.js new file mode 100644 index 0000000..b8c645e --- /dev/null +++ b/app/services/keychain.service.js @@ -0,0 +1,93 @@ +import keytar from 'keytar'; + +export const SERVICE_NAME = 'ReQLPro'; + +export function getAccountNameForPassword(host, port, user) { + if (!host) { + throw new Error('host is required'); + } + if (!port) { + throw new Error('port is required'); + } + if (!user) { + throw new Error('user is required'); + } + return user + '@' + host + ':' + port; +} + +export function getAccountNameForCert(host, port) { + if (!host) { + throw new Error('host is required'); + } + if (!port) { + throw new Error('port is required'); + } + return host + ':' + port + '_cert'; +} + +export function updatePassword(host, port, user, password) { + if (!user) { + return Promise.resolve(''); + } + const account = getAccountNameForPassword(host, port, user); + if (password) { + return keytar.setPassword(SERVICE_NAME, account, password); + } else { + return keytar.deletePassword(SERVICE_NAME, account); + } +} + +export function updateCert(host, port, cert) { + const account = getAccountNameForCert(host, port); + if (cert) { + return keytar.setPassword(SERVICE_NAME, account, cert); + } else { + return keytar.deletePassword(SERVICE_NAME, account); + } +} + +export function updateKeysForConnection(connection) { + if (!connection) { + throw new Error('connection is required'); + } + return new Promise(async (resolve, reject) => { + // Set default host and port if not supplied + const host = connection.host || 'localhost'; + const port = connection.port || '28015'; + resolve({ + pass: await updatePassword(host, port, connection.user, connection.pass), + ca: await updateCert(host, port, connection.ca) + }); + }); +} + +export function getPassword(host, port, user) { + if (!user) { + return Promise.resolve(''); + } + const account = getAccountNameForPassword(host, port, user); + return keytar.getPassword(SERVICE_NAME, account); +} + +export function getCert(host, port) { + const account = getAccountNameForCert(host, port); + return keytar.getPassword(SERVICE_NAME, account); +} + +export function getKeysForConnection(connection) { + if (!connection) { + throw new Error('connection is required'); + } + return new Promise(async (resolve, reject) => { + // Set default host and port if not supplied + const host = connection.host || 'localhost'; + const port = connection.port || '28015'; + + const pass = await getPassword(host, port, connection.user); + const ca = await getCert(host, port); + resolve({ + pass: pass || '', + ca: ca || '' + }); + }); +} diff --git a/app/services/reql-eval-service.spec.js b/app/services/reql-eval-service.spec.js index 07540d0..68b3ff2 100644 --- a/app/services/reql-eval-service.spec.js +++ b/app/services/reql-eval-service.spec.js @@ -44,7 +44,7 @@ describe('ReQLEval Tests', function() { ReQLEval("{ name: 'test' }") }).to.not.throw(Error); }); - it('should handle errors', function() { + it('should handle errors', function(done) { ReQLEval("undefinedThing").catch((err) => { expect(err instanceof ReferenceError).to.be.true; expect(err.message).to.equal('undefinedThing is not defined'); diff --git a/package.json b/package.json index 19a375b..f5459a7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "A simple desktop client to connect to RethinkDB servers to work with data.", "main": "main.js", "scripts": { + "postinstall": "electron-rebuild --version=1.4.15", "start": "node ./tools/start", "serve": "node server", "test": "mocha --compilers js:babel-core/register", @@ -49,6 +50,7 @@ "electron": "^1.4.15", "electron-packager": "^8.5.1", "electron-prebuilt": "^1.4.13", + "electron-rebuild": "^1.5.11", "electron-winstaller": "^2.3.1", "file-loader": "^0.9.0", "html-webpack-plugin": "2.16.1",