[WO-57] Introduce encrypted outbox

The outbox is encrypted using the sender's keys. Prior
to sending, every mail is buffered in the outbox.
This commit is contained in:
Felix Hammerl 2013-11-21 17:37:07 +01:00
parent 0c6d279e82
commit 81a56a77c0
10 changed files with 386 additions and 92 deletions

View File

@ -155,7 +155,7 @@ define(function(require) {
*/ */
self.init = function(userId, token, callback) { self.init = function(userId, token, callback) {
var auth, imapOptions, smtpOptions, certificate, var auth, imapOptions, smtpOptions, certificate,
lawnchairDao, restDao, pubkeyDao, invitationDao, lawnchairDao, restDao, pubkeyDao, invitationDao, emailDao,
keychain, imapClient, smtpClient, pgp, userStorage, xhr; keychain, imapClient, smtpClient, pgp, userStorage, xhr;
// fetch pinned local ssl certificate // fetch pinned local ssl certificate
@ -211,10 +211,10 @@ define(function(require) {
smtpClient = new SmtpClient(smtpOptions); smtpClient = new SmtpClient(smtpOptions);
pgp = new PGP(); pgp = new PGP();
userStorage = new DeviceStorageDAO(lawnchairDao); userStorage = new DeviceStorageDAO(lawnchairDao);
self._emailDao = new EmailDAO(keychain, imapClient, smtpClient, pgp, userStorage); self._emailDao = emailDao = new EmailDAO(keychain, imapClient, smtpClient, pgp, userStorage);
invitationDao = new InvitationDAO(restDao); invitationDao = new InvitationDAO(restDao);
self._outboxBo = new OutboxBO(self._emailDao, invitationDao); self._outboxBo = new OutboxBO(emailDao, keychain, userStorage, invitationDao);
// init email dao // init email dao
var account = { var account = {

View File

@ -12,10 +12,29 @@ define(function(require) {
* The local outbox takes care of the emails before they are being sent. * The local outbox takes care of the emails before they are being sent.
* It also checks periodically if there are any mails in the local device storage to be sent. * It also checks periodically if there are any mails in the local device storage to be sent.
*/ */
var OutboxBO = function(emailDao, invitationDao) { var OutboxBO = function(email, keychain, devicestorage, invitation) {
this._emailDao = emailDao; /** @private */
this._invitationDao = invitationDao; this._email = email;
/** @private */
this._keychain = keychain;
/** @private */
this._devicestorage = devicestorage;
/** @private */
this._invitation = invitation;
/**
* Semaphore-esque flag to avoid 'concurrent' calls to _processOutbox when the timeout fires, but a call is still in process.
* @private */
this._outboxBusy = false; this._outboxBusy = false;
/**
* Pending, unsent emails stored in the outbox. Updated on each call to _processOutbox
* @public */
this.pendingEmails = [];
}; };
/** /**
@ -60,7 +79,7 @@ define(function(require) {
self._outboxBusy = true; self._outboxBusy = true;
// get last item from outbox // get last item from outbox
self._emailDao._devicestorage.listItems(dbType, 0, null, function(err, pending) { self._email.list(function(err, pending) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);
@ -70,6 +89,9 @@ define(function(require) {
// update outbox folder count // update outbox folder count
emails = pending; emails = pending;
// keep an independent shallow copy of the pending mails array in the member
self.pendingEmails = pending.slice();
// sending pending mails // sending pending mails
processMails(); processMails();
}); });
@ -80,12 +102,12 @@ define(function(require) {
if (emails.length === 0) { if (emails.length === 0) {
// in the navigation controller, this updates the folder count // in the navigation controller, this updates the folder count
self._outboxBusy = false; self._outboxBusy = false;
callback(null, 0); callback(null, self.pendingEmails.length);
return; return;
} }
// in the navigation controller, this updates the folder count // in the navigation controller, this updates the folder count
callback(null, emails.length); callback(null, self.pendingEmails.length);
var email = emails.shift(); var email = emails.shift();
checkReceivers(email); checkReceivers(email);
} }
@ -107,7 +129,7 @@ define(function(require) {
// find out if there are unregistered users // find out if there are unregistered users
email.to.forEach(function(recipient) { email.to.forEach(function(recipient) {
self._emailDao._keychain.getReceiverPublicKey(recipient.address, function(err, key) { self._keychain.getReceiverPublicKey(recipient.address, function(err, key) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);
@ -125,7 +147,7 @@ define(function(require) {
// invite the unregistered receivers, if necessary // invite the unregistered receivers, if necessary
function invite(addresses) { function invite(addresses) {
var sender = self._emailDao._account.emailAddress; var sender = self._email._account.emailAddress;
var invitationFinished = _.after(addresses.length, function() { var invitationFinished = _.after(addresses.length, function() {
// after all of the invitations are checked and sent (if necessary), // after all of the invitations are checked and sent (if necessary),
@ -136,7 +158,7 @@ define(function(require) {
addresses.forEach(function(recipient) { addresses.forEach(function(recipient) {
var recipientAddress = recipient.address; var recipientAddress = recipient.address;
self._invitationDao.check({ self._invitation.check({
recipient: recipientAddress, recipient: recipientAddress,
sender: sender sender: sender
}, function(err, status) { }, function(err, status) {
@ -153,7 +175,7 @@ define(function(require) {
} }
// the recipient is not yet invited, so let's do that // the recipient is not yet invited, so let's do that
self._invitationDao.invite({ self._invitation.invite({
recipient: recipientAddress, recipient: recipientAddress,
sender: sender sender: sender
}, function(err, status) { }, function(err, status) {
@ -187,7 +209,7 @@ define(function(require) {
}; };
// send invitation mail // send invitation mail
self._emailDao.send(invitationMail, function(err) { self._email.send(invitationMail, function(err) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);
@ -199,7 +221,8 @@ define(function(require) {
} }
function sendEncrypted(email) { function sendEncrypted(email) {
self._emailDao.encryptedSend(email, function(err) { removeFromPendingMails(email);
self._email.encryptedSend(email, function(err) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);
@ -210,6 +233,12 @@ define(function(require) {
}); });
} }
// update the member so that the outbox can visualize
function removeFromPendingMails(email) {
var i = self.pendingEmails.indexOf(email);
self.pendingEmails.splice(i, 1);
}
function removeFromStorage(id) { function removeFromStorage(id) {
if (!id) { if (!id) {
self._outboxBusy = false; self._outboxBusy = false;
@ -221,7 +250,7 @@ define(function(require) {
// delete email from local storage // delete email from local storage
var key = dbType + '_' + id; var key = dbType + '_' + id;
self._emailDao._devicestorage.removeList(key, function(err) { self._devicestorage.removeList(key, function(err) {
if (err) { if (err) {
self._outboxBusy = false; self._outboxBusy = false;
callback(err); callback(err);

View File

@ -7,7 +7,7 @@ define(function(require) {
IScroll = require('iscroll'), IScroll = require('iscroll'),
str = require('js/app-config').string, str = require('js/app-config').string,
cfg = require('js/app-config').config, cfg = require('js/app-config').config,
emailDao; emailDao, outboxBo;
var MailListCtrl = function($scope) { var MailListCtrl = function($scope) {
var offset = 0, var offset = 0,
@ -19,6 +19,8 @@ define(function(require) {
// //
emailDao = appController._emailDao; emailDao = appController._emailDao;
outboxBo = appController._outboxBo;
if (emailDao) { if (emailDao) {
emailDao.onIncomingMessage = function(email) { emailDao.onIncomingMessage = function(email) {
if (email.subject.indexOf(str.subjectPrefix) === -1) { if (email.subject.indexOf(str.subjectPrefix) === -1) {
@ -67,6 +69,13 @@ define(function(require) {
}; };
$scope.synchronize = function(callback) { $scope.synchronize = function(callback) {
// if we're in the outbox, don't do an imap sync
if (getFolder().type === 'Outbox') {
updateStatus('Last update: ', new Date());
displayEmails(outboxBo.pendingEmails);
return;
}
updateStatus('Syncing ...'); updateStatus('Syncing ...');
// sync from imap to local db // sync from imap to local db
syncImapFolder({ syncImapFolder({
@ -93,13 +102,26 @@ define(function(require) {
return; return;
} }
var index, trashFolder; var index, currentFolder, trashFolder, outboxFolder;
currentFolder = getFolder();
trashFolder = _.findWhere($scope.folders, { trashFolder = _.findWhere($scope.folders, {
type: 'Trash' type: 'Trash'
}); });
if (getFolder() === trashFolder) { outboxFolder = _.findWhere($scope.folders, {
type: 'Outbox'
});
if (currentFolder === outboxFolder) {
$scope.onError({
errMsg: 'Deleting messages from the outbox is not yet supported.'
});
return;
}
if (currentFolder === trashFolder) {
$scope.state.dialog = { $scope.state.dialog = {
open: true, open: true,
title: 'Delete', title: 'Delete',
@ -159,22 +181,44 @@ define(function(require) {
} }
}; };
$scope.$watch('state.nav.currentFolder', function() { $scope._stopWatchTask = $scope.$watch('state.nav.currentFolder', function() {
if (!getFolder()) { if (!getFolder()) {
return; return;
} }
// production... in chrome packaged app // development... display dummy mail objects
if (window.chrome && chrome.identity) { if (!window.chrome || !chrome.identity) {
initList(); firstSelect = true;
updateStatus('Last update: ', new Date());
$scope.emails = createDummyMails();
$scope.select($scope.emails[0]);
return; return;
} }
// development... display dummy mail objects // production... in chrome packaged app
firstSelect = true;
updateStatus('Last update: ', new Date()); // if we're in the outbox, read directly from there.
$scope.emails = createDummyMails(); if (getFolder().type === 'Outbox') {
$scope.select($scope.emails[0]); updateStatus('Last update: ', new Date());
displayEmails(outboxBo.pendingEmails);
return;
}
updateStatus('Read cache ...');
// list messaged from local db
listLocalMessages({
folder: getFolder().path,
offset: offset,
num: num
}, function sync() {
updateStatus('Syncing ...');
$scope.$apply();
// sync imap folder to local db
$scope.synchronize();
});
}); });
// share local scope functions with root state // share local scope functions with root state
@ -196,23 +240,6 @@ define(function(require) {
}, function() {}); }, function() {});
} }
function initList() {
updateStatus('Read cache ...');
// list messaged from local db
listLocalMessages({
folder: getFolder().path,
offset: offset,
num: num
}, function sync() {
updateStatus('Syncing ...');
$scope.$apply();
// sync imap folder to local db
$scope.synchronize();
});
}
function syncImapFolder(options, callback) { function syncImapFolder(options, callback) {
emailDao.unreadMessages(getFolder().path, function(err, unreadCount) { emailDao.unreadMessages(getFolder().path, function(err, unreadCount) {
if (err) { if (err) {
@ -270,7 +297,12 @@ define(function(require) {
$scope.emails = emails; $scope.emails = emails;
$scope.select($scope.emails[0]); $scope.select($scope.emails[0]);
$scope.$apply();
// syncing from the outbox is a synchronous call, so we mustn't call $scope.$apply
// for every other IMAP folder, this call is asynchronous, hence we have to call $scope.$apply...
if (getFolder().type !== 'Outbox') {
$scope.$apply();
}
} }
function getFolder() { function getFolder() {
@ -278,6 +310,11 @@ define(function(require) {
} }
function markAsRead(email) { function markAsRead(email) {
// marking mails as read is meaningless in the outbox
if (getFolder().type === 'Outbox') {
return;
}
// don't mark top selected email automatically // don't mark top selected email automatically
if (firstSelect) { if (firstSelect) {
firstSelect = false; firstSelect = false;

View File

@ -79,7 +79,7 @@ define(function(require) {
// start checking outbox periodically // start checking outbox periodically
outboxBo.startChecking($scope.onOutboxUpdate); outboxBo.startChecking($scope.onOutboxUpdate);
// make function available globally for write controller // make function available globally for write controller
$scope.emptyOutbox = outboxBo._processOutbox; $scope.emptyOutbox = outboxBo._processOutbox.bind(outboxBo);
callback(folders); callback(folders);
$scope.$apply(); $scope.$apply();

View File

@ -165,9 +165,7 @@ define(function(require) {
}); });
}); });
// set an id for the email and store in outbox emailDao.store(email, function(err) {
email.id = util.UUID();
emailDao._devicestorage.storeList([email], 'email_OUTBOX', function(err) {
if (err) { if (err) {
$scope.onError(err); $scope.onError(err);
return; return;

View File

@ -592,17 +592,27 @@ define(function(require) {
} }
// public key found... encrypt and send // public key found... encrypt and send
self.encryptForUser(email, receiverPubkey.publicKey, callback); self.encryptForUser({
email: email,
receiverPubkey: receiverPubkey.publicKey
}, function(err, email) {
if (err) {
callback(err);
return;
}
self.send(email, callback);
});
}); });
}; };
/** /**
* Encrypt an email asymmetrically for an exisiting user with their public key * Encrypt an email asymmetrically for an exisiting user with their public key
*/ */
EmailDAO.prototype.encryptForUser = function(email, receiverPubkey, callback) { EmailDAO.prototype.encryptForUser = function(options, callback) {
var self = this, var self = this,
pt = email.body, pt = options.email.body,
receiverPubkeys = [receiverPubkey]; receiverPubkeys = options.receiverPubkey ? [options.receiverPubkey] : [];
// get own public key so send message can be read // get own public key so send message can be read
self._crypto.exportKeys(function(err, ownKeys) { self._crypto.exportKeys(function(err, ownKeys) {
@ -621,9 +631,8 @@ define(function(require) {
} }
// bundle encrypted email together for sending // bundle encrypted email together for sending
frameEncryptedMessage(email, ct); frameEncryptedMessage(options.email, ct);
callback(null, options.email);
self.send(email, callback);
}); });
}); });
}; };
@ -631,7 +640,6 @@ define(function(require) {
/** /**
* Frames an encrypted message in base64 Format. * Frames an encrypted message in base64 Format.
*/ */
function frameEncryptedMessage(email, ct) { function frameEncryptedMessage(email, ct) {
var to, greeting; var to, greeting;
@ -645,8 +653,6 @@ define(function(require) {
// build encrypted text body // build encrypted text body
email.body = greeting + MESSAGE + ct + SIGNATURE; email.body = greeting + MESSAGE + ct + SIGNATURE;
email.subject = str.subjectPrefix + email.subject; email.subject = str.subjectPrefix + email.subject;
return email;
} }
/** /**
@ -658,5 +664,63 @@ define(function(require) {
self._smtpClient.send(email, callback); self._smtpClient.send(email, callback);
}; };
EmailDAO.prototype.store = function(email, callback) {
var self = this,
dbType = 'email_OUTBOX';
email.id = util.UUID();
// encrypt
self.encryptForUser({
email: email
}, function(err, email) {
if (err) {
callback(err);
return;
}
// store to local storage
self._devicestorage.storeList([email], dbType, callback);
});
};
EmailDAO.prototype.list = function(callback) {
var self = this,
dbType = 'email_OUTBOX';
self._devicestorage.listItems(dbType, 0, null, function(err, mails) {
if (err) {
callback(err);
return;
}
if (mails.length === 0) {
callback(null, []);
return;
}
self._crypto.exportKeys(function(err, ownKeys) {
if (err) {
callback(err);
return;
}
var after = _.after(mails.length, function() {
callback(null, mails);
});
mails.forEach(function(mail) {
mail.body = str.cryptPrefix + mail.body.split(str.cryptPrefix)[1].split(str.cryptSuffix)[0] + str.cryptSuffix;
self._crypto.decrypt(mail.body, ownKeys.publicKeyArmored, function(err, decrypted) {
mail.body = err ? err.errMsg : decrypted;
mail.subject = mail.subject.split(str.subjectPrefix)[1];
after();
});
});
});
});
};
return EmailDAO; return EmailDAO;
}); });

View File

@ -785,6 +785,55 @@ define(function(require) {
}); });
}); });
describe('store', function() {
it('should work', function(done) {
pgpStub.exportKeys.yields(null, {
publicKeyArmored: 'omgsocrypto'
});
pgpStub.encrypt.yields(null, 'asdfasfd');
devicestorageStub.storeList.yields();
emailDao.store(dummyMail, function(err) {
expect(err).to.not.exist;
expect(pgpStub.exportKeys.calledOnce).to.be.true;
expect(pgpStub.encrypt.calledOnce).to.be.true;
expect(devicestorageStub.storeList.calledOnce).to.be.true;
done();
});
});
});
describe('list', function() {
it('should work', function(done) {
devicestorageStub.listItems.yields(null, [{
body: app.string.cryptPrefix + btoa('asdf') + app.string.cryptSuffix,
subject: '[whiteout] ZOMG!'
}, {
body: app.string.cryptPrefix + btoa('asdf') + app.string.cryptSuffix,
subject: '[whiteout] WTF!'
}]);
pgpStub.exportKeys.yields(null, {
publicKeyArmored: 'omgsocrypto'
});
pgpStub.decrypt.yields(null, 'asdfasfd');
emailDao.list(function(err, mails) {
expect(err).to.not.exist;
expect(devicestorageStub.listItems.calledOnce).to.be.true;
expect(pgpStub.exportKeys.calledOnce).to.be.true;
expect(pgpStub.decrypt.calledTwice).to.be.true;
expect(mails.length).to.equal(2);
expect(mails[0].body).to.equal('asdfasfd');
expect(mails[0].subject).to.equal('ZOMG!');
expect(mails[1].body).to.equal('asdfasfd');
expect(mails[1].subject).to.equal('WTF!');
done();
});
});
});
}); });
}); });

View File

@ -12,7 +12,7 @@ define(function(require) {
describe('Mail List controller unit test', function() { describe('Mail List controller unit test', function() {
var scope, ctrl, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock, var scope, ctrl, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock,
emailAddress, notificationClickedHandler, emailAddress, notificationClickedHandler, emails,
hasChrome, hasNotifications, hasSocket, hasRuntime, hasIdentity; hasChrome, hasNotifications, hasSocket, hasRuntime, hasIdentity;
beforeEach(function() { beforeEach(function() {
@ -44,6 +44,18 @@ define(function(require) {
if (!hasIdentity) { if (!hasIdentity) {
window.chrome.identity = {}; window.chrome.identity = {};
} }
emails = [{
unread: true
}, {
unread: true
}, {
unread: true
}];
appController._outboxBo = {
pendingEmails: emails
};
origEmailDao = appController._emailDao; origEmailDao = appController._emailDao;
emailDaoMock = sinon.createStubInstance(EmailDAO); emailDaoMock = sinon.createStubInstance(EmailDAO);
appController._emailDao = emailDaoMock; appController._emailDao = emailDaoMock;
@ -86,7 +98,7 @@ define(function(require) {
if (!hasIdentity) { if (!hasIdentity) {
delete window.chrome.identity; delete window.chrome.identity;
} }
// restore the module // restore the module
appController._emailDao = origEmailDao; appController._emailDao = origEmailDao;
}); });
@ -105,6 +117,8 @@ define(function(require) {
it('should focus mail and not mark it read', function(done) { it('should focus mail and not mark it read', function(done) {
var uid, mail, currentFolder; var uid, mail, currentFolder;
scope._stopWatchTask();
uid = 123; uid = 123;
mail = { mail = {
uid: uid, uid: uid,
@ -134,7 +148,6 @@ define(function(require) {
expect(opts.type).to.equal('basic'); expect(opts.type).to.equal('basic');
expect(opts.message).to.equal('asdasd'); expect(opts.message).to.equal('asdasd');
expect(opts.title).to.equal('asd'); expect(opts.title).to.equal('asd');
expect(scope.state.mailList.selected).to.deep.equal(mail);
expect(emailDaoMock.imapMarkMessageRead.callCount).to.equal(0); expect(emailDaoMock.imapMarkMessageRead.callCount).to.equal(0);
done(); done();
}; };
@ -147,6 +160,8 @@ define(function(require) {
it('should focus mail and mark it read', function() { it('should focus mail and mark it read', function() {
var uid, mail, currentFolder; var uid, mail, currentFolder;
scope._stopWatchTask();
uid = 123; uid = 123;
mail = { mail = {
uid: uid, uid: uid,
@ -172,10 +187,62 @@ define(function(require) {
notificationClickedHandler('123'); // first select, irrelevant notificationClickedHandler('123'); // first select, irrelevant
notificationClickedHandler('123'); notificationClickedHandler('123');
expect(scope.state.mailList.selected).to.deep.equal(mail); expect(scope.state.mailList.selected).to.equal(mail);
expect(emailDaoMock.imapMarkMessageRead.callCount).to.be.at.least(1); expect(emailDaoMock.imapMarkMessageRead.callCount).to.be.at.least(1);
}); });
}); });
describe('watch task', function() {
it('should do a local list and a full imap sync and mark the first message read', function(done) {
emailDaoMock.unreadMessages.yields(null, 3);
emailDaoMock.imapSync.yields();
emailDaoMock.listMessages.yieldsAsync(null, emails);
scope.state.read = {
toggle: function() {}
};
var currentFolder = {
type: 'Inbox'
};
scope.folders = [currentFolder];
scope.state.nav = {
currentFolder: currentFolder
};
// the behavior should be async and imapMarkMessageRead is
emailDaoMock.imapMarkMessageRead = function() {
expect(scope.emails).to.deep.equal(emails);
expect(scope.state.mailList.selected).to.equal(emails[0]);
expect(emailDaoMock.unreadMessages.callCount).to.equal(2);
expect(emailDaoMock.imapSync.callCount).to.equal(2);
expect(emailDaoMock.listMessages.callCount).to.equal(3);
done();
};
scope.synchronize();
});
});
describe('synchronize', function() {
it('should read directly from outbox instead of doing a full imap sync', function() {
scope._stopWatchTask();
var currentFolder = {
type: 'Outbox'
};
scope.folders = [currentFolder];
scope.state.nav = {
currentFolder: currentFolder
};
scope.synchronize();
expect(scope.state.mailList.selected).to.equal(emails[0]);
});
});
describe('remove', function() { describe('remove', function() {
it('should not delete without a selected mail', function() { it('should not delete without a selected mail', function() {
scope.remove(); scope.remove();
@ -183,9 +250,36 @@ define(function(require) {
expect(emailDaoMock.imapDeleteMessage.called).to.be.false; expect(emailDaoMock.imapDeleteMessage.called).to.be.false;
}); });
it('should not delete from the outbox', function(done) {
var currentFolder, mail;
scope._stopWatchTask();
mail = {};
currentFolder = {
type: 'Outbox'
};
scope.emails = [mail];
scope.folders = [currentFolder];
scope.state.nav = {
currentFolder: currentFolder
};
scope.onError = function(err) {
expect(err).to.exist; // would normally display the notification
expect(emailDaoMock.imapDeleteMessage.called).to.be.false;
done();
};
scope.remove(mail);
});
it('should delete the selected mail from trash folder after clicking ok', function() { it('should delete the selected mail from trash folder after clicking ok', function() {
var uid, mail, currentFolder; var uid, mail, currentFolder;
scope._stopWatchTask();
uid = 123; uid = 123;
mail = { mail = {
uid: uid, uid: uid,
@ -215,6 +309,8 @@ define(function(require) {
it('should move the selected mail to the trash folder', function() { it('should move the selected mail to the trash folder', function() {
var uid, mail, currentFolder, trashFolder; var uid, mail, currentFolder, trashFolder;
scope._stopWatchTask();
uid = 123; uid = 123;
mail = { mail = {
uid: uid, uid: uid,

View File

@ -18,10 +18,10 @@ define(function(require) {
emailDaoStub._account = { emailDaoStub._account = {
emailAddress: dummyUser emailAddress: dummyUser
}; };
emailDaoStub._devicestorage = devicestorageStub = sinon.createStubInstance(DeviceStorageDAO); devicestorageStub = sinon.createStubInstance(DeviceStorageDAO);
emailDaoStub._keychain = keychainStub = sinon.createStubInstance(KeychainDAO); keychainStub = sinon.createStubInstance(KeychainDAO);
invitationDaoStub = sinon.createStubInstance(InvitationDAO); invitationDaoStub = sinon.createStubInstance(InvitationDAO);
outbox = new OutboxBO(emailDaoStub, invitationDaoStub); outbox = new OutboxBO(emailDaoStub, keychainStub, devicestorageStub, invitationDaoStub);
}); });
afterEach(function() {}); afterEach(function() {});
@ -29,9 +29,12 @@ define(function(require) {
describe('init', function() { describe('init', function() {
it('should work', function() { it('should work', function() {
expect(outbox).to.exist; expect(outbox).to.exist;
expect(outbox._emailDao).to.equal(emailDaoStub); expect(outbox._email).to.equal(emailDaoStub);
expect(outbox._invitationDao).to.equal(invitationDaoStub); expect(outbox._keychain).to.equal(keychainStub);
expect(outbox._devicestorage).to.equal(devicestorageStub);
expect(outbox._invitation).to.equal(invitationDaoStub);
expect(outbox._outboxBusy).to.be.false; expect(outbox._outboxBusy).to.be.false;
expect(outbox.pendingEmails).to.be.empty;
}); });
}); });
@ -50,50 +53,71 @@ define(function(require) {
}); });
describe('process outbox', function() { describe('process outbox', function() {
it('should work', function(done) { it('should send to registered users and update pending mails', function(done) {
var dummyMails = [{ var member, invited, notinvited, dummyMails, unsentCount;
member = {
id: '123', id: '123',
to: [{ to: [{
name: 'member', name: 'member',
address: 'member@whiteout.io' address: 'member@whiteout.io'
}] }]
}, { };
invited = {
id: '456', id: '456',
to: [{ to: [{
name: 'invited', name: 'invited',
address: 'invited@whiteout.io' address: 'invited@whiteout.io'
}] }]
}, { };
notinvited = {
id: '789', id: '789',
to: [{ to: [{
name: 'notinvited', name: 'notinvited',
address: 'notinvited@whiteout.io' address: 'notinvited@whiteout.io'
}] }]
}]; };
dummyMails = [member, invited, notinvited];
devicestorageStub.listItems.yieldsAsync(null, dummyMails); emailDaoStub.list.yieldsAsync(null, dummyMails);
emailDaoStub.encryptedSend.yieldsAsync(); emailDaoStub.encryptedSend.yieldsAsync();
emailDaoStub.send.yieldsAsync(); emailDaoStub.send.yieldsAsync();
devicestorageStub.removeList.yieldsAsync(); devicestorageStub.removeList.yieldsAsync();
invitationDaoStub.check.withArgs(sinon.match(function(o) { return o.recipient === 'invited@whiteout.io'; })).yieldsAsync(null, InvitationDAO.INVITE_PENDING); invitationDaoStub.check.withArgs(sinon.match(function(o) {
invitationDaoStub.check.withArgs(sinon.match(function(o) { return o.recipient === 'notinvited@whiteout.io'; })).yieldsAsync(null, InvitationDAO.INVITE_MISSING); return o.recipient === 'invited@whiteout.io';
invitationDaoStub.invite.withArgs(sinon.match(function(o) { return o.recipient === 'notinvited@whiteout.io'; })).yieldsAsync(null, InvitationDAO.INVITE_SUCCESS); })).yieldsAsync(null, InvitationDAO.INVITE_PENDING);
keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) { return o === 'member@whiteout.io'; })).yieldsAsync(null, 'this is not the key you are looking for...'); invitationDaoStub.check.withArgs(sinon.match(function(o) {
keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) { return o === 'invited@whiteout.io' || o === 'notinvited@whiteout.io'; })).yieldsAsync(); return o.recipient === 'notinvited@whiteout.io';
})).yieldsAsync(null, InvitationDAO.INVITE_MISSING);
invitationDaoStub.invite.withArgs(sinon.match(function(o) {
return o.recipient === 'notinvited@whiteout.io';
})).yieldsAsync(null, InvitationDAO.INVITE_SUCCESS);
keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) {
return o === 'member@whiteout.io';
})).yieldsAsync(null, 'this is not the key you are looking for...');
keychainStub.getReceiverPublicKey.withArgs(sinon.match(function(o) {
return o === 'invited@whiteout.io' || o === 'notinvited@whiteout.io';
})).yieldsAsync();
var check = _.after(dummyMails.length + 1, function() { var check = _.after(dummyMails.length + 1, function() {
expect(devicestorageStub.listItems.callCount).to.equal(1); expect(unsentCount).to.equal(2);
expect(emailDaoStub.list.callCount).to.equal(1);
expect(emailDaoStub.encryptedSend.callCount).to.equal(1); expect(emailDaoStub.encryptedSend.callCount).to.equal(1);
expect(emailDaoStub.send.callCount).to.equal(1); expect(emailDaoStub.send.callCount).to.equal(1);
expect(devicestorageStub.removeList.callCount).to.equal(1); expect(devicestorageStub.removeList.callCount).to.equal(1);
expect(invitationDaoStub.check.callCount).to.equal(2); expect(invitationDaoStub.check.callCount).to.equal(2);
expect(invitationDaoStub.invite.callCount).to.equal(1); expect(invitationDaoStub.invite.callCount).to.equal(1);
expect(outbox.pendingEmails.length).to.equal(2);
expect(outbox.pendingEmails).to.contain(invited);
expect(outbox.pendingEmails).to.contain(notinvited);
done(); done();
}); });
function onOutboxUpdate(err, count) { function onOutboxUpdate(err, count) {
expect(err).to.not.exist; expect(err).to.not.exist;
expect(count).to.exist; expect(count).to.exist;
unsentCount = count;
check(); check();
} }

View File

@ -6,17 +6,17 @@ define(function(require) {
mocks = require('angularMocks'), mocks = require('angularMocks'),
WriteCtrl = require('js/controller/write'), WriteCtrl = require('js/controller/write'),
EmailDAO = require('js/dao/email-dao'), EmailDAO = require('js/dao/email-dao'),
DeviceStorageDAO = require('js/dao/devicestorage-dao'),
KeychainDAO = require('js/dao/keychain-dao'), KeychainDAO = require('js/dao/keychain-dao'),
appController = require('js/app-controller'); appController = require('js/app-controller');
describe('Write controller unit test', function() { describe('Write controller unit test', function() {
var ctrl, scope, origEmailDao, emailDaoMock, keychainMock, deviceStorageMock, emailAddress; var ctrl, scope, origEmailDao, emailDaoMock, keychainMock, emailAddress;
beforeEach(function() { beforeEach(function() {
origEmailDao = appController._emailDao; origEmailDao = appController._emailDao;
emailDaoMock = sinon.createStubInstance(EmailDAO); emailDaoMock = sinon.createStubInstance(EmailDAO);
appController._emailDao = emailDaoMock; appController._emailDao = emailDaoMock;
emailAddress = 'fred@foo.com'; emailAddress = 'fred@foo.com';
emailDaoMock._account = { emailDaoMock._account = {
emailAddress: emailAddress, emailAddress: emailAddress,
@ -25,9 +25,6 @@ define(function(require) {
keychainMock = sinon.createStubInstance(KeychainDAO); keychainMock = sinon.createStubInstance(KeychainDAO);
emailDaoMock._keychain = keychainMock; emailDaoMock._keychain = keychainMock;
deviceStorageMock = sinon.createStubInstance(DeviceStorageDAO);
emailDaoMock._devicestorage = deviceStorageMock;
angular.module('writetest', []); angular.module('writetest', []);
mocks.module('writetest'); mocks.module('writetest');
mocks.inject(function($rootScope, $controller) { mocks.inject(function($rootScope, $controller) {
@ -174,12 +171,12 @@ define(function(require) {
scope.subject = 'yaddablabla'; scope.subject = 'yaddablabla';
scope.toKey = 'Public Key'; scope.toKey = 'Public Key';
deviceStorageMock.storeList.withArgs(sinon.match(function(mail) { emailDaoMock.store.withArgs(sinon.match(function(mail) {
return mail[0].from[0].address === emailAddress && mail[0].to.length === 3; return mail.from[0].address === emailAddress && mail.to.length === 3;
})).yieldsAsync(); })).yieldsAsync();
scope.emptyOutbox = function() { scope.emptyOutbox = function() {
expect(scope.state.writer.open).to.be.false; expect(scope.state.writer.open).to.be.false;
expect(deviceStorageMock.storeList.calledOnce).to.be.true; expect(emailDaoMock.store.calledOnce).to.be.true;
done(); done();
}; };
@ -193,8 +190,8 @@ define(function(require) {
scope.subject = 'yaddablabla'; scope.subject = 'yaddablabla';
scope.toKey = 'Public Key'; scope.toKey = 'Public Key';
deviceStorageMock.storeList.withArgs(sinon.match(function(mail) { emailDaoMock.store.withArgs(sinon.match(function(mail) {
return mail[0].from[0].address === emailAddress && mail[0].to.length === 3; return mail.from[0].address === emailAddress && mail.to.length === 3;
})).yields({ })).yields({
errMsg: 'snafu' errMsg: 'snafu'
}); });
@ -202,7 +199,7 @@ define(function(require) {
scope.onError = function(err) { scope.onError = function(err) {
expect(err).to.exist; expect(err).to.exist;
expect(scope.state.writer.open).to.be.true; expect(scope.state.writer.open).to.be.true;
expect(deviceStorageMock.storeList.calledOnce).to.be.true; expect(emailDaoMock.store.calledOnce).to.be.true;
done(); done();
}; };