diff --git a/app/scripts/lib/app-start.js b/app/scripts/lib/app-start.js index 951d91cc7..e58ecfa83 100644 --- a/app/scripts/lib/app-start.js +++ b/app/scripts/lib/app-start.js @@ -642,14 +642,16 @@ define(function (require, exports, module) { }, _getErrorPage: function (err) { - if (OAuthErrors.is(err, 'MISSING_PARAMETER') || + if (AuthErrors.is(err, 'INVALID_PARAMETER') || + AuthErrors.is(err, 'MISSING_PARAMETER') || OAuthErrors.is(err, 'INVALID_PARAMETER') || + OAuthErrors.is(err, 'MISSING_PARAMETER') || OAuthErrors.is(err, 'UNKNOWN_CLIENT')) { var queryString = Url.objToSearchString({ client_id: err.client_id, //eslint-disable-line camelcase context: err.context, errno: err.errno, - message: OAuthErrors.toInterpolatedMessage(err, this._translator), + message: err.errorModule.toInterpolatedMessage(err, this._translator), namespace: err.namespace, param: err.param }); diff --git a/app/scripts/lib/auth-errors.js b/app/scripts/lib/auth-errors.js index e4beb96bb..7483fc1a3 100644 --- a/app/scripts/lib/auth-errors.js +++ b/app/scripts/lib/auth-errors.js @@ -260,7 +260,7 @@ define(function (require, exports, module) { try { if (this.is(err, 'INVALID_PARAMETER')) { return { - param: err.validation.keys + param: err.param || err.validation.keys }; } else if (this.is(err, 'MISSING_PARAMETER')) { return { diff --git a/app/scripts/lib/errors.js b/app/scripts/lib/errors.js index 01994e9e9..84501c967 100644 --- a/app/scripts/lib/errors.js +++ b/app/scripts/lib/errors.js @@ -128,6 +128,34 @@ define(function (require, exports, module) { return err; }, + /** + * Create an INVALID_PARAMETER error. The returned + * error will contain a `param` key with the parameter + * name + * + * @param {String} paramName + * @returns {Error} + */ + toInvalidParameterError: function (paramName) { + var err = this.toError('INVALID_PARAMETER'); + err.param = paramName; + return err; + }, + + /** + * Create a MISSING_PARAMETER error. The returned + * error will contain a `param` key with the parameter + * name + * + * @param {String} paramName + * @returns {Error} + */ + toMissingParameterError: function (paramName) { + var err = this.toError('MISSING_PARAMETER'); + err.param = paramName; + return err; + }, + /** * Check if an error is of the given type */ diff --git a/app/scripts/lib/transform.js b/app/scripts/lib/transform.js new file mode 100644 index 000000000..a28a8b76d --- /dev/null +++ b/app/scripts/lib/transform.js @@ -0,0 +1,45 @@ +/* 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/. */ + +/** + * Transform and validate data. Uses the [VAT + * library](https://github.com/shane-tomlinson/vat) to + * do the heavy lifting. + * + * If a required field is missing from the data, a + * `MISSING_ERROR` error is generated, with the error's + * `param` field set to the missing field's name. + * + * If a field does not pass validation, an + * `INVALID_PARAMETER` error is generated, with the error's + * `param` field set to the invalid field's name. + */ + +define(function (require, exports, module) { + 'use strict'; + + var Vat = require('lib/vat'); + + module.exports = { + /** + * Transform and validate `data` using `schema`. + * + * @param {object} data - data to validate + * @param {object} schema - schema that can be passed to the validator + * @param {object} Errors - Errors module used to create errors + * @returns {object} validation/transformation results + */ + transformUsingSchema: function (data, schema, Errors) { + var result = Vat.validate(data, schema); + var error = result.error; + if (error instanceof ReferenceError) { + throw Errors.toMissingParameterError(error.key); + } else if (error instanceof TypeError) { + throw Errors.toInvalidParameterError(error.key); + } + + return result.value; + } + }; +}); diff --git a/app/scripts/lib/validate.js b/app/scripts/lib/validate.js index f2910126e..dbc89790f 100644 --- a/app/scripts/lib/validate.js +++ b/app/scripts/lib/validate.js @@ -34,6 +34,9 @@ define(function (require, exports, module) { // * http://tools.ietf.org/html/rfc5321#section-4.5.3.1.1 var emailRegex = /^[\w.!#$%&’*+/=?^`{|}~-]{1,64}@[a-z\d](?:[a-z\d-]{0,253}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,253}[a-z\d])?)+$/i; + // A Base64 encoded JWT + var BASE64_JWT = /^(?:[a-zA-Z0-9-_]+[=]{0,2}\.){2}[a-zA-Z0-9-_]+[=]{0,2}$/; + var self = { /** * Check if an email address is valid @@ -233,7 +236,7 @@ define(function (require, exports, module) { }, /** - * Check if the verification redirect value is valid + * Check if the verification redirect value is valid. * * @param value * @returns {boolean} @@ -245,6 +248,16 @@ define(function (require, exports, module) { ]; return _.contains(valid, value); + }, + + /** + * Check if a JSON Web Token (JWT) is valid. + * + * @param value + * @returns {boolean} + */ + isBase64JwtValid: function isJwtValid(value) { + return BASE64_JWT.test(value); } }; diff --git a/app/scripts/lib/vat.js b/app/scripts/lib/vat.js new file mode 100644 index 000000000..829ff4079 --- /dev/null +++ b/app/scripts/lib/vat.js @@ -0,0 +1,21 @@ +/* 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/. */ + +// Do some validation. + +define(function (require, exports, module) { + 'use strict'; + + var Validate = require('lib/validate'); + var Vat = require('vat'); + + Vat.register('base64jwt', Vat.string().test(Validate.isBase64JwtValid)); + Vat.register('hex', Vat.string().test(Validate.isHexValid)); + Vat.register('uri', Vat.string().test(Validate.isUriValid)); + Vat.register('url', Vat.string().test(Validate.isUrlValid)); + Vat.register('urn', Vat.string().test(Validate.isUrnValid)); + + return Vat; +}); + diff --git a/app/scripts/models/auth_brokers/base.js b/app/scripts/models/auth_brokers/base.js index 6f487592e..02adaaa4f 100644 --- a/app/scripts/models/auth_brokers/base.js +++ b/app/scripts/models/auth_brokers/base.js @@ -10,12 +10,18 @@ define(function (require, exports, module) { 'use strict'; - var _ = require('underscore'); + var AuthErrors = require('lib/auth-errors'); var Backbone = require('backbone'); + var Cocktail = require('cocktail'); var NullBehavior = require('views/behaviors/null'); var p = require('lib/promise'); var SameBrowserVerificationModel = require('models/verification/same-browser'); var SearchParamMixin = require('models/mixins/search-param'); + var Vat = require('lib/vat'); + + var QUERY_PARAMETER_SCHEMA = { + automatedBrowser: Vat.boolean() + }; var BaseAuthenticationBroker = Backbone.Model.extend({ type: 'base', @@ -85,7 +91,7 @@ define(function (require, exports, module) { return p() .then(function () { self._isForceAuth = self._isForceAuthUrl(); - self.importBooleanSearchParam('automatedBrowser'); + self.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, AuthErrors); }); }, @@ -409,7 +415,10 @@ define(function (require, exports, module) { verificationInfo.clear(); } - _.extend(BaseAuthenticationBroker.prototype, SearchParamMixin); + Cocktail.mixin( + BaseAuthenticationBroker, + SearchParamMixin + ); module.exports = BaseAuthenticationBroker; }); diff --git a/app/scripts/models/auth_brokers/web-channel.js b/app/scripts/models/auth_brokers/web-channel.js index f5e8a8d2f..a15d29cd6 100644 --- a/app/scripts/models/auth_brokers/web-channel.js +++ b/app/scripts/models/auth_brokers/web-channel.js @@ -11,13 +11,20 @@ define(function (require, exports, module) { 'use strict'; var _ = require('underscore'); + var Cocktail = require('cocktail'); + var OAuthErrors = require('lib/oauth-errors'); var ChannelMixin = require('models/auth_brokers/mixins/channel'); var OAuthAuthenticationBroker = require('models/auth_brokers/oauth'); var p = require('lib/promise'); + var Vat = require('lib/vat'); var WebChannel = require('lib/channels/web'); var proto = OAuthAuthenticationBroker.prototype; + var QUERY_PARAMETER_SCHEMA = { + webChannelId: Vat.string() + }; + var WebChannelAuthenticationBroker = OAuthAuthenticationBroker.extend({ type: 'web-channel', defaults: _.extend({}, proto.defaults, { @@ -207,7 +214,7 @@ define(function (require, exports, module) { }, _setupSigninSignupFlow: function () { - this.importSearchParam('webChannelId'); + this.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, OAuthErrors); }, _setupVerificationFlow: function () { @@ -223,6 +230,10 @@ define(function (require, exports, module) { } }); - _.extend(WebChannelAuthenticationBroker.prototype, ChannelMixin); + Cocktail.mixin( + WebChannelAuthenticationBroker, + ChannelMixin + ); + module.exports = WebChannelAuthenticationBroker; }); diff --git a/app/scripts/models/mixins/search-param.js b/app/scripts/models/mixins/search-param.js index 3fdb04f3c..079c01e86 100644 --- a/app/scripts/models/mixins/search-param.js +++ b/app/scripts/models/mixins/search-param.js @@ -9,6 +9,7 @@ define(function (require, exports, module) { 'use strict'; + var Transform = require('lib/transform'); var Url = require('lib/url'); module.exports = { @@ -16,75 +17,43 @@ define(function (require, exports, module) { * Get a value from the URL search parameter * * @param {String} paramName - name of the search parameter to get + * @returns {String} */ getSearchParam: function (paramName) { return Url.searchParam(paramName, this.window.location.search); }, /** - * Set a value based on a value in window.location.search. Only updates - * model if parameter exists in window.location.search. + * Get values from the URL search parameters. * - * @param {String} paramName - name of the search parameter - * @param {String} [modelName] - name to set in model. If not specified, - * use the same value as `paramName` + * @param {array of Strings} paramNames - name of the search parameters + * to get + * @returns {Object} */ - importSearchParam: function (paramName, modelName) { - modelName = modelName || paramName; - - var value = this.getSearchParam(paramName); - if (typeof value !== 'undefined') { - this.set(modelName, value); - } + getSearchParams: function (paramNames) { + return Url.searchParams(this.window.location.search, paramNames); }, /** - * Import a boolean search parameter. Search parameter must be `true` - * nor `false` or model item will not be set. + * Import search parameters defined in the schema. Parameters are + * transformed and validated based on the rules defined in the `schema`. * - * @param {String} paramName - name of the search parameter - * @param {String} [modelName] - name to set in model. If not specified, - * use the same value as `paramName` - * @param {Errors} Errors - corresponding Errors object - * @throws {error} + * @param {Object} schema - schema used to define data to import + * and validate against + * @param {Object} Errors - errors object used to generate errors + * @throws + * If a required field is missing from the data, a + * `MISSING_ERROR` error is generated, with the error's + * `param` field set to the missing field's name. + * + * If a field does not pass validation, an + * `INVALID_PARAMETER` error is generated, with the error's + * `param` field set to the invalid field's name. */ - importBooleanSearchParam: function (paramName, modelName, Errors) { - modelName = modelName || paramName; - var self = this; - - var textValue = self.getSearchParam(paramName); - if (typeof textValue !== 'undefined') { - if (textValue === 'true') { - self.set(modelName, true); - } else if (textValue === 'false') { - self.set(modelName, false); - } else { - var err = Errors.toError('INVALID_PARAMETER'); - err.param = paramName; - throw err; - } - } - }, - - /** - * Set a value based on a value in window.location.search. Throws error - * if paramName param is not in window.location.search. - * - * Throws Error mapped to `MISSING_PARAMETER` in Errors object. - * - * @param {string} paramName - name of the search parameter - * @param {string} modelName - name to set in model - * @param {Errors} Errors - corresponding Errors object - * @throws {error} - */ - importRequiredSearchParam: function (paramName, modelName, Errors) { - var self = this; - self.importSearchParam(paramName, modelName); - if (! self.has(modelName || paramName)) { - var err = Errors.toError('MISSING_PARAMETER'); - err.param = paramName; - throw err; - } + importSearchParamsUsingSchema: function (schema, Errors) { + var params = this.getSearchParams(Object.keys(schema)); + var result = Transform.transformUsingSchema(params, schema, Errors); + this.set(result); } }; }); diff --git a/app/scripts/models/reliers/oauth.js b/app/scripts/models/reliers/oauth.js index e4d8b8717..344e83b5e 100644 --- a/app/scripts/models/reliers/oauth.js +++ b/app/scripts/models/reliers/oauth.js @@ -14,11 +14,52 @@ define(function (require, exports, module) { var OAuthErrors = require('lib/oauth-errors'); var Relier = require('models/reliers/relier'); var RelierKeys = require('lib/relier-keys'); + var Transform = require('lib/transform'); var Url = require('lib/url'); var Validate = require('lib/validate'); + var Vat = require('lib/vat'); var RELIER_FIELDS_IN_RESUME_TOKEN = ['state', 'verificationRedirect']; + /*eslint-disable camelcase*/ + var CLIENT_INFO_SCHEMA = { + id: Vat.hex().required().renameTo('clientId'), + image_uri: Vat.url().allow('').renameTo('imageUri'), + name: Vat.string().required().min(1).renameTo('serviceName'), + privacy_uri: Vat.url().allow('').renameTo('privacyUri'), + redirect_uri: Vat.uri().required().renameTo('redirectUri'), + terms_uri: Vat.url().allow('').renameTo('termsUri'), + trusted: Vat.boolean().required() + }; + + var SIGNIN_SIGNUP_QUERY_PARAM_SCHEMA = { + access_type: Vat.string().test(Validate.isAccessTypeValid).renameTo('accessType'), + client_id: Vat.hex().required().renameTo('clientId'), + keys: Vat.boolean(), + prompt: Vat.string().test(Validate.isPromptValid), + redirectTo: Vat.uri(), + redirect_uri: Vat.uri().renameTo('redirectUri'), + scope: Vat.string().required().min(1), + service: Vat.string(), + state: Vat.string(), + verification_redirect: Vat.string().test(Validate.isVerificationRedirectValid).renameTo('verificationRedirect') + }; + + var VERIFICATION_INFO_SCHEMA = { + access_type: Vat.string().test(Validate.isAccessTypeValid).renameTo('accessType'), + action: Vat.string().min(1), + client_id: Vat.hex().required().renameTo('clientId'), + keys: Vat.boolean(), + prompt: Vat.string().test(Validate.isPromptValid), + redirect_uri: Vat.uri().renameTo('redirectUri'), + // scopes are optional when verifying, user could be verifying in a 2nd browser + scope: Vat.string().min(1), + service: Vat.string().min(1), + state: Vat.string().min(1) + }; + /*eslint-enable camelcase*/ + + var OAuthRelier = Relier.extend({ defaults: _.extend({}, Relier.prototype.defaults, { accessType: null, @@ -74,16 +115,6 @@ define(function (require, exports, module) { return self._setupOAuthRPInfo(); }) .then(function () { - if (! self.get('clientId')) { - throw toMissingParameterError('client_id'); - } else if (! self.get('redirectUri')) { - // Guard against a possible empty redirect_uri - // sent by the server. redirect_uri is not required - // by the OAuth server, but if it's missing, we redirect - // to ourselves. See issue #3452 - throw toMissingParameterError('redirect_uri'); - } - if (self.has('scope')) { // normalization depends on `trusted` field set in // setupOAuthRPInfo. @@ -106,7 +137,7 @@ define(function (require, exports, module) { } if (! permissions.length) { - throw toInvalidParameterError('scope'); + throw OAuthErrors.toInvalidParameterError('scope'); } this.set('scope', permissions.join(' ')); @@ -150,90 +181,17 @@ define(function (require, exports, module) { }; } - this._validateOAuthValues(resumeObj); + var result = Transform.transformUsingSchema( + resumeObj, VERIFICATION_INFO_SCHEMA, OAuthErrors); - self.set({ - accessType: resumeObj.access_type, - clientId: resumeObj.client_id, - keys: resumeObj.keys, - redirectUri: resumeObj.redirect_uri, - scope: resumeObj.scope, - state: resumeObj.state - }); + self.set(result); }, _setupSignInSignUpFlow: function () { - var self = this; - /*eslint-disable camelcase*/ - var oauthOptions = { - access_type: self.getSearchParam('access_type'), - client_id: self.getSearchParam('client_id'), - prompt: self.getSearchParam('prompt'), - redirectTo: self.getSearchParam('redirectTo'), - redirect_uri: self.getSearchParam('redirect_uri'), - verification_redirect: self.getSearchParam('verification_redirect'), - }; - /*eslint-enable camelcase*/ - - this._validateOAuthValues(oauthOptions); - // params listed in: // https://github.com/mozilla/fxa-oauth-server/blob/master/docs/api.md#post-v1authorization - self.importSearchParam('access_type', 'accessType'); - self.importRequiredSearchParam('client_id', 'clientId', OAuthErrors); - self.importBooleanSearchParam('keys', 'keys', OAuthErrors); - self.importSearchParam('prompt'); - self.importSearchParam('redirectTo'); - self.importSearchParam('redirect_uri', 'redirectUri'); - self.importRequiredSearchParam('scope', 'scope', OAuthErrors); - self.importSearchParam('state'); - self.importSearchParam('verification_redirect', 'verificationRedirect'); - }, - - _validateOAuthValues: function (options) { - var accessType = options.access_type; - var clientId = options.client_id; - var prompt = options.prompt; - var redirectUri = options.redirect_uri; - var redirectTo = options.redirectTo; - var termsUri = options.terms_uri; - var privacyUri = options.privacy_uri; - var verificationRedirect = options.verification_redirect; - - if (isDefined(accessType) && ! Validate.isAccessTypeValid(accessType)) { - throw toInvalidParameterError('access_type'); - } - - if (isDefined(clientId) && ! Validate.isHexValid(clientId)) { - throw toInvalidParameterError('client_id'); - } - - // privacyUri is allowed to be `` - if (privacyUri && ! Validate.isUriValid(privacyUri)) { - throw toInvalidParameterError('privacy_uri'); - } - - if (isDefined(prompt) && ! Validate.isPromptValid(prompt)) { - throw toInvalidParameterError('prompt'); - } - - if (isDefined(redirectTo) && ! Validate.isUriValid(redirectTo)) { - throw toInvalidParameterError('redirectTo'); - } - - if (isDefined(redirectUri) && ! Validate.isUriValid(redirectUri)) { - throw toInvalidParameterError('redirect_uri'); - } - - // termsUri is allowed to be `` - if (termsUri && ! Validate.isUriValid(termsUri)) { - throw toInvalidParameterError('terms_uri'); - } - - if (isDefined(verificationRedirect) && - ! Validate.isVerificationRedirectValid(verificationRedirect)) { - throw toInvalidParameterError('verification_redirect'); - } + this.importSearchParamsUsingSchema( + SIGNIN_SIGNUP_QUERY_PARAM_SCHEMA, OAuthErrors); }, _setupOAuthRPInfo: function () { @@ -242,15 +200,9 @@ define(function (require, exports, module) { return self._oAuthClient.getClientInfo(clientId) .then(function (serviceInfo) { - self.set('serviceName', serviceInfo.name); - - self._validateOAuthValues(serviceInfo); - - // server version always takes precedent over the search parameter - self.set('redirectUri', serviceInfo.redirect_uri); - self.set('termsUri', serviceInfo.terms_uri); - self.set('privacyUri', serviceInfo.privacy_uri); - self.set('trusted', serviceInfo.trusted); + var result = Transform.transformUsingSchema( + serviceInfo, CLIENT_INFO_SCHEMA, OAuthErrors); + self.set(result); self.set('origin', Url.getOrigin(serviceInfo.redirect_uri)); }, function (err) { // the server returns an invalid request parameter for an @@ -327,23 +279,5 @@ define(function (require, exports, module) { return _.intersection(permissions, Constants.OAUTH_UNTRUSTED_ALLOWED_PERMISSIONS); } - function toInvalidParameterError(param) { - return toOAuthParameterError('INVALID_PARAMETER', param); - } - - function toMissingParameterError(param) { - return toOAuthParameterError('MISSING_PARAMETER', param); - } - - function toOAuthParameterError(type, param) { - var err = OAuthErrors.toError(type); - err.param = param; - return err; - } - - function isDefined(value) { - return ! _.isUndefined(value); - } - module.exports = OAuthRelier; }); diff --git a/app/scripts/models/reliers/relier.js b/app/scripts/models/reliers/relier.js index 406fd9e6e..3eda7defc 100644 --- a/app/scripts/models/reliers/relier.js +++ b/app/scripts/models/reliers/relier.js @@ -13,31 +13,61 @@ define(function (require, exports, module) { 'use strict'; + var AuthErrors = require('lib/auth-errors'); var BaseRelier = require('models/reliers/base'); var Cocktail = require('cocktail'); var Constants = require('lib/constants'); var p = require('lib/promise'); var ResumeTokenMixin = require('models/mixins/resume-token'); var SearchParamMixin = require('models/mixins/search-param'); + var Vat = require('lib/vat'); var RELIER_FIELDS_IN_RESUME_TOKEN = [ - 'utmTerm', - 'utmSource', - 'utmMedium', - 'utmContent', - 'utmCampaign', 'campaign', - 'entrypoint' + 'entrypoint', + 'utmCampaign', + 'utmContent', + 'utmMedium', + 'utmSource', + 'utmTerm' ]; + /*eslint-disable camelcase*/ + var QUERY_PARAMETER_SCHEMA = { + campaign: Vat.string(), + // `email` will be further validated by views to + // show the appropriate error message + email: Vat.string().allow(Constants.DISALLOW_CACHED_CREDENTIALS), + // FxDesktop declares both `entryPoint` (capital P) and + // `entrypoint` (lowcase p). Normalize to `entrypoint`. + entryPoint: Vat.string(), + entrypoint: Vat.string(), + migration: Vat.string().valid(Constants.AMO_MIGRATION, Constants.SYNC11_MIGRATION), + preVerifyToken: Vat.base64jwt(), + service: Vat.string(), + setting: Vat.string(), + // `uid` will be further validated by the views to + // show the appropriate error message + uid: Vat.string(), + utm_campaign: Vat.string().renameTo('utmCampaign'), + utm_content: Vat.string().renameTo('utmContent'), + utm_medium: Vat.string().renameTo('utmMedium'), + utm_source: Vat.string().renameTo('utmSource'), + utm_term: Vat.string().renameTo('utmTerm') + }; + /*eslint-enable camelcase*/ + var Relier = BaseRelier.extend({ defaults: { allowCachedCredentials: true, campaign: null, email: null, entrypoint: null, + migration: null, preVerifyToken: null, service: null, + setting: null, + uid: null, utmCampaign: null, utmContent: null, utmMedium: null, @@ -75,36 +105,20 @@ define(function (require, exports, module) { // query parameters and server provided data override // resume provided data. self.populateFromStringifiedResumeToken(self.getSearchParam('resume')); + // TODO - validate data coming from the resume token - self.importSearchParam('service'); - self.importSearchParam('preVerifyToken'); - self.importSearchParam('uid'); - self.importSearchParam('setting'); - self.importSearchParam('entrypoint'); - if (! self.has('entrypoint')) { - // FxDesktop declares both `entryPoint` (capital P) and - // `entrypoint` (lowcase p). Normalize to `entrypoint`. - self.importSearchParam('entryPoint', 'entrypoint'); + self.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, AuthErrors); + + // FxDesktop declares both `entryPoint` (capital P) and + // `entrypoint` (lowcase p). Normalize to `entrypoint`. + if (self.has('entryPoint') && ! self.has('entrypoint')) { + self.set('entrypoint', self.get('entryPoint')); } - self.importSearchParam('campaign'); - self.importSearchParam('utm_campaign', 'utmCampaign'); - self.importSearchParam('utm_content', 'utmContent'); - self.importSearchParam('utm_medium', 'utmMedium'); - self.importSearchParam('utm_source', 'utmSource'); - self.importSearchParam('utm_term', 'utmTerm'); - - // A relier can indicate they do not want to allow - // cached credentials if they set email === 'blank' - var email = self.getSearchParam('email'); - if (email === Constants.DISALLOW_CACHED_CREDENTIALS) { + if (self.get('email') === Constants.DISALLOW_CACHED_CREDENTIALS) { + self.unset('email'); self.set('allowCachedCredentials', false); - } else { - self.importSearchParam('email'); } - - self.importSearchParam('migration'); - }); }, diff --git a/app/scripts/models/reliers/sync.js b/app/scripts/models/reliers/sync.js index defa0695f..f0d6228ed 100644 --- a/app/scripts/models/reliers/sync.js +++ b/app/scripts/models/reliers/sync.js @@ -13,14 +13,24 @@ define(function (require, exports, module) { 'use strict'; + var AuthErrors = require('lib/auth-errors'); var _ = require('underscore'); var Relier = require('models/reliers/relier'); var ServiceNameTranslator = require('lib/service-name'); + var Vat = require('lib/vat'); + + /*eslint-disable camelcase*/ + var QUERY_PARAMETER_SCHEMA = { + // context is not available when verifying. + context: Vat.string().min(1), + customizeSync: Vat.boolean() + }; + /*eslint-enable camelcase*/ var SyncRelier = Relier.extend({ defaults: _.extend({}, Relier.prototype.defaults, { context: null, - migration: null + customizeSync: false }), initialize: function (options) { @@ -35,19 +45,7 @@ define(function (require, exports, module) { var self = this; return Relier.prototype.fetch.call(self) .then(function () { - self.importSearchParam('context'); - - try { - self.importBooleanSearchParam('customizeSync'); - } catch (e) { - // ignore it for now. - // TODO - handle the error whenever startup error handling is - // complete - see #1982. This includes logging the error. - // Use something like: - // var err = AuthErrors.toError('INVALID_PARAMETER') - // err.param = 'customizeSync'; - // throw err; - } + self.importSearchParamsUsingSchema(QUERY_PARAMETER_SCHEMA, AuthErrors); self._setupServiceName(); }); diff --git a/app/scripts/require_config.js b/app/scripts/require_config.js index af36f1c2c..476405733 100644 --- a/app/scripts/require_config.js +++ b/app/scripts/require_config.js @@ -48,6 +48,7 @@ require.config({ text: '../bower_components/requirejs-text/text', underscore: '../bower_components/underscore/underscore', uuid: '../bower_components/node-uuid/uuid', + vat: '../bower_components/vat/vat', webrtc: '../bower_components/webrtc-adapter/adapter' }, shim: { diff --git a/app/tests/lib/helpers.js b/app/tests/lib/helpers.js index 7d5a0ebcc..4c93d28b9 100644 --- a/app/tests/lib/helpers.js +++ b/app/tests/lib/helpers.js @@ -89,6 +89,18 @@ define(function (require, exports, module) { return email.split('@')[0]; } + function getValueLabel(value) { + if (_.isUndefined(value)) { + return 'not set'; + } else if (value === '') { + return 'empty'; + } else if (/^\s+$/.test(value)) { + return 'whitespace only'; + } + + return value; + } + function indexOfEvent(metrics, eventName) { var events = metrics.getFilteredData().events; @@ -145,6 +157,7 @@ define(function (require, exports, module) { createEmail: createEmail, createRandomHexString: createRandomHexString, emailToUser: emailToUser, + getValueLabel: getValueLabel, indexOfEvent: indexOfEvent, isErrorLogged: isErrorLogged, isEventLogged: isEventLogged, diff --git a/app/tests/spec/lib/app-start.js b/app/tests/spec/lib/app-start.js index adfee3221..4d0346968 100644 --- a/app/tests/spec/lib/app-start.js +++ b/app/tests/spec/lib/app-start.js @@ -536,19 +536,19 @@ define(function (require, exports, module) { }); describe('_getErrorPage', function () { - it('returns BAD_REQUEST_PAGE for a missing OAuth parameter', function () { - var errorUrl = appStart._getErrorPage(OAuthErrors.toError('MISSING_PARAMETER')); - assert.include(errorUrl, Constants.BAD_REQUEST_PAGE); - }); + var badRequestPageErrors = [ + AuthErrors.toError('INVALID_PARAMETER'), + AuthErrors.toError('MISSING_PARAMETER'), + OAuthErrors.toError('INVALID_PARAMETER'), + OAuthErrors.toError('MISSING_PARAMETER'), + OAuthErrors.toError('UNKNOWN_CLIENT') + ]; - it('returns BAD_REQUEST_PAGE for an invalid OAuth parameter', function () { - var errorUrl = appStart._getErrorPage(OAuthErrors.toError('INVALID_PARAMETER')); - assert.include(errorUrl, Constants.BAD_REQUEST_PAGE); - }); - - it('returns BAD_REQUEST_PAGE for an unknown OAuth client', function () { - var errorUrl = appStart._getErrorPage(OAuthErrors.toError('UNKNOWN_CLIENT')); - assert.include(errorUrl, Constants.BAD_REQUEST_PAGE); + badRequestPageErrors.forEach(function (err) { + it('redirects to BAD_REQUEST_PAGE for ' + err.message, function () { + var errorUrl = appStart._getErrorPage(OAuthErrors.toError('MISSING_PARAMETER')); + assert.include(errorUrl, Constants.BAD_REQUEST_PAGE); + }); }); it('returns INTERNAL_ERROR_PAGE by default', function () { diff --git a/app/tests/spec/lib/auth-errors.js b/app/tests/spec/lib/auth-errors.js index 94acc7555..a0d992a3f 100644 --- a/app/tests/spec/lib/auth-errors.js +++ b/app/tests/spec/lib/auth-errors.js @@ -32,6 +32,33 @@ define(function (require, exports, module) { }); }); + describe('toInvalidParameterError', function () { + var err; + + before(function () { + err = AuthErrors.toInvalidParameterError('param name', AuthErrors); + }); + + it('creates an INVALID_PARAMTER Error', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'param name'); + }); + }); + + describe('toMissingParameterError', function () { + var err; + + before(function () { + err = AuthErrors.toMissingParameterError('param name', AuthErrors); + }); + + it('creates an MISSING_PARAMTER Error', function () { + assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); + assert.equal(err.param, 'param name'); + }); + }); + + describe('toMessage', function () { it('converts a code to a message', function () { assert.equal(AuthErrors.toMessage(102), 'Unknown account'); diff --git a/app/tests/spec/lib/transform.js b/app/tests/spec/lib/transform.js new file mode 100644 index 000000000..b6f2bb7ad --- /dev/null +++ b/app/tests/spec/lib/transform.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/. */ + +define(function (require, exports, module) { + 'use strict'; + + var AuthErrors = require('lib/auth-errors'); + var chai = require('chai'); + var Transform = require('lib/transform'); + var Vat = require('lib/vat'); + + var assert = chai.assert; + + describe('lib/transform', function () { + describe('transformUsingSchema', function () { + describe('with a missing parameter', function () { + var err; + + before(function () { + var schema = { + optional: Vat.any(), + required: Vat.any().required() + }; + + try { + Transform.transformUsingSchema({}, schema, AuthErrors); + } catch (_err) { + err = _err; + } + }); + + it('throws a `MISSING_PARAMETER` error', function () { + assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); + assert.equal(err.param, 'required'); + }); + }); + + describe('with an invalid parameter', function () { + var err; + + before(function () { + var schema = { + numeric: Vat.number() + }; + + try { + Transform.transformUsingSchema({ + numeric: 'a' + }, schema, AuthErrors); + } catch (_err) { + err = _err; + } + }); + + it('throws a `INVALID_PARAMETER` error', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'numeric'); + }); + }); + + describe('valid', function () { + var result; + + before(function () { + var schema = { + numeric: Vat.number(), + optional: Vat.any(), + required: Vat.any().required() + }; + + result = Transform.transformUsingSchema({ + numeric: 123, + required: true, + }, schema, AuthErrors); + }); + + it('succeeds', function () { + assert.deepEqual(result, { + numeric: 123, + required: true + }); + }); + }); + }); + }); +}); diff --git a/app/tests/spec/lib/validate.js b/app/tests/spec/lib/validate.js index d06d81b23..cf83b7443 100644 --- a/app/tests/spec/lib/validate.js +++ b/app/tests/spec/lib/validate.js @@ -447,5 +447,25 @@ define(function (require, exports, module) { }); }); }); + + describe('isBase64JwtValid', function () { + it('is defined', function () { + assert.isFunction(Validate.isBase64JwtValid); + }); + + var invalidTypes = ['', 'word.asdf=', 'crazyType']; + invalidTypes.forEach( function (item) { + it('returns false for ' + item, function () { + assert.isFalse(Validate.isBase64JwtValid(item)); + }); + }); + + var validTypes = ['abcd-=.asdfbc==.asdf90154-_==']; + validTypes.forEach( function (item) { + it('returns true for ' + item, function () { + assert.isTrue(Validate.isBase64JwtValid(item)); + }); + }); + }); }); }); diff --git a/app/tests/spec/models/mixins/search-param.js b/app/tests/spec/models/mixins/search-param.js index 45c352970..d1b78557f 100644 --- a/app/tests/spec/models/mixins/search-param.js +++ b/app/tests/spec/models/mixins/search-param.js @@ -11,6 +11,7 @@ define(function (require, exports, module) { var Cocktail = require('cocktail'); var SearchParamMixin = require('models/mixins/search-param'); var TestHelpers = require('../../../lib/helpers'); + var Vat = require('vat'); var WindowMock = require('../../../mocks/window'); var assert = chai.assert; @@ -45,150 +46,67 @@ define(function (require, exports, module) { }); }); - describe('importSearchParam', function () { - it('imports the value of a search parameter, onto the model', function () { - windowMock.location.search = TestHelpers.toSearchString({ - searchParam: 'value' - }); + describe('importSearchParamsUsingSchema', function () { + var schema = { + optional: Vat.string().optional(), + required: Vat.string().valid('value').required() + }; - model.importSearchParam('searchParam'); - assert.equal(model.get('searchParam'), 'value'); - - model.importSearchParam('notAvailable'); - assert.isUndefined(model.get('notAvailable')); - }); - }); - - describe('importBooleanSearchParam', function () { - var err; - - function importExpectFailure(searchParams, sourceName, destName) { - windowMock.location.search = TestHelpers.toSearchString(searchParams); - try { - model.importBooleanSearchParam(sourceName, destName, AuthErrors); - } catch (e) { - err = e; - } - } - - it('sets value to the boolean `true` if search param is `true`', function () { - windowMock.location.search = TestHelpers.toSearchString({ - expectBoolean: true - }); - - model.importBooleanSearchParam('expectBoolean'); - assert.isTrue(model.get('expectBoolean')); - }); - - it('sets value to the boolean `false` if search param is `false`', function () { - windowMock.location.search = TestHelpers.toSearchString({ - expectBoolean: false - }); - - model.importBooleanSearchParam('expectBoolean'); - assert.isFalse(model.get('expectBoolean')); - }); - - describe('value is not boolean', function () { + describe('passes validation', function () { beforeEach(function () { - importExpectFailure({ expectBoolean: 'not a boolean' }, 'expectBoolean'); + windowMock.location.search = + TestHelpers.toSearchString({ ignored: true, required: 'value' }); + model.importSearchParamsUsingSchema(schema, AuthErrors); }); - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + it('imports fields in the schema that have values', function () { + assert.equal(model.get('required'), 'value'); + }); + + it('does not import optional fields in the schema w/o values', function () { + assert.isFalse(model.has('optional')); + }); + + it('ignores fields not in the schema', function () { + assert.isFalse(model.has('ignored')); }); }); - describe('value is empty', function () { - beforeEach(function () { - importExpectFailure({ expectBoolean: '' }, 'expectBoolean'); - }); + describe('does not pass validation', function () { + var err; - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); - }); - }); - - describe('value is a space', function () { - beforeEach(function () { - importExpectFailure({ expectBoolean: ' ' }, 'expectBoolean'); - }); - - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); - }); - }); - }); - - describe('importRequiredSearchParam', function () { - var err; - - function importExpectFailure(searchParams, sourceName, destName) { - windowMock.location.search = TestHelpers.toSearchString(searchParams); - try { - model.importRequiredSearchParam(sourceName, destName, AuthErrors); - } catch (e) { - err = e; - } - } - - describe('missing', function () { - beforeEach(function () { - importExpectFailure({}, 'searchParam'); - }); - - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); - assert.equal(err.param, 'searchParam'); - }); - }); - - describe('empty', function () { - beforeEach(function () { - importExpectFailure({searchParam: ''}, 'searchParam'); - }); - - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); - assert.equal(err.param, 'searchParam'); - }); - }); - - describe('space', function () { - beforeEach(function () { - importExpectFailure({searchParam: ' '}, 'searchParam'); - }); - - it('errors correctly', function () { - assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); - assert.equal(err.param, 'searchParam'); - }); - }); - - describe('available', function () { - describe('without destName', function () { + describe('missing data', function () { beforeEach(function () { - windowMock.location.search = TestHelpers.toSearchString({ searchParam: 'value'}); - model.importRequiredSearchParam('searchParam', 'searchParam', AuthErrors); + windowMock.location.search = TestHelpers.toSearchString({}); + try { + model.importSearchParamsUsingSchema(schema, AuthErrors); + } catch (e) { + err = e; + } }); - it('imports the value', function () { - assert.equal(model.get('searchParam'), 'value'); + it('throws a MISSING_PARAMETER error', function () { + assert.isTrue(AuthErrors.is(err, 'MISSING_PARAMETER')); + assert.equal(err.param, 'required'); }); }); - describe('with destName', function () { + describe('invalid data', function () { beforeEach(function () { - windowMock.location.search = TestHelpers.toSearchString({ searchParam: 'value'}); - model.importRequiredSearchParam('searchParam', 'key2', AuthErrors); + windowMock.location.search = TestHelpers.toSearchString({ + required: 'invalid' + }); + + try { + model.importSearchParamsUsingSchema(schema, AuthErrors); + } catch (e) { + err = e; + } }); - it('does not import to `sourceName`', function () { - assert.isFalse(model.has('searchParam')); - }); - - it('imports the value to `destName`', function () { - assert.equal(model.get('key2'), 'value'); + it('throws a INVALID_PARAMETER', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'required'); }); }); }); diff --git a/app/tests/spec/models/reliers/oauth.js b/app/tests/spec/models/reliers/oauth.js index 401ce2c47..d1db22cd8 100644 --- a/app/tests/spec/models/reliers/oauth.js +++ b/app/tests/spec/models/reliers/oauth.js @@ -22,6 +22,7 @@ define(function (require, exports, module) { /*eslint-disable camelcase */ var assert = chai.assert; + var getValueLabel = TestHelpers.getValueLabel; describe('models/reliers/oauth', function () { var err; @@ -34,7 +35,8 @@ define(function (require, exports, module) { var ACCESS_TYPE = 'offline'; var ACTION = 'signup'; var CLIENT_ID = 'dcdb5ae7add825d2'; - var PREVERIFY_TOKEN = 'abigtoken'; + var CLIENT_IMAGE_URI = 'https://mozorg.cdn.mozilla.net/media/img/firefox/new/header-firefox.pngx'; + var PREVERIFY_TOKEN = 'a=.big==.token=='; var PROMPT = Constants.OAUTH_PROMPT_CONSENT; var REDIRECT_URI = 'http://redirect.here'; var SCOPE = 'profile:email profile:uid'; @@ -157,7 +159,7 @@ define(function (require, exports, module) { }); }); - describe('parameter validation', function () { + describe('query parameter validation', function () { describe('access_type', function () { var validValues = [undefined, 'offline', 'online']; testValidQueryParams('access_type', validValues, 'accessType', validValues); @@ -253,14 +255,6 @@ define(function (require, exports, module) { testValidQueryParams('keys', validValues, 'keys', expectedValues); }); - describe('privacy_uri', function () { - var validValues = ['', PRIVACY_URI]; - testValidClientInfoValues('privacy_uri', validValues, 'privacyUri', validValues); - - var invalidValues = [' ', 'not-a-url']; - testInvalidClientInfoValues('privacy_uri', invalidValues); - }); - describe('prompt', function () { var invalidValues = ['', ' ', 'invalid']; testInvalidQueryParams('prompt', invalidValues); @@ -278,10 +272,6 @@ define(function (require, exports, module) { }); describe('redirect_uri', function () { - describe('is missing on the server', function () { - testMissingClientInfoValue('redirect_uri'); - }); - var validQueryParamValues = [undefined, REDIRECT_URI]; // redirectUri will always be loaded from the server var expectedValues = [SERVER_REDIRECT_URI, SERVER_REDIRECT_URI]; @@ -289,9 +279,6 @@ define(function (require, exports, module) { var invalidQueryParamValues = ['', ' ', 'not-a-url']; testInvalidQueryParams('redirect_uri', invalidQueryParamValues); - - var invalidClientInfoValues = ['', ' ']; - testInvalidClientInfoValues('redirect_uri', invalidClientInfoValues); }); describe('scope', function () { @@ -336,14 +323,6 @@ define(function (require, exports, module) { }); }); - describe('terms_uri', function () { - var invalidValues = [' ', 'not-a-url']; - testInvalidClientInfoValues('terms_uri', invalidValues); - - var validValues = ['', TERMS_URI]; - testValidClientInfoValues('terms_uri', validValues, 'termsUri', validValues); - }); - describe('verification_redirect', function () { var invalidValues = ['', ' ', 'invalid']; testInvalidQueryParams('verification_redirect', invalidValues); @@ -352,37 +331,94 @@ define(function (require, exports, module) { var expectedValues = ['no', 'no', 'always']; testValidQueryParams('verification_redirect', validValues, 'verificationRedirect', expectedValues); }); - }); - describe('isTrusted', function () { + describe('client info validation', function () { + + describe('image_uri', function () { + // leading & trailing whitespace will be trimmed + var validValues = ['', ' ', CLIENT_IMAGE_URI, ' ' + CLIENT_IMAGE_URI]; + var expectedValues = ['', '', CLIENT_IMAGE_URI, CLIENT_IMAGE_URI]; + testValidClientInfoValues( + 'image_uri', validValues, 'imageUri', expectedValues); + + var invalidValues = ['not-a-url']; + testInvalidClientInfoValues('image_uri', invalidValues); + }); + + describe('name', function () { + var validValues = ['client name']; + testValidClientInfoValues('name', validValues, 'serviceName', validValues); + + var invalidValues = ['', ' ']; + testInvalidClientInfoValues('name', invalidValues); + }); + + describe('privacy_uri', function () { + var validValues = ['', ' ', PRIVACY_URI, PRIVACY_URI + ' ']; + var expectedValues = ['', '', PRIVACY_URI, PRIVACY_URI]; + testValidClientInfoValues( + 'privacy_uri', validValues, 'privacyUri', expectedValues); + + var invalidValues = ['not-a-url']; + testInvalidClientInfoValues('privacy_uri', invalidValues); + }); + + describe('redirect_uri', function () { + describe('is missing on the server', function () { + testMissingClientInfoValue('redirect_uri'); + }); + + var invalidClientInfoValues = ['', ' ']; + testInvalidClientInfoValues('redirect_uri', invalidClientInfoValues); + }); + + describe('terms_uri', function () { + var invalidValues = ['not-a-url']; + testInvalidClientInfoValues('terms_uri', invalidValues); + + var validValues = ['', ' ', TERMS_URI, ' ' + TERMS_URI]; + var expectedValues = ['', '', TERMS_URI, TERMS_URI]; + testValidClientInfoValues('terms_uri', validValues, 'termsUri', expectedValues); + }); + + describe('trusted', function () { + var validValues = ['true', true, 'false', false]; + var expected = [true, true, false, false]; + testValidClientInfoValues('trusted', validValues, 'trusted', expected); + var invalidValues = ['', 'not-a-boolean']; + testInvalidClientInfoValues('trusted', invalidValues); + }); + }); + }); + + describe('isTrusted', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + client_id: CLIENT_ID, + scope: SCOPE + }); + }); + + describe('when `trusted` is true', function () { beforeEach(function () { - windowMock.location.search = TestHelpers.toSearchString({ - client_id: CLIENT_ID, - scope: SCOPE - }); + isTrusted = true; + return relier.fetch(); }); - describe('when `trusted` is true', function () { - beforeEach(function () { - isTrusted = true; - return relier.fetch(); - }); + it('returns `true`', function () { + assert.isTrue(relier.isTrusted()); + }); + }); - it('returns `true`', function () { - assert.isTrue(relier.isTrusted()); - }); + describe('when `trusted` is false', function () { + beforeEach(function () { + isTrusted = false; + return relier.fetch(); }); - describe('when `trusted` is false', function () { - beforeEach(function () { - isTrusted = false; - return relier.fetch(); - }); - - it('returns `false`', function () { - assert.isFalse(relier.isTrusted()); - }); + it('returns `false`', function () { + assert.isFalse(relier.isTrusted()); }); }); }); @@ -581,6 +617,7 @@ define(function (require, exports, module) { sinon.stub(oAuthClient, 'getClientInfo', function () { var clientInfo = { + id: CLIENT_ID, name: SERVICE_NAME, privacy_uri: PRIVACY_URI, redirect_uri: SERVER_REDIRECT_URI, @@ -774,18 +811,6 @@ define(function (require, exports, module) { }); }); } - - function getValueLabel(value) { - if (_.isUndefined(value)) { - return 'not set'; - } else if (value === '') { - return 'empty'; - } else if (/^\s+$/.test(value)) { - return 'whitespace only'; - } - - return value; - } }); }); diff --git a/app/tests/spec/models/reliers/relier.js b/app/tests/spec/models/reliers/relier.js index 36e64b029..708b68266 100644 --- a/app/tests/spec/models/reliers/relier.js +++ b/app/tests/spec/models/reliers/relier.js @@ -5,6 +5,8 @@ define(function (require, exports, module) { 'use strict'; + var _ = require('underscore'); + var AuthErrors = require('lib/auth-errors'); var chai = require('chai'); var Constants = require('lib/constants'); var Relier = require('models/reliers/relier'); @@ -13,18 +15,19 @@ define(function (require, exports, module) { var WindowMock = require('../../../mocks/window'); var assert = chai.assert; + var getValueLabel = TestHelpers.getValueLabel; describe('models/reliers/relier', function () { var relier; var windowMock; - var SERVICE = 'service'; - var PREVERIFY_TOKEN = 'abigtoken'; - var EMAIL = 'email@moz.org'; - var UID = 'uid'; - var ENTRYPOINT = 'preferences'; var CAMPAIGN = 'fennec'; + var EMAIL = 'email@moz.org'; + var ENTRYPOINT = 'preferences'; + var PREVERIFY_TOKEN = 'a=.big==.token=='; + var SERVICE = 'sync'; var SETTING = 'avatar'; + var UID = TestHelpers.createRandomHexString(Constants.UID_LENGTH); var UTM_CAMPAIGN = 'utm_campaign'; var UTM_CONTENT = 'utm_content'; var UTM_MEDIUM = 'utm_medium'; @@ -119,6 +122,46 @@ define(function (require, exports, module) { }); }); + describe('preVerifyToken', function () { + describe('invalid', function () { + var invalidTokens = ['', ' ', 'invalid token']; + invalidTokens.forEach(function (value) { + describe(getValueLabel(value), function () { + testInvalidQueryParam('preVerifyToken', value); + }); + }); + }); + + describe('valid', function () { + var validTokens = [undefined, PREVERIFY_TOKEN]; + validTokens.forEach(function (value) { + describe(getValueLabel(value), function () { + testValidQueryParam('preVerifyToken', value, 'preVerifyToken', value); + }); + }); + }); + }); + + describe('migration', function () { + describe('invalid', function () { + var invalidMigrations = ['', ' ', 'invalid migration']; + invalidMigrations.forEach(function (token) { + describe(getValueLabel(token), function () { + testInvalidQueryParam('migration', token); + }); + }); + }); + + describe('valid', function () { + var validMigrations = [undefined, Constants.AMO_MIGRATION, Constants.SYNC11_MIGRATION]; + validMigrations.forEach(function (value) { + describe(getValueLabel(value), function () { + testValidQueryParam('migration', value, 'migration', value); + }); + }); + }); + }); + describe('isOAuth', function () { it('returns `false`', function () { assert.isFalse(relier.isOAuth()); @@ -225,6 +268,56 @@ define(function (require, exports, module) { }); }); }); + + function testInvalidQueryParam(paramName, value) { + var err; + + beforeEach(function () { + var params = {}; + + if (! _.isUndefined(value)) { + params[paramName] = value; + } else { + delete params[paramName]; + } + + windowMock.location.search = TestHelpers.toSearchString(params); + + return relier.fetch() + .then(assert.fail, function (_err) { + err = _err; + }); + }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, paramName); + }); + } + + function testValidQueryParam(paramName, paramValue, modelName, expectedValue) { + beforeEach(function () { + var params = {}; + + if (! _.isUndefined(paramValue)) { + params[paramName] = paramValue; + } else { + delete params[paramName]; + } + + windowMock.location.search = TestHelpers.toSearchString(params); + + return relier.fetch(); + }); + + it('is successful', function () { + if (_.isUndefined(expectedValue)) { + assert.isFalse(relier.has(modelName)); + } else { + assert.equal(relier.get(modelName), expectedValue); + } + }); + } }); }); diff --git a/app/tests/spec/models/reliers/sync.js b/app/tests/spec/models/reliers/sync.js index 6a825ecb8..1c1ddaecd 100644 --- a/app/tests/spec/models/reliers/sync.js +++ b/app/tests/spec/models/reliers/sync.js @@ -5,6 +5,7 @@ define(function (require, exports, module) { 'use strict'; + var AuthErrors = require('lib/auth-errors'); var chai = require('chai'); var Relier = require('models/reliers/sync'); var TestHelpers = require('../../../lib/helpers'); @@ -14,13 +15,25 @@ define(function (require, exports, module) { var assert = chai.assert; describe('models/reliers/sync', function () { - var windowMock; - var translator; + var err; var relier; + var translator; + var windowMock; + + var CONTEXT = 'fx_desktop_v1'; + var SYNC_MIGRATION = 'sync11'; + var SYNC_SERVICE = 'sync'; + + function fetchExpectError () { + return relier.fetch() + .then(assert.fail, function (_err) { + err = _err; + }); + } beforeEach(function () { - windowMock = new WindowMock(); translator = new Translator('en-US', ['en-US']); + windowMock = new WindowMock(); relier = new Relier({ translator: translator, @@ -31,35 +44,134 @@ define(function (require, exports, module) { describe('fetch', function () { it('populates model from the search parameters', function () { windowMock.location.search = TestHelpers.toSearchString({ - context: 'fx_desktop_v1', + context: CONTEXT, customizeSync: 'true', - migration: 'sync11', - service: 'sync' + migration: SYNC_MIGRATION, + service: SYNC_SERVICE }); return relier.fetch() .then(function () { - assert.equal(relier.get('context'), 'fx_desktop_v1'); - assert.equal(relier.get('migration'), 'sync11'); - assert.equal(relier.get('service'), 'sync'); + assert.equal(relier.get('context'), CONTEXT); + assert.equal(relier.get('migration'), SYNC_MIGRATION); + assert.equal(relier.get('service'), SYNC_SERVICE); assert.isTrue(relier.get('customizeSync')); }); }); - it('does not throw if `customizeSync` is not a boolean', function () { - windowMock.location.search = TestHelpers.toSearchString({ - customizeSync: 'not a boolean' + describe('context query parameter', function () { + describe('missing', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({}); + + return relier.fetch(); + }); + + it('succeeds', function () { + // it's OK + assert.isFalse(relier.has('context')); + }); }); - return relier.fetch() - .then(function () { - assert.isFalse(relier.has('customizeSync')); + describe('emtpy', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: '' + }); + + return fetchExpectError(); }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'context'); + }); + }); + + describe('whitepsace', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: ' ' + }); + + return fetchExpectError(); + }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'context'); + }); + }); + }); + + describe('customizeSync query parameter', function () { + describe('missing', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT + }); + + return relier.fetch(); + }); + + it('succeeds', function () { + assert.isFalse(relier.get('customizeSync')); + }); + }); + + describe('emtpy', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT, + customizeSync: '' + }); + + return fetchExpectError(); + }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'customizeSync'); + }); + }); + + describe('whitepsace', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT, + customizeSync: ' ' + }); + + return fetchExpectError(); + }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'customizeSync'); + }); + }); + + describe('not a boolean', function () { + beforeEach(function () { + windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT, + customizeSync: 'not a boolean' + }); + + return fetchExpectError(); + }); + + it('errors correctly', function () { + assert.isTrue(AuthErrors.is(err, 'INVALID_PARAMETER')); + assert.equal(err.param, 'customizeSync'); + }); + }); }); it('translates `service` to `serviceName`', function () { windowMock.location.search = TestHelpers.toSearchString({ - service: 'sync' + context: CONTEXT, + service: SYNC_SERVICE }); return relier.fetch() @@ -78,6 +190,7 @@ define(function (require, exports, module) { describe('isCustomizeSyncChecked', function () { it('returns true if `customizeSync=true`', function () { windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT, customizeSync: 'true' }); @@ -89,6 +202,7 @@ define(function (require, exports, module) { it('returns false if `customizeSync=false`', function () { windowMock.location.search = TestHelpers.toSearchString({ + context: CONTEXT, customizeSync: 'false' }); diff --git a/app/tests/test_start.js b/app/tests/test_start.js index 26b5be564..77f49251f 100644 --- a/app/tests/test_start.js +++ b/app/tests/test_start.js @@ -61,6 +61,7 @@ function (Translator, Session) { '../tests/spec/lib/height-observer', '../tests/spec/lib/config-loader', '../tests/spec/lib/require-on-demand', + '../tests/spec/lib/transform', '../tests/spec/head/startup-styles', '../tests/spec/views/app', '../tests/spec/views/base', diff --git a/bower.json b/bower.json index ec93f546c..940037f44 100644 --- a/bower.json +++ b/bower.json @@ -36,7 +36,8 @@ "speed-trap": "0.0.5", "tos-pp": "https://github.com/mozilla/legal-docs.git", "underscore": "1.8.3", - "webrtc-adapter": "0.2.5" + "webrtc-adapter": "0.2.5", + "vat": "0.0.5" }, "devDependencies": { "moment": "2.8.3"