diff --git a/actions/resetPassword.js b/actions/resetPassword.js index e2bfafe21..da8e2010a 100644 --- a/actions/resetPassword.js +++ b/actions/resetPassword.js @@ -1,20 +1,24 @@ /* * Copyright (C) 2014 TopCoder Inc., All Rights Reserved. * - * @version 1.1 - * @author LazyChild, isv + * @version 1.2 + * @author LazyChild, isv, Ghost_141 * * changes in 1.1 * - implemented generateResetToken function + * Changes in 1.2: + * - Implement the Reset Password API */ "use strict"; var async = require('async'); var stringUtils = require("../common/stringUtils.js"); var moment = require('moment-timezone'); +var _ = require('underscore'); var NotFoundError = require('../errors/NotFoundError'); var BadRequestError = require('../errors/BadRequestError'); +var IllegalArgumentError = require('../errors/IllegalArgumentError'); var UnauthorizedError = require('../errors/UnauthorizedError'); var ForbiddenError = require('../errors/ForbiddenError'); var TOKEN_ALPHABET = stringUtils.ALPHABET_ALPHA_EN + stringUtils.ALPHABET_DIGITS_EN; @@ -46,30 +50,82 @@ var resolveUserByHandleOrEmail = function (handle, email, api, dbConnectionMap, }; /** - * This is the function that stub reset password + * Reset Password. * * @param {Object} api - The api object that is used to access the global infrastructure * @param {Object} connection - The connection object for the current request * @param {Function} next - The callback to be called after this function is done */ function resetPassword(api, connection, next) { - var result, helper = api.helper; + var result, helper = api.helper, sqlParams, userId, ldapEntryParams, oldPassword, + dbConnectionMap = connection.dbConnectionMap, + token = connection.params.token, + handle = decodeURI(connection.params.handle).toLowerCase(), + newPassword = connection.params.password, + tokenKey = api.config.general.resetTokenPrefix + handle + api.config.general.resetTokenSuffix; + async.waterfall([ function (cb) { - if (connection.params.handle === "nonValid") { - cb(new BadRequestError("The handle you entered is not valid")); - } else if (connection.params.handle === "badLuck") { - cb(new Error("Unknown server error. Please contact support.")); - } else if (connection.params.token === "unauthorized_token") { - cb(new UnauthorizedError("Authentication credentials were missing or incorrect.")); - } else if (connection.params.token === "forbidden_token") { - cb(new ForbiddenError("The request is understood, but it has been refused or access is not allowed.")); - } else { - result = { - "description": "Your password has been reset!" - }; - cb(); + var error = helper.checkStringPopulated(token, 'token') || + helper.checkStringPopulated(handle, 'handle') || + helper.validatePassword(newPassword); + if (error) { + cb(error); + return; + } + sqlParams = { + handle: handle + }; + api.dataAccess.executeQuery('get_user_information', sqlParams, dbConnectionMap, cb); + }, + function (result, cb) { + if (result.length === 0) { + cb(new NotFoundError('The user is not exist.')); + return; + } + userId = result[0].user_id; + oldPassword = helper.decodePassword(result[0].old_password, helper.PASSWORD_HASH_KEY); + sqlParams.handle = result[0].handle; + helper.getCachedValue(tokenKey, cb); + }, + function (cache, cb) { + if (!_.isDefined(cache)) { + // The token is either not assigned or is expired. + cb(new BadRequestError('The token is expired or not existed. Please apply a new one.')); + return; + } + if (cache !== token) { + // The token don't match + cb(new IllegalArgumentError('The token is incorrect.')); + return; + } + sqlParams.password = helper.encodePassword(newPassword, helper.PASSWORD_HASH_KEY); + api.dataAccess.executeQuery('update_password', sqlParams, dbConnectionMap, cb); + }, + function (count, cb) { + if (count !== 1) { + cb(new Error('password is not updated successfully')); + return; } + ldapEntryParams = { + userId: userId, + handle: sqlParams.handle, + oldPassword: oldPassword, + newPassword: newPassword + }; + api.ldapHelper.updateMemberPasswordLDAPEntry(ldapEntryParams, cb); + }, + function (cb) { + // Delete the token from cache system. + api.cache.destroy(tokenKey, function (err) { + cb(err); + }); + }, + function (cb) { + result = { + description: 'Your password has been reset!' + }; + cb(); } ], function (err) { if (err) { @@ -93,7 +149,7 @@ function resetPassword(api, connection, next) { * @param {Function} callback - the callback function. */ var generateResetToken = function (userHandle, userEmailAddress, api, callback) { - var tokenCacheKey = 'tokens-' + userHandle + '-reset-token', + var tokenCacheKey = api.config.general.resetTokenPrefix + userHandle + api.config.general.resetTokenSuffix, current, expireDate, expireDateString, @@ -144,10 +200,16 @@ exports.resetPassword = { blockedConnectionTypes: [], outputExample: {}, version: 'v2', - cacheEnabled: false, + transaction: 'write', + cacheEnabled: false, + databases: ["common_oltp"], run: function (api, connection, next) { - api.log("Execute resetPassword#run", 'debug'); - resetPassword(api, connection, next); + if (connection.dbConnectionMap) { + api.log("Execute resetPassword#run", 'debug'); + resetPassword(api, connection, next); + } else { + api.helper.handleNoConnection(api, connection, next); + } } }; diff --git a/common/stringUtils.js b/common/stringUtils.js index 0187d02a6..22fa756b7 100644 --- a/common/stringUtils.js +++ b/common/stringUtils.js @@ -1,10 +1,11 @@ /* - * Copyright (C) 2013-2014 TopCoder Inc., All Rights Reserved. + * Copyright (C) 2013 - 2014 TopCoder Inc., All Rights Reserved. * - * Version: 1.1 - * Author: isv - * - * changes in 1.1: + * Version: 1.2 + * Author: TCSASSEMBLER, Ghost_141, isv + * Changes in 1.1: + * - add PUNCTUATION and PASSWORD_ALPHABET. + * Changes in 1.2: * - add generateRandomString function. */ @@ -21,6 +22,18 @@ var ALPHABET_ALPHA_EN = ALPHABET_ALPHA_LOWER_EN + ALPHABET_ALPHA_UPPER_EN; var ALPHABET_DIGITS_EN = "0123456789"; +/** + * The valid characters for punctuation. + * @since 1.1 + */ +var PUNCTUATION = "-_.{}[]()"; + +/** + * The valid characters for password. + * @since 1.1 + */ +var PASSWORD_ALPHABET = ALPHABET_ALPHA_EN + ALPHABET_DIGITS_EN + PUNCTUATION; + /** * Checks if string has all its characters in alphabet given. * @@ -62,4 +75,6 @@ exports.generateRandomString = function (alphabet, length) { exports.ALPHABET_ALPHA_UPPER_EN = ALPHABET_ALPHA_UPPER_EN; exports.ALPHABET_ALPHA_LOWER_EN = ALPHABET_ALPHA_LOWER_EN; exports.ALPHABET_ALPHA_EN = ALPHABET_ALPHA_EN; -exports.ALPHABET_DIGITS_EN = ALPHABET_DIGITS_EN; \ No newline at end of file +exports.ALPHABET_DIGITS_EN = ALPHABET_DIGITS_EN; +exports.PUNCTUATION = PUNCTUATION; +exports.PASSWORD_ALPHABET = PASSWORD_ALPHABET; diff --git a/config.js b/config.js index dafc1334f..fa1453f95 100644 --- a/config.js +++ b/config.js @@ -2,7 +2,7 @@ * Copyright (C) 2013 - 2014 TopCoder Inc., All Rights Reserved. * * @author vangavroche, Ghost_141, kurtrips, Sky_, isv, bugbuka - * @version 1.20 + * @version 1.21 * changes in 1.1: * - add defaultCacheLifetime parameter * changes in 1.2: @@ -48,6 +48,9 @@ * changes in 1.20: * - add tcForumsServer property. * - add studioForumsServer property. + * Changes in 1.21: + * - add minPasswordLength and maxPasswordLength + * - add resetTokenSuffix */ "use strict"; @@ -89,6 +92,10 @@ config.general = { defaultCacheLifetime : process.env.CACHE_EXPIRY || 1000 * 60 * 10, //10 min default defaultAuthMiddlewareCacheLifetime : process.env.AUTH_MIDDLEWARE_CACHE_EXPIRY || 1000 * 60 * 10, //10 min default defaultUserCacheLifetime: process.env.USER_CACHE_EXPIRY || 1000 * 60 * 60 * 24, //24 hours default + resetTokenPrefix: 'tokens-', + resetTokenSuffix: '-reset-token', + minPasswordLength: 8, + maxPasswordLength: 30, defaultResetPasswordTokenCacheLifetime: process.env.RESET_PASSWORD_TOKEN_CACHE_EXPIRY ? parseInt(process.env.RESET_PASSWORD_TOKEN_CACHE_EXPIRY, 10) : 1000 * 60 * 30, //30 min resetPasswordTokenEmailSubject: process.env.RESET_PASSWORD_TOKEN_EMAIL_SUBJECT || "TopCoder Account Password Reset", cachePrefix: '', diff --git a/deploy/development.bat b/deploy/development.bat index 4639c3dc0..ce723cd83 100644 --- a/deploy/development.bat +++ b/deploy/development.bat @@ -11,7 +11,6 @@ REM - added RESET_PASSWORD_TOKEN_EMAIL_SUBJECT environment variable REM - added REDIS_HOST environment variable REM - added REDIS_PORT environment variable - REM tests rely on caching being off. But set this to a real value (or remove) while coding. set VM_IP=%TC_VM_IP% @@ -78,8 +77,8 @@ set DEV_FORUM_JNDI=jnp://env.topcoder.com:1199 set ACTIONHERO_CONFIG=./config.js REM The period for expiring the generated tokens for password resetting (in milliseconds) -set RESET_PASSWORD_TOKEN_CACHE_EXPIRY=15000 set RESET_PASSWORD_TOKEN_EMAIL_SUBJECT=TopCoder Account Password Reset +set RESET_PASSWORD_TOKEN_CACHE_EXPIRY=180000 rem set REDIS_HOST=localhost rem set REDIS_PORT=6379 diff --git a/deploy/development.sh b/deploy/development.sh index 4f232b681..6cba8a38b 100755 --- a/deploy/development.sh +++ b/deploy/development.sh @@ -80,8 +80,9 @@ export DEV_FORUM_JNDI=jnp://env.topcoder.com:1199 export ACTIONHERO_CONFIG=./config.js ## The period for expiring the generated tokens for password resetting -export RESET_PASSWORD_TOKEN_CACHE_EXPIRY=1800000 export RESET_PASSWORD_TOKEN_EMAIL_SUBJECT=TopCoder Account Password Reset +# Set this to 180000 which is 3 mins. This will help saving time for test. +export RESET_PASSWORD_TOKEN_CACHE_EXPIRY=180000 export REDIS_HOST=localhost export REDIS_PORT=6379 diff --git a/initializers/helper.js b/initializers/helper.js index 4c2f90cf9..ed0202905 100644 --- a/initializers/helper.js +++ b/initializers/helper.js @@ -6,7 +6,7 @@ /** * This module contains helper functions. * @author Sky_, Ghost_141, muzehyun, kurtrips, isv, LazyChild, hesibo - * @version 1.22 + * @version 1.23 * changes in 1.1: * - add mapProperties * changes in 1.2: @@ -62,6 +62,10 @@ * - add LIST_TYPE_REGISTRATION_STATUS_MAP and VALID_LIST_TYPE. * Changes in 1.22: * - add allTermsAgreed method. + * Changes in 1.23: + * - add validatePassword method. + * - introduce the stringUtils in this file. + * - add PASSWORD_HASH_KEY. */ "use strict"; @@ -79,6 +83,7 @@ if (typeof String.prototype.startsWith !== 'function') { var async = require('async'); var _ = require('underscore'); var moment = require('moment'); +var stringUtils = require('../common/stringUtils'); var IllegalArgumentError = require('../errors/IllegalArgumentError'); var NotFoundError = require('../errors/NotFoundError'); var BadRequestError = require('../errors/BadRequestError'); @@ -120,6 +125,13 @@ helper.both = { */ helper.MAX_INT = 2147483647; +/** + * HASH KEY For Password + * + * @since 1.23 + */ +helper.PASSWORD_HASH_KEY = process.env.PASSWORD_HASH_KEY || 'default'; + /** * The name in api response to database name map. */ @@ -1197,12 +1209,42 @@ helper.checkUserExists = function (handle, api, dbConnectionMap, callback) { }); }; +/** + * Validate the given password value. + * @param {String} password - the password value. + * @returns {Object} - Return error if the given password is invalid. + * @since 1.23 + */ +helper.validatePassword = function (password) { + var value = password.trim(), + configGeneral = helper.api.config.general, + i, + error; + error = helper.checkStringPopulated(password, 'password'); + if (error) { + return error; + } + if (value.length > configGeneral.maxPasswordLength) { + return new IllegalArgumentError('password may contain at most ' + configGeneral.maxPasswordLength + ' characters.'); + } + if (value.length < configGeneral.minPasswordLength) { + return new IllegalArgumentError('password must be at least ' + configGeneral.minPasswordLength + ' characters in length.'); + } + for (i = 0; i < password.length; i += 1) { + if (!_.contains(stringUtils.PASSWORD_ALPHABET, password.charAt(i))) { + return new IllegalArgumentError('Your password may contain only letters, numbers and ' + stringUtils.PUNCTUATION); + } + } + + return null; +}; + /** * check if the every terms has been agreed * * @param {Array} terms - The terms. * @returns {Boolean} true if all terms agreed otherwise false. - * @since 1.16 + * @since 1.22 */ helper.allTermsAgreed = function (terms) { return _.every(terms, function (term) { diff --git a/initializers/ldapHelper.js b/initializers/ldapHelper.js index ab922a797..d41706d69 100644 --- a/initializers/ldapHelper.js +++ b/initializers/ldapHelper.js @@ -1,12 +1,14 @@ /*jslint nomen: true */ /* - * Copyright (C) 2013 TopCoder Inc., All Rights Reserved. + * Copyright (C) 2013 - 2014 TopCoder Inc., All Rights Reserved. * - * Version: 1.1 - * Author: TCSASSEMBLER, muzehyun + * Version: 1.2 + * Author: TCSASSEMBLER, muzehyun, Ghost_141 * changes in 1.1 * - add retrieveMemberProfileLDAPEntry * - fix bugs (returing too early without any result) + * Changes in 1.2: + * - updateMemberPasswordLDAPEntry for update member password. */ "use strict"; @@ -253,7 +255,6 @@ exports.ldapHelper = function (api, next) { /** * Main function of addMemberProfileLDAPEntry * - * @param {Object} api - object used to access infrastructure * @param {Object} params require fields: userId, handle, password * @param {Function} next - callback function */ @@ -303,7 +304,6 @@ exports.ldapHelper = function (api, next) { /** * Main function of removeMemberProfileLDAPEntry * - * @param {Object} api - object used to access infrastructure * @param {Object} userId - the user id * @param {Function} next - callback function */ @@ -413,7 +413,7 @@ exports.ldapHelper = function (api, next) { ], function (err, result) { var entry; if (result.length >= 2) { - entry = result[2]; + entry = result[2]; } if (err) { error = result.pop(); @@ -424,6 +424,56 @@ exports.ldapHelper = function (api, next) { api.log('Leave retrieveMemberProfileLDAPEntry', 'debug'); next(err, entry); }); + return next(null, true); + }, + + /** + * Main function of updateMemberPasswordLDAPEntry + * + * @param {Object} params require fields: userId, handle, newPassword, oldPassword + * @param {Function} next - callback function + * @since 1.1 + */ + updateMemberPasswordLDAPEntry: function (params, next) { + api.log('Enter updateMemberPasswordLDAPEntry', 'debug'); + + var client, error, index, requiredParams = ['userId', 'handle', 'newPassword', 'oldPassword']; + + for (index = 0; index < requiredParams.length; index += 1) { + error = api.helper.checkDefined(params[requiredParams[index]], requiredParams[index]); + if (error) { + api.log('updateMemberPasswordLDAPEntry: error occurred: ' + error + " " + (error.stack || ''), "error"); + next(error, null); + return; + } + } + try { + async.series([ + function (callback) { + client = createClient(); + callback(null, 'create client'); + }, + function (callback) { + bindClient(api, client, callback); + }, + function (callback) { + passwordModify(api, client, params, callback); + } + ], function (err, result) { + if (err) { + error = result.pop(); + api.log('updateMemberPasswordLDAPEntry: error occurred: ' + err + " " + (err.stack || ''), "error"); + next(error, null); + } else { + client.unbind(); + api.log('Leave updateMemberPasswordLDAPEntry', 'debug'); + next(); + } + }); + } catch (err) { + console.log('CAUGHT: ' + err); + next(error, null); + } } }; next(); diff --git a/queries/get_user_information b/queries/get_user_information new file mode 100644 index 000000000..a56b0183c --- /dev/null +++ b/queries/get_user_information @@ -0,0 +1,7 @@ +SELECT + u.user_id +, u.handle +, su.password AS old_password +FROM user u +INNER JOIN security_user su ON su.user_id = u.handle +WHERE handle_lower = LOWER('@handle@') diff --git a/queries/get_user_information.json b/queries/get_user_information.json new file mode 100644 index 000000000..1552ca16e --- /dev/null +++ b/queries/get_user_information.json @@ -0,0 +1,5 @@ +{ + "name" : "get_user_information", + "db" : "common_oltp", + "sqlfile" : "get_user_information" +} diff --git a/queries/update_password b/queries/update_password new file mode 100644 index 000000000..68963e985 --- /dev/null +++ b/queries/update_password @@ -0,0 +1 @@ +UPDATE security_user SET password = '@password@' WHERE user_id = '@handle@' diff --git a/queries/update_password.json b/queries/update_password.json new file mode 100644 index 000000000..b75812766 --- /dev/null +++ b/queries/update_password.json @@ -0,0 +1,5 @@ +{ + "name" : "update_password", + "db" : "common_oltp", + "sqlfile" : "update_password" +} diff --git a/routes.js b/routes.js index ddac9d330..5c396a594 100755 --- a/routes.js +++ b/routes.js @@ -231,8 +231,8 @@ exports.routes = { ].concat(testMethods.get), post: [ // Stub API - { path: "/:apiVersion/users/resetPassword/:handle", action: "resetPassword" }, + { path: "/:apiVersion/users/resetPassword/:handle", action: "resetPassword" }, { path: "/:apiVersion/develop/reviewOpportunities/:challengeId/apply", action: "applyDevelopReviewOpportunity" }, { path: "/:apiVersion/terms/docusignCallback", action: "docusignCallback" }, { path: "/:apiVersion/terms/:termsOfUseId/agree", action: "agreeTermsOfUse" }, diff --git a/test/helpers/testHelper.js b/test/helpers/testHelper.js index 8a05944e7..2e91fabdb 100644 --- a/test/helpers/testHelper.js +++ b/test/helpers/testHelper.js @@ -1,7 +1,7 @@ /* * Copyright (C) 2013 - 2014 TopCoder Inc., All Rights Reserved. * - * @version 1.4 + * @version 1.5 * @author Sky_, muzehyun, Ghost_141, OlinaRuan * changes in 1.1: * - add getTrimmedData method @@ -11,6 +11,10 @@ * - add getAdminJwt and getMemberJwt * changes in 1.4 * - add updateTextColumn method. + * Changes in 1.5: + * - add deleteCachedKey, addCacheValue and getCachedValue method. + * - add PASSWORD_HASH_KEY + * - remove unused dependency. */ "use strict"; /*jslint node: true, stupid: true, unparam: true */ @@ -18,11 +22,11 @@ var async = require('async'); var fs = require('fs'); -var util = require('util'); var _ = require('underscore'); var assert = require('chai').assert; var crypto = require("crypto"); var jwt = require('jsonwebtoken'); +var redis = require('redis'); /** * The test helper @@ -51,6 +55,12 @@ var DEFAULT_TIMEOUT = 30000; // 30s var CLIENT_ID = configs.config.general.oauthClientId; var SECRET = configs.config.general.oauthClientSecret; +/** + * The password hash key. + * @since 1.5 + */ +helper.PASSWORD_HASH_KEY = process.env.PASSWORD_HASH_KEY || "default"; + /** * create connection for given database * @param {String} databaseName - the database name @@ -400,4 +410,49 @@ helper.getMemberJwt = function (userId) { return jwt.sign({sub: "ad|" + (userId || "132458")}, SECRET, {expiresInMinutes: 1000, audience: CLIENT_ID}); }; +/** + * Get cached value from redis server. + * @param {String} key - the key value. + * @param {Function} cb - the callback function. + * @since 1.5 + */ +helper.getCachedValue = function (key, cb) { + var client = redis.createClient(); + client.get(key, function (err, value) { + cb(err, JSON.parse(value)); + }); + // Quit the client. + client.quit(); +}; + +/** + * Delete the key from redis server. + * + * @param {String} key - the key value. + * @param {Function} cb - the callback function. + * @since 1.5 + */ +helper.deleteCachedKey = function (key, cb) { + var client = redis.createClient(); + client.del(key, function (err) { + cb(err); + client.quit(); + }); +}; + +/** + * Add cache to redis server. + * @param {String} key - the key for cache value. + * @param {Object} value - the value to cache. + * @param {Function} cb - the callback function. + * @since 1.5 + */ +helper.addCacheValue = function (key, value, cb) { + var client = redis.createClient(); + client.set(key, JSON.stringify(value), function (err) { + cb(err); + client.quit(); + }); +}; + module.exports = helper; diff --git a/test/test.generateResetToken.js b/test/test.generateResetToken.js new file mode 100644 index 000000000..fdf814053 --- /dev/null +++ b/test/test.generateResetToken.js @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2014 TopCoder Inc., All Rights Reserved. + * + * @version 1.0 + * @author isv + */ +"use strict"; + +/*global describe, it, before, beforeEach, after, afterEach, __dirname */ +/*jslint node: true, stupid: true, unparam: true */ + +/** + * Module dependencies. + */ +var request = require('supertest'); +var assert = require('chai').assert; +var async = require('async'); +var testHelper = require('./helpers/testHelper'); +var stringUtils = require("../common/stringUtils.js"); +var redis = require('redis'); + +var API_ENDPOINT = process.env.API_ENDPOINT || 'http://localhost:8080'; +var SQL_DIR = __dirname + "/sqls/resetPassword/"; +var DATABASE_NAME = "common_oltp"; +var TOKEN_LIFETIME = require('../config').config.general.defaultResetPasswordTokenCacheLifetime; +var IS_FAKE_REDIS_USED = require('../config').config.redis.fake; +if (typeof TOKEN_LIFETIME === 'string') { + TOKEN_LIFETIME = parseInt(TOKEN_LIFETIME, 10); +} + +var CLIENT_ID = require('../config').config.general.oauthClientId; +var SECRET = require('../config').config.general.oauthClientSecret; +var jwt = require('jsonwebtoken'); + +describe('Test Generate Reset Token API', function () { + this.timeout(120000); + + /** + * Gets the token which must have been generated for the specified user and saved to Redis database. + * + * @param {String} handle - the username to get the token for. + * @param {Function} callback - the callback function. + */ + function getCachedToken(handle, callback) { + var client = redis.createClient(); + client.get('tokens-' + handle + '-reset-token', function (err, value) { + callback(err, JSON.parse(value)); + }); + client.quit(); + } + + /** + * Delays the execution of current thread to let the token generated previously ot expire. + */ + function delay() { + var delayPeriod = TOKEN_LIFETIME + 1000, + now = new Date(), + desiredTime = new Date(); + desiredTime.setTime(now.getTime() + delayPeriod); + while (now < desiredTime) { + now = new Date(); + } + console.log("The token should have expired."); + } + + /** + * Clear database + * @param {Function} done the callback + */ + function clearDb(done) { + testHelper.runSqlFile(SQL_DIR + "common_oltp__clean", DATABASE_NAME, done); + } + + /** + * This function is run before all tests. + * Generate tests data. + * @param {Function} done the callback + */ + before(function (done) { + async.waterfall([ + clearDb, + function (cb) { + testHelper.runSqlFile(SQL_DIR + "common_oltp__insert_test_data", DATABASE_NAME, cb); + } + ], done); + }); + + /** + * This function is run after all tests. + * Clean up all data. + * @param {Function} done the callback + */ + after(function (done) { + clearDb(done); + }); + + /** + * Tests the generateResetToken action against failure test case. Posts a request for generating the token for + * user specified by handle or email and expects the server to respond with HTTP response of specified status + * providing the specified expected error details. + * + * @param {String} handle - a handle for user to pass to tested action. + * @param {String} email - an email for user to pass to tested action. + * @param {Number} expectedStatusCode - status code for HTTP response expected to be returned from server. + * @param {String} expectedErrorMessage - error message expected to be returned from server. + * @param {Function} callback - a callback to be called when test finishes. + */ + function testFailureScenario(handle, email, expectedStatusCode, expectedErrorMessage, callback) { + var queryParams = '?'; + if (handle !== null) { + queryParams += 'handle=' + handle; + } + if (email !== null) { + queryParams += '&email=' + email; + } + + request(API_ENDPOINT) + .get('/v2/users/resetToken' + queryParams) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(expectedStatusCode) + .end(function (err, res) { + if (err) { + callback(err); + return; + } + var body = res.body; + assert.equal(body.error.details, expectedErrorMessage); + callback(); + }); + } + + /** + * Tests the generateResetToken action against success test case. Posts a request for generating the token for + * user specified by handle or email and expects the server to respond with HTTP response of 200 OK status and + * return generated token or social login provider name in case the handle or email corresponds to social login. + * + * @param {String} handle - a handle for user to pass to tested action. + * @param {String} email - an email for user to pass to tested action. + * @param {String} socialLoginProvider - a name for social login provider in case specified handle is from social + * login. + * @param {Function} callback - a callback to be called when test finishes. + * @param {String} handleForEmail - a user handle corresponding to specified email address. This is just for tests + * which pass email and expect the token to be generated. + * @param {boolean} skipCheckingTokenInRedis - flag indicating if test has skip checking the token for presence in + * Redis database. + */ + function testSuccessScenario(handle, email, socialLoginProvider, callback, handleForEmail, skipCheckingTokenInRedis) { + var queryParams = '?'; + if (handle !== null) { + queryParams += 'handle=' + handle; + } + if (email !== null) { + queryParams += '&email=' + email; + } + + request(API_ENDPOINT) + .get('/v2/users/resetToken' + queryParams) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .end(function (err, res) { + assert.notOk(err, 'There should be no error for successful scenario'); + + var body = res.body, + alphabet = stringUtils.ALPHABET_ALPHA_EN + stringUtils.ALPHABET_DIGITS_EN, + i, + ch; + + if (socialLoginProvider === null) { + assert.ok(body.successful, "There is no successful returned"); + assert.isTrue(body.successful, "Wrong successful flag is returned"); + if (!IS_FAKE_REDIS_USED && !skipCheckingTokenInRedis) { + async.waterfall([ + function (cb) { + if (handle) { + getCachedToken(handle, cb); + } else { + getCachedToken(handleForEmail, cb); + } + } + ], function (err, token) { + assert.ok(token, 'The token is not stored in Redis database'); + assert.equal(token.value.length, 6, "Token of wrong length returned"); + for (i = 0; i < token.value.length; i = i + 1) { + ch = token.value.charAt(i); + assert.isTrue(alphabet.indexOf(ch) >= 0, "Token contains wrong character '" + ch + "'"); + } + callback(err); + }); + } else { + callback(); + } + } else { + assert.ok(body.socialProvider, "There is no social login provider name returned"); + assert.equal(body.socialProvider, socialLoginProvider, "Wrong social login provider name returned"); + callback(); + } + }); + } + + // Failure test cases + it('Neither handle nor email are provided - should return HTTP 400', function (done) { + testFailureScenario(null, null, 400, 'Either handle or email must be specified', done); + }); + + it('Both handle and email are provided - should return HTTP 400', function (done) { + testFailureScenario("heffan", "foo@bar.com", 400, 'Both handle and email are specified', done); + }); + + it('Both empty handle and email are provided - should return HTTP 400', function (done) { + testFailureScenario("", "", 400, 'Either handle or email must be specified', done); + }); + + it('Empty handle provided - should return HTTP 400', function (done) { + testFailureScenario("", null, 400, 'Either handle or email must be specified', done); + }); + + it('Empty email provided - should return HTTP 400', function (done) { + testFailureScenario(null, "", 400, 'Either handle or email must be specified', done); + }); + + it('Non-existing handle is provided - should return HTTP 404', function (done) { + testFailureScenario("Undioiwfibiiv3vb3i", null, 404, 'User does not exist', done); + }); + + it('Non-existing email is provided - should return HTTP 404', function (done) { + testFailureScenario(null, '912837197@akjsdnakd.com', 404, 'User does not exist', done); + }); + + it('Non-expired token already exists - should return HTTP 400', function (done) { + // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the + // generated token to expire + this.timeout(TOKEN_LIFETIME * 2); + + async.waterfall([ + function (cb) { + testSuccessScenario('normal_user_11', null, null, cb, null, false); + }, function (cb) { + testFailureScenario('normal_user_11', null, 400, "You have already requested the reset token, " + + "please find it in your email inbox. If it's not there. Please contact support@topcoder.com.", + cb); + }, function (cb) { + console.log("\nWaiting for generated token to expire to prevent multiple test suite execution to fail (" + + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); + delay(); + cb(); + } + ], function (err) { + done(err); + }); + }); + + // Accuracy test cases + it('Existing TopCoder username is provided - should respond with HTTP 200 and return token', function (done) { + testSuccessScenario('normal_user_13', null, null, done, null, false); + }); + + it('Existing email address is provided - should respond with HTTP 200 and return token', function (done) { + testSuccessScenario(null, 'normal_user_14@test.com', null, done, 'normal_user_14', false); + }); + + it('Existing social login handle is provided - should respond with HTTP 200 and provider name', function (done) { + testSuccessScenario('user2', null, 'Facebook', done, null, true); + }); + + it('Existing social login email is provided - should respond with HTTP 200 and provider name', function (done) { + testSuccessScenario(null, 'social.email21@test.com', 'Twitter', done, null, true); + }); + + it('Username that matches handle for TC user account (which also have a social login username) and social ' + + 'login username for another TC user account is provided - should respond with HTTP 200 and social ' + + 'provider name for user with matching TC handle', function (done) { + testSuccessScenario('common_handle', null, 'Google', done, null, true); + }); + + it('Username that matches handle for TC user account (which does not have a social login username) and social ' + + 'login username for another TC user account is provided - should respond with HTTP 200 and generated ' + + 'token for user with matching TC handle', function (done) { + testSuccessScenario('common_handle2', null, null, done, null, false); + }); + + it('Email address that matches email for TC user account (which also have a social login username) and social ' + + 'login email for another TC user account is provided - should respond with HTTP 200 and social ' + + 'provider name for user with matching TC email address', function (done) { + testSuccessScenario(null, 'common_email@test.com', 'Google', done, null, true); + }); + + it('Email address that matches email for TC user account (which does not have a social login account) and social ' + + 'login email for another TC user account is provided - should respond with HTTP 200 and generated ' + + 'token for user with matching TC email', function (done) { + testSuccessScenario(null, 'common_email2@test.com', null, done, 'normal_user_25', false); + }); + + it('Requesting new token once previous has expired - should respond with HTTP 200 and new token', function (done) { + // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the + // generated token to expire + this.timeout(TOKEN_LIFETIME * 2); + + async.waterfall([ + function (cb) { + testSuccessScenario('normal_user_15', null, null, cb, null, false); + }, function (cb) { + console.log("\nWaiting for generated token to expire (" + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); + delay(); + testSuccessScenario('normal_user_15', null, null, cb, null, true); + } + ], function (err) { + done(err); + }); + }); +}); diff --git a/test/test.resetPassword.js b/test/test.resetPassword.js index 3209535ec..bca68e99b 100644 --- a/test/test.resetPassword.js +++ b/test/test.resetPassword.js @@ -2,72 +2,51 @@ * Copyright (C) 2014 TopCoder Inc., All Rights Reserved. * * @version 1.0 - * @author isv + * @author Ghost_141 */ -"use strict"; - -/*global describe, it, before, beforeEach, after, afterEach, __dirname */ +'use strict'; +/*global describe, it, before, beforeEach, after, afterEach */ /*jslint node: true, stupid: true, unparam: true */ /** * Module dependencies. */ +var fs = require('fs'); var request = require('supertest'); var assert = require('chai').assert; var async = require('async'); -var testHelper = require('./helpers/testHelper'); -var stringUtils = require("../common/stringUtils.js"); -var redis = require('redis'); +var testHelper = require('./helpers/testHelper'); +var configs = require('../config'); var API_ENDPOINT = process.env.API_ENDPOINT || 'http://localhost:8080'; -var SQL_DIR = __dirname + "/sqls/resetPassword/"; -var DATABASE_NAME = "common_oltp"; -var TOKEN_LIFETIME = require('../config').config.general.defaultResetPasswordTokenCacheLifetime; -var IS_FAKE_REDIS_USED = require('../config').config.redis.fake; -if (typeof TOKEN_LIFETIME === 'string') { - TOKEN_LIFETIME = parseInt(TOKEN_LIFETIME, 10); -} -var CLIENT_ID = require('../config').config.general.oauthClientId; -var SECRET = require('../config').config.general.oauthClientSecret; -var jwt = require('jsonwebtoken'); +describe('Reset Password API', function () { + this.timeout(120000); // The api with testing remote db could be quit slow -describe('Test Generate Reset Token API', function () { - - /** - * Gets the token which must have been generated for the specified user and saved to Redis database. - * - * @param {String} handle - the username to get the token for. - * @param {Function} callback - the callback function. - */ - function getCachedToken(handle, callback) { - var client = redis.createClient(); - client.get('tokens-' + handle + '-reset-token', function (err, value) { - callback(err, JSON.parse(value)); - }); - client.quit(); - } - - /** - * Delays the execution of current thread to let the token generated previously ot expire. - */ - function delay() { - var delayPeriod = TOKEN_LIFETIME + 1000, - now = new Date(), - desiredTime = new Date(); - desiredTime.setTime(now.getTime() + delayPeriod); - while (now < desiredTime) { - now = new Date(); - } - console.log("The token should have expired."); - } + var errorObject = require('../test/test_files/expected_reset_password_error_message'), + configGeneral = configs.config.general, + heffan = configGeneral.cachePrefix + configGeneral.resetTokenPrefix + 'heffan' + configGeneral.resetTokenSuffix, + user = configGeneral.cachePrefix + configGeneral.resetTokenPrefix + 'user' + configGeneral.resetTokenSuffix, + superUser = configGeneral.cachePrefix + configGeneral.resetTokenPrefix + 'super' + configGeneral.resetTokenSuffix; /** * Clear database - * @param {Function} done the callback + * @param {Function} done the callback */ function clearDb(done) { - testHelper.runSqlFile(SQL_DIR + "common_oltp__clean", DATABASE_NAME, done); + async.parallel({ + heffan: function (cbx) { + testHelper.deleteCachedKey(heffan, cbx); + }, + user: function (cbx) { + testHelper.deleteCachedKey(user, cbx); + }, + superUser: function (cbx) { + testHelper.deleteCachedKey(superUser, cbx); + } + }, function (err) { + done(err); + }); } /** @@ -79,7 +58,28 @@ describe('Test Generate Reset Token API', function () { async.waterfall([ clearDb, function (cb) { - testHelper.runSqlFile(SQL_DIR + "common_oltp__insert_test_data", DATABASE_NAME, cb); + async.parallel({ + heffan: function (cbx) { + testHelper.addCacheValue(heffan, + { + value: 'abcde', + expireTimestamp: new Date('2016-1-1').getTime(), + createdAt: new Date().getTime(), + readAt: null + }, cbx); + }, + user: function (cbx) { + testHelper.addCacheValue(user, + { + value: 'abcde', + expireTimestamp: new Date('2014-1-1').getTime(), + createdAt: new Date().getTime(), + readAt: null + }, cbx); + } + }, function (err) { + cb(err); + }); } ], done); }); @@ -94,218 +94,147 @@ describe('Test Generate Reset Token API', function () { }); /** - * Tests the generateResetToken action against failure test case. Posts a request for generating the token for - * user specified by handle or email and expects the server to respond with HTTP response of specified status - * providing the specified expected error details. - * - * @param {String} handle - a handle for user to pass to tested action. - * @param {String} email - an email for user to pass to tested action. - * @param {Number} expectedStatusCode - status code for HTTP response expected to be returned from server. - * @param {String} expectedErrorMessage - error message expected to be returned from server. - * @param {Function} callback - a callback to be called when test finishes. + * Create a http request and test it. + * @param {String} handle - the request handle. + * @param {Number} expectStatus - the expected request response status. + * @param {Object} postData - the data that will be post to api. + * @param {Function} cb - the call back function. */ - function testFailureScenario(handle, email, expectedStatusCode, expectedErrorMessage, callback) { - var queryParams = '?'; - if (handle !== null) { - queryParams += 'handle=' + handle; - } - if (email !== null) { - queryParams += '&email=' + email; - } - + function createRequest(handle, expectStatus, postData, cb) { request(API_ENDPOINT) - .get('/v2/users/resetToken' + queryParams) + .post('/v2/users/resetPassword/' + handle) .set('Accept', 'application/json') .expect('Content-Type', /json/) - .expect(expectedStatusCode) - .end(function (err, res) { - if (err) { - callback(err); - return; - } - var body = res.body; - assert.equal(body.error.details, expectedErrorMessage); - callback(); - }); + .expect(expectStatus) + .send(postData) + .end(cb); } /** - * Tests the generateResetToken action against success test case. Posts a request for generating the token for - * user specified by handle or email and expects the server to respond with HTTP response of 200 OK status and - * return generated token or social login provider name in case the handle or email corresponds to social login. - * - * @param {String} handle - a handle for user to pass to tested action. - * @param {String} email - an email for user to pass to tested action. - * @param {String} socialLoginProvider - a name for social login provider in case specified handle is from social - * login. - * @param {Function} callback - a callback to be called when test finishes. - * @param {String} handleForEmail - a user handle corresponding to specified email address. This is just for tests - * which pass email and expect the token to be generated. - * @param {boolean} skipCheckingTokenInRedis - flag indicating if test has skip checking the token for presence in - * Redis database. + * assert the bad response. + * @param {String} handle - the request handle + * @param {Number} expectStatus - the expect status. + * @param {String} errorMessage - the expected error message. + * @param {Object} postData - the data post to api. + * @param {Function} cb - the callback function. */ - function testSuccessScenario(handle, email, socialLoginProvider, callback, handleForEmail, skipCheckingTokenInRedis) { - var queryParams = '?'; - if (handle !== null) { - queryParams += 'handle=' + handle; - } - if (email !== null) { - queryParams += '&email=' + email; - } - - request(API_ENDPOINT) - .get('/v2/users/resetToken' + queryParams) - .set('Accept', 'application/json') - .expect('Content-Type', /json/) - .expect(200) - .end(function (err, res) { - assert.notOk(err, 'There should be no error for successful scenario'); - - var body = res.body, - alphabet = stringUtils.ALPHABET_ALPHA_EN + stringUtils.ALPHABET_DIGITS_EN, - i, - ch; - - if (socialLoginProvider === null) { - assert.ok(body.successful, "There is no successful returned"); - assert.isTrue(body.successful, "Wrong successful flag is returned"); - if (!IS_FAKE_REDIS_USED && !skipCheckingTokenInRedis) { - async.waterfall([ - function (cb) { - if (handle) { - getCachedToken(handle, cb); - } else { - getCachedToken(handleForEmail, cb); - } - } - ], function (err, token) { - assert.ok(token, 'The token is not stored in Redis database'); - assert.equal(token.value.length, 6, "Token of wrong length returned"); - for (i = 0; i < token.value.length; i = i + 1) { - ch = token.value.charAt(i); - assert.isTrue(alphabet.indexOf(ch) >= 0, "Token contains wrong character '" + ch + "'"); - } - callback(err); - }); - } else { - callback(); - } - } else { - assert.ok(body.socialProvider, "There is no social login provider name returned"); - assert.equal(body.socialProvider, socialLoginProvider, "Wrong social login provider name returned"); - callback(); - } - }); + function assertBadResponse(handle, expectStatus, errorMessage, postData, cb) { + createRequest(handle, expectStatus, postData, function (err, result) { + if (!err) { + assert.equal(result.body.error.details, errorMessage, 'invalid error message'); + } else { + cb(err); + return; + } + cb(); + }); } - // Failure test cases - it('Neither handle nor email are provided - should return HTTP 400', function (done) { - testFailureScenario(null, null, 400, 'Either handle or email must be specified', done); + /** + * Test when password is too short. + */ + it('should return bad Request. The password is too short.', function (done) { + assertBadResponse('heffan', 400, errorObject.password.tooShort, { token: 'abcde', password: '123' }, done); }); - it('Both handle and email are provided - should return HTTP 400', function (done) { - testFailureScenario("heffan", "foo@bar.com", 400, 'Both handle and email are specified', done); + /** + * Test when password is too long. + */ + it('should return bad Request. The password is too long.', function (done) { + assertBadResponse('heffan', 400, errorObject.password.tooLong, { token: 'abcde', password: '1234567890abcdefghijklmnopqrstuvwxyz'}, done); }); - it('Both empty handle and email are provided - should return HTTP 400', function (done) { - testFailureScenario("", "", 400, 'Either handle or email must be specified', done); + /** + * Test when password is just spaces. + */ + it('should return bad Request. The password is just spaces.', function (done) { + assertBadResponse('heffan', 400, errorObject.password.empty, { token: 'abcde', password: ' ' }, done); }); - it('Empty handle provided - should return HTTP 400', function (done) { - testFailureScenario("", null, 400, 'Either handle or email must be specified', done); + /** + * Test when password contains the invalid characters. + */ + it('should return bad Request. The password contains invalid characters.', function (done) { + assertBadResponse('heffan', 400, errorObject.password.invalidCharacters, { token: 'abcde', password: '+*&^%$$#@' }, done); }); - it('Empty email provided - should return HTTP 400', function (done) { - testFailureScenario(null, "", 400, 'Either handle or email must be specified', done); + /** + * Test when token is not existed in cache system. + */ + it('should return bad Request. The token is not existed in cache system.', function (done) { + assertBadResponse('super', 400, errorObject.token.notExistedOrExpired, { token: 'djoisdfj', password: 'password' }, done); }); - it('Non-existing handle is provided - should return HTTP 404', function (done) { - testFailureScenario("Undioiwfibiiv3vb3i", null, 404, 'User does not exist', done); + /** + * Test when token is in system but expired. + */ + it('should return bad Request. The token is in system but expired.', function (done) { + assertBadResponse('user', 400, errorObject.token.notExistedOrExpired, { token: 'djoisdfj', password: 'password' }, done); }); - it('Non-existing email is provided - should return HTTP 404', function (done) { - testFailureScenario(null, '912837197@akjsdnakd.com', 404, 'User does not exist', done); + /** + * Test when token is incorrect. + */ + it('should return bad Request. The token is incorrect.', function (done) { + assertBadResponse('heffan', 400, errorObject.token.inCorrect, { token: 'ajdoijfiodsfj', password: 'password' }, done); }); - it('Non-expired token already exists - should return HTTP 400', function (done) { - // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the - // generated token to expire - this.timeout(TOKEN_LIFETIME * 2); + /** + * Test when user in not exist. + */ + it('should return not Found Error. The user is not existed', function (done) { + assertBadResponse('notExist', 404, errorObject.notExist, { token: 'abcde', password: 'password' }, done); + }); + /** + * Test success results. + */ + it('should return success results. The password has been saved.', function (done) { + var newPassword = 'abcdefghijk'; async.waterfall([ function (cb) { - testSuccessScenario('normal_user_11', null, null, cb, null, false); - }, function (cb) { - testFailureScenario('normal_user_11', null, 400, "You have already requested the reset token, " - + "please find it in your email inbox. If it's not there. Please contact support@topcoder.com.", - cb); - }, function (cb) { - console.log("\nWaiting for generated token to expire to prevent multiple test suite execution to fail (" - + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); - delay(); + createRequest('heffan', 200, { token: 'abcde', password: newPassword }, function (err) { + cb(err); + }); + }, + function (cb) { + testHelper.runSqlSelectQuery('password FROM security_user WHERE user_id = \'heffan\'', 'common_oltp', cb); + }, + function (value, cb) { + assert.equal(testHelper.decodePassword(value[0].password, testHelper.PASSWORD_HASH_KEY), newPassword, 'invalid password'); cb(); } - ], function (err) { - done(err); - }); - }); - - // Accuracy test cases - it('Existing TopCoder username is provided - should respond with HTTP 200 and return token', function (done) { - testSuccessScenario('normal_user_13', null, null, done, null, false); - }); - - it('Existing email address is provided - should respond with HTTP 200 and return token', function (done) { - testSuccessScenario(null, 'normal_user_14@test.com', null, done, 'normal_user_14', false); - }); - - it('Existing social login handle is provided - should respond with HTTP 200 and provider name', function (done) { - testSuccessScenario('user2', null, 'Facebook', done, null, true); - }); - - it('Existing social login email is provided - should respond with HTTP 200 and provider name', function (done) { - testSuccessScenario(null, 'social.email21@test.com', 'Twitter', done, null, true); + ], done); }); - it('Username that matches handle for TC user account (which also have a social login username) and social ' - + 'login username for another TC user account is provided - should respond with HTTP 200 and social ' - + 'provider name for user with matching TC handle', function (done) { - testSuccessScenario('common_handle', null, 'Google', done, null, true); - }); - - it('Username that matches handle for TC user account (which does not have a social login username) and social ' - + 'login username for another TC user account is provided - should respond with HTTP 200 and generated ' - + 'token for user with matching TC handle', function (done) { - testSuccessScenario('common_handle2', null, null, done, null, false); - }); - - it('Email address that matches email for TC user account (which also have a social login username) and social ' - + 'login email for another TC user account is provided - should respond with HTTP 200 and social ' - + 'provider name for user with matching TC email address', function (done) { - testSuccessScenario(null, 'common_email@test.com', 'Google', done, null, true); - }); - - it('Email address that matches email for TC user account (which does not have a social login account) and social ' - + 'login email for another TC user account is provided - should respond with HTTP 200 and generated ' - + 'token for user with matching TC email', function (done) { - testSuccessScenario(null, 'common_email2@test.com', null, done, 'normal_user_25', false); - }); - - it('Requesting new token once previous has expired - should respond with HTTP 200 and new token', function (done) { - // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the - // generated token to expire - this.timeout(TOKEN_LIFETIME * 2); - + /** + * Test success results. The user handle is in upper case. + */ + it('should return success results. The user handle is in upper case.', function (done) { + var newPassword = 'abcdefghijk'; async.waterfall([ function (cb) { - testSuccessScenario('normal_user_15', null, null, cb, null, false); - }, function (cb) { - console.log("\nWaiting for generated token to expire (" + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); - delay(); - testSuccessScenario('normal_user_15', null, null, cb, null, true); + // Insert again. + testHelper.addCacheValue(heffan, + { + value: 'abcde', + expireTimestamp: new Date('2016-1-1').getTime(), + createdAt: new Date().getTime(), + readAt: null + }, cb); + }, + function (cb) { + createRequest('HEFFAN', 200, { token: 'abcde', password: newPassword }, function (err) { + cb(err); + }); + }, + function (cb) { + testHelper.runSqlSelectQuery('password FROM security_user WHERE user_id = \'heffan\'', 'common_oltp', cb); + }, + function (value, cb) { + assert.equal(testHelper.decodePassword(value[0].password, testHelper.PASSWORD_HASH_KEY), newPassword, 'invalid password'); + cb(); } - ], function (err) { - done(err); - }); + ], done); }); }); diff --git a/test/test_files/expected_reset_password_error_message.json b/test/test_files/expected_reset_password_error_message.json new file mode 100644 index 000000000..842ac059f --- /dev/null +++ b/test/test_files/expected_reset_password_error_message.json @@ -0,0 +1,13 @@ +{ + "password": { + "empty": "password should be non-null and non-empty string.", + "tooShort": "password must be at least 8 characters in length.", + "tooLong": "password may contain at most 30 characters.", + "invalidCharacters": "Your password may contain only letters, numbers and -_.{}[]()" + }, + "token": { + "notExistedOrExpired": "The token is expired or not existed. Please apply a new one.", + "inCorrect": "The token is incorrect." + }, + "notExist": "The user is not exist." +}