diff --git a/packages/fxa-auth-server/lib/routes/account.js b/packages/fxa-auth-server/lib/routes/account.js index d50dbb61eb6..fe7ed04ab4f 100644 --- a/packages/fxa-auth-server/lib/routes/account.js +++ b/packages/fxa-auth-server/lib/routes/account.js @@ -100,6 +100,7 @@ module.exports = ( .regex(HEX_STRING) .optional(), authAt: isA.number().integer(), + verificationMethod: validators.verificationMethod.optional(), }, }, }, @@ -446,6 +447,10 @@ module.exports = ( response.keyFetchToken = keyFetchToken.data; } + if (verificationMethod) { + response.verificationMethod = verificationMethod; + } + return response; } }, diff --git a/packages/fxa-auth-server/test/local/routes/account.js b/packages/fxa-auth-server/test/local/routes/account.js index dccb8021e2f..756a56e8b7f 100644 --- a/packages/fxa-auth-server/test/local/routes/account.js +++ b/packages/fxa-auth-server/test/local/routes/account.js @@ -955,7 +955,7 @@ describe('/account/create', () => { mockRequest.payload.verificationMethod = 'email-otp'; - await runTest(route, mockRequest, () => { + await runTest(route, mockRequest, res => { assert.calledOnce(mockMailer.sendVerifyShortCode); const authenticator = new otplib.authenticator.Authenticator(); @@ -972,6 +972,10 @@ describe('/account/create', () => { mockRequest.app.geo.location, 'location set' ); + assert.equal( + res.verificationMethod, + mockRequest.payload.verificationMethod + ); }); }); }); diff --git a/packages/fxa-auth-server/test/mail_helper.js b/packages/fxa-auth-server/test/mail_helper.js index eff320478c0..6f825908da1 100644 --- a/packages/fxa-auth-server/test/mail_helper.js +++ b/packages/fxa-auth-server/test/mail_helper.js @@ -43,6 +43,7 @@ module.exports = printLogs => { const rul = mail.headers['x-report-signin-link']; const uc = mail.headers['x-unblock-code']; const vc = mail.headers['x-verify-code']; + const vsc = mail.headers['x-verify-short-code']; const sc = mail.headers['x-signin-verify-code']; const template = mail.headers['x-template-name']; @@ -56,7 +57,9 @@ module.exports = printLogs => { // See: https://github.com/mozilla/fxa-content-server/pull/6470#issuecomment-415224438 const name = emailName(mail.headers.to.replace(/\<(.*?)\>/g, '$1')); - if (vc) { + if (vsc) { + console.log('\x1B[34mSignin code', vsc, '\x1B[39m'); + } else if (vc) { console.log('\x1B[32m', link, '\x1B[39m'); } else if (sc) { console.log('\x1B[32mToken code: ', sc, '\x1B[39m'); diff --git a/packages/fxa-auth-server/test/mailbox.js b/packages/fxa-auth-server/test/mailbox.js index a445732d68a..f7942965bbf 100644 --- a/packages/fxa-auth-server/test/mailbox.js +++ b/packages/fxa-auth-server/test/mailbox.js @@ -25,7 +25,8 @@ module.exports = function(host, port, printLogs) { return waitForEmail(email).then(emailData => { const code = emailData.headers['x-verify-code'] || - emailData.headers['x-recovery-code']; + emailData.headers['x-recovery-code'] || + emailData.headers['x-verify-short-code']; if (!code) { throw new Error('email did not contain a verification code'); } diff --git a/packages/fxa-content-server/app/scripts/lib/auth-errors.js b/packages/fxa-content-server/app/scripts/lib/auth-errors.js index 6834922f5e1..065e47be14d 100644 --- a/packages/fxa-content-server/app/scripts/lib/auth-errors.js +++ b/packages/fxa-content-server/app/scripts/lib/auth-errors.js @@ -268,6 +268,10 @@ var ERRORS = { errno: 181, message: t('Update was rejected, please try again'), }, + INVALID_EXPIRED_SIGNUP_CODE: { + errno: 182, + message: t('Expired or invalid signup code'), + }, SERVER_BUSY: { errno: 201, message: t('Server busy, try again soon'), diff --git a/packages/fxa-content-server/app/scripts/lib/constants.js b/packages/fxa-content-server/app/scripts/lib/constants.js index 730304d844e..c12f2f39f93 100644 --- a/packages/fxa-content-server/app/scripts/lib/constants.js +++ b/packages/fxa-content-server/app/scripts/lib/constants.js @@ -151,5 +151,7 @@ module.exports = { // https://stripe.com/docs/error-codes#expired-card CC_EXPIRED: 'expired_card', + + SIGNUP_CODE_LENGTH: 6, }; /*eslint-enable sorting/sort-object-props*/ diff --git a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js index 3e3ca0aa213..748994b34c1 100644 --- a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js +++ b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/index.js @@ -17,6 +17,7 @@ const experimentGroupingRules = [ require('./send-sms-install-link'), require('./sentry'), require('./token-code'), + require('./signup-code'), ].map(ExperimentGroupingRule => new ExperimentGroupingRule()); class ExperimentChoiceIndex { diff --git a/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/signup-code.js b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/signup-code.js new file mode 100644 index 00000000000..2e9dd12d197 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/lib/experiments/grouping-rules/signup-code.js @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const BaseGroupingRule = require('./base'); +const Constants = require('../../../lib/constants'); +const GROUPS_DEFAULT = ['control, treatment']; + +const ROLLOUT_CLIENTS = { + '37fdfa37698f251a': { + enableTestEmails: false, + groups: GROUPS_DEFAULT, + name: 'Lockbox Extension', + rolloutRate: 0.0, + }, + '3c49430b43dfba77': { + enableTestEmails: false, + groups: GROUPS_DEFAULT, + name: 'Android Components Reference Browser', + rolloutRate: 0.0, + }, + '98adfa37698f255b': { + enableTestEmails: true, + groups: GROUPS_DEFAULT, + name: 'Lockbox Extension iOS', + rolloutRate: 0.0, + }, + ecdb5ae7add825d4: { + enableTestEmails: false, + groups: GROUPS_DEFAULT, + name: 'TestClient', + rolloutRate: 0.0, + }, +}; + +module.exports = class SignupCodeGroupingRule extends BaseGroupingRule { + constructor() { + super(); + this.name = 'signupCode'; + this.SYNC_ROLLOUT_RATE = 0.0; + this.ROLLOUT_CLIENTS = ROLLOUT_CLIENTS; + } + + choose(subject) { + if ( + !subject || + !subject.uniqueUserId || + !subject.experimentGroupingRules || + !subject.isSignupCodeSupported || + !subject.account + ) { + return false; + } + + const { featureFlags } = subject; + + if (subject.clientId) { + let client = this.ROLLOUT_CLIENTS[subject.clientId]; + if (featureFlags && featureFlags.signupCodeClients) { + client = featureFlags.signupCodeClients[subject.clientId]; + } + + if (client) { + const groups = client.groups || GROUPS_DEFAULT; + + // Check if this client supports test emails + if ( + client.enableTestEmails && + this.isTestEmail(subject.account.get('email')) + ) { + return this.uniformChoice(groups, subject.uniqueUserId); + } + + if (this.bernoulliTrial(client.rolloutRate, subject.uniqueUserId)) { + return this.uniformChoice(groups, subject.uniqueUserId); + } + } + + // If a clientId was specified but not defined in the rollout configuration, the default + // is to disable the experiment for them. + return false; + } + + if (subject.service && subject.service === Constants.SYNC_SERVICE) { + let syncRolloutRate = this.SYNC_ROLLOUT_RATE; + if (featureFlags && featureFlags.signupCodeClients) { + syncRolloutRate = featureFlags.signupCodeClients.sync.rolloutRate; + } + + if (this.bernoulliTrial(syncRolloutRate, subject.uniqueUserId)) { + return this.uniformChoice(GROUPS_DEFAULT, subject.uniqueUserId); + } + } + + return false; + } +}; diff --git a/packages/fxa-content-server/app/scripts/lib/fxa-client.js b/packages/fxa-content-server/app/scripts/lib/fxa-client.js index 009221462a7..b023dedc99b 100644 --- a/packages/fxa-content-server/app/scripts/lib/fxa-client.js +++ b/packages/fxa-content-server/app/scripts/lib/fxa-client.js @@ -420,6 +420,10 @@ FxaClientWrapper.prototype = { signUpOptions.resume = options.resume; } + if (options.verificationMethod) { + signUpOptions.verificationMethod = options.verificationMethod; + } + if (relier.has('style')) { signUpOptions.style = relier.get('style'); } @@ -712,6 +716,23 @@ FxaClientWrapper.prototype = { */ sessionStatus: createClientDelegate('sessionStatus'), + /** + * Verify an account and a session using a otp based code. + * + * @param {String} sessionToken User session token + * @param {String} code Code to verify account and session + * @return {Promise} resolves when complete + */ + sessionVerifyCode: createClientDelegate('sessionVerifyCode'), + + /** + * Resend the verify code based on otp. + * + * @param {String} sessionToken User session token + * @return {Promise} resolves when complete + */ + sessionResendVerifyCode: createClientDelegate('sessionResendVerifyCode'), + /** * Check if `sessionToken` is valid * diff --git a/packages/fxa-content-server/app/scripts/lib/router.js b/packages/fxa-content-server/app/scripts/lib/router.js index 6a29dc2e8f5..502bfba56c4 100644 --- a/packages/fxa-content-server/app/scripts/lib/router.js +++ b/packages/fxa-content-server/app/scripts/lib/router.js @@ -23,6 +23,7 @@ import CompleteResetPasswordView from '../views/complete_reset_password'; import CompleteSignUpView from '../views/complete_sign_up'; import ConfirmResetPasswordView from '../views/confirm_reset_password'; import ConfirmView from '../views/confirm'; +import ConfirmSignupCodeView from '../views/confirm_signup_code'; import ConnectAnotherDeviceView from '../views/connect_another_device'; import CookiesDisabledView from '../views/cookies_disabled'; import DeleteAccountView from '../views/settings/delete_account'; @@ -119,6 +120,7 @@ const Router = Backbone.Router.extend({ 'confirm_signin(/)': createViewHandler(ConfirmView, { type: VerificationReasons.SIGN_IN, }), + 'confirm_signup_code(/)': createViewHandler(ConfirmSignupCodeView), 'connect_another_device(/)': createViewHandler(ConnectAnotherDeviceView), 'connect_another_device/why(/)': createChildViewHandler( WhyConnectAnotherDeviceView, diff --git a/packages/fxa-content-server/app/scripts/models/account.js b/packages/fxa-content-server/app/scripts/models/account.js index bf84207ce70..05585ff1e13 100644 --- a/packages/fxa-content-server/app/scripts/models/account.js +++ b/packages/fxa-content-server/app/scripts/models/account.js @@ -701,6 +701,7 @@ const Account = Backbone.Model.extend( .signUp(this.get('email'), password, relier, { metricsContext: this._metrics.getFlowEventMetadata(), resume: options.resume, + verificationMethod: options.verificationMethod, }) .then(updatedSessionData => { this.set(updatedSessionData); @@ -771,6 +772,37 @@ const Account = Backbone.Model.extend( }); }, + /** + * Verify the session and account using the verification code. + * + * @param {String} code - the verification code + * @param {Object} [options] + * @param {Object} [options.service] - the service issuing signup request + * @returns {Promise} - resolves when complete + */ + verifySessionCode(code, options = {}) { + const newsletters = this.get('newsletters'); + if (newsletters && newsletters.length) { + this.unset('newsletters'); + options.newsletters = newsletters; + } + + return this._fxaClient.sessionVerifyCode( + this.get('sessionToken'), + code, + options + ); + }, + + /** + * Resend the session and account verification code. + * + * @returns {Promise} - resolves when complete + */ + verifySessionResendCode() { + return this._fxaClient.sessionResendVerifyCode(this.get('sessionToken')); + }, + /** * Verify the account using the token code * diff --git a/packages/fxa-content-server/app/scripts/models/auth_brokers/base.js b/packages/fxa-content-server/app/scripts/models/auth_brokers/base.js index dde73b8d839..e4d87cf0c68 100644 --- a/packages/fxa-content-server/app/scripts/models/auth_brokers/base.js +++ b/packages/fxa-content-server/app/scripts/models/auth_brokers/base.js @@ -581,6 +581,10 @@ const BaseAuthenticationBroker = Backbone.Model.extend({ * security events will be shown with `&security_events=true` in the url */ showSecurityEvents: false, + /* + * Is using signup codes supported? + */ + signupCode: true, /** * Does this environment support pairing? */ diff --git a/packages/fxa-content-server/app/scripts/templates/confirm_signup_code.mustache b/packages/fxa-content-server/app/scripts/templates/confirm_signup_code.mustache new file mode 100644 index 00000000000..113e6d8b9ac --- /dev/null +++ b/packages/fxa-content-server/app/scripts/templates/confirm_signup_code.mustache @@ -0,0 +1,36 @@ +
+
+ {{#isTrailhead}} +
+ {{/isTrailhead}} +

+ {{#serviceName}} + + {{#t}}Enter verification code{{/t}} {{#t}}Continue to %(serviceName)s{{/t}} + {{/serviceName}} + {{^serviceName}} + {{#t}}Enter verification code{{/t}} + {{/serviceName}} +

+
+ +
+
+
+ +
+ +

{{#unsafeTranslate}}Please enter the verification code that was sent to %(escapedEmail)s within 20 minutes.{{/unsafeTranslate}}

+
+
+ +
+
+ +
+ +
+
+
diff --git a/packages/fxa-content-server/app/scripts/views/confirm_signup_code.js b/packages/fxa-content-server/app/scripts/views/confirm_signup_code.js new file mode 100644 index 00000000000..bc48d009f93 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/views/confirm_signup_code.js @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import _ from 'underscore'; +import Cocktail from 'cocktail'; +import FlowEventsMixin from './mixins/flow-events-mixin'; +import FormView from './form'; +import ServiceMixin from './mixins/service-mixin'; +import Template from 'templates/confirm_signup_code.mustache'; +import ResendMixin from './mixins/resend-mixin'; + +const CODE_INPUT_SELECTOR = 'input.token-code'; + +const proto = FormView.prototype; + +class ConfirmSignupCodeView extends FormView { + template = Template; + className = 'confirm-signup-code'; + + afterVisible() { + // the view is always rendered, but the confirmation may be + // prevented by the broker. + const account = this.getAccount(); + return proto.afterVisible + .call(this) + .then(() => this.broker.persistVerificationData(account)) + .then(() => + this.invokeBrokerMethod('beforeSignUpConfirmationPoll', account) + ); + } + + getAccount() { + return this.model.get('account'); + } + + setInitialContext(context) { + const email = this.getAccount().get('email'); + + context.set({ + email, + isTrailhead: this.isTrailhead(), + escapedEmail: `${_.escape(email)}`, + }); + } + + beforeRender() { + // User cannot confirm if they have not initiated a sign up. + if (!this.getAccount()) { + this.navigate('signup'); + } + } + + resend() { + const account = this.getAccount(); + return account.verifySessionResendCode(); + } + + submit() { + const account = this.getAccount(); + const code = this.getElementValue(CODE_INPUT_SELECTOR); + const newsletters = account.get('newsletters'); + return account + .verifySessionCode(code) + .then(() => { + this.logViewEvent('verification.success'); + this.notifier.trigger('verification.success'); + if (newsletters) { + this.notifier.trigger('flow.event', { + event: 'newsletter.subscribed', + }); + } + + return this.invokeBrokerMethod('afterSignUpConfirmationPoll', account); + }) + .catch(err => this.showValidationError(this.$(CODE_INPUT_SELECTOR), err)); + } +} + +Cocktail.mixin( + ConfirmSignupCodeView, + FlowEventsMixin, + ResendMixin(), + ServiceMixin +); + +export default ConfirmSignupCodeView; diff --git a/packages/fxa-content-server/app/scripts/views/mixins/signup-code-experiment-mixin.js b/packages/fxa-content-server/app/scripts/views/mixins/signup-code-experiment-mixin.js new file mode 100644 index 00000000000..852d0eabf33 --- /dev/null +++ b/packages/fxa-content-server/app/scripts/views/mixins/signup-code-experiment-mixin.js @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * An SignupCodeExperimentMixin factory. + * + * @mixin SignupCodeExperimentMixin + */ +import ExperimentMixin from './experiment-mixin'; +const EXPERIMENT_NAME = 'signupCode'; + +/** + * Creates the mixin + * + * @returns {Object} mixin + */ +export default { + dependsOn: [ExperimentMixin], + + beforeRender() { + if (this.isInSignupCodeExperiment()) { + const experimentGroup = this.getSignupCodeExperimentGroup(); + this.createExperiment(EXPERIMENT_NAME, experimentGroup); + } + }, + + /** + * Get signup code experiment group + * + * @returns {String} + */ + getSignupCodeExperimentGroup() { + return this.getExperimentGroup( + EXPERIMENT_NAME, + this._getSignupCodeExperimentSubject() + ); + }, + + /** + * Is the user in the experiment? + * + * @returns {Boolean} + */ + isInSignupCodeExperiment() { + return this.isInExperiment( + EXPERIMENT_NAME, + this._getSignupCodeExperimentSubject() + ); + }, + + /** + * Get the experiment choice subject + * + * @returns {Object} + * @private + */ + _getSignupCodeExperimentSubject() { + const subject = { + account: this.model.get('account'), + clientId: this.relier.get('clientId'), + isSignupCodeSupported: this.broker.getCapability('signupCode'), + service: this.relier.get('service'), + }; + return subject; + }, +}; diff --git a/packages/fxa-content-server/app/scripts/views/mixins/signup-mixin.js b/packages/fxa-content-server/app/scripts/views/mixins/signup-mixin.js index a6c133a822c..79fcdf3787d 100644 --- a/packages/fxa-content-server/app/scripts/views/mixins/signup-mixin.js +++ b/packages/fxa-content-server/app/scripts/views/mixins/signup-mixin.js @@ -5,9 +5,10 @@ // Shared implementation of `signUp` view method import ResumeTokenMixin from './resume-token-mixin'; +import SignupCodeExperimentMixin from '../mixins/signup-code-experiment-mixin'; export default { - dependsOn: [ResumeTokenMixin], + dependsOn: [ResumeTokenMixin, SignupCodeExperimentMixin], /*anchor tag present in both signin and signup views*/ events: { @@ -29,9 +30,15 @@ export default { // This is important for the infamous signin-from-signup feature. this.logFlowEvent('attempt', 'signup'); - return this.user.signUpAccount(account, password, this.relier, { + const options = { resume: this.getStringifiedResumeToken(account), - }); + }; + + if (this.getSignupCodeExperimentGroup() === 'treatment') { + options.verificationMethod = 'email-otp'; + } + + return this.user.signUpAccount(account, password, this.relier, options); }) .then(account => { if (this.formPrefill) { @@ -67,12 +74,16 @@ export default { this.logViewEvent('success'); this.logViewEvent('signup.success'); - // do NOT propagate the returned promise. The broker - // delegates to a NavigateBehavior which returns a promise - // that never resolves. The next screen ends up invoking - // this function in their submit handler, which causes - // a "Working" error to be logged. See #5655 - this.invokeBrokerMethod('afterSignUp', account); + if (account.get('verificationMethod') === 'email-otp') { + this.navigate('/confirm_signup_code', { account }); + } else { + // do NOT propagate the returned promise. The broker + // delegates to a NavigateBehavior which returns a promise + // that never resolves. The next screen ends up invoking + // this function in their submit handler, which causes + // a "Working" error to be logged. See #5655 + this.invokeBrokerMethod('afterSignUp', account); + } }, onSuggestSyncClick() { diff --git a/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/index.js b/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/index.js index 44f10e7156c..7ce4234955e 100644 --- a/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/index.js +++ b/packages/fxa-content-server/app/tests/spec/lib/experiments/grouping-rules/index.js @@ -9,7 +9,7 @@ import sinon from 'sinon'; describe('lib/experiments/grouping-rules/index', () => { it('EXPERIMENT_NAMES is exported', () => { - assert.lengthOf(ExperimentGroupingRules.EXPERIMENT_NAMES, 6); + assert.lengthOf(ExperimentGroupingRules.EXPERIMENT_NAMES, 7); }); describe('choose', () => { diff --git a/packages/fxa-content-server/app/tests/spec/lib/fxa-client.js b/packages/fxa-content-server/app/tests/spec/lib/fxa-client.js index ddfd0ad20e4..20bdccc5566 100644 --- a/packages/fxa-content-server/app/tests/spec/lib/fxa-client.js +++ b/packages/fxa-content-server/app/tests/spec/lib/fxa-client.js @@ -261,6 +261,29 @@ describe('lib/fxa-client', function() { ); }); }); + + it('passes along an optional `verificationMethod`', function() { + sinon.stub(realClient, 'signUp').callsFake(function() { + return Promise.resolve({}); + }); + + return client + .signUp(email, password, relier, { + resume: resumeToken, + verificationMethod: 'email-otp', + }) + .then(function() { + assert.isTrue( + realClient.signUp.calledWith(trim(email), password, { + keys: false, + redirectTo: REDIRECT_TO, + resume: resumeToken, + service: 'sync', + verificationMethod: 'email-otp', + }) + ); + }); + }); }); describe('recoveryEmailStatus', function() { diff --git a/packages/fxa-content-server/app/tests/spec/models/account.js b/packages/fxa-content-server/app/tests/spec/models/account.js index e7bcb6e47cb..d286e785430 100644 --- a/packages/fxa-content-server/app/tests/spec/models/account.js +++ b/packages/fxa-content-server/app/tests/spec/models/account.js @@ -1208,6 +1208,7 @@ describe('models/account', function() { foo: 'bar', }, resume: 'resume token', + verificationMethod: undefined, }) ); }); diff --git a/packages/fxa-content-server/app/tests/spec/views/confirm_signup_code.js b/packages/fxa-content-server/app/tests/spec/views/confirm_signup_code.js new file mode 100644 index 00000000000..388628bdea6 --- /dev/null +++ b/packages/fxa-content-server/app/tests/spec/views/confirm_signup_code.js @@ -0,0 +1,261 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import _ from 'underscore'; +import { assert } from 'chai'; +import AuthErrors from 'lib/auth-errors'; +import Backbone from 'backbone'; +import BaseBroker from 'models/auth_brokers/base'; +import Constants from 'lib/constants'; +import helpers from '../../lib/helpers'; +import Metrics from 'lib/metrics'; +import Relier from 'models/reliers/relier'; +import sinon from 'sinon'; +import SentryMetrics from 'lib/sentry'; +import User from 'models/user'; +import View from 'views/confirm_signup_code'; +import WindowMock from '../../mocks/window'; + +const { createRandomString } = helpers; + +const CODE = createRandomString(Constants.SIGNUP_CODE_LENGTH, 10); + +describe('views/confirm_signup_code', () => { + let account; + let broker; + let metrics; + let model; + let notifier; + let relier; + let sentryMetrics; + let user; + let view; + let windowMock; + + beforeEach(() => { + windowMock = new WindowMock(); + user = new User(); + sentryMetrics = new SentryMetrics(); + + relier = new Relier({ + window: windowMock, + }); + + broker = new BaseBroker({ + relier, + window: windowMock, + }); + + account = user.initAccount({ + email: 'a@a.com', + sessionToken: 'fake session token', + uid: 'uid', + }); + + model = new Backbone.Model({ + account, + }); + + notifier = _.extend({}, Backbone.Events); + metrics = new Metrics({ notifier, sentryMetrics }); + + view = new View({ + broker, + metrics, + model, + notifier, + relier, + user, + viewName: 'confirm-signup-code', + window: windowMock, + }); + + return view.render(); + }); + + afterEach(function() { + metrics.destroy(); + view.remove(); + view.destroy(); + view = metrics = null; + }); + + describe('render', () => { + it('renders the view', () => { + assert.lengthOf( + view.$('#fxa-confirm-signup-code-header'), + 1, + 'has header' + ); + assert.lengthOf(view.$('.token-code'), 1, 'has input'); + assert.include( + view.$('.verification-email-message').text(), + 'a@a.com', + 'has email' + ); + assert.lengthOf(view.$('.step-3'), 0, 'no trailhead progress indicator'); + }); + + it('renders the view with trailhead', () => { + sinon.stub(view, 'isTrailhead').callsFake(() => true); + + return view.render().then(() => { + assert.lengthOf( + view.$('#fxa-confirm-signup-code-header'), + 1, + 'has header' + ); + assert.lengthOf(view.$('.token-code'), 1, 'has input'); + assert.include( + view.$('.verification-email-message').text(), + 'a@a.com', + 'has email' + ); + assert.lengthOf( + view.$('.step-3'), + 1, + 'has trailhead progress indicator' + ); + }); + }); + + describe('without a session', () => { + beforeEach(function() { + model.unset('account'); + view = new View({ + relier, + broker, + model, + notifier, + user, + window: windowMock, + }); + + sinon.spy(view, 'navigate'); + + return view.render(); + }); + + it('redirects to the signup page', () => { + assert.isTrue(view.navigate.calledWith('signup')); + }); + }); + }); + + describe('afterVisible', () => { + it('notifies the broker before the confirmation', () => { + sinon.spy(broker, 'persistVerificationData'); + + sinon + .stub(broker, 'beforeSignUpConfirmationPoll') + .callsFake(() => Promise.resolve()); + + return view.afterVisible().then(function() { + assert.isTrue( + broker.persistVerificationData.calledOnce, + 'called persistVerificationData' + ); + assert.isTrue( + broker.beforeSignUpConfirmationPoll.calledOnce, + 'called beforeSignUpConfirmationPoll' + ); + assert.isTrue(broker.beforeSignUpConfirmationPoll.calledWith(account)); + }); + }); + }); + + describe('validateAndSubmit', () => { + beforeEach(() => { + sinon.stub(view, 'submit').callsFake(() => Promise.resolve()); + sinon.spy(view, 'showValidationError'); + }); + + describe('with an empty code', () => { + beforeEach(() => { + view.$('input.token-code').val(''); + return view.validateAndSubmit().then(assert.fail, () => {}); + }); + + it('displays a tooltip, does not call submit', () => { + assert.isTrue(view.showValidationError.called); + assert.isFalse(view.submit.called); + }); + }); + + const validCodes = [CODE, ' ' + CODE, CODE + ' ', ' ' + CODE + ' ']; + validCodes.forEach(code => { + describe(`with a valid code: '${code}'`, () => { + beforeEach(() => { + view.$('input.token-code').val(code); + return view.validateAndSubmit(); + }); + + it('calls submit', () => { + assert.equal(view.submit.callCount, 1); + }); + }); + }); + }); + + describe('submit', () => { + describe('success', () => { + beforeEach(() => { + sinon + .stub(account, 'verifySessionCode') + .callsFake(() => Promise.resolve()); + sinon + .stub(view, 'invokeBrokerMethod') + .callsFake(() => Promise.resolve()); + view.$('input.token-code').val(CODE); + return view.submit(); + }); + + it('calls correct broker methods', () => { + assert.isTrue( + account.verifySessionCode.calledWith(CODE), + 'verify with correct code' + ); + assert.isTrue( + view.invokeBrokerMethod.calledWith( + 'afterSignUpConfirmationPoll', + account + ) + ); + }); + }); + + describe('errors', () => { + const error = AuthErrors.toError('INVALID_EXPIRED_SIGNUP_CODE'); + + beforeEach(() => { + sinon + .stub(account, 'verifySessionCode') + .callsFake(() => Promise.reject(error)); + sinon.spy(view, 'showValidationError'); + view.$('input.token-code').val(CODE); + return view.submit(); + }); + + it('rejects with the error for display', () => { + const args = view.showValidationError.args[0]; + assert.equal(args[1], error); + }); + }); + }); + + describe('resend', () => { + describe('success', () => { + beforeEach(() => { + sinon + .stub(account, 'verifySessionResendCode') + .callsFake(() => Promise.resolve()); + return view.resend(); + }); + + it('calls correct methods', () => { + assert.equal(account.verifySessionResendCode.callCount, 1); + }); + }); + }); +}); diff --git a/packages/fxa-content-server/app/tests/spec/views/mixins/signup-mixin.js b/packages/fxa-content-server/app/tests/spec/views/mixins/signup-mixin.js index 9761ea77d29..d6936a08ce9 100644 --- a/packages/fxa-content-server/app/tests/spec/views/mixins/signup-mixin.js +++ b/packages/fxa-content-server/app/tests/spec/views/mixins/signup-mixin.js @@ -54,6 +54,7 @@ describe('views/mixins/signup-mixin', function() { notifier: { trigger: sinon.spy(), }, + getSignupCodeExperimentGroup: sinon.spy(), onSignUpSuccess: SignUpMixin.onSignUpSuccess, relier, signUp: SignUpMixin.signUp, @@ -200,5 +201,19 @@ describe('views/mixins/signup-mixin', function() { assert.deepEqual(args[1], account); }); }); + + describe('onSignUpSuccess w/ signupCode `treatment` experiment', () => { + beforeEach(() => { + account.set('verificationMethod', 'email-otp'); + return view.signUp(account); + }); + + it('navigates to `confirm_signup_code`', () => { + assert.equal(view.navigate.callCount, 1); + const args = view.navigate.args[0]; + assert.lengthOf(args, 2); + assert.equal(args[0], '/confirm_signup_code'); + }); + }); }); }); diff --git a/packages/fxa-content-server/app/tests/spec/views/sign_up_password.js b/packages/fxa-content-server/app/tests/spec/views/sign_up_password.js index 70062bc16f7..793f5cef777 100644 --- a/packages/fxa-content-server/app/tests/spec/views/sign_up_password.js +++ b/packages/fxa-content-server/app/tests/spec/views/sign_up_password.js @@ -6,6 +6,7 @@ import Account from 'models/account'; import { assert } from 'chai'; import AuthErrors from 'lib/auth-errors'; import Backbone from 'backbone'; +import Broker from 'models/auth_brokers/base'; import FormPrefill from 'models/form-prefill'; import Notifier from 'lib/channels/notifier'; import Relier from 'models/reliers/relier'; @@ -27,6 +28,7 @@ describe('views/sign_up_password', () => { let relier; let view; let windowMock; + let broker; beforeEach(() => { account = new Account({ email: EMAIL }); @@ -37,14 +39,17 @@ describe('views/sign_up_password', () => { model = new Backbone.Model({ account }); notifier = new Notifier(); sinon.spy(notifier, 'trigger'); + broker = new Broker(); relier = new Relier({ service: 'sync', serviceName: 'Firefox Sync', }); windowMock = new WindowMock(); + broker = new Broker(); view = new View({ + broker, experimentGroupingRules, formPrefill, model, diff --git a/packages/fxa-content-server/app/tests/test_start.js b/packages/fxa-content-server/app/tests/test_start.js index 419e4bc92de..d7778a1240b 100644 --- a/packages/fxa-content-server/app/tests/test_start.js +++ b/packages/fxa-content-server/app/tests/test_start.js @@ -145,6 +145,7 @@ require('./spec/views/complete_reset_password'); require('./spec/views/complete_sign_up'); require('./spec/views/confirm'); require('./spec/views/confirm_reset_password'); +require('./spec/views/confirm_signup_code'); require('./spec/views/connect_another_device'); require('./spec/views/cookies_disabled'); require('./spec/views/decorators/progress_indicator'); diff --git a/packages/fxa-content-server/server/lib/routes/get-frontend.js b/packages/fxa-content-server/server/lib/routes/get-frontend.js index 516a63771e8..e386a4820ca 100644 --- a/packages/fxa-content-server/server/lib/routes/get-frontend.js +++ b/packages/fxa-content-server/server/lib/routes/get-frontend.js @@ -17,6 +17,7 @@ module.exports = function() { 'confirm', 'confirm_reset_password', 'confirm_signin', + 'confirm_signup_code', 'connect_another_device', 'connect_another_device/why', 'cookies_disabled', diff --git a/packages/fxa-content-server/tests/functional.js b/packages/fxa-content-server/tests/functional.js index 906e15ca7ba..d22a71fc360 100644 --- a/packages/fxa-content-server/tests/functional.js +++ b/packages/fxa-content-server/tests/functional.js @@ -5,6 +5,7 @@ module.exports = [ 'tests/functional/reset_password.js', 'tests/functional/oauth_require_totp.js', + 'tests/functional/sign_up_with_code.js', // new and flaky tests above here', 'tests/functional/404.js', 'tests/functional/500.js', diff --git a/packages/fxa-content-server/tests/functional/lib/helpers.js b/packages/fxa-content-server/tests/functional/lib/helpers.js index dd414aaa2ad..f5ea113f2fb 100644 --- a/packages/fxa-content-server/tests/functional/lib/helpers.js +++ b/packages/fxa-content-server/tests/functional/lib/helpers.js @@ -886,6 +886,29 @@ const getTokenCode = thenify(function(user, index) { }); }); +/** + * Get the signup code from the verify sign-up email. + * + * @param {string} user or email + * @param {number} index + * @returns {promise} that resolves with token code + */ +const getSignupCode = thenify(function(user, index) { + if (/@/.test(user)) { + user = TestHelpers.emailToUser(user); + } + + return this.parent.then(getEmailHeaders(user, index)).then(headers => { + const code = headers['x-verify-short-code']; + if (!code) { + throw new Error( + 'Email does not contain signup code: ' + headers['x-template-name'] + ); + } + return code; + }); +}); + /** * Test to ensure an expected email arrives * @@ -1518,6 +1541,15 @@ const fillOutSignInTokenCode = thenify(function(email, number) { .then(click('button[type=submit]')); }); +const fillOutSignUpCode = thenify(function(email, number) { + return this.parent + .then(getSignupCode(email, number)) + .then(code => { + return this.parent.then(type(selectors.SIGNIN_TOKEN_CODE.INPUT, code)); + }) + .then(click('button[type=submit]')); +}); + const fillOutSignUp = thenify(function(email, password, options) { options = options || {}; @@ -2396,12 +2428,14 @@ module.exports = { fillOutSignInTokenCode, fillOutSignInUnblock, fillOutSignUp, + fillOutSignUpCode, focus, generateTotpCode, getEmail, getEmailHeaders, getFxaClient, getQueryParamValue, + getSignupCode, getSms, getSmsSigninCode, getStoredAccountByEmail, diff --git a/packages/fxa-content-server/tests/functional/lib/selectors.js b/packages/fxa-content-server/tests/functional/lib/selectors.js index 5c30ea4166c..71da6903d17 100644 --- a/packages/fxa-content-server/tests/functional/lib/selectors.js +++ b/packages/fxa-content-server/tests/functional/lib/selectors.js @@ -99,6 +99,13 @@ module.exports = { LINK_BACK: '#back', PROGRESS_INDICATOR: '.step-3', }, + CONFIRM_SIGNUP_CODE: { + HEADER: '#fxa-confirm-signup-code-header', + EMAIL_FIELD: '.verification-email-message', + INPUT: '.token-code', + LINK_BACK: '#back', + PROGRESS_INDICATOR: '.step-3', + }, CONNECT_ANOTHER_DEVICE: { HEADER: '#fxa-connect-another-device-header', LINK_INSTALL_ANDROID: '.marketing-link-android', diff --git a/packages/fxa-content-server/tests/functional/sign_up_with_code.js b/packages/fxa-content-server/tests/functional/sign_up_with_code.js new file mode 100644 index 00000000000..f905e992da7 --- /dev/null +++ b/packages/fxa-content-server/tests/functional/sign_up_with_code.js @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const { registerSuite } = intern.getInterface('object'); +const TestHelpers = require('../lib/helpers'); +const FunctionalHelpers = require('./lib/helpers'); +const selectors = require('./lib/selectors'); +const config = intern._config; + +const PASSWORD = 'passwordzxcv'; +let email; + +const { + click, + clearBrowserState, + closeCurrentWindow, + fillOutSignUp, + fillOutSignUpCode, + getSignupCode, + openPage, + openVerificationLinkInNewTab, + switchToWindow, + testElementExists, + testElementTextInclude, + testSuccessWasShown, + type, + visibleByQSA, +} = FunctionalHelpers; + +const experimentTreatmentParams = { + query: { + forceExperiment: 'signupCode', + forceExperimentGroup: 'treatment', + }, +}; + +const experimentControlParams = { + query: { + forceExperiment: 'signupCode', + forceExperimentGroup: 'control', + }, +}; + +const SIGNUP_URL = config.fxaContentRoot + 'signup'; + +function testAtConfirmScreen(email) { + return function() { + return this.parent + .then(testElementExists(selectors.CONFIRM_SIGNUP_CODE.HEADER)) + .then( + testElementTextInclude(selectors.CONFIRM_SIGNUP_CODE.EMAIL_FIELD, email) + ); + }; +} + +registerSuite('signup with code', { + beforeEach: function() { + email = TestHelpers.createEmail(); + return this.remote.then(clearBrowserState({ force: true })); + }, + + afterEach: function() { + return this.remote.then(clearBrowserState()); + }, + + tests: { + control: function() { + return this.remote + .then( + openPage(SIGNUP_URL, selectors.SIGNUP.HEADER, experimentControlParams) + ) + .then(visibleByQSA(selectors.SIGNUP.SUGGEST_SYNC)) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + .then(openVerificationLinkInNewTab(email, 0)) + + .then(switchToWindow(1)) + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testSuccessWasShown()) + .then(closeCurrentWindow()) + + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testSuccessWasShown()); + }, + + 'treatment - valid code': function() { + return this.remote + .then( + openPage( + SIGNUP_URL, + selectors.SIGNUP.HEADER, + experimentTreatmentParams + ) + ) + .then(visibleByQSA(selectors.SIGNUP.SUGGEST_SYNC)) + .then(fillOutSignUp(email, PASSWORD)) + .then(testAtConfirmScreen(email)) + .then(fillOutSignUpCode(email, 0)) + + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testSuccessWasShown()); + }, + + 'treatment - valid code then click back': function() { + return this.remote + .then( + openPage( + SIGNUP_URL, + selectors.SIGNUP.HEADER, + experimentTreatmentParams + ) + ) + .then(visibleByQSA(selectors.SIGNUP.SUGGEST_SYNC)) + .then(fillOutSignUp(email, PASSWORD)) + .then(testAtConfirmScreen(email)) + .then(fillOutSignUpCode(email, 0)) + + .then(testElementExists(selectors.SETTINGS.HEADER)) + .then(testSuccessWasShown()) + .goBack() + .then(testAtConfirmScreen(email)); + }, + + 'treatment - invalid code': function() { + return this.remote + .then( + openPage( + SIGNUP_URL, + selectors.SIGNUP.HEADER, + experimentTreatmentParams + ) + ) + .then(visibleByQSA(selectors.SIGNUP.SUGGEST_SYNC)) + .then(fillOutSignUp(email, PASSWORD)) + .then(testAtConfirmScreen(email)) + .then(getSignupCode(email, 0)) + .then(code => { + code = code === '123123' ? '123124' : '123123'; + return this.remote.then( + type(selectors.SIGNIN_TOKEN_CODE.INPUT, code) + ); + }) + .then(click(selectors.SIGNIN_TOKEN_CODE.SUBMIT)) + .then( + testElementTextInclude('.tooltip', 'invalid or expired otp code') + ); + }, + }, +}); diff --git a/packages/fxa-content-server/tests/functional/sync_v3_sign_up.js b/packages/fxa-content-server/tests/functional/sync_v3_sign_up.js index d56af0504cd..4f27ff3b734 100644 --- a/packages/fxa-content-server/tests/functional/sync_v3_sign_up.js +++ b/packages/fxa-content-server/tests/functional/sync_v3_sign_up.js @@ -21,6 +21,7 @@ const { click, closeCurrentWindow, fillOutSignUp, + fillOutSignUpCode, getVerificationLink, getWebChannelMessageData, storeWebChannelMessageData, @@ -410,3 +411,83 @@ registerSuite('Firefox Desktop Sync v3 signup', { }, }, }); + +registerSuite('Firefox Desktop Sync v3 signup with code', { + beforeEach: function() { + email = TestHelpers.createEmail(); + return this.remote.then(clearBrowserState()); + }, + + afterEach: function() { + return this.remote.then(clearBrowserState()); + }, + + tests: { + control: function() { + return ( + this.remote + .then( + openPage(SIGNUP_PAGE_URL, selectors.SIGNUP.HEADER, { + query: { + forceExperiment: 'signupCode', + forceExperimentGroup: 'control', + forceUA: uaStrings['desktop_firefox_58'], + }, + webChannelResponses: { + 'fxaccounts:can_link_account': { ok: true }, + 'fxaccounts:fxa_status': { + capabilities: null, + signedInUser: null, + }, + }, + }) + ) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + .then(testIsBrowserNotified('fxaccounts:login')) + + .then(testElementExists(selectors.CONFIRM_SIGNUP.HEADER)) + + .then(openVerificationLinkInDifferentBrowser(email)) + + // about:accounts does not take over, expect a screen transition. + .then(testElementExists(selectors.CONNECT_ANOTHER_DEVICE.HEADER)) + ); + }, + + treatment: function() { + return ( + this.remote + .then( + openPage(SIGNUP_PAGE_URL, selectors.SIGNUP.HEADER, { + query: { + forceExperiment: 'signupCode', + forceExperimentGroup: 'treatment', + forceUA: uaStrings['desktop_firefox_58'], + }, + webChannelResponses: { + 'fxaccounts:can_link_account': { ok: true }, + 'fxaccounts:fxa_status': { + capabilities: null, + signedInUser: null, + }, + }, + }) + ) + .then(fillOutSignUp(email, PASSWORD)) + .then(testElementExists(selectors.CHOOSE_WHAT_TO_SYNC.HEADER)) + .then(click(selectors.CHOOSE_WHAT_TO_SYNC.SUBMIT)) + + .then(testIsBrowserNotified('fxaccounts:login')) + + .then(testElementExists(selectors.CONFIRM_SIGNUP_CODE.HEADER)) + + .then(fillOutSignUpCode(email, 0)) + + // about:accounts does not take over, expect a screen transition. + .then(testElementExists(selectors.CONNECT_ANOTHER_DEVICE.HEADER)) + ); + }, + }, +});