diff --git a/app.js b/app.js index d230596c0..aca80a4b5 100644 --- a/app.js +++ b/app.js @@ -7,7 +7,7 @@ const passport = require('./services/passport'); const session = require('express-session'); const RedisStore = require('connect-redis')(session); const redis = require('./services/redis'); -const cookieParser = require('cookie-parser'); +const csrf = require('csurf'); const app = express(); @@ -43,6 +43,7 @@ const session_opts = { rolling: true, saveUninitialized: false, resave: false, + unset: 'destroy', name: 'talk.sid', cookie: { secure: false, @@ -66,12 +67,6 @@ if (app.get('env') === 'production') { app.use(session(session_opts)); -//============================================================================== -// CSRF MIDDLEWARE -//============================================================================== - -app.use(cookieParser()); - //============================================================================== // PASSPORT MIDDLEWARE //============================================================================== @@ -80,6 +75,29 @@ app.use(cookieParser()); app.use(passport.initialize()); app.use(passport.session()); +//============================================================================== +// CSRF MIDDLEWARE +//============================================================================== + +if (process.env.TEST_MODE === 'unit') { + + // Add this fake test token in the event we are in unit test mode, and don't + // include the CSRF protection. + app.locals.csrfToken = 'UNIT_TESTS'; + +} else { + + // Setup route middlewares for CSRF protection. + // Default ignore methods are GET, HEAD, OPTIONS + app.use(csrf({})); + app.use((req, res, next) => { + res.locals.csrfToken = req.csrfToken(); + + next(); + }); + +} + //============================================================================== // ROUTES //============================================================================== diff --git a/bin/cli-jobs b/bin/cli-jobs index 60a7b8efb..879927197 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -6,6 +6,7 @@ const program = require('commander'); const scraper = require('../services/scraper'); +const mailer = require('../services/mailer'); const util = require('../util'); const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); @@ -19,13 +20,16 @@ util.onshutdown([ */ function processJobs() { - // Start the processor. + // Start the scraper processor. scraper.process(); + // Start the mail processor. + mailer.process(); + // The scraper only needs to shutdown when the scraper has actually been // started. util.onshutdown([ - () => scraper.shutdown() + () => kue.Task.shutdown() ]); } diff --git a/bin/cli-serve b/bin/cli-serve index 05fe6efc7..c8cf37a47 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -5,6 +5,8 @@ const debug = require('debug')('talk:server'); const http = require('http'); const init = require('../init'); const scraper = require('../services/scraper'); +const mailer = require('../services/mailer'); +const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('../util'); @@ -12,7 +14,7 @@ const util = require('../util'); * Get port from environment and store in Express. */ -const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000')); +const port = normalizePort(process.env.TALK_PORT || '3000'); app.set('port', port); @@ -119,15 +121,18 @@ startApp(); // Enable job processing on the thread if enabled. if (program.jobs) { - // Start the processor. + // Start the scraper processor. scraper.process(); + + // Start the mail processor. + mailer.process(); } // Define a safe shutdown function to call in the event we need to shutdown // because the node hooks are below which will interrupt the shutdown process. // Shutdown the mongoose connection, the app server, and the scraper. util.onshutdown([ - () => program.jobs ? scraper.shutdown() : null, + () => program.jobs ? kue.Task.shutdown() : null, () => mongoose.disconnect(), () => server.close() ]); diff --git a/bin/cli-users b/bin/cli-users index ae688e122..fd9e30c7f 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -80,12 +80,16 @@ function createUser(options) { .then((user) => { console.log(`Created user ${user.id}.`); - return User - .addRoleToUser(user.id, result.role.trim()) - .then(() => { - console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); - util.shutdown(); - }); + if (result.role && result.role.length > 0) { + return User + .addRoleToUser(user.id, result.role.trim()) + .then(() => { + console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`); + util.shutdown(); + }); + } else { + util.shutdown(); + } }) .catch((err) => { console.error(err); diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index 8cc094d5f..fb469588b 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -11,19 +11,12 @@ export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); coralApi('/auth') .then(result => { - if (result.csrfToken !== null) { - dispatch(check_csrf(result.csrfToken)); - } - const isAdmin = !!result.user.roles.filter(i => i === 'admin').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => dispatch(checkLoginFailure(error))); }; -// Set CSRF Token -export const check_csrf = (_csrf) => ({type: actions.CHECK_CSRF_TOKEN, _csrf}); - // LogOut Actions const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index 98efae60d..e8276a9da 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -34,9 +34,8 @@ export const fetchModerationQueueComments = () => { // Create a new comment export const createComment = (name, body) => { - return (dispatch, getState) => { - const _csrf = getState().auth.get('_csrf'); - const formData = {body, name, _csrf}; + return (dispatch) => { + const formData = {body, name}; return coralApi('/comments', {method: 'POST', body: formData}) .then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res})) .catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error})); diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 35ba11fac..c2d738460 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -41,18 +41,16 @@ export const newPage = () => ({ type: COMMENTERS_NEW_PAGE }); -export const setRole = (id, role) => (dispatch, getState) => { +export const setRole = (id, role) => (dispatch) => { - const _csrf = getState().auth.get('_csrf'); - return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}, _csrf: _csrf}) + return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}}) .then(() => { return dispatch({type: SET_ROLE, id, role}); }); }; -export const setCommenterStatus = (id, status) => (dispatch, getState) => { - const _csrf = getState().auth.get('_csrf'); - return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}, _csrf: _csrf}) +export const setCommenterStatus = (id, status) => (dispatch) => { + return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}}) .then(() => { return dispatch({type: SET_COMMENTER_STATUS, id, status}); }); diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index 30c20290f..30d9cd34b 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -6,10 +6,9 @@ import * as actions from '../constants/user'; */ // change status of a user export const userStatusUpdate = (status, userId, commentId) => { - return (dispatch, getState) => { + return (dispatch) => { dispatch({type: actions.UPDATE_STATUS_REQUEST}); - const _csrf = getState().auth.get('_csrf'); - return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}, _csrf}) + return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) .then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res})) .catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error})); }; diff --git a/client/coral-admin/src/components/CommentBox.js b/client/coral-admin/src/components/CommentBox.js index fc9e6c9ea..ce3438960 100644 --- a/client/coral-admin/src/components/CommentBox.js +++ b/client/coral-admin/src/components/CommentBox.js @@ -7,13 +7,13 @@ import {Button} from 'react-mdl'; export default class CommentBox extends React.Component { constructor (props) { super(props); - this.state = {name: '', body: '', _csrf: props._csrf}; + this.state = {name: '', body: ''}; this.onSubmit = this.onSubmit.bind(this); } onSubmit () { - const {name, body, _csrf} = this.state; - this.props.onSubmit({name, body, _csrf}); + const {name, body} = this.state; + this.props.onSubmit({name, body}); this.setState({body: '', name: ''}); } diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index 6f3a102de..095ef7aac 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -4,8 +4,7 @@ import * as actions from '../constants/auth'; const initialState = Map({ loggedIn: false, user: null, - isAdmin: false, - _csrf: '' + isAdmin: false }); export default function auth (state = initialState, action) { @@ -26,9 +25,6 @@ export default function auth (state = initialState, action) { .set('user', action.user); case actions.LOGOUT_SUCCESS: return initialState; - case actions.CHECK_CSRF_TOKEN: - return state - .set('_csrf', action._csrf); default : return state; } diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 581fb729c..106224c25 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -23,10 +23,9 @@ const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST}); const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin}); const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error}); -export const fetchSignIn = (formData) => (dispatch, getState) => { +export const fetchSignIn = (formData) => (dispatch) => { dispatch(signInRequest()); - const _csrf = getState().auth.get('_csrf'); - coralApi('/auth/local', {method: 'POST', body: formData, _csrf}) + coralApi('/auth/local', {method: 'POST', body: formData}) .then(({user}) => { const isAdmin = !!user.roles.filter(i => i === 'admin').length; dispatch(signInSuccess(user, isAdmin)); @@ -73,11 +72,10 @@ const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST}); const signUpSuccess = user => ({type: actions.FETCH_SIGNUP_SUCCESS, user}); const signUpFailure = error => ({type: actions.FETCH_SIGNUP_FAILURE, error}); -export const fetchSignUp = formData => (dispatch, getState) => { +export const fetchSignUp = formData => (dispatch) => { dispatch(signUpRequest()); - const _csrf = getState().auth.get('_csrf'); - coralApi('/users', {method: 'POST', body: formData, _csrf}) + coralApi('/users', {method: 'POST', body: formData}) .then(({user}) => { dispatch(signUpSuccess(user)); setTimeout(() =>{ @@ -93,11 +91,9 @@ const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUE const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); -export const fetchForgotPassword = email => (dispatch, getState) => { +export const fetchForgotPassword = email => (dispatch) => { dispatch(forgotPassowordRequest(email)); - - const _csrf = getState().auth.get('_csrf'); - coralApi('/users/request-password-reset', {method: 'POST', body: {email}, _csrf}) + coralApi('/account/password/reset', {method: 'POST', body: {email}}) .then(() => dispatch(forgotPassowordSuccess())) .catch(error => dispatch(forgotPassowordFailure(error))); }; @@ -125,15 +121,11 @@ export const invalidForm = error => ({type: actions.INVALID_FORM, error}); const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST}); const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin}); const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); -const checkCSRF = (_csrf) => ({type: actions.CHECK_CSRF_TOKEN, _csrf}); export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); coralApi('/auth') .then((result) => { - if (result.csrfToken !== null) { - dispatch(checkCSRF(result.csrfToken)); - } if (!result.user) { throw new Error('Not logged in'); } diff --git a/client/coral-framework/actions/items.js b/client/coral-framework/actions/items.js index 303800a8f..48418b2a2 100644 --- a/client/coral-framework/actions/items.js +++ b/client/coral-framework/actions/items.js @@ -190,11 +190,10 @@ export function getItemsArray (ids) { */ export function postItem (item, type, id) { - return (dispatch, getState) => { + return (dispatch) => { if (id) { item.id = id; } - item._csrf = getState().auth.get('_csrf'); return coralApi(`/${type}`, {method: 'POST', body: item}) .then((json) => { dispatch(addItem({...item, id:json.id}, type)); @@ -221,8 +220,7 @@ export function postItem (item, type, id) { */ export function postAction (item_id, item_type, action) { - return (dispatch, getState) => { - action._csrf = getState().auth.get('_csrf'); + return (dispatch) => { return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action}) .then((json) => { dispatch(updateItem(action.item_id, action.action_type, action.id, item_type)); diff --git a/client/coral-framework/actions/user.js b/client/coral-framework/actions/user.js index 2206d306b..aeb8d0989 100644 --- a/client/coral-framework/actions/user.js +++ b/client/coral-framework/actions/user.js @@ -14,10 +14,10 @@ const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error}); export const saveBio = (user_id, formData) => dispatch => { dispatch(saveBioRequest()); - coralApi(`/users/${user_id}/bio`, {method: 'PUT', body: formData}) - .then(({settings}) => { + coralApi('/account/settings', {method: 'PUT', body: formData}) + .then(() => { dispatch(addNotification('success', lang.t('successBioUpdate'))); - dispatch(saveBioSuccess(settings)); + dispatch(saveBioSuccess(formData)); }) .catch(error => dispatch(saveBioFailure(error))); }; diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index d8bfd28c4..d612aefb9 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -2,19 +2,28 @@ export const base = '/api/v1'; const buildOptions = (inputOptions = {}) => { + const csurfDOM = document.head.querySelector('[property=csrf]'); + const defaultOptions = { method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - credentials: 'same-origin' + credentials: 'same-origin', + _csrf: csurfDOM ? csurfDOM.content : false }; + const options = Object.assign({}, defaultOptions, inputOptions); - // Add CSRF field to each POST. - if (options.method.toLowerCase() === 'post' && options._csrf) { - options.body._csrf = options._csrf; + if (options._csrf) { + switch (options.method.toLowerCase()) { + case 'post': + case 'put': + case 'delete': + options.headers['x-csrf-token'] = options._csrf; + break; + } } if (options.method.toLowerCase() !== 'get') { diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index a003b20ed..36f8e0764 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -11,8 +11,7 @@ const initialState = Map({ error: '', passwordRequestSuccess: null, passwordRequestFailure: null, - successSignUp: false, - _csrf: '' + successSignUp: false }); const purge = user => { diff --git a/client/coral-framework/reducers/user.js b/client/coral-framework/reducers/user.js index a6f980fd8..7e0604c4b 100644 --- a/client/coral-framework/reducers/user.js +++ b/client/coral-framework/reducers/user.js @@ -31,8 +31,7 @@ export default function user (state = initialState, action) { case authActions.FETCH_SIGNIN_FACEBOOK_FAILURE: return initialState; case actions.SAVE_BIO_SUCCESS: - return state - .set('settings', action.settings); + return state.set('settings', action.settings); case actions.COMMENTS_BY_USER_SUCCESS: return state.set('myComments', action.comments); case assetActions.MULTIPLE_ASSETS_SUCCESS: diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8fe8627b0..554afb29e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -583,7 +583,7 @@ paths: description: The user that has been created. schema: $ref: '#/definitions/User' - /users/update-password: + /account/password/reset: post: parameters: - name: body diff --git a/models/action.js b/models/action.js index ee698029d..8e6634072 100644 --- a/models/action.js +++ b/models/action.js @@ -13,7 +13,7 @@ const ActionSchema = new Schema({ item_type: String, item_id: String, user_id: String, - metadata: Object, //Holds arbitrary metadata about the action. + metadata: Schema.Types.Mixed }, { timestamps: { createdAt: 'created_at', diff --git a/models/setting.js b/models/setting.js index e9e38f463..ad9fa6599 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,7 +1,6 @@ const mongoose = require('../services/mongoose'); const Schema = mongoose.Schema; const _ = require('lodash'); -const cache = require('../services/cache'); const WordlistSchema = new Schema({ banned: [String], @@ -53,6 +52,10 @@ const SettingSchema = new Schema({ charCountEnable: { type: Boolean, default: false + }, + requireEmailConfirmation: { + type: Boolean, + default: false } }, { timestamps: { @@ -98,7 +101,8 @@ SettingSchema.method('filterForUser', function(user = false) { 'closeTimeout', 'closedMessage', 'charCountEnable', - 'charCount' + 'charCount', + 'requireEmailConfirmation' ]); } @@ -121,19 +125,11 @@ const SettingService = module.exports = {}; */ const selector = {id: '1'}; -/** - * Cache expiry time in seconds for when the cached entry of the settings object - * expires. 2 minutes. - */ -const EXPIRY_TIME = 60 * 2; - /** * Gets the entire settings record and sends it back * @return {Promise} settings the whole settings record */ -SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => { - return Setting.findOne(selector); -}).then((setting) => new Setting(setting)); +SettingService.retrieve = () => Setting.findOne(selector); /** * This will update the settings object with whatever you pass in @@ -146,12 +142,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, { upsert: true, new: true, setDefaultsOnInsert: true -}).then((settings) => { - - // Invalidate the settings cache. - return cache - .set('settings', settings, EXPIRY_TIME) - .then(() => settings); }); /** diff --git a/models/user.js b/models/user.js index 7df713337..5a0e7a640 100644 --- a/models/user.js +++ b/models/user.js @@ -4,9 +4,11 @@ const _ = require('lodash'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const Action = require('./action'); - const Comment = require('./comment'); +const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; +const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; + // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. const SALT_ROUNDS = 10; @@ -31,6 +33,37 @@ if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); } +// ProfileSchema is the mongoose schema defined as the representation of a +// User's profile stored in MongoDB. +const ProfileSchema = new mongoose.Schema({ + + // ID provides the identifier for the user profile, in the case of a local + // provider, the id would be an email, in the case of a social provider, + // the id would be the foreign providers identifier. + id: { + type: String, + required: true + }, + + // Provider is simply the name attached to the authentication mode. In the + // case of a locally provided profile, this will simply be `local`, or a + // social provider which for Facebook would just be `facebook`. + provider: { + type: String, + required: true + }, + + // Metadata provides a place to put provider specific details. An example of + // something that could be stored here is the `metadata.confirmed_at` could be + // used by the `local` provider to indicate when the email address was + // confirmed. + metadata: { + type: mongoose.Schema.Types.Mixed + } +}, { + _id: false +}); + // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. const UserSchema = new mongoose.Schema({ @@ -60,26 +93,7 @@ const UserSchema = new mongoose.Schema({ // Profiles describes the array of identities for a given user. Any one user // can have multiple profiles associated with them, including multiple email // addresses. - profiles: [new mongoose.Schema({ - - // ID provides the identifier for the user profile, in the case of a local - // provider, the id would be an email, in the case of a social provider, - // the id would be the foreign providers identifier. - id: { - type: String, - required: true - }, - - // Provider is simply the name attached to the authentication mode. In the - // case of a locally provided profile, this will simply be `local`, or a - // social provider which for Facebook would just be `facebook`. - provider: { - type: String, - required: true - } - }, { - _id: false - })], + profiles: [ProfileSchema], // Roles provides an array of roles (as strings) that is associated with a // user. @@ -499,47 +513,63 @@ UserService.createPasswordResetToken = function (email) { email = email.toLowerCase(); return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) - .then(user => { + .then((user) => { + if (!user) { - if (user === null) { - - // since we don't want to reveal that the email does/doesn't exist - // just go ahead and resolve the Promise with null and check in the endpoint - return Promise.resolve(null); + // Since we don't want to reveal that the email does/doesn't exist + // just go ahead and resolve the Promise with null and check in the + // endpoint. + return; } - const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v}; - const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'}); + const payload = { + jti: uuid.v4(), + email, + userId: user.id, + version: user.__v + }; - return token; + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + algorithm: 'HS256', + expiresIn: '1d', + subject: PASSWORD_RESET_JWT_SUBJECT + }); }); }; /** - * verifies a jwt and returns the associated user - * @param {String} token the JSON Web Token to verify + * Verifies that the token was indeed signed by the session secret. + * @param {String} token JWT token from the client + * @return {Promise} */ -UserService.verifyPasswordResetToken = token => { +UserService.verifyToken = (token, options = {}) => { return new Promise((resolve, reject) => { - jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => { - if (error) { - return reject(error); + + // Set the allowed algorithms. + options.algorithms = ['HS256']; + + jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { + if (err) { + return reject(err); } resolve(decoded); }); - }) - .then(decoded => { - - /** - * TODO: check the jti from this decoded token in redis - * and make an entry if it does not exist. - * reject if entry already exists. - */ - return UserService.findById(decoded.userId); }); }; +/** + * Verifies a jwt and returns the associated user. + * @param {String} token the JSON Web Token to verify + */ +UserService.verifyPasswordResetToken = (token) => { + return UserService + .verifyToken(token, { + subject: PASSWORD_RESET_JWT_SUBJECT + }) + .then((decoded) => UserService.findById(decoded.userId)); +}; + /** * Finds a user using a value which gets compared using a prefix match against * the user's email address and/or their display name. @@ -578,34 +608,25 @@ UserService.search = (value) => { * Returns a count of the current users. * @return {Promise} */ -UserService.count = () => { - return UserModel.count(); -}; +UserService.count = () => UserModel.count(); /** * Returns all the users. * @return {Promise} */ -UserService.all = () => { - return UserModel.find(); -}; +UserService.all = () => UserModel.find(); /** - * Adds a new User bio + * Updates the user's settings. * @return {Promise} */ - -UserService.addBio = (id, bio) => ( - UserModel.findOneAndUpdate({ - id - }, { - $set: { - 'settings.bio': bio - } - }, { - new: true - }) -); +UserService.updateSettings = (id, settings) => UserModel.update({ + id +}, { + $set: { + settings + } +}); /** * Add an action to the user. @@ -621,3 +642,77 @@ UserService.addAction = (item_id, user_id, action_type, metadata) => Action.inse action_type, metadata }); + +/** + * This creates a token based around confirming the local profile. + * @param {String} userID The user id for the user that we are creating the + * token for. + * @param {String} email The email that we are needing to get confirmed. + * @return {Promise} + */ +UserService.createEmailConfirmToken = (userID, email) => { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserService + .findById(userID) + .then((user) => { + if (!user) { + return Promise.reject(new Error('user not found')); + } + + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local'); + + // Ensure that the user email hasn't already been verified. + if (profile && profile.metadata && profile.metadata.confirmed_at) { + return Promise.reject(new Error('email address already confirmed')); + } + + const payload = { + email, + userID + }; + + return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + jwtid: uuid.v4(), + algorithm: 'HS256', + expiresIn: '1d', + subject: EMAIL_CONFIRM_JWT_SUBJECT + }); + }); +}; + +/** + * This verifies that a given token was for the email confirmation and updates + * that user's profile with a 'confirmed_at' parameter with the current date. + * @param {String} token the token containing the email confirmation details + * signed with our secret. + * @return {Promise} + */ +UserService.verifyEmailConfirmation = (token) => { + return UserService + .verifyToken(token, { + subject: EMAIL_CONFIRM_JWT_SUBJECT + }) + .then(({userID, email}) => { + + return UserModel + .update({ + id: userID, + profiles: { + $elemMatch: { + id: email, + provider: 'local' + } + } + }, { + $set: { + 'profiles.$.metadata.confirmed_at': new Date() + } + }); + }); +}; diff --git a/package.json b/package.json index 9cedc7a82..495ac74fe 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint bin/* . --fix", - "test": "NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests", - "test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests", + "test": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests", + "test-watch": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests", "pree2e": "NODE_ENV=test scripts/pree2e.sh", "e2e": "NODE_ENV=test nightwatch", "embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs", @@ -52,6 +52,7 @@ "cli-table": "^0.3.1", "commander": "^2.9.0", "connect-redis": "^3.1.0", + "csurf": "^1.9.0", "debug": "^2.2.0", "ejs": "^2.5.2", "env-rewrite": "^1.0.2", @@ -92,9 +93,7 @@ "babel-preset-stage-0": "^6.16.0", "chai": "^3.5.0", "chai-http": "^3.0.0", - "cookie-parser": "^1.4.3", "copy-webpack-plugin": "^4.0.0", - "csurf": "^1.9.0", "css-loader": "^0.25.0", "dialog-polyfill": "^0.4.4", "enzyme": "^2.6.0", diff --git a/routes/api/account/index.js b/routes/api/account/index.js new file mode 100644 index 000000000..645b9c652 --- /dev/null +++ b/routes/api/account/index.js @@ -0,0 +1,141 @@ +const express = require('express'); +const router = express.Router(); +const User = require('../../../models/user'); +const mailer = require('../../../services/mailer'); +const authorization = require('../../../middleware/authorization'); + +// ErrPasswordTooShort is returned when the password length is too short. +const ErrPasswordTooShort = new Error('password must be at least 8 characters'); +ErrPasswordTooShort.status = 400; + +// ErrMissingToken is returned in the event that the password reset is requested +// without a token. +const ErrMissingToken = new Error('token is required'); +ErrMissingToken.status = 400; + +//============================================================================== +// ROUTES +//============================================================================== + +router.get('/', authorization.needed(), (req, res, next) => { + res.json(req.user); +}); + +// POST /email/confirm takes the password confirmation token available as a +// payload parameter and if it verifies, it updates the confirmed_at date on the +// local profile. +router.post('/email/confirm', (req, res, next) => { + + const { + token + } = req.body; + + if (!token) { + return next(ErrMissingToken); + } + + User + .verifyEmailConfirmation(token) + .then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); +}); + +/** + * this endpoint takes an email (username) and checks if it belongs to a User account + * if it does, create a JWT and send an email + */ +router.post('/password/reset', (req, res, next) => { + const {email} = req.body; + + if (!email) { + return next('you must submit an email when requesting a password.'); + } + + User + .createPasswordResetToken(email) + .then((token) => { + + // Check to see if the token isn't defined. + if (!token) { + + // As it isn't, don't send any emails! + return; + } + + return mailer.sendSimple({ + app: req.app, // needed to render the templates. + template: 'email/password-reset', // needed to know which template to render! + locals: { // specifies the template locals. + token, + rootURL: process.env.TALK_ROOT_URL + }, + subject: 'Password Reset Requested - Talk', + to: email + }); + }) + .then(() => { + + // we want to send a 204 regardless of the user being found in the db + // if we fail on missing emails, it would reveal if people are registered or not. + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); +}); + +/** + * expects 2 fields in the body of the request + * 1) the token that was in the url of the email link {String} + * 2) the new password {String} + */ +router.put('/password/reset', (req, res, next) => { + + const { + token, + password + } = req.body; + + if (!token) { + return next(ErrMissingToken); + } + + if (!password || password.length < 8) { + return next(ErrPasswordTooShort); + } + + User.verifyPasswordResetToken(token) + .then(user => { + return User.changePassword(user.id, password); + }) + .then(() => { + res.status(204).end(); + }) + .catch(error => { + console.error(error); + + next(authorization.ErrNotAuthorized); + }); +}); + +router.put('/settings', authorization.needed(), (req, res, next) => { + + const { + bio + } = req.body; + + User + .updateSettings(req.user.id, {bio}) + .then(() => { + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); +}); + +module.exports = router; diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index 52feb37f0..f5a4afe44 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -4,14 +4,6 @@ const router = express.Router(); const Asset = require('../../../models/asset'); const scraper = require('../../../services/scraper'); -const csrf = require('csurf'); -const bodyParser = require('body-parser'); - -// Setup route middlewares for CSRF protection. -// Default ignore methods are GET, HEAD, OPTIONS -const csrfProtection = csrf({}); -const parseForm = bodyParser.urlencoded({extended: false}); - // List assets. router.get('/', (req, res, next) => { @@ -96,7 +88,7 @@ router.get('/:asset_id', (req, res, next) => { }); // Adds the asset id to the queue to be scraped. -router.post('/:asset_id/scrape', parseForm, csrfProtection, (req, res, next) => { +router.post('/:asset_id/scrape', (req, res, next) => { // Create a new asset scrape job. Asset diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index 8b30ab35e..c7cfde9b5 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -4,40 +4,35 @@ const authorization = require('../../../middleware/authorization'); const router = express.Router(); -const csrf = require('csurf'); -const bodyParser = require('body-parser'); - -// Setup route middlewares for CSRF protection. -// Default ignore methods are GET, HEAD, OPTIONS -const csrfProtection = csrf({}); -const parseForm = bodyParser.urlencoded({extended: false}); - /** * This returns the user if they are logged in. */ -router.get('/', csrfProtection, (req, res, next) => { +router.get('/', (req, res, next) => { if (req.user) { return next(); } - // When there is no user on the request, then just send back the CSRF token. - res.json({csrfToken: req.csrfToken()}); + res.status(204).end(); }, (req, res) => { // Send back the user object. - res.json({user: req.user.toObject(), csrfToken: req.csrfToken()}); + res.json({user: req.user.toObject()}); }); /** * This destroys the session of a user, if they have one. */ router.delete('/', authorization.needed(), (req, res) => { - req.session.destroy(() => { - res.status(204).end(); - }); + delete req.session.passport; + + res.status(204).end(); }); +//============================================================================== +// PASSPORT ROUTES +//============================================================================== + /** * This sends back the user data as JSON. */ @@ -57,7 +52,7 @@ const HandleAuthCallback = (req, res, next) => (err, user) => { } // We logged in the user! Let's send back the user data and the CSRF token. - res.json({user, '_csrf': req.csrfToken()}); + res.json({user}); }); }; @@ -87,7 +82,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => { /** * Local auth endpoint, will recieve a email and password */ -router.post('/local', parseForm, csrfProtection, (req, res, next) => { +router.post('/local', (req, res, next) => { // Perform the local authentication. passport.authenticate('local', HandleAuthCallback(req, res, next))(req, res, next); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index c7f09d30d..21426d217 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -7,14 +7,6 @@ const wordlist = require('../../../services/wordlist'); const authorization = require('../../../middleware/authorization'); const _ = require('lodash'); -const csrf = require('csurf'); -const bodyParser = require('body-parser'); - -// Setup route middlewares for CSRF protection. -// Default ignore methods are GET, HEAD, OPTIONS -const csrfProtection = csrf({}); -const parseForm = bodyParser.urlencoded({extended: false}); - const router = express.Router(); router.get('/', (req, res, next) => { @@ -90,7 +82,7 @@ router.get('/', (req, res, next) => { }); }); -router.post('/', parseForm, csrfProtection, wordlist.filter('body'), (req, res, next) => { +router.post('/', wordlist.filter('body'), (req, res, next) => { const { body, @@ -203,7 +195,7 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next }); }); -router.post('/:comment_id/actions', parseForm, csrfProtection, (req, res, next) => { +router.post('/:comment_id/actions', (req, res, next) => { const { action_type, diff --git a/routes/api/index.js b/routes/api/index.js index 316d284e0..120fd47ec 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -17,6 +17,7 @@ router.use('/actions', authorization.needed(), require('./actions')); router.use('/auth', require('./auth')); router.use('/stream', require('./stream')); router.use('/users', require('./users')); +router.use('/account', require('./account')); // Bind the kue handler to the /kue path. router.use('/kue', authorization.needed('admin'), require('../../services/kue').kue.app); diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 6c1fb38ab..68d0f0ad9 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -8,6 +8,7 @@ const User = require('../../../models/user'); const Action = require('../../../models/action'); const Asset = require('../../../models/asset'); const Setting = require('../../../models/setting'); + const ErrInvalidAssetURL = new Error('asset_url is invalid'); ErrInvalidAssetURL.status = 400; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 20d117503..1fadc7857 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -1,22 +1,10 @@ const express = require('express'); const router = express.Router(); const User = require('../../../models/user'); +const Setting = require('../../../models/setting'); const mailer = require('../../../services/mailer'); -const ejs = require('ejs'); -const fs = require('fs'); -const path = require('path'); -const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs')); -const resetEmailTemplate = ejs.compile(resetEmailFile.toString()); const authorization = require('../../../middleware/authorization'); -const csrf = require('csurf'); -const bodyParser = require('body-parser'); - -// Setup route middlewares for CSRF protection. -// Default ignore methods are GET, HEAD, OPTIONS -const csrfProtection = csrf({}); -const parseForm = bodyParser.urlencoded({extended: false}); - router.get('/', authorization.needed('admin'), (req, res, next) => { const { value = '', @@ -46,7 +34,7 @@ router.get('/', authorization.needed('admin'), (req, res, next) => { .catch(next); }); -router.post('/:user_id/role', parseForm, csrfProtection, authorization.needed('admin'), (req, res, next) => { +router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { User .addRoleToUser(req.params.user_id, req.body.role) .then(() => { @@ -55,119 +43,69 @@ router.post('/:user_id/role', parseForm, csrfProtection, authorization.needed('a .catch(next); }); -router.post('/:user_id/status', parseForm, csrfProtection, (req, res, next) => { +router.post('/:user_id/status', (req, res, next) => { User .setStatus(req.params.user_id, req.body.status, req.body.comment_id) - .then(status => { - res.json(status); + .then((status) => { + res.status(201).json(status); }) .catch(next); }); -router.post('/', parseForm, csrfProtection, (req, res, next) => { - const {email, password, displayName} = req.body; +router.post('/', (req, res, next) => { + const { + email, + password, + displayName + } = req.body; User .createLocalUser(email, password, displayName) - .then(user => { + .then((user) => { - res.status(201).json(user); + // Get the settings from the database to find out if we need to send an + // email confirmation. The Front end will know about the + // requireEmailConfirmation as it's included in the settings get endpoint. + return Setting.retrieve().then(({requireEmailConfirmation = false}) => { + + 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 + }); + }) + .then(() => { + + // Then send back the user. + res.status(201).json(user); + }); + } else { + + // We don't need to confirm the email, let's just send back the user! + res.status(201).json(user); + } + }); }) .catch(err => { next(err); }); }); -const ErrPasswordTooShort = new Error('password must be at least 8 characters'); -ErrPasswordTooShort.status = 400; +router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { -/** - * expects 2 fields in the body of the request - * 1) the token that was in the url of the email link {String} - * 2) the new password {String} - */ -router.post('/update-password', parseForm, csrfProtection, (req, res, next) => { - const {token, password} = req.body; - - if (!password || password.length < 8) { - return next(ErrPasswordTooShort); - } - - User.verifyPasswordResetToken(token) - .then(user => { - return User.changePassword(user.id, password); - }) - .then(() => { - res.status(204).end(); - }) - .catch(error => { - console.error(error); - - next(authorization.ErrNotAuthorized); - }); -}); - -/** - * this endpoint takes an email (username) and checks if it belongs to a User account - * if it does, create a JWT and send an email - */ -router.post('/request-password-reset', parseForm, csrfProtection, (req, res, next) => { - const {email} = req.body; - - if (!email) { - return next('you must submit an email when requesting a password.'); - } - - User - .createPasswordResetToken(email) - .then(token => { - if (token === null) { - return Promise.resolve('the email was not found in the db.'); - } - - const options = { - subject: 'Password Reset Requested - Talk', - from: process.env.TALK_SMTP_FROM_ADDRESS, - to: email, - html: resetEmailTemplate({ - token, - - // probably more clear to explicitly pass this - rootURL: process.env.TALK_ROOT_URL - }) - }; - return mailer.sendSimple(options); - }) - .then(() => { - - // we want to send a 204 regardless of the user being found in the db - // if we fail on missing emails, it would reveal if people are registered or not. - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); -}); - -router.put('/:user_id/bio', (req, res, next) => { - const {user_id} = req.params; - const {bio} = req.body; - - if (!bio) { - return next('You must submit a new bio'); - } - - User - .addBio(user_id, bio) - .then(user => { - res.json(user); - }) - .catch((err) => { - next(err); - }); -}); - -router.post('/:user_id/actions', parseForm, csrfProtection, authorization.needed(), (req, res, next) => { const { action_type, metadata diff --git a/routes/index.js b/routes/index.js index 4d2f4236f..9ab0dff14 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,28 +1,21 @@ const express = require('express'); const router = express.Router(); -const csrf = require('csurf'); - -// Setup route middlewares for CSRF protection. -// Default ignore methods are GET, HEAD, OPTIONS -const csrfProtection = csrf({}); router.use('/api/v1', require('./api')); router.use('/admin', require('./admin')); router.use('/embed', require('./embed')); -router.get('/', csrfProtection, (req, res) => { +router.get('/', (req, res) => { return res.render('article', { title: 'Coral Talk', - basePath: '/client/embed/stream', - csrfToken: req.csrfToken() + basePath: '/client/embed/stream' }); }); -router.get('/assets/:asset_title', csrfProtection, (req, res) => { +router.get('/assets/:asset_title', (req, res) => { return res.render('article', { title: req.params.asset_title.split('-').join(' '), - basePath: '/client/embed/stream', - _csrf: req.csrfToken() + basePath: '/client/embed/stream' }); }); diff --git a/scripts/pree2e.sh b/scripts/pree2e.sh index e79addc2e..0487a9187 100755 --- a/scripts/pree2e.sh +++ b/scripts/pree2e.sh @@ -4,12 +4,12 @@ selenium-standalone install # Creating Admin Test User -{ echo admin@test.com; echo test; echo test; echo Admin Test User; echo admin;} | dotenv ./bin/cli-users create +./bin/cli-users create --flag_mode --email "admin@test.com" --password "test" --name "Admin Test User" --role "admin" # Creating Moderator Test User -{ echo moderator@test.com; echo test; echo test; echo Moderator Test User; echo moderator;} | dotenv ./bin/cli-users create +./bin/cli-users create --flag_mode --email "moderator@test.com" --password "test" --name "Moderator Test User" --role "moderator" # Creating Commenter Test User -{ echo commenter@test.com; echo test; echo test; echo Commenter Test User; echo ;} | dotenv ./bin/cli-users create +./bin/cli-users create --flag_mode --email "commenter@test.com" --password "test" --name "commenter@test.com" -npm start +npm start & diff --git a/services/kue.js b/services/kue.js index e2229d424..e9fbe43e8 100644 --- a/services/kue.js +++ b/services/kue.js @@ -1,11 +1,79 @@ -const kue = require('kue'); +const debug = require('debug')('talk:services:kue'); const redis = require('./redis'); -module.exports = { - queue: kue.createQueue({ - redis: { - createClientFactory: () => redis.createClient() - } - }), - kue +module.exports = {}; + +const kue = module.exports.kue = require('kue'); + +// Note that unlike what the name createQueue suggests, it currently returns a +// singleton Queue instance. So you can configure and use only a single Queue +// object within your node.js process. +const Queue = module.exports.queue = kue.createQueue({ + redis: { + createClientFactory: () => redis.createClient() + } +}); + +module.exports.Task = class Task { + + constructor({name, attempts = 3, delay = 1000}) { + this.name = name; + this.attempts = attempts; + this.delay = delay; + } + + /** + * Add a new job to the queue. + */ + create(data) { + + debug(`Creating new job for Queue[${this.name}]`); + + return new Promise((resolve, reject) => { + let job = Queue + .create(this.name, data) + .attempts(this.attempts) + .delay(this.delay) + .backoff({type: 'exponential'}) + .save((err) => { + if (err) { + return reject(err); + } + + debug(`Job[${job.id}] created on Queue[${this.name}]`); + + return resolve(job); + }); + }); + } + + /** + * Process jobs for the queue. + */ + process(callback) { + return Queue.process(this.name, callback); + } + + /** + * Shutdown running jobs. + */ + static shutdown() { + + debug('Shutting down the Queue'); + + return new Promise((resolve, reject) => { + + // Shutdown and give the queue 5 seconds to shutdown before we start + // killing jobs. + Queue.shutdown(5000, (err) => { + if (err) { + return reject(err); + } + + debug('Queue shut down.'); + + resolve(); + }); + }); + } }; diff --git a/services/mailer.js b/services/mailer.js index 9ff7291d4..6fef876d6 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -1,4 +1,6 @@ +const debug = require('debug')('talk:services:mailer'); const nodemailer = require('nodemailer'); +const kue = require('./kue'); const smtpRequiredProps = [ 'TALK_SMTP_FROM_ADDRESS', @@ -7,11 +9,9 @@ const smtpRequiredProps = [ 'TALK_SMTP_HOST' ]; -smtpRequiredProps.forEach(prop => { - if (!process.env[prop]) { - console.error(`process.env.${prop} should be defined if you would like to send password reset emails from Talk`); - } -}); +if (smtpRequiredProps.some(prop => !process.env[prop])) { + console.error(`${smtpRequiredProps.join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`); +} const options = { host: process.env.TALK_SMTP_HOST, @@ -29,29 +29,89 @@ if (process.env.TALK_SMTP_PORT) { const defaultTransporter = nodemailer.createTransport(options); -const mailer = { +const mailer = module.exports = { /** - * sendSimple - * - * @param {Object} {from, to, subject, text = '', html = ''} - * @returns - */ - sendSimple({from, to, subject, text = '', html = '', transporter = defaultTransporter}) { - return new Promise((resolve, reject) => { - if (!from) { - reject('sendSimple requires a from address'); - } - if (!to) { - reject('sendSimple requires a comma-separated list of "to" addresses'); - } - if (!subject) { - reject('sendSimple requires a subject for the email'); - } + * Create the new Task kue. + */ + task: new kue.Task({ + name: 'mailer' + }), - return resolve(transporter.sendMail({from, to, subject, text, html})); + /** + * Render renders the template with the given locals and returns the rendered + * html/text. + */ + render(app, template, locals = {}) { + return new Promise((resolve, reject) => { + + // Render the template with the app.render method. + app.render(template, locals, (err, rendered) => { + if (err) { + return reject(err); + } + + return resolve(rendered); + }); + }); + }, + + sendSimple({app, template, locals, to, subject}) { + if (!to) { + return Promise.reject('sendSimple requires a comma-separated list of "to" addresses'); + } + + if (!subject) { + return Promise.reject('sendSimple requires a subject for the email'); + } + + return Promise.all([ + + // Render the HTML version of the email. + mailer.render(app, template, locals), + + // Render the TEXT version of the email. + mailer.render(app, `${template}.txt`, locals) + ]) + .then(([html, text]) => { + + // Create the job. + return mailer.task.create({ + title: 'Mail', + message: { + to, + subject, + text, + html + } + }); + }); + }, + + /** + * Start the queue processor for the mailer job. + */ + process() { + + debug(`Now processing ${mailer.task.name} jobs`); + + return mailer.task.process(({id, data}, done) => { + debug(`Starting to send mail for Job[${id}]`); + + // Set the `from` field. + data.message.from = process.env.TALK_SMTP_FROM_ADDRESS; + + // Actually send the email. + defaultTransporter.sendMail(data.message, (err) => { + if (err) { + debug(`Failed to send mail for Job[${id}]:`, err); + return done(err); + } + + debug(`Finished sending mail for Job[${id}]`); + return done(); + }); }); } -}; -module.exports = mailer; +}; diff --git a/services/mongoose.js b/services/mongoose.js index 03c87b937..1b9097760 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -1,21 +1,53 @@ const mongoose = require('mongoose'); const debug = require('debug')('talk:db'); +const queryDebuger = require('debug')('talk:db:query'); + +// Loading the formatter from Mongoose: +// +// https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182 +// +// so we can wrap parameters. +const formatter = require('mongoose').Collection.prototype.$format; + +// Provide a newly wrapped debugQuery function which wraps the `debug` package. +function debugQuery(name, i, ...args) { + let functionCall = ['db', name, i].join('.'); + let _args = []; + for (let j = args.length - 1; j >= 0; --j) { + if (formatter(args[j]) || _args.length) { + _args.unshift(formatter(args[j])); + } + } + + let params = `(${_args.join(', ')})`; + + queryDebuger(functionCall + params); +} + const enabled = require('debug').enabled; -// Append '-test' to the db if node_env === 'test' -let url = process.env.TALK_MONGO_URL || 'mongodb://localhost/coral-talk'; +// Pull the mongo url out of the environment. +let url = process.env.TALK_MONGO_URL; -if (process.env.NODE_ENV === 'test') { - url += '-test'; +// Reset the mongo url in the event it hasn't been overrided and we are in a +// testing environment. Every new mongo instance comes with a test database by +// default, this is consistent with common testing and use case practices. +if (process.env.NODE_ENV === 'test' && !url) { + url = 'mongodb://localhost/test'; } // Use native promises mongoose.Promise = global.Promise; +// Check if debugging is enabled on the talk:db prefix. if (enabled('talk:db')) { - mongoose.set('debug', true); + + // Enable the mongoose debugger, here we wrap the similar print function + // provided by setting the debug parameter. + mongoose.set('debug', debugQuery); } +// Connect to the Mongo instance. mongoose.connect(url, (err) => { if (err) { throw err; diff --git a/services/passport.js b/services/passport.js index f709970dc..130de234d 100644 --- a/services/passport.js +++ b/services/passport.js @@ -1,5 +1,6 @@ const passport = require('passport'); const User = require('../models/user'); +const Setting = require('../models/setting'); const LocalStrategy = require('passport-local').Strategy; const FacebookStrategy = require('passport-facebook').Strategy; @@ -27,7 +28,7 @@ passport.deserializeUser((id, done) => { * @param {User} user the user to be validated * @param {Function} done the callback for the validation */ -function ValidateUserLogin(user, done) { +function ValidateUserLogin(loginProfile, user, done) { if (!user) { return done(new Error('user not found')); } @@ -36,7 +37,36 @@ function ValidateUserLogin(user, done) { return done(null, false, {message: 'Account disabled'}); } - return done(null, user); + // If the user isn't a local user (i.e., a social user). + if (loginProfile.provider !== 'local') { + return done(null, user); + } + + // The user is a local user, check if we need email confirmation. + return Setting.retrieve().then(({requireEmailConfirmation = false}) => { + + // If we have the requirement of checking that emails for users are + // verified, then we need to check the email address to ensure that it has + // been verified. + if (requireEmailConfirmation) { + + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === loginProfile.id); + + // This should never get to this point, if it does, don't let this past. + if (!profile) { + throw new Error('ID indicated by loginProfile is not on user object'); + } + + // If the profile doesn't have a metadata field, or it does not have a + // confirmed_at field, or that field is null, then send them back. + if (!profile.metadata || !profile.metadata.confirmed_at || profile.metadata.confirmed_at === null) { + return done(null, false, {message: `Email address ${loginProfile.id} not verified.`}); + } + } + + return done(null, user); + }); } //============================================================================== @@ -54,7 +84,12 @@ passport.use(new LocalStrategy({ return done(null, false, {message: 'Incorrect email/password combination'}); } - return ValidateUserLogin(user, done); + // Define the loginProfile being used to perform an additional + // verificaiton. + let loginProfile = {id: email, provider: 'local'}; + + // Validate the user login. + return ValidateUserLogin(loginProfile, user, done); }) .catch((err) => { done(err); @@ -70,9 +105,9 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && }, (accessToken, refreshToken, profile, done) => { User .findOrCreateExternalUser(profile) - .then((user) => - ValidateUserLogin(user, done) - ) + .then((user) => { + return ValidateUserLogin(profile, user, done); + }) .catch((err) => { done(err); }); diff --git a/services/scraper.js b/services/scraper.js index c75a12750..6a3260757 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -1,7 +1,6 @@ const kue = require('./kue'); const debug = require('debug')('talk:services:scraper'); const Asset = require('../models/asset'); -const JOB_NAME = 'scraper'; const metascraper = require('metascraper'); @@ -12,29 +11,27 @@ const metascraper = require('metascraper'); const scraper = { /** - * creates a new scraper job and scrapes the url when it gets processed. + * Create the new Task kue. + */ + task: new kue.Task({ + name: 'scraper' + }), + + /** + * Creates a new scraper job and scrapes the url when it gets processed. */ create(asset) { - return new Promise((resolve, reject) => { - debug(`Creating job for Asset[${asset.id}]`); - let job = kue.queue - .create(JOB_NAME, { - title: `Scrape for asset ${asset.id}`, - asset_id: asset.id - }) - .attempts(3) - .delay(1000) - .backoff({type: 'exponential'}) - .save((err) => { - if (err) { - return reject(err); - } + debug(`Creating job for Asset[${asset.id}]`); - debug(`Created Job[${job.id}] for Asset[${asset.id}]`); + return scraper.task.create({ + title: `Scrape for asset ${asset.id}`, + asset_id: asset.id + }).then((job) => { - return resolve(job); - }); + debug(`Created Job[${job.id}] for Asset[${asset.id}]`); + + return job; }); }, @@ -48,6 +45,9 @@ const scraper = { })); }, + /** + * Updates an Asset based on scraped asset metadata. + */ update(id, meta) { return Asset.update({id}, { $set: { @@ -68,10 +68,9 @@ const scraper = { */ process() { - debug(`Now processing ${JOB_NAME} jobs`); + debug(`Now processing ${scraper.task.name} jobs`); - // Process jobs with the processJob function. - kue.queue.process(JOB_NAME, (job, done) => { + scraper.task.process((job, done) => { debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`); @@ -111,27 +110,6 @@ const scraper = { done(err); }); }); - }, - - /** - * Shuts down the current queue to ensure that the application can shutdown - * cleanly. - */ - shutdown() { - return new Promise((resolve, reject) => { - - // Shutdown and give the queue 5 seconds to shutdown before we start - // killing jobs. - kue.queue.shutdown(5000, (err) => { - if (err) { - return reject(err); - } - - debug(`Processing for ${JOB_NAME} jobs stopped`); - - resolve(); - }); - }); } }; diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json index b188e92cf..8aceb06d2 100644 --- a/tests/.eslintrc.json +++ b/tests/.eslintrc.json @@ -9,7 +9,6 @@ ], "extends": "../.eslintrc.json", "rules": { - "no-undef": [0], "mocha/no-exclusive-tests": "warn" } } diff --git a/tests/client/coral-framework/store/itemActions.js b/tests/client/coral-framework/store/itemActions.js index fa976beb4..24d06f928 100644 --- a/tests/client/coral-framework/store/itemActions.js +++ b/tests/client/coral-framework/store/itemActions.js @@ -104,7 +104,7 @@ describe('itemActions', () => { }); it('should handle an error', () => { fetchMock.get('*', 404); - return actions.getItemsArray(ids, host)(store.dispatch) + return actions.getItemsArray(ids)(store.dispatch) .catch((err) => { expect(err).to.be.truthy; }); diff --git a/tests/e2e/tests/EmbedStreamTests.js b/tests/e2e/tests/EmbedStreamTests.js index 93b84b383..d572c2695 100644 --- a/tests/e2e/tests/EmbedStreamTests.js +++ b/tests/e2e/tests/EmbedStreamTests.js @@ -1,4 +1,4 @@ -const utils = require('../../utils/e2e-mongoose'); +const mongoose = require('../../helpers/mongoose'); const mocks = require('../mocks'); const mockComment = 'This is a test comment.'; @@ -12,7 +12,11 @@ const mockUser = { module.exports = { '@tags': ['embed-stream', 'comment', 'premodoff', 'premodon'], before: () => { - utils.before(); + mongoose.waitTillConnect(function(err) { + if (err) { + console.error(err); + } + }); }, 'User registers and posts a comment with premod off': client => { client.perform((client, done) => { @@ -171,7 +175,11 @@ module.exports = { }); }, after: client => { - utils.after(); + mongoose.disconnect(function(err) { + if (err) { + console.error(err); + } + }); client.end(); } }; diff --git a/tests/e2e/tests/Visitor/SignUpTest.js b/tests/e2e/tests/Visitor/SignUpTest.js index 4c7650630..9399b47ca 100644 --- a/tests/e2e/tests/Visitor/SignUpTest.js +++ b/tests/e2e/tests/Visitor/SignUpTest.js @@ -1,3 +1,5 @@ +const uuid = require('uuid'); + module.exports = { '@tags': ['signup', 'visitor'], before: client => { @@ -9,11 +11,10 @@ module.exports = { }, 'Visitor signs up': client => { const embedStreamPage = client.page.embedStreamPage(); - const hash = Math.floor(Math.random() * (999 - 0)); embedStreamPage .signUp({ - email: `visitor_${hash}@test.com`, + email: `visitor_${uuid.v4()}@test.com`, displayName: 'Visitor', pass: 'testtest' }); diff --git a/tests/helpers/browser.js b/tests/helpers/browser.js index 3fd54e0fe..07da2a566 100644 --- a/tests/helpers/browser.js +++ b/tests/helpers/browser.js @@ -1,3 +1,5 @@ +/* eslint-env browser */ + const jsdom = require('jsdom').jsdom; const fs = require('fs'); const path = require('path'); diff --git a/tests/helpers/mongoose.js b/tests/helpers/mongoose.js new file mode 100644 index 000000000..48cea6b3f --- /dev/null +++ b/tests/helpers/mongoose.js @@ -0,0 +1,38 @@ +const mongoose = require('../../services/mongoose'); + +module.exports = {}; + +module.exports.waitTillConnect = function(done) { + mongoose.connection.on('open', function(err) { + if (err) { + return done(err); + } + + return done(); + }); +}; + +module.exports.clearDB = function(done) { + Promise.all(Object.keys(mongoose.connection.collections).map((collection) => { + return new Promise((resolve, reject) => { + mongoose.connection.collections[collection].remove(function(err) { + if (err) { + return reject(err); + } + + return resolve(); + }); + }); + })) + .then(() => { + done(); + }) + .catch((err) => { + done(err); + }); +}; + +module.exports.disconnect = function(done) { + mongoose.disconnect(); + return done(); +}; diff --git a/tests/models/action.js b/tests/models/action.js index 5f05dea71..a1ebdc027 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -4,24 +4,29 @@ const expect = require('chai').expect; describe('models.Action', () => { let mockActions = []; - beforeEach(() => Action.create([{ - action_type: 'flag', - item_id: '123', - item_type: 'comment', - user_id: 'flagginguserid' - }, { - action_type: 'flag', - item_id: '456', - item_type: 'comment' - }, { - action_type: 'flag', - item_id: '123', - item_type: 'comment' - }, { - action_type: 'like', - item_id: '123', - item_type: 'comment' - }]).then((actions) => { + beforeEach(() => Action.create([ + { + action_type: 'flag', + item_id: '123', + item_type: 'comment', + user_id: 'flagginguserid' + }, + { + action_type: 'flag', + item_id: '456', + item_type: 'comment' + }, + { + action_type: 'flag', + item_id: '123', + item_type: 'comment' + }, + { + action_type: 'like', + item_id: '123', + item_type: 'comment' + } + ]).then((actions) => { mockActions = actions; })); diff --git a/tests/models/user.js b/tests/models/user.js index 3883e2e42..7484880bf 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -80,6 +80,74 @@ describe('models.User', () => { }); + describe('#createEmailConfirmToken', () => { + + it('should create a token for a valid user', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + }); + }); + + it('should not create a token for a user already verified', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }) + .then(() => { + return User.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id); + }) + .catch((err) => { + expect(err).to.have.property('message', 'email address already confirmed'); + }); + }); + + }); + + describe('#verifyEmailConfirmation', () => { + + it('should correctly validate a valid token', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }); + }); + + it('should correctly reject an invalid token', () => { + return User + .verifyEmailConfirmation('cats') + .catch((err) => { + expect(err).to.not.be.null; + }); + }); + + it('should update the user model when verification is complete', () => { + return User + .createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id) + .then((token) => { + expect(token).to.not.be.null; + + return User.verifyEmailConfirmation(token); + }) + .then(() => { + return User.findById(mockUsers[0].id); + }) + .then((user) => { + expect(user.profiles[0]).to.have.property('metadata'); + expect(user.profiles[0].metadata).to.have.property('confirmed_at'); + expect(user.profiles[0].metadata.confirmed_at).to.not.be.null; + }); + }); + + }); + describe('#setStatus', () => { it('should set the status to active', () => { return User diff --git a/tests/mongoose.js b/tests/mongoose.js index 7544072ec..6fac193fa 100644 --- a/tests/mongoose.js +++ b/tests/mongoose.js @@ -1,27 +1,15 @@ -const mongoose = require('../services/mongoose'); +const mongoose = require('./helpers/mongoose'); -beforeEach(function (done) { - function clearDB() { - for (let collection in mongoose.connection.collections) { - mongoose.connection.collections[collection].remove(function() {}); - } - return done(); - } +before(function(done) { + this.timeout(30000); - if (mongoose.connection.readyState === 0) { - mongoose.on('open', function() { - if (err) { - throw err; - } - - return clearDB(); - }); - } else { - return clearDB(); - } + mongoose.waitTillConnect(done); }); -after(function (done) { - mongoose.disconnect(); - return done(); +beforeEach(function(done) { + mongoose.clearDB(done); +}); + +after(function(done) { + mongoose.disconnect(done); }); diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js index 11791ab28..e3907575a 100644 --- a/tests/routes/api/auth/index.js +++ b/tests/routes/api/auth/index.js @@ -4,8 +4,6 @@ const expect = chai.expect; chai.use(require('chai-http')); -const agent = chai.request.agent(app); - const User = require('../../../../models/user'); describe('/api/v1/auth', () => { @@ -14,56 +12,81 @@ describe('/api/v1/auth', () => { return chai.request(app) .get('/api/v1/auth') .then((res) => { - expect(res.status).to.be.equal(200); - expect(res.body).to.have.property('csrfToken'); + expect(res.status).to.be.equal(204); + expect(res).to.not.have.a.body; }); }); }); }); +const Setting = require('../../../../models/setting'); + describe('/api/v1/auth/local', () => { - beforeEach(() => { - return User.createLocalUser('maria@gmail.com', 'password!', 'Maria'); - }); + let mockUser; + beforeEach(() => User.createLocalUser('maria@gmail.com', 'password!', 'Maria').then((user) => { + mockUser = user; + })); - describe('#post', () => { - it('should send back the user on a successful login', () => { - return agent.get('/api/v1/auth') - .then((res) => { - expect(res.status).to.be.equal(200); - expect(res.body).to.have.property('csrfToken'); - return agent.post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!', _csrf: res.body.csrfToken}) - .then((res2) => { - expect(res2).to.have.status(200); - expect(res2).to.be.json; - expect(res2.body).to.have.property('user'); - expect(res2.body.user).to.have.property('displayName', 'Maria'); - }) - .catch((error) => { - expect(error).to.be.null; - }); - }) - .catch((error) => { - expect(error).to.be.null; - }); - }); + describe('email confirmation disabled', () => { - it('should not send back the user on a unsuccessful login', () => { - agent - .get('/api/v1/auth') - .then((res) => { - expect(res.status).to.be.equal(200); - expect(res.body).to.have.property('csrfToken'); - return agent.post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!3', _csrf: res.body.csrfToken}) + beforeEach(() => Setting.init({requireEmailConfirmation: false})); + + describe('#post', () => { + it('should send back the user on a successful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .then((res2) => { + expect(res2).to.have.status(200); + expect(res2).to.be.json; + expect(res2.body).to.have.property('user'); + expect(res2.body.user).to.have.property('displayName', 'Maria'); + }); + }); + + it('should not send back the user on a unsuccessful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!3'}) .catch((err) => { expect(err).to.not.be.null; expect(err.response).to.have.status(401); expect(err.response.body).to.have.property('message', 'not authorized'); }); - }); + }); + + }); + + }); + + describe('email confirmation enabled', () => { + + beforeEach(() => Setting.init({requireEmailConfirmation: true})); + + describe('#post', () => { + it('should not allow a login from a user that is not confirmed', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .catch((err) => { + err.response.should.have.status(401); + + return User.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id); + }) + .then(User.verifyEmailConfirmation) + .then(() => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}); + }) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); }); }); }); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index d4233cafa..5aaad43db 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -4,8 +4,6 @@ const app = require('../../../../app'); const chai = require('chai'); const expect = chai.expect; -const agent = chai.request.agent(app); - // Setup chai. chai.should(); chai.use(require('chai-http')); @@ -185,66 +183,49 @@ describe('/api/v1/comments', () => { ])); it('should create a comment', () => { - agent - .get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset_id, 'parent_id': '', _csrf: resa.body.csrfToken}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - }); + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); }); }); it('should create a comment with a rejected status if it contains a bad word', () => { - agent - .get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': '', _csrf: resa.body.csrfToken}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); - }); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': asset_id, 'parent_id': ''}) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('status', 'rejected'); }); }); it('should create a comment with no status and a flag if it contains a suspected word', () => { - agent - .get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', null); - return Promise.all([ - res.body, - Action.findByType('flag', 'comments') - ]); - }) - .then(([comment, actions]) => { - expect(actions).to.have.length(1); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'suspect words are the most suspicious', 'author_id': '123', 'asset_id': postmod_asset_id, 'parent_id': ''}) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('status', null); + return Promise.all([ + res.body, + Action.findByType('flag', 'comments') + ]); + }) + .then(([comment, actions]) => { + expect(actions).to.have.length(1); - let action = actions[0]; + let action = actions[0]; - expect(action).to.have.property('item_id', comment.id); - expect(action).to.have.property('metadata'); - expect(action.metadata).to.have.property('field', 'body'); - expect(action.metadata).to.have.property('details', 'Matched suspect word filters.'); - }); + expect(action).to.have.property('item_id', comment.id); + expect(action).to.have.property('metadata'); + expect(action.metadata).to.have.property('field', 'body'); + expect(action.metadata).to.have.property('details', 'Matched suspect word filters.'); }); }); @@ -257,14 +238,9 @@ describe('/api/v1/comments', () => { .then(() => asset); }) .then((asset) => { - return agent.get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': '', _csrf: resa.body.csrfToken}); - }); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); }) .then((res) => { expect(res).to.have.status(201); @@ -283,14 +259,9 @@ describe('/api/v1/comments', () => { .then(() => asset); }) .then((asset) => { - return agent.get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'This is way way way way way too long.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': '', _csrf: resa.body.csrfToken}); - }); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'This is way way way way way too long.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); }) .then((res) => { expect(res).to.have.status(201); @@ -306,14 +277,9 @@ describe('/api/v1/comments', () => { closedMessage: 'tests said expired!' }) .then((asset) => { - return agent.get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': '', _csrf: resa.body.csrfToken}); - }); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); }) .then((res) => { expect(res).to.have.status(500); @@ -331,14 +297,9 @@ describe('/api/v1/comments', () => { closedMessage: 'tests said expired!' }) .then((asset) => { - return agent.get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments') - .set(passport.inject({roles: []})) - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': '', _csrf: resa.body.csrfToken}); - }); + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); }) .then((res) => { expect(res).to.have.status(201); @@ -500,22 +461,19 @@ describe('/api/v1/comments/:comment_id/actions', () => { }); describe('#post', () => { - it('it should update actions', () => { - agent.get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/comments/abc/actions') - .set(passport.inject({id: '456', roles: ['admin']})) - .send({'action_type': 'flag', 'detail': 'Comment is too awesome.', _csrf: resa.csrfToken}) - .then((res) => { - expect(res).to.have.status(201); - expect(res).to.have.body; - expect(res.body).to.have.property('action_type', 'flag'); - expect(res.body).to.have.property('metadata') - .and.to.deep.equal({'reason': 'Comment is too awesome.'}); - expect(res.body).to.have.property('item_id', 'abc'); - }); + it('it should create an action', () => { + return chai.request(app) + .post('/api/v1/comments/abc/actions') + .set(passport.inject({id: '456', roles: ['admin']})) + .send({'action_type': 'flag', 'metadata': {'reason': 'Comment is too awesome.'}}) + .then((res) => { + expect(res).to.have.status(201); + expect(res).to.have.body; + + expect(res.body).to.have.property('action_type', 'flag'); + expect(res.body).to.have.property('metadata'); + expect(res.body.metadata).to.deep.equal({'reason': 'Comment is too awesome.'}); + expect(res.body).to.have.property('item_id', 'abc'); }); }); }); diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index ce55836ab..77f96bd9a 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -81,17 +81,15 @@ describe('/api/v1/queue', () => { }); }); - it('should return all the pending comments, users and actions', function(done){ - chai.request(app) + it('should return all the pending comments, users and actions', () => { + return chai.request(app) .get('/api/v1/queue/comments/pending') .set(passport.inject({roles: ['admin']})) - .end(function(err, res){ - expect(err).to.be.null; + .then((res) => { expect(res).to.have.status(200); expect(res.body.comments[0]).to.have.property('body'); expect(res.body.users[0]).to.have.property('displayName'); expect(res.body.actions[0]).to.have.property('action_type'); - done(); }); }); }); diff --git a/tests/routes/api/user/index.js b/tests/routes/api/user/index.js index 774b43a4b..01c48d2de 100644 --- a/tests/routes/api/user/index.js +++ b/tests/routes/api/user/index.js @@ -4,8 +4,6 @@ const app = require('../../../../app'); const chai = require('chai'); const expect = chai.expect; -const agent = chai.request.agent(app); - // Setup chai. chai.should(); chai.use(require('chai-http')); @@ -30,23 +28,17 @@ describe('/api/v1/users/:user_id/actions', () => { describe('#post', () => { it('it should update actions', () => { - agent - .get('/api/v1/auth') - .then((resa) => { - expect(resa.status).to.be.equal(200); - expect(resa.body).to.have.property('csrfToken'); - return agent.post('/api/v1/users/abc/actions') - .set(passport.inject({id: '456', roles: ['admin']})) - .send({'action_type': 'flag', 'detail': 'Bio is too awesome.', _csrf: resa.csrfToken}) - .then((res) => { - expect(res).to.have.status(201); - expect(res).to.have.body; - expect(res.body).to.have.property('action_type', 'flag'); - expect(res.body).to.have.property('metadata') - .and.to.deep.equal({'reason': 'Bio is too awesome.'}); - expect(res.body).to.have.property('item_id', 'abc'); - }) - .catch(err => console.error(err.message)); + return chai.request(app) + .post('/api/v1/users/abc/actions') + .set(passport.inject({id: '456', roles: ['admin']})) + .send({'action_type': 'flag', metadata: {reason: 'Bio is too awesome.'}}) + .then((res) => { + expect(res).to.have.status(201); + expect(res).to.have.body; + expect(res.body).to.have.property('action_type', 'flag'); + expect(res.body).to.have.property('metadata') + .and.to.deep.equal({'reason': 'Bio is too awesome.'}); + expect(res.body).to.have.property('item_id', 'abc'); }); }); }); diff --git a/tests/utils/e2e-mongoose.js b/tests/utils/e2e-mongoose.js deleted file mode 100644 index fd639864e..000000000 --- a/tests/utils/e2e-mongoose.js +++ /dev/null @@ -1,36 +0,0 @@ -const mongoose = require('../../services/mongoose'); - -// Ensure the NODE_ENV is set to 'test', -// this is helpful when you would like to change behavior when testing. -function clearDB() { - - // console.log('Clearing DB', mongoose.connection); - for (let i in mongoose.connection.collections) { - - // console.log('Clearing', i); - mongoose.connection.collections[i].remove(function() {}); - } -} - -module.exports = { - before: () => { - clearDB(); - }, - beforeEach: () => { - if (mongoose.connection.readyState === 0) { - mongoose.on('open', function() { - if (err) { - throw err; - } - - return clearDB(); - }); - } else { - return clearDB(); - } - }, - after: () => { - clearDB(); - mongoose.disconnect(); - } -}; diff --git a/views/admin.ejs b/views/admin.ejs index 49a921875..efcf1dfc9 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -3,6 +3,7 @@
+A email confirmation has been requested for the following account: <%= email %>.
+To confirm the account, please visit the following link: http://example.com/email/confirm/endpoint#<%= token %>
+If you did not request this, you can safely ignore this email.
diff --git a/views/email/email-confirm.txt.ejs b/views/email/email-confirm.txt.ejs new file mode 100644 index 000000000..764991d8d --- /dev/null +++ b/views/email/email-confirm.txt.ejs @@ -0,0 +1,9 @@ +A email confirmation has been requested for the following account: + + <%= email %> + +To confirm the account, please visit the following link: + + http://example.com/email/confirm/endpoint#<%= token %> + +If you did not request this, you can safely ignore this email. diff --git a/views/password-reset-email.ejs b/views/email/password-reset.ejs similarity index 58% rename from views/password-reset-email.ejs rename to views/email/password-reset.ejs index 17ed9e39b..e478ceeba 100644 --- a/views/password-reset-email.ejs +++ b/views/email/password-reset.ejs @@ -1,6 +1,2 @@ -We received a request to reset your password. If you did not request this change, you can ignore this email.
If you did, please click here to reset password.
<%= token %>
-<% } %> diff --git a/views/email/password-reset.txt.ejs b/views/email/password-reset.txt.ejs new file mode 100644 index 000000000..1e44a6629 --- /dev/null +++ b/views/email/password-reset.txt.ejs @@ -0,0 +1,5 @@ +We received a request to reset your password, click here to reset your password: + +<%= rootURL %>/admin/password-reset#<%= token %> + +If you did not request this change, you can ignore this email. diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index f87318db6..722864338 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -1,13 +1,13 @@ + - + - \ No newline at end of file + diff --git a/views/password-reset.ejs b/views/password-reset.ejs index 13652c1c1..ec905c041 100644 --- a/views/password-reset.ejs +++ b/views/password-reset.ejs @@ -3,6 +3,7 @@ +