diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 8d01bc1157..7e27239c59 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1,6 +1,7 @@ "use strict"; const request = require('request'); +const requestp = require('request-promise'); const Config = require('../src/Config'); describe("Email Verification Token Expiration: ", () => { @@ -482,6 +483,257 @@ describe("Email Verification Token Expiration: ", () => { }); }); + it('should send a new verification email when a resend is requested and the user is UNVERIFIED', done => { + var user = new Parse.User(); + var sendEmailOptions; + var sendVerificationEmailCallCount = 0; + var emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1' + }) + .then(() => { + user.setUsername('resends_verification_token'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + expect(sendVerificationEmailCallCount).toBe(1); + + return requestp.post({ + uri: 'http://localhost:8378/1/verificationEmailRequest', + body: { + email: 'user@parse.com' + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + resolveWithFullResponse: true, + simple: false // this promise is only rejected if the call itself failed + }) + .then((response) => { + expect(response.statusCode).toBe(200); + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('should not send a new verification email when a resend is requested and the user is VERIFIED', done => { + var user = new Parse.User(); + var sendEmailOptions; + var sendVerificationEmailCallCount = 0; + var emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1' + }) + .then(() => { + user.setUsername('no_new_verification_token_once_verified'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + return requestp.get({ + url: sendEmailOptions.link, + followRedirect: false, + resolveWithFullResponse: true, + simple: false + }) + .then((response) => { + expect(response.statusCode).toEqual(302); + }); + }) + .then(() => { + expect(sendVerificationEmailCallCount).toBe(1); + + return requestp.post({ + uri: 'http://localhost:8378/1/verificationEmailRequest', + body: { + email: 'user@parse.com' + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + resolveWithFullResponse: true, + simple: false // this promise is only rejected if the call itself failed + }) + .then((response) => { + expect(response.statusCode).toBe(400); + expect(sendVerificationEmailCallCount).toBe(1); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('should not send a new verification email if this user does not exist', done => { + var sendEmailOptions; + var sendVerificationEmailCallCount = 0; + var emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1' + }) + .then(() => { + return requestp.post({ + uri: 'http://localhost:8378/1/verificationEmailRequest', + body: { + email: 'user@parse.com' + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + resolveWithFullResponse: true, + simple: false + }) + .then(response => { + expect(response.statusCode).toBe(400); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('should fail if no email is supplied', done => { + var sendEmailOptions; + var sendVerificationEmailCallCount = 0; + var emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1' + }) + .then(() => { + request.post({ + uri: 'http://localhost:8378/1/verificationEmailRequest', + body: {}, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + resolveWithFullResponse: true, + simple: false + }, (err, response) => { + expect(response.statusCode).toBe(400); + expect(response.body.code).toBe(Parse.Error.EMAIL_MISSING); + expect(response.body.error).toBe('you must provide an email'); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('should fail if email is not a string', done => { + var sendEmailOptions; + var sendVerificationEmailCallCount = 0; + var emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1' + }) + .then(() => { + request.post({ + uri: 'http://localhost:8378/1/verificationEmailRequest', + body: {email: 3}, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + resolveWithFullResponse: true, + simple: false + }, (err, response) => { + expect(response.statusCode).toBe(400); + expect(response.body.code).toBe(Parse.Error.INVALID_EMAIL_ADDRESS); + expect(response.body.error).toBe('you must provide a valid email string'); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + done(); + }); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + it('client should not see the _email_verify_token_expires_at field', done => { var user = new Parse.User(); var sendEmailOptions; diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index cb4de0e686..e127fd03b5 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -383,7 +383,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { fail('sending password reset email should not have succeeded'); done(); }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') done(); }); }) @@ -414,7 +414,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { fail('sending password reset email should not have succeeded'); done(); }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') done(); }); }) @@ -442,7 +442,7 @@ describe("Custom Pages, Email Verification, Password Reset", () => { fail('sending password reset email should not have succeeded'); done(); }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.') done(); }); }) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index a281bb4208..39273418b2 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -191,7 +191,7 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } - handleResetRequest(req) { + _throwOnBadEmailConfig(req) { try { Config.validateEmailConfiguration({ emailAdapter: req.config.userController.adapter, @@ -202,11 +202,16 @@ export class UsersRouter extends ClassesRouter { } catch (e) { if (typeof e === 'string') { // Maybe we need a Bad Configuration error, but the SDKs won't understand it. For now, Internal Server Error. - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'An appName, publicServerURL, and emailAdapter are required for password reset functionality.'); + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.'); } else { throw e; } } + } + + handleResetRequest(req) { + this._throwOnBadEmailConfig(req); + const { email } = req.body; if (!email) { throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); @@ -228,6 +233,33 @@ export class UsersRouter extends ClassesRouter { }); } + handleVerificationEmailRequest(req) { + this._throwOnBadEmailConfig(req); + + const { email } = req.body; + if (!email) { + throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); + } + if (typeof email !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'you must provide a valid email string'); + } + + return req.config.database.find('_User', { email: email }).then((results) => { + if (!results.length || results.length < 1) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`); + } + const user = results[0]; + + if (user.emailVerified) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`); + } + + const userController = req.config.userController; + userController.sendVerificationEmail(user); + return { response: {} }; + }); + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -238,7 +270,8 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) + this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }); + this.route('POST', '/verificationEmailRequest', req => { return this.handleVerificationEmailRequest(req); }); } }