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",