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}}
+
+
+
+
+
+
+
+
+
+ {{#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))
+ );
+ },
+ },
+});