Exploring the interface of a mail adapter

Add some tests and demonstrate the adapter loading interface
This commit is contained in:
Drew Gross
2016-02-16 23:43:09 -08:00
committed by Florent Vilmart
parent d9f1e00345
commit 8dc37b9d30
13 changed files with 525 additions and 37 deletions

View File

@@ -26,6 +26,7 @@
"commander": "^2.9.0",
"deepcopy": "^0.6.1",
"express": "^4.13.4",
"mailgun-js": "^0.7.7",
"mime": "^1.3.4",
"mongodb": "~2.1.0",
"multer": "^1.1.0",

3
spec/MockEmailAdapter.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
sendVerificationEmail: () => Promise.resolve();
}

View File

@@ -0,0 +1,8 @@
module.exports = options => {
if (!options) {
throw "Options were not provided"
}
return {
sendVerificationEmail: () => Promise.resolve()
}
}

View File

@@ -49,6 +49,217 @@ describe('Parse.User testing', () => {
});
});
it('sends verification email if email verification is enabled', done => {
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve()
}
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
});
spyOn(emailAdapter, 'sendVerificationEmail');
var user = new Parse.User();
user.setPassword("asdf");
user.setUsername("zxcv");
user.signUp(null, {
success: function(user) {
expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled();
user.fetch()
.then(() => {
expect(user.get('emailVerified')).toEqual(false);
done();
});
},
error: function(userAgain, error) {
fail('Failed to save user');
done();
}
});
});
it('does not send verification email if email verification is disabled', done => {
var emailAdapter = {
sendVerificationEmail: () => Promise.resolve()
}
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: false,
emailAdapter: emailAdapter,
});
spyOn(emailAdapter, 'sendVerificationEmail');
var user = new Parse.User();
user.setPassword("asdf");
user.setUsername("zxcv");
user.signUp(null, {
success: function(user) {
user.fetch()
.then(() => {
expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0);
expect(user.get('emailVerified')).toEqual(undefined);
done();
});
},
error: function(userAgain, error) {
fail('Failed to save user');
done();
}
});
});
it('receives the app name and user in the adapter', done => {
var emailAdapter = {
sendVerificationEmail: options => {
expect(options.appName).toEqual('emailing app');
expect(options.user.get('email')).toEqual('user@parse.com');
done();
}
}
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'emailing app',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
});
var user = new Parse.User();
user.setPassword("asdf");
user.setUsername("zxcv");
user.set('email', 'user@parse.com');
user.signUp(null, {
success: () => {},
error: function(userAgain, error) {
fail('Failed to save user');
done();
}
});
})
it('when you click the link in the email it sets emailVerified to true and redirects you', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: options => {
request.get(options.link, {
followRedirect: false,
}, (error, response, body) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/verify_email_success.html?username=zxcv');
user.fetch()
.then(() => {
expect(user.get('emailVerified')).toEqual(true);
done();
});
});
}
}
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'emailing app',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
});
user.setPassword("asdf");
user.setUsername("zxcv");
user.set('email', 'user@parse.com');
user.signUp();
});
it('redirects you to invalid link if you try to verify email incorrecly', done => {
request.get('http://localhost:8378/1/verify_email', {
followRedirect: false,
}, (error, response, body) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html');
done()
});
});
it('redirects you to invalid link if you try to validate a nonexistant users email', done => {
request.get('http://localhost:8378/1/verify_email?token=asdfasdf&username=sadfasga', {
followRedirect: false,
}, (error, response, body) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html');
done();
});
});
it('does not update email verified if you use an invalid token', done => {
var user = new Parse.User();
var emailAdapter = {
sendVerificationEmail: options => {
request.get('http://localhost:8378/1/verify_email?token=invalid&username=zxcv', {
followRedirect: false,
}, (error, response, body) => {
expect(response.statusCode).toEqual(302);
expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html');
user.fetch()
.then(() => {
expect(user.get('emailVerified')).toEqual(false);
done();
});
});
}
}
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'emailing app',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: emailAdapter,
});
user.setPassword("asdf");
user.setUsername("zxcv");
user.set('email', 'user@parse.com');
user.signUp(null, {
success: () => {},
error: function(userAgain, error) {
fail('Failed to save user');
done();
}
});
});
it("user login wrong username", (done) => {
Parse.User.signUp("asdf", "zxcv", null, {
success: function(user) {
@@ -1704,7 +1915,7 @@ describe('Parse.User testing', () => {
done();
});
});
it('test parse user become', (done) => {
var sessionToken = null;
Parse.Promise.as().then(function() {

View File

@@ -1,4 +1,5 @@
var request = require('request');
var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions');
describe('server', () => {
it('requires a master key and app id', done => {
@@ -37,4 +38,114 @@ describe('server', () => {
done();
});
});
it('can load email adapter via object', done => {
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: MockEmailAdapterWithOptions({
apiKey: 'k',
domain: 'd',
}),
});
done();
});
it('can load email adapter via class', done => {
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: {
class: MockEmailAdapterWithOptions,
options: {
apiKey: 'k',
domain: 'd',
}
},
});
done();
});
it('can load email adapter via module name', done => {
setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: {
module: './Email/SimpleMailgunAdapter',
options: {
apiKey: 'k',
domain: 'd',
}
},
});
done();
});
it('can load email adapter via only module name', done => {
expect(() => setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: './Email/SimpleMailgunAdapter',
})).toThrow('SimpleMailgunAdapter requires an API Key and domain.');
done();
});
it('throws if you initialize email adapter incorrecly', done => {
expect(() => setServerConfiguration({
serverURL: 'http://localhost:8378/1',
appId: 'test',
appName: 'unused',
javascriptKey: 'test',
dotNetKey: 'windows',
clientKey: 'client',
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test',
verifyUserEmails: true,
emailAdapter: {
module: './Email/SimpleMailgunAdapter',
options: {
domain: 'd',
}
},
})).toThrow('SimpleMailgunAdapter requires an API Key and domain.');
done();
});
});

View File

@@ -1,4 +1,3 @@
export function loadAdapter(options, defaultAdapter) {
let adapter;
@@ -12,7 +11,7 @@ export function loadAdapter(options, defaultAdapter) {
adapter = options.adapter;
}
}
if (!adapter) {
adapter = defaultAdapter;
}
@@ -26,10 +25,12 @@ export function loadAdapter(options, defaultAdapter) {
}
}
// From there it's either a function or an object
// if it's an function, instanciate and pass the options
// if it's an function, instanciate and pass the options
if (typeof adapter === "function") {
var Adapter = adapter;
adapter = new Adapter(options);
}
return adapter;
}
module.exports = { loadAdapter }

View File

@@ -0,0 +1,39 @@
import Mailgun from 'mailgun-js';
let SimpleMailgunAdapter = mailgunOptions => {
if (!mailgunOptions || !mailgunOptions.apiKey || !mailgunOptions.domain) {
throw 'SimpleMailgunAdapter requires an API Key and domain.';
}
let mailgun = Mailgun(mailgunOptions);
let sendMail = (to, subject, text) => {
let data = {
from: mailgunOptions.fromAddress,
to: to,
subject: subject,
text: text,
}
return new Promise((resolve, reject) => {
mailgun.messages().send(data, (err, body) => {
if (typeof err !== 'undefined') {
reject(err);
}
resolve(body);
});
});
}
return {
sendVerificationEmail: ({ link, user, appName, }) => {
let verifyMessage =
"Hi,\n\n" +
"You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" +
"" +
"Click here to confirm it:\n" + link;
return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage);
}
}
}
module.exports = SimpleMailgunAdapter

View File

@@ -0,0 +1,25 @@
export default options => {
if (!options) {
return undefined;
}
if (typeof options === 'string') {
//Configuring via module name with no options
return require(options)();
}
if (!options.module && !options.class) {
//Configuring via object
return options;
}
if (options.module) {
//Configuring via module name + options
return require(options.module)(options.options)
}
if (options.class) {
//Configuring via class + options
return options.class(options.options);
}
}

View File

@@ -23,9 +23,14 @@ export class Config {
this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers;
this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix);
this.verifyUserEmails = cacheInfo.verifyUserEmails;
this.emailAdapter = cacheInfo.emailAdapter;
this.appName = cacheInfo.appName;
this.hooksController = cacheInfo.hooksController;
this.filesController = cacheInfo.filesController;
this.pushController = cacheInfo.pushController;
this.pushController = cacheInfo.pushController;
this.loggerController = cacheInfo.loggerController;
this.oauth = cacheInfo.oauth;

View File

@@ -1,14 +1,15 @@
// These methods handle the User-related routes.
import deepcopy from 'deepcopy';
import deepcopy from 'deepcopy';
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import Auth from '../Auth';
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import Auth from '../Auth';
import passwordCrypto from '../password';
import RestWrite from '../RestWrite';
import { newToken } from '../cryptoUtils';
import RestWrite from '../RestWrite';
let cryptoUtils = require('../cryptoUtils');
let triggers = require('../triggers');
export class UsersRouter extends ClassesRouter {
handleFind(req) {
@@ -25,7 +26,26 @@ export class UsersRouter extends ClassesRouter {
let data = deepcopy(req.body);
req.body = data;
req.params.className = '_User';
return super.handleCreate(req);
if (req.config.verifyUserEmails) {
req.body._email_verify_token = cryptoUtils.randomString(25);
req.body.emailVerified = false;
}
let p = super.handleCreate(req);
if (req.config.verifyUserEmails) {
// Send email as fire-and-forget once the user makes it into the DB.
p.then(() => {
let link = req.config.mount + "/verify_email?token=" + encodeURIComponent(req.body._email_verify_token) + "&username=" + encodeURIComponent(req.body.username);
req.config.emailAdapter.sendVerificationEmail({
appName: req.config.appName,
link: link,
user: triggers.inflate('_User', req.body),
});
});
}
return p;
}
handleUpdate(req) {
@@ -87,7 +107,7 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
let token = 'r:' + newToken();
let token = 'r:' + cryptoUtils.newToken();
user.sessionToken = token;
delete user.password;
@@ -153,6 +173,7 @@ export class UsersRouter extends ClassesRouter {
this.route('POST', '/requestPasswordReset', () => {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.');
});
this.route('POST', '/requestPasswordReset', req => this.handleReset(req));
}
}

View File

@@ -11,32 +11,34 @@ var batch = require('./batch'),
Parse = require('parse/node').Parse;
import cache from './cache';
import PromiseRouter from './PromiseRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { FilesController } from './Controllers/FilesController';
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
import { PushController } from './Controllers/PushController';
import { ClassesRouter } from './Routers/ClassesRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { UsersRouter } from './Routers/UsersRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { RolesRouter } from './Routers/RolesRouter';
//import passwordReset from './passwordReset';
import PromiseRouter from './PromiseRouter';
import verifyEmail from './verifyEmail';
import loadAdapter from './Adapters/loadAdapter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { PushRouter } from './Routers/PushRouter';
import { ClassesRouter } from './Routers/ClassesRouter';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { FilesController } from './Controllers/FilesController';
import { FilesRouter } from './Routers/FilesRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { LogsRouter } from './Routers/LogsRouter';
import { HooksRouter } from './Routers/HooksRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { LoggerController } from './Controllers/LoggerController';
import { HooksController } from './Controllers/HooksController';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { AdapterLoader } from './Adapters/AdapterLoader';
import { LoggerController } from './Controllers/LoggerController';
import { PushController } from './Controllers/PushController';
import { PushRouter } from './Routers/PushRouter';
import { RolesRouter } from './Routers/RolesRouter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { UsersRouter } from './Routers/UsersRouter';
import requiredParameter from './requiredParameter';
import { randomString } from './cryptoUtils';
@@ -69,9 +71,24 @@ addParseCloud();
// "javascriptKey": optional key from Parse dashboard
// "push": optional key from configure push
let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => {
if (verifyUserEmails) {
if (typeof appName !== 'string') {
throw 'An app name is required when using email verification.';
}
if (!emailAdapter) {
throw 'User email verification was enabled, but no email adapter was provided';
}
if (typeof emailAdapter.sendVerificationEmail !== 'function') {
throw 'Invalid email adapter: no sendVerificationEmail() function was provided';
}
}
}
function ParseServer({
appId = requiredParameter('You must provide an appId!'),
masterKey = requiredParameter('You must provide a masterKey!'),
appName,
databaseAdapter,
filesAdapter,
push,
@@ -89,7 +106,9 @@ function ParseServer({
allowClientClassCreation = true,
oauth = {},
serverURL = requiredParameter('You must provide a serverURL!'),
maxUploadSize = '20mb'
maxUploadSize = '20mb',
verifyUserEmails = false,
emailAdapter,
}) {
// Initialize the node client SDK automatically
@@ -141,10 +160,18 @@ function ParseServer({
hooksController: hooksController,
enableAnonymousUsers: enableAnonymousUsers,
allowClientClassCreation: allowClientClassCreation,
oauth: oauth
oauth: oauth,
appName: appName,
});
// To maintain compatibility. TODO: Remove in v2.1
if (verifyUserEmails && process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) {
emailAdapter = loadAdapter(emailAdapter);
validateEmailConfiguration(verifyUserEmails, appName, emailAdapter);
cache.apps[appId].verifyUserEmails = verifyUserEmails;
cache.apps[appId].emailAdapter = emailAdapter;
}
// To maintain compatibility. TODO: Remove in some version that breaks backwards compatability
if (process.env.FACEBOOK_APP_ID) {
cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID);
}
@@ -158,6 +185,12 @@ function ParseServer({
maxUploadSize: maxUploadSize
}));
if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) {
//api.use('/request_password_reset', passwordReset.reset(appName, appId));
//api.get('/password_reset_success', passwordReset.success);
api.get('/verify_email', verifyEmail(appId, serverURL));
}
// TODO: separate this from the regular ParseServer object
if (process.env.TESTING == 1) {
api.use('/', require('./testing-routes').router);
@@ -222,5 +255,5 @@ function addParseCloud() {
module.exports = {
ParseServer: ParseServer,
S3Adapter: S3Adapter
S3Adapter: S3Adapter,
};

View File

@@ -42,6 +42,9 @@ export function transformKeyValue(schema, className, restKey, restValue, options
key = '_updated_at';
timeField = true;
break;
case '_email_verify_token':
key = "_email_verify_token";
break;
case 'sessionToken':
case '_session_token':
key = '_session_token';
@@ -649,7 +652,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
restObject['authData'][provider] = mongoObject[key];
break;
}
if (key.indexOf('_p_') == 0) {
var newKey = key.substring(3);
var expected;

27
src/verifyEmail.js Normal file
View File

@@ -0,0 +1,27 @@
function verifyEmail(appId, serverURL) {
var DatabaseAdapter = require('./DatabaseAdapter');
var database = DatabaseAdapter.getDatabaseConnection(appId);
return (req, res) => {
var token = req.query.token;
var username = req.query.username;
if (!token || !username) {
res.redirect(302, serverURL + '/invalid_link.html');
return;
}
database.collection('_User').then(coll => {
// Need direct database access because verification token is not a parse field
coll.findAndModify({
username: username,
_email_verify_token: token,
}, null, {$set: {emailVerified: true}}, (err, doc) => {
if (err || !doc.value) {
res.redirect(302, serverURL + '/invalid_link.html');
} else {
res.redirect(302, serverURL + '/verify_email_success.html?username=' + username);
}
});
});
}
}
module.exports = verifyEmail;