diff --git a/src/js/app-config.js b/src/js/app-config.js index 0fd10d9..ec7a4be 100644 --- a/src/js/app-config.js +++ b/src/js/app-config.js @@ -177,7 +177,8 @@ define(function(require) { appVersion: appVersion, outboxMailboxPath: 'OUTBOX', outboxMailboxName: 'Outbox', - outboxMailboxType: 'Outbox' + outboxMailboxType: 'Outbox', + connDocTimeout: 5000, }; /** @@ -207,7 +208,14 @@ define(function(require) { bugReportTitle: 'Report a bug', bugReportSubject: '[Bug] I want to report a bug', bugReportBody: 'Steps to reproduce\n1. \n2. \n3. \n\nWhat happens?\n\n\nWhat do you expect to happen instead?\n\n\n\n== PLEASE DONT PUT ANY KEYS HERE! ==\n\n\n## Log\n\nBelow is the log. It includes your interactions with your email provider in an anonymized way from the point where you started the app for the last time. Any information provided by you will be used for the porpose of locating and fixing the bug you reported. It will be deleted subsequently. However, you can edit this log and/or remove log data in the event that something would show up.\n\n', - supportAddress: 'mail.support@whiteout.io' + supportAddress: 'mail.support@whiteout.io', + connDocOffline: 'It appears that you are offline. Please retry when you are online.', + connDocTlsWrongCert: 'A connection to {0} was rejected because the TLS certificate is invalid. Please have a look at the FAQ for information on how to fix this error.', + connDocHostUnreachable: 'We could not establish a connection to {0}. Please check the server settings!', + connDocHostTimeout: 'We could not establish a connection to {0} within {1} ms. Please check the server settings and encryption mode!', + connDocAuthRejected: 'Your credentials for {0} were rejected. Please check your username and password!', + connDocNoInbox: 'We could not detect an IMAP inbox folder on {0}. Please have a look at the FAQ for information on how to fix this error.', + connDocGenericError: 'There was an error connecting to {0}: {1}' }; return app; diff --git a/src/js/app-controller.js b/src/js/app-controller.js index f515f91..0535017 100644 --- a/src/js/app-controller.js +++ b/src/js/app-controller.js @@ -24,6 +24,7 @@ define(function(require) { PrivateKeyDAO = require('js/dao/privatekey-dao'), InvitationDAO = require('js/dao/invitation-dao'), DeviceStorageDAO = require('js/dao/devicestorage-dao'), + ConnectionDoctor = require('js/util/connection-doctor'), UpdateHandler = require('js/util/update/update-handler'), config = appConfig.config, str = appConfig.string; @@ -104,6 +105,7 @@ define(function(require) { self._outboxBo = new OutboxBO(emailDao, keychain, userStorage); self._updateHandler = new UpdateHandler(appConfigStore, userStorage, auth); self._adminDao = new AdminDao(new RestDAO(config.adminUrl)); + self._doctor = new ConnectionDoctor(); emailDao.onError = self.onError; }; diff --git a/src/js/controller/login-set-credentials.js b/src/js/controller/login-set-credentials.js index 81efbde..5b34df0 100644 --- a/src/js/controller/login-set-credentials.js +++ b/src/js/controller/login-set-credentials.js @@ -6,21 +6,22 @@ define(function(require) { var ENCRYPTION_METHOD_TLS = 2; var appCtrl = require('js/app-controller'), - config = require('js/app-config').config, - ImapClient = require('imap-client'), - SmtpClient = require('smtpclient'); + config = require('js/app-config').config; var SetCredentialsCtrl = function($scope, $location, $routeParams) { - if (!appCtrl._emailDao && !$routeParams.dev) { + if (!appCtrl._auth && !$routeParams.dev) { $location.path('/'); // init app return; } var auth = appCtrl._auth; + var doctor = appCtrl._doctor; - var provider; + // + // Presets and Settings + // - provider = $location.search().provider; + var provider = $location.search().provider; $scope.hasProviderPreset = !!config[provider]; $scope.useOAuth = !!auth.oauthToken; $scope.showDetails = (provider === 'custom'); @@ -30,12 +31,15 @@ define(function(require) { } if ($scope.hasProviderPreset) { - // use non-editable smtp and imap presets for provider + // use non-editable presets + + // SMTP config $scope.smtpHost = config[provider].smtp.host; $scope.smtpPort = config[provider].smtp.port; $scope.smtpCert = config[provider].smtp.ca; $scope.smtpPinned = config[provider].smtp.pinned; + // transport encryption method if (config[provider].smtp.secure && !config[provider].smtp.ignoreTLS) { $scope.smtpEncryption = ENCRYPTION_METHOD_TLS; } else if (!config[provider].smtp.secure && !config[provider].smtp.ignoreTLS) { @@ -44,11 +48,13 @@ define(function(require) { $scope.smtpEncryption = ENCRYPTION_METHOD_NONE; } + // IMAP config $scope.imapHost = config[provider].imap.host; $scope.imapPort = config[provider].imap.port; $scope.imapCert = config[provider].imap.ca; $scope.imapPinned = config[provider].imap.pinned; + // transport encryption method if (config[provider].imap.secure && !config[provider].imap.ignoreTLS) { $scope.imapEncryption = ENCRYPTION_METHOD_TLS; } else if (!config[provider].imap.secure && !config[provider].imap.ignoreTLS) { @@ -58,109 +64,19 @@ define(function(require) { } } - $scope.test = function(imapClient, smtpClient) { - var imapEncryption = parseInt($scope.imapEncryption, 10); - var smtpEncryption = parseInt($scope.smtpEncryption, 10); - $scope.credentialsIncomplete = false; - $scope.connectionError = false; - $scope.smtpOk = undefined; - $scope.imapOk = undefined; - - if (!(($scope.username || $scope.emailAddress) && ($scope.password || $scope.useOAuth))) { - $scope.credentialsIncomplete = true; - return; - } - - var imap = imapClient || new ImapClient({ - host: $scope.imapHost.toLowerCase(), - port: $scope.imapPort, - secure: imapEncryption === ENCRYPTION_METHOD_TLS, - ignoreTLS: imapEncryption === ENCRYPTION_METHOD_NONE, - ca: $scope.imapCert, - auth: { - user: $scope.username || $scope.emailAddress, - pass: $scope.password, - xoauth2: auth.oauthToken - } - }); - - imap.onCert = function(pemEncodedCert) { - if (!$scope.imapPinned) { - $scope.imapCert = pemEncodedCert; - } - }; - - imap.onError = function(err) { - $scope.imapOk = !err; - $scope.connectionError = err; - done(); - }; - - var smtp = smtpClient || new SmtpClient($scope.smtpHost.toLowerCase(), $scope.smtpPort, { - useSecureTransport: smtpEncryption === ENCRYPTION_METHOD_TLS, - ignoreTLS: smtpEncryption === ENCRYPTION_METHOD_NONE, - ca: $scope.smtpCert, - auth: { - user: $scope.username || $scope.emailAddress, - pass: $scope.password, - xoauth2: auth.oauthToken - } - }); - - smtp.oncert = function(pemEncodedCert) { - if (!$scope.smtpPinned) { - $scope.smtpCert = pemEncodedCert; - } - }; - - smtp.onerror = function(err) { - $scope.smtpOk = !err; - $scope.connectionError = $scope.connectionError || err; - done(); - }; - - smtp.onidle = function() { - smtp.onerror = function() {}; // don't care about errors after discarding connection - $scope.smtpOk = true; - smtp.quit(); - done(); - }; - - $scope.busy = 2; - - // fire away - imap.login(function(err) { - $scope.connectionError = $scope.connectionError || err; - $scope.imapOk = !err; - imap.logout(function() {}); // don't care about errors after discarding connection - done(); - }); - - smtp.connect(); - }; - - function done() { - if ($scope.busy > 0) { - $scope.busy--; - } - - if ($scope.smtpOk && $scope.imapOk) { - login(); - } - - $scope.$apply(); - } - - function login() { + $scope.test = function() { + // parse the diff --git a/test/unit/connection-doctor-test.js b/test/unit/connection-doctor-test.js new file mode 100644 index 0000000..97f2ed9 --- /dev/null +++ b/test/unit/connection-doctor-test.js @@ -0,0 +1,390 @@ +define(function(require) { + 'use strict'; + + var ConnectionDoctor = require('js/util/connection-doctor'), + TCPSocket = require('tcp-socket'), + ImapClient = require('imap-client'), + SmtpClient = require('smtpclient'), + cfg = require('js/app-config').config, + expect = chai.expect; + + describe('Connection Doctor', function() { + var doctor; + var socketStub, imapStub, smtpStub, credentials; + + beforeEach(function() { + // + // Stubs + // + + // there is no socket shim for for this use case, use dummy object + socketStub = { + close: function() { + this.onclose(); + } + }; + imapStub = sinon.createStubInstance(ImapClient); + smtpStub = sinon.createStubInstance(SmtpClient); + + // + // Fixture + // + credentials = { + imap: { + host: 'asd', + port: 1234, + secure: true, + ca: 'cert' + }, + smtp: { + host: 'qwe', + port: 5678, + secure: false, + ca: 'cert' + }, + username: 'username', + password: 'password' + }; + + sinon.stub(TCPSocket, 'open').returns(socketStub); // convenience constructors suck + + // + // Setup SUT + // + doctor = new ConnectionDoctor(); + doctor.configure(credentials); + doctor._imap = imapStub; + doctor._smtp = smtpStub; + }); + + afterEach(function() { + TCPSocket.open.restore(); + }); + + describe('#_checkOnline', function() { + it('should check if browser is online', function(done) { + doctor._checkOnline(function(error) { + if (navigator.onLine) { + expect(error).to.not.exist; + } else { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.OFFLINE); + } + done(); + }); + }); + }); + + describe('#_checkReachable', function() { + it('should be able to reach the host w/o cert', function(done) { + credentials.imap.ca = undefined; + + doctor._checkReachable(credentials.imap, function(error) { + expect(error).to.not.exist; + expect(TCPSocket.open.calledOnce).to.be.true; + expect(TCPSocket.open.calledWith(credentials.imap.host, credentials.imap.port, { + binaryType: 'arraybuffer', + useSecureTransport: credentials.imap.secure, + ca: credentials.imap.ca + })).to.be.true; + + done(); + }); + + socketStub.oncert(); + socketStub.onopen(); + }); + + it('should fail w/ wrong cert', function(done) { + doctor._checkReachable(credentials.imap, function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.TLS_WRONG_CERT); + expect(TCPSocket.open.calledOnce).to.be.true; + expect(TCPSocket.open.calledWith(credentials.imap.host, credentials.imap.port, { + binaryType: 'arraybuffer', + useSecureTransport: credentials.imap.secure, + ca: credentials.imap.ca + })).to.be.true; + + done(); + }); + + socketStub.oncert(); + socketStub.onerror(); + socketStub.onclose(); + }); + + it('should fail w/ host unreachable', function(done) { + doctor._checkReachable(credentials.imap, function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.HOST_UNREACHABLE); + expect(TCPSocket.open.calledOnce).to.be.true; + + done(); + }); + + socketStub.onerror({ + data: new Error() + }); + socketStub.onclose(); + }); + + it('should fail w/ timeout', function(done) { + var origTimeout = cfg.connDocTimeout; // remember timeout from the config to reset it on done + cfg.connDocTimeout = 20; // set to 20ms for the test + + doctor._checkReachable(credentials.imap, function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.HOST_TIMEOUT); + expect(TCPSocket.open.calledOnce).to.be.true; + cfg.connDocTimeout = origTimeout; + + done(); + }); + }); + }); + + describe('#_checkImap', function() { + it('should perform IMAP login, list folders, logout', function(done) { + imapStub.login.yieldsAsync(); + imapStub.listWellKnownFolders.yieldsAsync(null, { + Inbox: [{}] + }); + imapStub.logout.yieldsAsync(); + + doctor._checkImap(function(error) { + expect(error).to.not.exist; + expect(imapStub.login.calledOnce).to.be.true; + expect(imapStub.listWellKnownFolders.calledOnce).to.be.true; + expect(imapStub.logout.calledOnce).to.be.true; + + done(); + }); + }); + + it('should fail w/ generic error on logout', function(done) { + imapStub.login.yieldsAsync(); + imapStub.listWellKnownFolders.yieldsAsync(null, { + Inbox: [{}] + }); + + doctor._checkImap(function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.GENERIC_ERROR); + expect(error.underlyingError).to.exist; + expect(imapStub.login.calledOnce).to.be.true; + expect(imapStub.listWellKnownFolders.calledOnce).to.be.true; + expect(imapStub.logout.calledOnce).to.be.true; + + done(); + }); + + setTimeout(function() { + // this error is thrown while we're waiting for the logout + imapStub.onError(new Error()); + }, 50); + }); + + it('should fail w/ generic error on inbox missing', function(done) { + imapStub.login.yieldsAsync(); + imapStub.listWellKnownFolders.yieldsAsync(null, { + Inbox: [] + }); + + doctor._checkImap(function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.NO_INBOX); + expect(imapStub.login.calledOnce).to.be.true; + expect(imapStub.listWellKnownFolders.calledOnce).to.be.true; + expect(imapStub.logout.called).to.be.false; + + done(); + }); + }); + + it('should fail w/ generic error on listing folders fails', function(done) { + imapStub.login.yieldsAsync(); + imapStub.listWellKnownFolders.yieldsAsync(new Error()); + + doctor._checkImap(function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.GENERIC_ERROR); + expect(error.underlyingError).to.exist; + expect(imapStub.login.calledOnce).to.be.true; + expect(imapStub.listWellKnownFolders.calledOnce).to.be.true; + expect(imapStub.logout.called).to.be.false; + + done(); + }); + }); + + it('should fail w/ auth rejected', function(done) { + doctor._checkImap(function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.AUTH_REJECTED); + expect(error.underlyingError).to.exist; + expect(imapStub.login.calledOnce).to.be.true; + expect(imapStub.listWellKnownFolders.called).to.be.false; + expect(imapStub.logout.called).to.be.false; + + done(); + }); + + setTimeout(function() { + // this error is thrown while we're waiting for the login + imapStub.onError(new Error()); + }, 50); + }); + }); + + describe('#_checkSmtp', function() { + it('should perform SMTP login, logout', function(done) { + doctor._checkSmtp(function(error) { + expect(error).to.not.exist; + expect(smtpStub.connect.calledOnce).to.be.true; + expect(smtpStub.quit.calledOnce).to.be.true; + + done(); + }); + + smtpStub.onidle(); + smtpStub.onclose(); + }); + + it('should fail w/ auth rejected', function(done) { + doctor._checkSmtp(function(error) { + expect(error).to.exist; + expect(error.code).to.equal(ConnectionDoctor.AUTH_REJECTED); + expect(error.underlyingError).to.exist; + expect(smtpStub.connect.calledOnce).to.be.true; + expect(smtpStub.quit.called).to.be.false; + + done(); + }); + + smtpStub.onerror(new Error()); + }); + }); + + describe('#check', function() { + beforeEach(function() { + sinon.stub(doctor, '_checkOnline'); + sinon.stub(doctor, '_checkReachable'); + sinon.stub(doctor, '_checkImap'); + sinon.stub(doctor, '_checkSmtp'); + }); + + it('should perform all tests', function(done) { + doctor._checkOnline.yieldsAsync(); + doctor._checkReachable.withArgs(credentials.imap).yieldsAsync(); + doctor._checkReachable.withArgs(credentials.smtp).yieldsAsync(); + doctor._checkImap.yieldsAsync(); + doctor._checkSmtp.yieldsAsync(); + + doctor.check(function(err) { + expect(err).to.not.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.calledTwice).to.be.true; + expect(doctor._checkImap.calledOnce).to.be.true; + expect(doctor._checkSmtp.calledOnce).to.be.true; + + done(); + }); + }); + + it('should fail for smtp', function(done) { + doctor._checkOnline.yieldsAsync(); + doctor._checkReachable.withArgs(credentials.imap).yieldsAsync(); + doctor._checkReachable.withArgs(credentials.smtp).yieldsAsync(); + doctor._checkImap.yieldsAsync(); + doctor._checkSmtp.yieldsAsync(new Error()); + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.calledTwice).to.be.true; + expect(doctor._checkImap.calledOnce).to.be.true; + expect(doctor._checkSmtp.calledOnce).to.be.true; + + done(); + }); + }); + + it('should fail for imap', function(done) { + doctor._checkOnline.yieldsAsync(); + doctor._checkReachable.withArgs(credentials.imap).yieldsAsync(); + doctor._checkReachable.withArgs(credentials.smtp).yieldsAsync(); + doctor._checkImap.yieldsAsync(new Error()); + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.calledTwice).to.be.true; + expect(doctor._checkImap.calledOnce).to.be.true; + expect(doctor._checkSmtp.called).to.be.false; + + done(); + }); + }); + + it('should fail for smtp reachability', function(done) { + doctor._checkOnline.yieldsAsync(); + doctor._checkReachable.withArgs(credentials.imap).yieldsAsync(); + doctor._checkReachable.withArgs(credentials.smtp).yieldsAsync(new Error()); + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.calledTwice).to.be.true; + expect(doctor._checkImap.called).to.be.false; + expect(doctor._checkSmtp.called).to.be.false; + + done(); + }); + }); + + it('should fail for imap reachability', function(done) { + doctor._checkOnline.yieldsAsync(); + doctor._checkReachable.withArgs(credentials.imap).yieldsAsync(new Error()); + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.calledOnce).to.be.true; + expect(doctor._checkImap.called).to.be.false; + expect(doctor._checkSmtp.called).to.be.false; + + done(); + }); + }); + + it('should fail for offline', function(done) { + doctor._checkOnline.yieldsAsync(new Error()); + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.calledOnce).to.be.true; + expect(doctor._checkReachable.called).to.be.false; + expect(doctor._checkImap.called).to.be.false; + expect(doctor._checkSmtp.called).to.be.false; + + done(); + }); + }); + + it('should fail w/o config', function(done) { + doctor.credentials = doctor._imap = doctor._smtp = undefined; + + doctor.check(function(err) { + expect(err).to.exist; + expect(doctor._checkOnline.called).to.be.false; + expect(doctor._checkReachable.called).to.be.false; + expect(doctor._checkImap.called).to.be.false; + expect(doctor._checkSmtp.called).to.be.false; + + done(); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/login-set-credentials-ctrl-test.js b/test/unit/login-set-credentials-ctrl-test.js index e39d248..41c1398 100644 --- a/test/unit/login-set-credentials-ctrl-test.js +++ b/test/unit/login-set-credentials-ctrl-test.js @@ -4,24 +4,29 @@ define(function(require) { var expect = chai.expect, angular = require('angular'), mocks = require('angularMocks'), - ImapClient = require('imap-client'), - SmtpClient = require('smtpclient'), + Auth = require('js/bo/auth'), + ConnectionDoctor = require('js/util/connection-doctor'), SetCredentialsCtrl = require('js/controller/login-set-credentials'), appController = require('js/app-controller'); describe('Login (Set Credentials) Controller unit test', function() { - var scope, location, setCredentialsCtrl; - var imap, smtp; - var origAuth; - var provider = 'providerproviderprovider'; + // Angular parameters + var scope, location, provider; + + // Stubs + var auth, origAuth, doctor, origDoctor; + + // SUT + var setCredentialsCtrl; beforeEach(function() { + // remeber pre-test state to restore later origAuth = appController._auth; - appController._auth = {}; - - imap = sinon.createStubInstance(ImapClient); - smtp = sinon.createStubInstance(SmtpClient); + origDoctor = appController._doctor; + auth = appController._auth = sinon.createStubInstance(Auth); + doctor = appController._doctor = sinon.createStubInstance(ConnectionDoctor); + // setup the controller angular.module('setcredentialstest', []); mocks.module('setcredentialstest'); mocks.inject(function($rootScope, $controller, $location) { @@ -40,14 +45,13 @@ define(function(require) { }); afterEach(function() { + // restore pre-test state appController._auth = origAuth; + appController._doctor = origDoctor; }); describe('set credentials', function() { - it('should work', function(done) { - var imapCert = 'imapcertimapcertimapcertimapcertimapcertimapcert', - smtpCert = 'smtpcertsmtpcertsmtpcertsmtpcertsmtpcertsmtpcert'; - + it('should work', function() { scope.emailAddress = 'emailemailemailemail'; scope.password = 'passwdpasswdpasswdpasswd'; scope.smtpHost = 'hosthosthost'; @@ -58,44 +62,39 @@ define(function(require) { scope.imapEncryption = '2'; // TLS scope.realname = 'peter pan'; - imap.login.yields(); - - appController._auth.setCredentials = function(args) { - expect(smtp.connect.calledOnce).to.be.true; - expect(imap.login.calledOnce).to.be.true; - - expect(args).to.deep.equal({ - provider: provider, - emailAddress: scope.emailAddress, - username: scope.username || scope.emailAddress, - realname: scope.realname, - password: scope.password, - imap: { - host: scope.imapHost.toLowerCase(), - port: scope.imapPort, - secure: true, - ignoreTLS: false, - ca: scope.imapCert, - pinned: false - }, - smtp: { - host: scope.smtpHost.toLowerCase(), - port: scope.smtpPort, - secure: false, - ignoreTLS: false, - ca: scope.smtpCert, - pinned: false - } - }); - done(); + var expectedCredentials = { + provider: provider, + emailAddress: scope.emailAddress, + username: scope.username || scope.emailAddress, + realname: scope.realname, + password: scope.password, + xoauth2: undefined, + imap: { + host: scope.imapHost.toLowerCase(), + port: scope.imapPort, + secure: true, + ignoreTLS: false, + ca: undefined, + pinned: false + }, + smtp: { + host: scope.smtpHost.toLowerCase(), + port: scope.smtpPort, + secure: false, + ignoreTLS: false, + ca: undefined, + pinned: false + } }; - scope.test(imap, smtp); + doctor.check.yields(); // synchronous yields! - imap.onCert(imapCert); - smtp.oncert(smtpCert); + scope.test(); - smtp.onidle(); + expect(doctor.check.calledOnce).to.be.true; + expect(doctor.configure.calledOnce).to.be.true; + expect(doctor.configure.calledWith(expectedCredentials)).to.be.true; + expect(auth.setCredentials.calledOnce).to.be.true; }); }); }); diff --git a/test/unit/main.js b/test/unit/main.js index e51935e..b889aba 100644 --- a/test/unit/main.js +++ b/test/unit/main.js @@ -103,7 +103,8 @@ function startTests() { 'test/unit/write-ctrl-test', 'test/unit/outbox-bo-test', 'test/unit/invitation-dao-test', - 'test/unit/update-handler-test' + 'test/unit/update-handler-test', + 'test/unit/connection-doctor-test' ], function() { //Tests loaded, run tests mocha.run();