Added tests + model implementation.

This commit is contained in:
Wyatt Johnson
2017-01-05 17:01:41 -07:00
parent 298e1e8d73
commit be72ebf2a1
7 changed files with 279 additions and 64 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error});
export const saveBio = (user_id, formData) => dispatch => {
dispatch(saveBioRequest());
coralApi('/account/bio', {method: 'PUT', body: formData})
coralApi('/account/settings', {method: 'PUT', body: formData})
.then(() => {
dispatch(addNotification('success', lang.t('successBioUpdate')));
dispatch(saveBioSuccess(formData));
+2 -1
View File
@@ -101,7 +101,8 @@ SettingSchema.method('filterForUser', function(user = false) {
'closeTimeout',
'closedMessage',
'charCountEnable',
'charCount'
'charCount',
'requireEmailConfirmation'
]);
}
+108 -17
View File
@@ -6,6 +6,9 @@ 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;
@@ -526,27 +529,45 @@ UserService.createPasswordResetToken = function (email) {
version: user.__v
};
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {algorithm: 'HS256'}, {
expiresIn: '1d'
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, (err, decoded) => {
// 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 => 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));
};
/**
@@ -587,27 +608,23 @@ 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.update({
UserService.updateSettings = (id, settings) => UserModel.update({
id
}, {
$set: {
'settings.bio': bio
settings
}
});
@@ -625,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()
}
});
});
};
+38 -15
View File
@@ -4,10 +4,46 @@ 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
@@ -52,15 +88,6 @@ router.post('/password/reset', (req, res, next) => {
});
});
// 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;
/**
* expects 2 fields in the body of the request
* 1) the token that was in the url of the email link {String}
@@ -95,18 +122,14 @@ router.put('/password/reset', (req, res, next) => {
});
});
router.put('/bio', authorization.needed(), (req, res, next) => {
router.put('/settings', authorization.needed(), (req, res, next) => {
const {
bio
} = req.body;
if (!bio) {
return next(new Error('You must submit a new bio'));
}
User
.addBio(req.user.id, bio)
.updateSettings(req.user.id, {bio})
.then(() => {
res.status(204).end();
})
-1
View File
@@ -27,7 +27,6 @@ describe('models.Action', () => {
item_type: 'comment'
}
]).then((actions) => {
console.log('all created');
mockActions = actions;
}));
+68
View File
@@ -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
+62 -29
View File
@@ -20,40 +20,73 @@ describe('/api/v1/auth', () => {
});
const Setting = require('../../../../models/setting');
const settings = {id: '1'};
describe('/api/v1/auth/local', () => {
beforeEach(() => Promise.all([
User.createLocalUser('maria@gmail.com', 'password!', 'Maria'),
Setting.init(settings)
]));
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 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');
})
.catch((err) => {
console.error(err);
});
});
describe('email confirmation disabled', () => {
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((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');
});
});
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');
});
});
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');
});
});
});
});
});