diff options
| author | Erik Larkö <erik@mullvad.net> | 2017-07-03 15:26:08 +0200 |
|---|---|---|
| committer | Erik Larkö <erik@mullvad.net> | 2017-07-03 15:26:08 +0200 |
| commit | c714a1cb4ea2cdb0a21e3c71684e7047a77da10e (patch) | |
| tree | e1bc465e4e1b54b9ccbb8c500ad9b921fd2e16f9 | |
| parent | 6b8f00eb25e85eb466fd60e28df43e591f372507 (diff) | |
| parent | d06281eb566330a38b13a56bb9fdadbd7ef9aa99 (diff) | |
| download | mullvadvpn-c714a1cb4ea2cdb0a21e3c71684e7047a77da10e.tar.xz mullvadvpn-c714a1cb4ea2cdb0a21e3c71684e7047a77da10e.zip | |
Merge branch 'login-flow'
| -rw-r--r-- | app/app.js | 2 | ||||
| -rw-r--r-- | app/lib/backend.js | 39 | ||||
| -rw-r--r-- | test/actions.spec.js | 156 | ||||
| -rw-r--r-- | test/helpers/IpcChain.js | 84 | ||||
| -rw-r--r-- | test/helpers/ipc-helpers.js | 51 | ||||
| -rw-r--r-- | test/login.spec.js | 30 | ||||
| -rw-r--r-- | test/routing.spec.js | 20 |
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')); - }); - }); |
