summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorErik Larkö <erik@mullvad.net>2017-07-03 15:26:08 +0200
committerErik Larkö <erik@mullvad.net>2017-07-03 15:26:08 +0200
commitc714a1cb4ea2cdb0a21e3c71684e7047a77da10e (patch)
treee1bc465e4e1b54b9ccbb8c500ad9b921fd2e16f9
parent6b8f00eb25e85eb466fd60e28df43e591f372507 (diff)
parentd06281eb566330a38b13a56bb9fdadbd7ef9aa99 (diff)
downloadmullvadvpn-c714a1cb4ea2cdb0a21e3c71684e7047a77da10e.tar.xz
mullvadvpn-c714a1cb4ea2cdb0a21e3c71684e7047a77da10e.zip
Merge branch 'login-flow'
-rw-r--r--app/app.js2
-rw-r--r--app/lib/backend.js39
-rw-r--r--test/actions.spec.js156
-rw-r--r--test/helpers/IpcChain.js84
-rw-r--r--test/helpers/ipc-helpers.js51
-rw-r--r--test/login.spec.js30
-rw-r--r--test/routing.spec.js20
7 files changed, 200 insertions, 182 deletions
diff --git a/app/app.js b/app/app.js
index 499ffe18c4..bff4c7f6fc 100644
--- a/app/app.js
+++ b/app/app.js
@@ -21,7 +21,7 @@ import type { TrayIconType } from './lib/tray-icon-manager';
const initialState = null;
const memoryHistory = createMemoryHistory();
const store = configureStore(initialState, memoryHistory);
-const backend = new Backend();
+const backend = new Backend(store);
// reset login state if user quit the app during login
if((['connecting', 'failed']: Array<LoginState>).includes(store.getState().account.status)) {
diff --git a/app/lib/backend.js b/app/lib/backend.js
index 076a49ca32..4cd2cf49e9 100644
--- a/app/lib/backend.js
+++ b/app/lib/backend.js
@@ -3,6 +3,9 @@ import log from 'electron-log';
import EventEmitter from 'events';
import { servers } from '../config';
import { IpcFacade, RealIpc } from './ipc-facade';
+import accountActions from '../redux/account/actions';
+import type { ReduxStore } from '../redux/store';
+import { push } from 'react-router-redux';
export type EventType = 'connect' | 'connecting' | 'disconnect' | 'login' | 'logging' | 'logout' | 'updatedIp' | 'updatedLocation' | 'updatedReachability';
export type ErrorType = 'NO_CREDIT' | 'NO_INTERNET' | 'INVALID_ACCOUNT';
@@ -61,9 +64,11 @@ export class BackendError extends Error {
export class Backend {
_ipc: IpcFacade;
+ _store: ReduxStore;
_eventEmitter = new EventEmitter();
- constructor(ipc: ?IpcFacade) {
+ constructor(store: ReduxStore, ipc: ?IpcFacade) {
+ this._store = store;
this._ipc = ipc || new RealIpc('');
this._registerIpcListeners();
this._startReachability();
@@ -131,17 +136,24 @@ export class Backend {
};
}
- login(account: string) {
- log.info('Attempting to login with account number', account);
+
+ login(accountNumber: string) {
+ log.info('Attempting to login with account number', accountNumber);
// emit: logging in
- this._emit('logging', { accountNumber: account }, null);
+ this._emit('logging', { accountNumber: accountNumber }, null);
+
+ this._store.dispatch(accountActions.loginChange({
+ accountNumber: accountNumber,
+ status: 'connecting',
+ error: null,
+ }));
- this._ipc.getAccountData(account)
+ this._ipc.getAccountData(accountNumber)
.then( response => {
log.info('Account exists', response);
- return this._ipc.setAccount(account)
+ return this._ipc.setAccount(accountNumber)
.then( () => response );
}).then( accountData => {
@@ -151,13 +163,28 @@ export class Backend {
paidUntil: accountData.paid_until,
}, undefined);
+ this._store.dispatch(accountActions.loginChange({
+ status: 'ok',
+ paidUntil: accountData.paid_until,
+ error: null,
+ }));
+
+ this._store.dispatch(push('/connect'));
}).catch(e => {
log.error('Failed to log in', e);
+
+ // TODO: This is not true. If there is a communication link failure the promise will be rejected too
const err = new BackendError('INVALID_ACCOUNT');
+ this._store.dispatch(accountActions.loginChange({
+ status: 'failed',
+ error: err,
+ }));
+
this._emit('login', {}, err);
});
}
+
logout() {
// @TODO: What does it mean for a logout to be successful or failed?
this._ipc.setAccount('')
diff --git a/test/actions.spec.js b/test/actions.spec.js
deleted file mode 100644
index af98cd83a9..0000000000
--- a/test/actions.spec.js
+++ /dev/null
@@ -1,156 +0,0 @@
-// @flow
-
-import { expect } from 'chai';
-import { filterMinorActions, mockState, mockStore } from './mocks/redux';
-import { Backend } from '../app/lib/backend';
-import { newMockIpc } from './mocks/ipc';
-import accountActions from '../app/redux/account/actions';
-import connectionActions from '../app/redux/connection/actions';
-import mapBackendEventsToReduxActions from '../app/lib/backend-redux-actions';
-
-describe('actions', function() {
- this.timeout(10000);
-
- it('should login', (done) => {
- const expectedActions = [
- { type: 'USER_LOGIN_CHANGE', payload: { status: 'connecting', error: null, accountNumber: '1'} },
- { type: 'USER_LOGIN_CHANGE', payload: { paidUntil: '2013-01-01T00:00:00.000Z', status: 'ok', error: undefined } }
- ];
- const store = mockStore(mockState());
- const mockIpc = newMockIpc();
- mockIpc.getAccountData = () => {
- return new Promise(r => r({
- paid_until: '2013-01-01T00:00:00.000Z',
- }));
- };
-
- const backend = new Backend(mockIpc);
-
- mapBackendEventsToReduxActions(backend, store);
-
- backend.once('login', () => {
- const storeActions = filterMinorActions(store.getActions());
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
-
- store.dispatch(accountActions.login(backend, '1'));
- });
-
- it('should logout', (done) => {
- const expectedActions = [
- { type: 'USER_LOGIN_CHANGE', payload: { accountNumber: '', paidUntil: null, status: 'none', error: null } },
- ];
-
- const store = mockStore(mockState());
- const mockIpc = newMockIpc();
- const backend = new Backend(mockIpc);
- mapBackendEventsToReduxActions(backend, store);
-
- backend.once('logout', () => {
- const storeActions = filterMinorActions(store.getActions());
-
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
-
- store.dispatch(accountActions.logout(backend));
- });
-
- it('should connect to VPN server', (done) => {
- const expectedActions = [
- { type: 'CONNECTION_CHANGE', payload: { serverAddress: '1.2.3.4', status: 'connecting' } },
- { type: 'CONNECTION_CHANGE', payload: { status: 'connected' } }
- ];
-
- const store = mockStore(mockState());
- const mockIpc = newMockIpc();
- const backend = new Backend(mockIpc);
- mockIpc.getAccountData = () => {
- return new Promise(r => r({
- paid_until: '2038-01-01T00:00:00.000Z',
- }));
- };
- mapBackendEventsToReduxActions(backend, store);
-
- backend.once('connect', () => {
-
- const storeActions = filterMinorActions(store.getActions())
- .filter(action => {
- return action.type === 'CONNECTION_CHANGE' && action.payload.status !== 'disconnected';
- });
-
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
-
- backend.once('login', () => {
- store.dispatch(connectionActions.connect(backend, '1.2.3.4'));
- });
- store.dispatch(accountActions.login(backend, '1'));
- });
-
- it('should disconnect from VPN server', (done) => {
- const expectedActions = [
- { type: 'CONNECTION_CHANGE', payload: { status: 'disconnected', serverAddress: null } }
- ];
-
- let state = Object.assign(mockState(), {
- account: {
- accountNumber: '3333234567890',
- paidUntil: '2038-01-01T00:00:00.000Z',
- status: 'ok'
- },
- connect: {
- serverAddress: '1.2.3.4',
- status: 'connected'
- }
- });
-
- const store = mockStore(state);
- const backend = new Backend(newMockIpc());
- mapBackendEventsToReduxActions(backend, store);
-
- backend.once('disconnect', () => {
- const storeActions = filterMinorActions(store.getActions());
-
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
-
- store.dispatch(connectionActions.disconnect(backend));
- });
-
- it('should disconnect from VPN server on logout', (done) => {
- const expectedActions = [
- { type: 'USER_LOGIN_CHANGE', payload: { accountNumber: '', paidUntil: null, status: 'none', error: null } },
- { type: 'CONNECTION_CHANGE', payload: { serverAddress: null, status: 'disconnected' } }
- ];
-
- let state = Object.assign(mockState(), {
- account: {
- accountNumber: '3333234567890',
- paidUntil: '2038-01-01T00:00:00.000Z',
- status: 'ok'
- },
- connect: {
- serverAddress: '1.2.3.4',
- status: 'connected'
- }
- });
-
- const store = mockStore(state);
- const backend = new Backend(newMockIpc());
- mapBackendEventsToReduxActions(backend, store);
-
- backend.once('disconnect', () => {
- const storeActions = filterMinorActions(store.getActions());
-
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
-
- store.dispatch(accountActions.logout(backend));
- });
-
-});
diff --git a/test/helpers/IpcChain.js b/test/helpers/IpcChain.js
new file mode 100644
index 0000000000..247ea0526b
--- /dev/null
+++ b/test/helpers/IpcChain.js
@@ -0,0 +1,84 @@
+// @flow
+
+import { expect } from 'chai';
+import { check, failFast } from './ipc-helpers';
+
+export class IpcChain {
+ _expectedCalls: Array<string>;
+ _recordedCalls: Array<string>;
+ _mockIpc: {};
+ _done: (*) => void;
+
+ constructor(mockIpc: {}, done: (*) => void) {
+ this._expectedCalls = [];
+ this._recordedCalls = [];
+ this._mockIpc = mockIpc;
+ this._done = done;
+ }
+
+ addRequiredStep(ipcCall: string): StepBuilder {
+ this._expectedCalls.push(ipcCall);
+ return new StepBuilder(ipcCall, this._addStep.bind(this));
+ }
+
+ _addStep(step: StepBuilder) {
+ const me = this;
+ this._mockIpc[step.ipcCall] = function() {
+ return new Promise(r => me._stepPromiseCallback(step, r, arguments));
+ };
+ }
+
+ _stepPromiseCallback(step, resolve, args) {
+ this._registerCall(step.ipcCall);
+
+ if (step.inputValidation) {
+ failFast(() => step.inputValidation(...args), this._done);
+ }
+
+ if (this._isLastCall()) {
+ this._ensureChainCalledCorrectly();
+ }
+
+ resolve(step.returnValue);
+ }
+
+ _isLastCall(): boolean {
+ return this._recordedCalls.length === this._expectedCalls.length;
+ }
+
+ _ensureChainCalledCorrectly() {
+ check(() => {
+ expect(this._expectedCalls).to.deep.equal(this._recordedCalls);
+ }, this._done);
+ }
+
+ _registerCall(ipcCall: string) {
+ this._recordedCalls.push(ipcCall);
+ }
+}
+
+class StepBuilder {
+ ipcCall: string;
+ inputValidation: () => void;
+ returnValue: *;
+ _cb: (StepBuilder) => void;
+
+ constructor(ipcCall: string, cb: (StepBuilder)=> void) {
+ this.ipcCall = ipcCall;
+ this._cb = cb;
+ }
+
+ withInputValidation(iv: () => void): StepBuilder {
+ this.inputValidation = iv;
+ return this;
+ }
+
+ withReturnValue(rv: *): StepBuilder {
+ this.returnValue = rv;
+ return this;
+ }
+
+ done() {
+ this._cb(this);
+ }
+}
diff --git a/test/helpers/ipc-helpers.js b/test/helpers/ipc-helpers.js
new file mode 100644
index 0000000000..39fc9fba06
--- /dev/null
+++ b/test/helpers/ipc-helpers.js
@@ -0,0 +1,51 @@
+// @flow
+
+import { Backend } from '../../app/lib/backend';
+import { newMockIpc } from '../mocks/ipc';
+import configureStore from '../../app/redux/store';
+import { createMemoryHistory } from 'history';
+
+type DoneCallback = (?mixed) => void;
+type Check = () => void;
+
+// Mock localStorage because redux-localstorage has no test helpers
+// We use redux-localstorage when we setup the redux store to have the
+// store persist when the application is shut down.
+global.localStorage = {getItem: ()=>'{}', setItem: ()=>{}};
+
+export function setupBackendAndStore() {
+
+ const memoryHistory = createMemoryHistory();
+ const store = configureStore(null, memoryHistory);
+
+ const mockIpc = newMockIpc();
+
+ const backend = new Backend(store, mockIpc);
+
+ return { store, mockIpc, backend };
+}
+
+// chai and async aren't the best of friends. To allow us
+// to get the assertion error in the output of failed async
+// tests we need to do this try-catch thing.
+export function check(fn: Check, done: DoneCallback) {
+ try {
+ fn();
+ done();
+ } catch (e) {
+ done(e);
+ }
+}
+
+
+// In async tests where we want to test a chain of IPC messages
+// we can only invoke `done` for the last message. This function
+// is for the intermediate messages.
+export function failFast(fn: Check, done: DoneCallback) {
+ try {
+ fn();
+ } catch(e) {
+ done(e);
+ }
+}
+
diff --git a/test/login.spec.js b/test/login.spec.js
new file mode 100644
index 0000000000..84ca0f431a
--- /dev/null
+++ b/test/login.spec.js
@@ -0,0 +1,30 @@
+// @flow
+
+import { expect } from 'chai';
+import { setupBackendAndStore } from './helpers/ipc-helpers';
+import { IpcChain } from './helpers/IpcChain';
+import accountActions from '../app/redux/account/actions';
+
+describe('Logging in', () => {
+
+ it('should validate the account number and then set it in the backend', (done) => {
+ const { store, mockIpc, backend } = setupBackendAndStore();
+
+ const chain = new IpcChain(mockIpc, done);
+ chain.addRequiredStep('getAccountData')
+ .withInputValidation((an) => {
+ expect(an).to.equal('123');
+ })
+ .done();
+
+ chain.addRequiredStep('setAccount')
+ .withInputValidation((an) => {
+ expect(an).to.equal('123');
+ })
+ .done();
+
+ const action: any = accountActions.login(backend, '123');
+ store.dispatch(action);
+ });
+});
+
diff --git a/test/routing.spec.js b/test/routing.spec.js
index 66fe3e72e3..ccb702de8b 100644
--- a/test/routing.spec.js
+++ b/test/routing.spec.js
@@ -24,7 +24,7 @@ describe('routing', function() {
});
const store = mockStore(state);
- const backend = new Backend(newMockIpc());
+ const backend = new Backend(store, newMockIpc());
mapBackendEventsToRouter(backend, store);
store.dispatch(accountActions.logout(backend));
@@ -34,22 +34,4 @@ describe('routing', function() {
expect(storeActions).deep.equal(expectedActions);
}, 0);
});
-
- it('should redirect to connect screen on login', (done) => {
- const expectedActions = [
- { type: '@@router/CALL_HISTORY_METHOD', payload: { method: 'replace', args: [ '/connect' ] } }
- ];
-
- const store = mockStore(mockState());
- const backend = new Backend(newMockIpc());
- mapBackendEventsToRouter(backend, store);
-
- store.subscribe(() => {
- const storeActions = filterMinorActions(store.getActions());
- expect(storeActions).deep.equal(expectedActions);
- done();
- });
- store.dispatch(accountActions.login(backend, '1'));
- });
-
});