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." +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy