diff --git a/app.js b/app.js index aca80a4b5..b128eb283 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ const path = require('path'); const helmet = require('helmet'); const passport = require('./services/passport'); const session = require('express-session'); +const enabled = require('debug').enabled; const RedisStore = require('connect-redis')(session); const redis = require('./services/redis'); const csrf = require('csurf'); @@ -120,7 +121,7 @@ app.use((req, res, next) => { // returning a status code that makes sense. app.use('/api', (err, req, res, next) => { if (err !== ErrNotFound) { - if (app.get('env') !== 'test') { + if (app.get('env') !== 'test' || enabled('talk:errors')) { console.error(err); } } diff --git a/routes/api/account/index.js b/routes/api/account/index.js index 645b9c652..4db4f6e7f 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -73,7 +73,7 @@ router.post('/password/reset', (req, res, next) => { token, rootURL: process.env.TALK_ROOT_URL }, - subject: 'Password Reset Requested - Talk', + subject: 'Password Reset', to: email }); }) diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 79e28c0b7..5dc71264d 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -80,6 +80,34 @@ router.post('/:user_id/email', authorization.needed('admin'), (req, res, next) = .catch(next); }); +// /** +// * SendEmailConfirmation sends a confirmation email to the user. +// * @param {Request} req express request object +// * @param {String} email user email address +// */ + +/** + * SendEmailConfirmation sends a confirmation email to the user. + * @param {ExpressApp} app the instance of the express app + * @param {String} userID the id for the user to send the email to + * @param {String} email the email for the user to send the email to + */ +const SendEmailConfirmation = (app, userID, email) => User + .createEmailConfirmToken(userID, email) + .then((token) => { + return mailer.sendSimple({ + app, // needed to render the templates. + template: 'email/email-confirm', // needed to know which template to render! + locals: { // specifies the template locals. + token, + rootURL: process.env.TALK_ROOT_URL, + email + }, + subject: 'Email Confirmation', + to: email + }); + }); + router.post('/', (req, res, next) => { const { email, @@ -98,23 +126,7 @@ router.post('/', (req, res, next) => { if (requireEmailConfirmation) { - // Email confirmation is required, let's generate that token and send - // the email. - return User - .createEmailConfirmToken(user.id, email) - .then((token) => { - return mailer.sendSimple({ - app: req.app, // needed to render the templates. - template: 'email/email-confirm', // needed to know which template to render! - locals: { // specifies the template locals. - token, - rootURL: process.env.TALK_ROOT_URL, - email - }, - subject: 'Email Confirmation - Talk', - to: email - }); - }) + SendEmailConfirmation(req.app, user.id, email) .then(() => { // Then send back the user. @@ -158,4 +170,37 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { }); }); +router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => { + const { + user_id + } = req.params; + + User + .findById(user_id) + .then((user) => { + if (!user) { + res.status(404).end(); + return; + } + + // Find the first local profile. + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + + // If there was no local profile for the user, error out. + if (!localProfile) { + res.status(404).end(); + return; + } + + // Send the email to the first local profile that was found. + return SendEmailConfirmation(req.app, user.id, localProfile.id) + .then(() => { + res.status(204).end(); + }); + }) + .catch((err) => { + next(err); + }); +}); + module.exports = router; diff --git a/services/kue.js b/services/kue.js index e9fbe43e8..ac299a3d5 100644 --- a/services/kue.js +++ b/services/kue.js @@ -14,7 +14,7 @@ const Queue = module.exports.queue = kue.createQueue({ } }); -module.exports.Task = class Task { +class Task { constructor({name, attempts = 3, delay = 1000}) { this.name = name; @@ -76,4 +76,59 @@ module.exports.Task = class Task { }); }); } -}; +} + +/** + * Stores the tasks during testing. + * @type {Array} + */ +const TestQueue = []; + +/** + * TestTask is a Task queue that is implemented for when the application is in + * test mode, and does not send the jobs to redis, instead it queues them in + * an array which can be inspected. + */ +class TestTask { + + constructor({name}) { + this.name = name; + } + + /** + * Push the task into the fake queue. + */ + create(task) { + let id = TestQueue.push({ + name: this.name, + task + }); + + return Promise.resolve({id}); + } + + // This is a NO-OP action simply provided to match the Task interface. + process() { return null; } + + /** + * Returns the current tasks for this queue. + * @return {Array} the tasks in the queue + */ + get tasks() { + return TestQueue + .filter((testTask) => testTask.name === this.name) + .map((testTask) => testTask.task); + } + + static shutdown() { + return Task.shutdown(); + } + +} + +if (process.env.NODE_ENV === 'test') { + module.exports.Task = TestTask; + module.exports.TestQueue = TestQueue; +} else { + module.exports.Task = Task; +} diff --git a/services/mailer.js b/services/mailer.js index 6fef876d6..6615ee9dc 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -65,13 +65,16 @@ const mailer = module.exports = { return Promise.reject('sendSimple requires a subject for the email'); } + // Prefix the subject with `[Talk]`. + subject = `[Talk] ${subject}`; + return Promise.all([ // Render the HTML version of the email. - mailer.render(app, template, locals), + mailer.render(app, `${template}.ejs`, locals), // Render the TEXT version of the email. - mailer.render(app, `${template}.txt`, locals) + mailer.render(app, `${template}.txt.ejs`, locals) ]) .then(([html, text]) => { diff --git a/tests/kue.js b/tests/kue.js new file mode 100644 index 000000000..504760b82 --- /dev/null +++ b/tests/kue.js @@ -0,0 +1,7 @@ +const kue = require('../services/kue'); + +beforeEach(() => { + + // Empty the test tasks before finishing. + kue.TestQueue.splice(0, kue.TestQueue.length); +}); diff --git a/tests/routes/api/user/index.js b/tests/routes/api/user/index.js index 01c48d2de..a7cd43dc9 100644 --- a/tests/routes/api/user/index.js +++ b/tests/routes/api/user/index.js @@ -1,6 +1,7 @@ const passport = require('../../../passport'); const app = require('../../../../app'); +const mailer = require('../../../../services/mailer'); const chai = require('chai'); const expect = chai.expect; @@ -10,6 +11,39 @@ chai.use(require('chai-http')); const User = require('../../../../models/user'); +describe('/api/v1/users/:user_id/email/confirm', () => { + + let mockUser; + + beforeEach(() => User.createLocalUser('ana@gmail.com', '123', 'Ana').then((user) => { + mockUser = user; + })); + + describe('#post', () => { + it('should send an email when we hit the endpoint', () => { + expect(mailer.task.tasks).to.have.length(0); + + return chai.request(app) + .post(`/api/v1/users/${mockUser.id}/email/confirm`) + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(204); + expect(mailer.task.tasks).to.have.length(1); + }); + }); + + it('should send a 404 on not matching a user', () => { + return chai.request(app) + .post(`/api/v1/users/${mockUser.id}/email/confirm`) + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(204); + expect(mailer.task.tasks).to.have.length(1); + }); + }); + }); +}); + describe('/api/v1/users/:user_id/actions', () => { const users = [{