diff --git a/docs/client/data.js b/docs/client/data.js index ad1abab816..8afefc01ee 100644 --- a/docs/client/data.js +++ b/docs/client/data.js @@ -9,10 +9,52 @@ DocsData = { "scope": "global", "summary": "The namespace for all server-side accounts-related methods." }, + "Accounts.addEmail": { + "filepath": "accounts-password/password_server.js", + "kind": "function", + "lineno": 847, + "locus": "Server", + "longname": "Accounts.addEmail", + "memberof": "Accounts", + "name": "addEmail", + "options": [], + "params": [ + { + "description": "

The ID of the user to update.

", + "name": "userId", + "type": { + "names": [ + "String" + ] + } + }, + { + "description": "

A new email address for the user.

", + "name": "newEmail", + "type": { + "names": [ + "String" + ] + } + }, + { + "description": "

Optional - whether the new email address should\nbe marked as verified. Defaults to false.

", + "name": "verified", + "optional": true, + "type": { + "names": [ + "Boolean" + ] + } + } + ], + "scope": "static", + "summary": "Add an email address for a user. Use this instead of directly\nupdating the database. The operation will fail if there is a different user\nwith an email only differing in case. If the specified user has an existing\nemail only differing in case however, we replace it." + }, "Accounts.changePassword": { "filepath": "accounts-password/password_client.js", "kind": "function", - "lineno": 150, + "lineno": 148, "locus": "Client", "longname": "Accounts.changePassword", "memberof": "Accounts", @@ -131,10 +173,76 @@ DocsData = { "scope": "static", "summary": "Options to customize emails sent from the Accounts system." }, + "Accounts.findUserByEmail": { + "filepath": "accounts-password/password_server.js", + "kind": "function", + "lineno": 138, + "locus": "Server", + "longname": "Accounts.findUserByEmail", + "memberof": "Accounts", + "name": "findUserByEmail", + "options": [], + "params": [ + { + "description": "

The email address to look for

", + "name": "email", + "type": { + "names": [ + "String" + ] + } + } + ], + "returns": [ + { + "description": "

A user if found, else null

", + "type": { + "names": [ + "Object" + ] + } + } + ], + "scope": "static", + "summary": "Finds the user with the specified email.\nFirst tries to match email case sensitively; if that fails, it\ntries case insensitively; but if more than one user matches the case\ninsensitive search, it returns null." + }, + "Accounts.findUserByUsername": { + "filepath": "accounts-password/password_server.js", + "kind": "function", + "lineno": 123, + "locus": "Server", + "longname": "Accounts.findUserByUsername", + "memberof": "Accounts", + "name": "findUserByUsername", + "options": [], + "params": [ + { + "description": "

The username to look for

", + "name": "username", + "type": { + "names": [ + "String" + ] + } + } + ], + "returns": [ + { + "description": "

A user if found, else null

", + "type": { + "names": [ + "Object" + ] + } + } + ], + "scope": "static", + "summary": "Finds the user with the specified username.\nFirst tries to match username case sensitively; if that fails, it\ntries case insensitively; but if more than one user matches the case\ninsensitive search, it returns null." + }, "Accounts.forgotPassword": { "filepath": "accounts-password/password_client.js", "kind": "function", - "lineno": 212, + "lineno": 210, "locus": "Client", "longname": "Accounts.forgotPassword", "memberof": "Accounts", @@ -173,10 +281,42 @@ DocsData = { "scope": "static", "summary": "Request a forgot password email." }, + "Accounts.removeEmail": { + "filepath": "accounts-password/password_server.js", + "kind": "function", + "lineno": 930, + "locus": "Server", + "longname": "Accounts.removeEmail", + "memberof": "Accounts", + "name": "removeEmail", + "options": [], + "params": [ + { + "description": "

The ID of the user to update.

", + "name": "userId", + "type": { + "names": [ + "String" + ] + } + }, + { + "description": "

The email address to remove.

", + "name": "email", + "type": { + "names": [ + "String" + ] + } + } + ], + "scope": "static", + "summary": "Remove an email address for a user. Use this instead of updating\nthe database directly." + }, "Accounts.resetPassword": { "filepath": "accounts-password/password_client.js", "kind": "function", - "lineno": 232, + "lineno": 230, "locus": "Client", "longname": "Accounts.resetPassword", "memberof": "Accounts", @@ -218,7 +358,7 @@ DocsData = { "Accounts.sendEnrollmentEmail": { "filepath": "accounts-password/password_server.js", "kind": "function", - "lineno": 509, + "lineno": 584, "locus": "Server", "longname": "Accounts.sendEnrollmentEmail", "memberof": "Accounts", @@ -251,7 +391,7 @@ DocsData = { "Accounts.sendResetPasswordEmail": { "filepath": "accounts-password/password_server.js", "kind": "function", - "lineno": 444, + "lineno": 519, "locus": "Server", "longname": "Accounts.sendResetPasswordEmail", "memberof": "Accounts", @@ -284,7 +424,7 @@ DocsData = { "Accounts.sendVerificationEmail": { "filepath": "accounts-password/password_server.js", "kind": "function", - "lineno": 648, + "lineno": 723, "locus": "Server", "longname": "Accounts.sendVerificationEmail", "memberof": "Accounts", @@ -317,7 +457,7 @@ DocsData = { "Accounts.setPassword": { "filepath": "accounts-password/password_server.js", "kind": "function", - "lineno": 396, + "lineno": 471, "locus": "Server", "longname": "Accounts.setPassword", "memberof": "Accounts", @@ -365,6 +505,38 @@ DocsData = { "scope": "static", "summary": "Forcibly change the password for a user." }, + "Accounts.setUsername": { + "filepath": "accounts-password/password_server.js", + "kind": "function", + "lineno": 372, + "locus": "Server", + "longname": "Accounts.setUsername", + "memberof": "Accounts", + "name": "setUsername", + "options": [], + "params": [ + { + "description": "

The ID of the user to update.

", + "name": "userId", + "type": { + "names": [ + "String" + ] + } + }, + { + "description": "

A new username for the user.

", + "name": "newUsername", + "type": { + "names": [ + "String" + ] + } + } + ], + "scope": "static", + "summary": "Change a user's username. Use this instead of updating the\ndatabase directly. The operation will fail if there is an existing user\nwith a username only differing in case." + }, "Accounts.ui": { "filepath": "accounts-ui-unstyled/accounts_ui.js", "kind": "namespace", @@ -437,7 +609,7 @@ DocsData = { "Accounts.verifyEmail": { "filepath": "accounts-password/password_client.js", "kind": "function", - "lineno": 259, + "lineno": 257, "locus": "Client", "longname": "Accounts.verifyEmail", "memberof": "Accounts", diff --git a/docs/client/full-api/api/passwords.md b/docs/client/full-api/api/passwords.md index f109e3cc9d..2f32c71aad 100644 --- a/docs/client/full-api/api/passwords.md +++ b/docs/client/full-api/api/passwords.md @@ -31,9 +31,10 @@ id. On the client, you must pass `password` and at least one of `username` or `email` — enough information for the user to be able to log in again -later. If there are existing users with a username or email only differing in case, `createUser` will fail. On the server, you do not need to specify `password`, but the user will -not be able to log in until it has a password (eg, set with -[`Accounts.setPassword`](#accounts_setpassword)). +later. If there are existing users with a username or email only differing in +case, `createUser` will fail. On the server, you do not need to specify +`password`, but the user will not be able to log in until it has a password (eg, +set with [`Accounts.setPassword`](#accounts_setpassword)). To create an account without a password on the server and still let the user pick their own password, call `createUser` with the `email` option @@ -47,6 +48,35 @@ override this behavior, use [`Accounts.onCreateUser`](#accounts_oncreateuser). This function is only used for creating users with passwords. The external service login flows do not use this function. +### Managing usernames and emails + +Instead of modifying documents in the [`Meteor.users`](#meteor_users) collection +directly, use these convenience functions which correctly check for case +insensitive duplicates before updates. + +{{> autoApiBox "Accounts.setUsername"}} + +{{> autoApiBox "Accounts.addEmail"}} + +By default, an email address is added with `{ verified: false }`. Use +[`Accounts.sendVerificationEmail`](#Accounts-sendVerificationEmail) to send an +email with a link the user can use verify their email address. + +{{> autoApiBox "Accounts.removeEmail"}} + +{{> autoApiBox "Accounts.verifyEmail"}} + +This function accepts tokens passed into the callback registered with +[`Accounts.onEmailVerificationLink`](#Accounts-onEmailVerificationLink). + +{{> autoApiBox "Accounts.findUserByUsername"}} + +{{> autoApiBox "Accounts.findUserByEmail"}} + +### Managing passwords + +Use the below functions to initiate password changes or resets from the server +or the client. {{> autoApiBox "Accounts.changePassword"}} @@ -70,10 +100,9 @@ This function accepts tokens passed into the callbacks registered with {{> autoApiBox "Accounts.setPassword"}} -{{> autoApiBox "Accounts.verifyEmail"}} -This function accepts tokens passed into the callback registered with -[`Accounts.onEmailVerificationLink`](#Accounts-onEmailVerificationLink). + +

Sending emails

{{> autoApiBox "Accounts.sendResetPasswordEmail"}} diff --git a/docs/client/full-api/tableOfContents.js b/docs/client/full-api/tableOfContents.js index 60f4b7a025..12a1bfdda2 100644 --- a/docs/client/full-api/tableOfContents.js +++ b/docs/client/full-api/tableOfContents.js @@ -138,11 +138,20 @@ var toc = [ {name: "Passwords", id: "accounts_passwords"}, [ "Accounts.createUser", + {type: "spacer"}, + + {name: "Accounts.setUsername", id: "Accounts-setUsername"}, + {name: "Accounts.addEmail", id: "Accounts-addEmail"}, + {name: "Accounts.removeEmail", id: "Accounts-removeEmail"}, + {name: "Accounts.verifyEmail", id: "accounts_verifyemail"}, + {name: "Accounts.findUserByUsername", id: "Accounts-findUserByUsername"}, + {name: "Accounts.findUserByEmail", id: "Accounts-findUserByEmail"}, + {type: "spacer"}, + "Accounts.changePassword", "Accounts.forgotPassword", "Accounts.resetPassword", "Accounts.setPassword", - "Accounts.verifyEmail", {type: "spacer"}, "Accounts.sendResetPasswordEmail", diff --git a/docs/client/names.json b/docs/client/names.json index 30b9d14c4e..051374eea4 100644 --- a/docs/client/names.json +++ b/docs/client/names.json @@ -1,15 +1,20 @@ [ "Accounts", "Accounts", + "Accounts.addEmail", "Accounts.changePassword", "Accounts.createUser", "Accounts.emailTemplates", + "Accounts.findUserByEmail", + "Accounts.findUserByUsername", "Accounts.forgotPassword", + "Accounts.removeEmail", "Accounts.resetPassword", "Accounts.sendEnrollmentEmail", "Accounts.sendResetPasswordEmail", "Accounts.sendVerificationEmail", "Accounts.setPassword", + "Accounts.setUsername", "Accounts.ui", "Accounts.ui.config", "Accounts.verifyEmail", diff --git a/packages/accounts-password/password_client.js b/packages/accounts-password/password_client.js index ab6cda5e1c..b6aa443695 100644 --- a/packages/accounts-password/password_client.js +++ b/packages/accounts-password/password_client.js @@ -130,8 +130,6 @@ Accounts.createUser = function (options, callback) { }); }; - - // Change password. Must be logged in. // // @param oldPassword {String|null} By default servers no longer allow diff --git a/packages/accounts-password/password_server.js b/packages/accounts-password/password_server.js index cc4a7335de..6e7c1f8f76 100644 --- a/packages/accounts-password/password_server.js +++ b/packages/accounts-password/password_server.js @@ -77,13 +77,7 @@ var checkPassword = Accounts._checkPassword; /// LOGIN /// -// Attempts to find a user from a user query. -// First tries to match username or email case sensitively; if that fails, it -// tries case insensitively; but if more than one user matches the case -// insensitive search, it returns null -// @param query {Object} with one of `id`, `username`, or `email`. -// @returns A user if found, else null -var findUserFromQuery = function (query) { +Accounts._findUserByQuery = function (query) { var user = null; if (query.id) { @@ -110,8 +104,6 @@ var findUserFromQuery = function (query) { // No match if multiple candidates are found if (candidateUsers.length === 1) { user = candidateUsers[0]; - } else { - console.error('Found multiple users with ' + fieldName + ' = ' + fieldValue + ' only differing in case. Requiring case sensitive login.'); } } } @@ -119,6 +111,35 @@ var findUserFromQuery = function (query) { return user; }; +/** + * @summary Finds the user with the specified username. + * First tries to match username case sensitively; if that fails, it + * tries case insensitively; but if more than one user matches the case + * insensitive search, it returns null. + * @locus Server + * @param {String} username The username to look for + * @returns {Object} A user if found, else null + */ +Accounts.findUserByUsername = function (username) { + return Accounts._findUserByQuery({ + username: username + }); +}; + +/** + * @summary Finds the user with the specified email. + * First tries to match email case sensitively; if that fails, it + * tries case insensitively; but if more than one user matches the case + * insensitive search, it returns null. + * @locus Server + * @param {String} email The email address to look for + * @returns {Object} A user if found, else null + */ +Accounts.findUserByEmail = function (email) { + return Accounts._findUserByQuery({ + email: email + }); +}; // Generates a MongoDB selector that can be used to perform a fast case // insensitive lookup for the given fieldName and string. Since MongoDB does @@ -164,6 +185,26 @@ var generateCasePermutationsForString = function (string) { return permutations; } +var checkForCaseInsensitiveDuplicates = function (fieldName, displayName, fieldValue, ownUserId) { + // Some tests need the ability to add users with the same case insensitive + // value, hence the _skipCaseInsensitiveChecksForTest check + var skipCheck = _.has(Accounts._skipCaseInsensitiveChecksForTest, fieldValue); + + if (fieldValue && !skipCheck) { + var matchedUsers = Meteor.users.find( + selectorForFastCaseInsensitiveLookup(fieldName, fieldValue)).fetch(); + + if (matchedUsers.length > 0 && + // If we don't have a userId yet, any match we find is a duplicate + (!ownUserId || + // Otherwise, check to see if there are multiple matches or a match + // that is not us + (matchedUsers.length > 1 || matchedUsers[0]._id !== ownUserId))) { + throw new Meteor.Error(403, displayName + " already exists."); + } + } +}; + // XXX maybe this belongs in the check package var NonEmptyString = Match.Where(function (x) { check(x, String); @@ -210,7 +251,7 @@ Accounts.registerLoginHandler("password", function (options) { }); - var user = findUserFromQuery(options.user); + var user = Accounts._findUserByQuery(options.user); if (!user) throw new Meteor.Error(403, "User not found"); @@ -276,7 +317,7 @@ Accounts.registerLoginHandler("password", function (options) { password: passwordValidator }); - var user = findUserFromQuery(options.user); + var user = Accounts._findUserByQuery(options.user); if (!user) throw new Meteor.Error(403, "User not found"); @@ -320,6 +361,40 @@ Accounts.registerLoginHandler("password", function (options) { /// CHANGING /// +/** + * @summary Change a user's username. Use this instead of updating the + * database directly. The operation will fail if there is an existing user + * with a username only differing in case. + * @locus Server + * @param {String} userId The ID of the user to update. + * @param {String} newUsername A new username for the user. + */ +Accounts.setUsername = function (userId, newUsername) { + check(userId, NonEmptyString); + check(newUsername, NonEmptyString); + + var user = Meteor.users.findOne(userId); + if (!user) + throw new Meteor.Error(403, "User not found"); + + var oldUsername = user.username; + + // Perform a case insensitive check fro duplicates before update + checkForCaseInsensitiveDuplicates('username', 'Username', newUsername, user._id); + + Meteor.users.update({_id: user._id}, {$set: {username: newUsername}}); + + // Perform another check after update, in case a matching user has been + // inserted in the meantime + try { + checkForCaseInsensitiveDuplicates('username', 'Username', newUsername, user._id); + } catch (ex) { + // Undo update if the check fails + Meteor.users.update({_id: user._id}, {$set: {username: oldUsername}}); + throw ex; + } +}; + // Let the user change their own password if they know the old // password. `oldPassword` and `newPassword` should be objects with keys // `digest` and `algorithm` (representing the SHA256 of the password). @@ -758,7 +833,111 @@ Meteor.methods({verifyEmail: function (token) { ); }}); +/** + * @summary Add an email address for a user. Use this instead of directly + * updating the database. The operation will fail if there is a different user + * with an email only differing in case. If the specified user has an existing + * email only differing in case however, we replace it. + * @locus Server + * @param {String} userId The ID of the user to update. + * @param {String} newEmail A new email address for the user. + * @param {Boolean} [verified] Optional - whether the new email address should + * be marked as verified. Defaults to false. + */ +Accounts.addEmail = function (userId, newEmail, verified) { + check(userId, NonEmptyString); + check(newEmail, NonEmptyString); + check(verified, Match.Optional(Boolean)); + if (_.isUndefined(verified)) { + verified = false; + } + + var user = Meteor.users.findOne(userId); + if (!user) + throw new Meteor.Error(403, "User not found"); + + // Allow users to change their own email to a version with a different case + + // We don't have to call checkForCaseInsensitiveDuplicates to do a case + // insensitive check across all emails in the database here because: (1) if + // there is no case-insensitive duplicate between this user and other users, + // then we are OK and (2) if this would create a conflict with other users + // then there would already be a case-insensitive duplicate and we can't fix + // that in this code anyway. + var caseInsensitiveRegExp = + new RegExp('^' + Meteor._escapeRegExp(newEmail) + '$', 'i'); + + var didUpdateOwnEmail = _.any(user.emails, function(email, index) { + if (caseInsensitiveRegExp.test(email.address)) { + Meteor.users.update({ + _id: user._id, + 'emails.address': email.address + }, {$set: { + 'emails.$.address': newEmail, + 'emails.$.verified': verified + }}); + return true; + } + + return false; + }); + + // In the other updates below, we have to do another call to + // checkForCaseInsensitiveDuplicates to make sure that no conflicting values + // were added to the database in the meantime. We don't have to do this for + // the case where the user is updating their email address to one that is the + // same as before, but only different because of capitalization. Read the + // big comment above to understand why. + + if (didUpdateOwnEmail) { + return; + } + + // Perform a case insensitive check for duplicates before update + checkForCaseInsensitiveDuplicates('emails.address', 'Email', newEmail, user._id); + + Meteor.users.update({ + _id: user._id + }, { + $addToSet: { + emails: { + address: newEmail, + verified: verified + } + } + }); + + // Perform another check after update, in case a matching user has been + // inserted in the meantime + try { + checkForCaseInsensitiveDuplicates('emails.address', 'Email', newEmail, user._id); + } catch (ex) { + // Undo update if the check fails + Meteor.users.update({_id: user._id}, + {$pull: {emails: {address: newEmail}}}); + throw ex; + } +} + +/** + * @summary Remove an email address for a user. Use this instead of updating + * the database directly. + * @locus Server + * @param {String} userId The ID of the user to update. + * @param {String} email The email address to remove. + */ +Accounts.removeEmail = function (userId, email) { + check(userId, NonEmptyString); + check(email, NonEmptyString); + + var user = Meteor.users.findOne(userId); + if (!user) + throw new Meteor.Error(403, "User not found"); + + Meteor.users.update({_id: user._id}, + {$pull: {emails: {address: email}}}); +} /// /// CREATING USERS @@ -794,34 +973,16 @@ var createUser = function (options) { if (email) user.emails = [{address: email, verified: false}]; - // Check if there is no other user with a username or email only differing - // in case. - var performCaseInsensitiveCheck = function () { - // Some tests need the ability to add users with the same case insensitive - // username or email, hence the _skipCaseInsensitiveChecksForTest check - - if (username && - !_.has(Accounts._skipCaseInsensitiveChecksForTest, username) && - Meteor.users.find(selectorForFastCaseInsensitiveLookup( - "username", username)).count() > 1) { - throw new Meteor.Error(403, "Username already exists."); - } - - if (email && - !_.has(Accounts._skipCaseInsensitiveChecksForTest, email) && - Meteor.users.find(selectorForFastCaseInsensitiveLookup( - "emails.address", email)).count() > 1) { - throw new Meteor.Error(403, "Email already exists."); - } - } - // Perform a case insensitive check before insert - performCaseInsensitiveCheck(); + checkForCaseInsensitiveDuplicates('username', 'Username', username); + checkForCaseInsensitiveDuplicates('emails.address', 'Email', email); + var userId = Accounts.insertUserDoc(options, user); // Perform another check after insert, in case a matching user has been // inserted in the meantime try { - performCaseInsensitiveCheck(); + checkForCaseInsensitiveDuplicates('username', 'Username', username, userId); + checkForCaseInsensitiveDuplicates('emails.address', 'Email', email, userId); } catch (ex) { // Remove inserted user if the check fails Meteor.users.remove(userId); diff --git a/packages/accounts-password/password_tests.js b/packages/accounts-password/password_tests.js index 32c361d9bb..943062b74d 100644 --- a/packages/accounts-password/password_tests.js +++ b/packages/accounts-password/password_tests.js @@ -2,9 +2,6 @@ Accounts._noConnectionCloseDelayForTest = true; Accounts.removeDefaultRateLimit(); if (Meteor.isServer) { Meteor.methods({ - getUserId: function () { - return this.userId; - }, getResetToken: function () { var token = Meteor.users.findOne(this.userId).services.password.reset; return token; @@ -48,14 +45,29 @@ if (Meteor.isClient) (function () { }; var logoutStep = function (test, expect) { Meteor.logout(expect(function (error) { - test.equal(error, undefined); + if (error) { + test.fail(error.message); + } test.equal(Meteor.user(), null); })); }; var loggedInAs = function (someUsername, test, expect) { return expect(function (error) { - test.equal(error, undefined); - test.equal(Meteor.user().username, someUsername); + if (error) { + test.fail(error.message); + } + test.equal(Meteor.userId() && Meteor.user().username, someUsername); + }); + }; + var loggedInUserHasEmail = function (someEmail, test, expect) { + return expect(function (error) { + if (error) { + test.fail(error.message); + } + var user = Meteor.user(); + test.isTrue(user && _.some(user.emails, function(email) { + return email.address === someEmail; + })); }); }; var expectError = function (expectedError, test, expect) { @@ -74,17 +86,23 @@ if (Meteor.isClient) (function () { }; var invalidateLoginsStep = function (test, expect) { Meteor.call("testInvalidateLogins", 'fail', expect(function (error) { - test.isFalse(error); + if (error) { + test.fail(error.message); + } })); }; var hideActualLoginErrorStep = function (test, expect) { Meteor.call("testInvalidateLogins", 'hide', expect(function (error) { - test.isFalse(error); + if (error) { + test.fail(error.message); + } })); }; var validateLoginsStep = function (test, expect) { Meteor.call("testInvalidateLogins", false, expect(function (error) { - test.isFalse(error); + if (error) { + test.fail(error.message); + } })); }; @@ -206,7 +224,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive username with non-ASCII characters", [ + testAsyncMulti("passwords - logging in with case insensitive username " + + "with non-ASCII characters", [ function (test, expect) { // Hack because Tinytest does not clean the database between tests/runs this.randomSuffix = Random.id(10); @@ -226,7 +245,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive username should escape regex special characters", [ + testAsyncMulti("passwords - logging in with case insensitive username " + + "should escape regex special characters", [ createUserStep, logoutStep, // We shouldn't be able to log in with a regex expression for the username @@ -238,7 +258,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive username should require a match of the full string", [ + testAsyncMulti("passwords - logging in with case insensitive username " + + "should require a match of the full string", [ createUserStep, logoutStep, // We shouldn't be able to log in with a partial match for the username @@ -250,21 +271,22 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive username when there are multiple matches", [ + testAsyncMulti("passwords - logging in with case insensitive username when " + + "there are multiple matches", [ createUserStep, logoutStep, function (test, expect) { - this.otherUserName = 'Adalovelace' + this.randomSuffix; - addSkipCaseInsensitiveChecksForTest(this.otherUserName, test, expect); + this.otherUsername = 'Adalovelace' + this.randomSuffix; + addSkipCaseInsensitiveChecksForTest(this.otherUsername, test, expect); }, // Create another user with a username that only differs in case function (test, expect) { Accounts.createUser( - { username: this.otherUserName, password: this.password }, - loggedInAs(this.otherUserName, test, expect)); + { username: this.otherUsername, password: this.password }, + loggedInAs(this.otherUsername, test, expect)); }, function (test, expect) { - removeSkipCaseInsensitiveChecksForTest(this.otherUserName, test, expect); + removeSkipCaseInsensitiveChecksForTest(this.otherUsername, test, expect); }, // We shouldn't be able to log in with the username in lower case function (test, expect) { @@ -282,7 +304,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - creating users with the same case insensitive username", [ + testAsyncMulti("passwords - creating users with the same case insensitive " + + "username", [ createUserStep, logoutStep, // Attempting to create another user with a username that only differs in @@ -318,7 +341,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive email should escape regex special characters", [ + testAsyncMulti("passwords - logging in with case insensitive email should " + + "escape regex special characters", [ createUserStep, logoutStep, // We shouldn't be able to log in with a regex expression for the email @@ -330,7 +354,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive email should require a match of the full string", [ + testAsyncMulti("passwords - logging in with case insensitive email should " + + "require a match of the full string", [ createUserStep, logoutStep, // We shouldn't be able to log in with a partial match for the email @@ -342,24 +367,25 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - logging in with case insensitive email when there are multiple matches", [ + testAsyncMulti("passwords - logging in with case insensitive email when " + + "there are multiple matches", [ createUserStep, logoutStep, function (test, expect) { - this.otherUserName = 'AdaLovelace' + Random.id(10); + this.otherUsername = 'AdaLovelace' + Random.id(10); this.otherEmail = "ADA-intercept@lovelace.com" + this.randomSuffix; addSkipCaseInsensitiveChecksForTest(this.otherEmail, test, expect); }, // Create another user with an email that only differs in case function (test, expect) { Accounts.createUser( - { username: this.otherUserName, + { username: this.otherUsername, email: this.otherEmail, password: this.password }, - loggedInAs(this.otherUserName, test, expect)); + loggedInAs(this.otherUsername, test, expect)); }, function (test, expect) { - removeSkipCaseInsensitiveChecksForTest(this.otherUserName, test, expect); + removeSkipCaseInsensitiveChecksForTest(this.otherUsername, test, expect); }, logoutStep, // We shouldn't be able to log in with the email in lower case @@ -378,7 +404,8 @@ if (Meteor.isClient) (function () { } ]); - testAsyncMulti("passwords - creating users with the same case insensitive email", [ + testAsyncMulti("passwords - creating users with the same case insensitive " + + "email", [ createUserStep, logoutStep, // Attempting to create another user with an email that only differs in @@ -1229,7 +1256,8 @@ if (Meteor.isServer) (function () { }); }); - // XXX would be nice to test Accounts.config({forbidClientAccountCreation: true}) + // XXX would be nice to test + // Accounts.config({forbidClientAccountCreation: true}) Tinytest.addAsync( 'passwords - login token observes get cleaned up', @@ -1295,7 +1323,8 @@ if (Meteor.isServer) (function () { Accounts.sendResetPasswordEmail(userId, email); - var resetPasswordEmailOptions = Meteor.call("getInterceptedEmails", email)[0]; + var resetPasswordEmailOptions = + Meteor.call("getInterceptedEmails", email)[0]; var re = new RegExp(Meteor.absoluteUrl() + "#/reset-password/(\\S*)"); var match = resetPasswordEmailOptions.text.match(re); @@ -1312,4 +1341,164 @@ if (Meteor.isServer) (function () { Meteor.call("login", {user: {username: username}, password: "new-password"}); }, /Incorrect password/); }); + + // We should be able to change the username + Tinytest.add("passwords - change username", function (test) { + var username = Random.id(); + var userId = Accounts.createUser({ + username: username + }); + + test.isTrue(userId); + + var newUsername = Random.id(); + Accounts.setUsername(userId, newUsername); + + test.equal(Accounts._findUserByQuery({id: userId}).username, newUsername); + + // Test findUserByUsername as well while we're here + test.equal(Accounts.findUserByUsername(newUsername)._id, userId); + }); + + Tinytest.add("passwords - change username to a new one only differing " + + "in case", function (test) { + var username = Random.id() + "user"; + var userId = Accounts.createUser({ + username: username.toUpperCase() + }); + + test.isTrue(userId); + + var newUsername = username.toLowerCase(); + Accounts.setUsername(userId, newUsername); + + test.equal(Accounts._findUserByQuery({id: userId}).username, newUsername); + }); + + // We should not be able to change the username to one that only + // differs in case from an existing one + Tinytest.add("passwords - change username should fail when there are " + + "existing users with a username only differing in case", function (test) { + var username = Random.id() + "user"; + var usernameUpper = username.toUpperCase(); + + var userId1 = Accounts.createUser({ + username: username + }); + + var user2OriginalUsername = Random.id(); + var userId2 = Accounts.createUser({ + username: user2OriginalUsername + }); + + test.isTrue(userId1); + test.isTrue(userId2); + + test.throws(function () { + Accounts.setUsername(userId2, usernameUpper); + }, /Username already exists/); + + test.equal(Accounts._findUserByQuery({id: userId2}).username, + user2OriginalUsername); + }); + + Tinytest.add("passwords - add email", function (test) { + var origEmail = Random.id() + "@turing.com"; + var userId = Accounts.createUser({ + email: origEmail + }); + + var newEmail = Random.id() + "@turing.com"; + Accounts.addEmail(userId, newEmail); + + var thirdEmail = Random.id() + "@turing.com"; + Accounts.addEmail(userId, thirdEmail, true); + + test.equal(Accounts._findUserByQuery({id: userId}).emails, [ + { address: origEmail, verified: false }, + { address: newEmail, verified: false }, + { address: thirdEmail, verified: true } + ]); + + // Test findUserByEmail as well while we're here + test.equal(Accounts.findUserByEmail(origEmail)._id, userId); + }); + + Tinytest.add("passwords - add email when the user has an existing email " + + "only differing in case", function (test) { + var origEmail = Random.id() + "@turing.com"; + var userId = Accounts.createUser({ + email: origEmail + }); + + var newEmail = Random.id() + "@turing.com"; + Accounts.addEmail(userId, newEmail); + + var thirdEmail = origEmail.toUpperCase(); + Accounts.addEmail(userId, thirdEmail, true); + + test.equal(Accounts._findUserByQuery({id: userId}).emails, [ + { address: thirdEmail, verified: true }, + { address: newEmail, verified: false } + ]); + }); + + Tinytest.add("passwords - add email should fail when there is an existing " + + "user with an email only differing in case", function (test) { + var user1Email = Random.id() + "@turing.com"; + var userId1 = Accounts.createUser({ + email: user1Email + }); + + var user2Email = Random.id() + "@turing.com"; + var userId2 = Accounts.createUser({ + email: user2Email + }); + + var dupEmail = user1Email.toUpperCase(); + test.throws(function () { + Accounts.addEmail(userId2, dupEmail); + }, /Email already exists/); + + test.equal(Accounts._findUserByQuery({id: userId1}).emails, [ + { address: user1Email, verified: false } + ]); + + test.equal(Accounts._findUserByQuery({id: userId2}).emails, [ + { address: user2Email, verified: false } + ]); + }); + + Tinytest.add("passwords - remove email", function (test) { + var origEmail = Random.id() + "@turing.com"; + var userId = Accounts.createUser({ + email: origEmail + }); + + var newEmail = Random.id() + "@turing.com"; + Accounts.addEmail(userId, newEmail); + + var thirdEmail = Random.id() + "@turing.com"; + Accounts.addEmail(userId, thirdEmail, true); + + test.equal(Accounts._findUserByQuery({id: userId}).emails, [ + { address: origEmail, verified: false }, + { address: newEmail, verified: false }, + { address: thirdEmail, verified: true } + ]); + + Accounts.removeEmail(userId, newEmail); + + test.equal(Accounts._findUserByQuery({id: userId}).emails, [ + { address: origEmail, verified: false }, + { address: thirdEmail, verified: true } + ]); + + Accounts.removeEmail(userId, origEmail); + + test.equal(Accounts._findUserByQuery({id: userId}).emails, [ + { address: thirdEmail, verified: true } + ]); + }); + }) ();