diff --git a/.eslintrc b/.eslintrc index 061fb58..62b8883 100644 --- a/.eslintrc +++ b/.eslintrc @@ -38,6 +38,10 @@ "no-underscore-dangle": 0, "no-console": 0, "one-var-declaration-per-line": 0, - "one-var": 0 + "one-var": 0, + "function-paren-newline": 0, + "object-curly-newline": 0, + "require-string-literals": 0, + "import/prefer-default-export": 0 } } diff --git a/.meteor/packages b/.meteor/packages index 8b6296a..a3d547a 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -21,3 +21,4 @@ tprzytula:remember-me accounts-base meteortesting:mocha lmieulet:meteor-coverage +check diff --git a/.versions b/.versions index bdf9fa7..bc97517 100644 --- a/.versions +++ b/.versions @@ -1,45 +1,56 @@ accounts-base@1.4.2 +accounts-password@1.5.1 allow-deny@1.1.0 -babel-compiler@6.24.7 -babel-runtime@1.1.1 -base64@1.0.10 +babel-compiler@7.1.1 +babel-runtime@1.2.4 +base64@1.0.11 binary-heap@1.0.10 -boilerplate-generator@1.3.1 -callback-hook@1.0.10 -check@1.2.5 +boilerplate-generator@1.5.0 +callback-hook@1.1.0 +check@1.3.1 ddp@1.4.0 -ddp-client@2.2.0 -ddp-common@1.3.0 +ddp-client@2.3.2 +ddp-common@1.4.0 ddp-rate-limiter@1.0.7 -ddp-server@2.1.1 -diff-sequence@1.0.7 -ecmascript@0.9.0 -ecmascript-runtime@0.5.0 -ecmascript-runtime-client@0.5.0 -ecmascript-runtime-server@0.5.0 +ddp-server@2.2.0 +diff-sequence@1.1.0 +dynamic-import@0.4.1 +ecmascript@0.11.1 +ecmascript-runtime@0.7.0 +ecmascript-runtime-client@0.7.1 +ecmascript-runtime-server@0.7.0 ejson@1.1.0 +email@1.2.3 geojson-utils@1.0.10 -id-map@1.0.9 +http@1.4.1 +id-map@1.1.0 localstorage@1.2.0 -logging@1.1.19 -meteor@1.8.2 -minimongo@1.4.3 -modules@0.11.2 -modules-runtime@0.9.1 -mongo@1.3.1 +logging@1.1.20 +meteor@1.9.0 +minimongo@1.4.4 +modern-browsers@0.1.2 +modules@0.12.2 +modules-runtime@0.10.0 +mongo@1.5.0 mongo-dev-server@1.1.0 -mongo-id@1.0.6 -npm-mongo@2.2.33 -ordered-dict@1.0.9 -promise@0.10.1 -random@1.0.10 -rate-limit@1.0.8 +mongo-id@1.0.7 +npm-bcrypt@0.9.3 +npm-mongo@3.0.11 +ordered-dict@1.1.0 +promise@0.11.1 +random@1.1.0 +rate-limit@1.0.9 reactive-var@1.0.11 -retry@1.0.9 -routepolicy@1.0.12 +reload@1.2.0 +retry@1.1.0 +routepolicy@1.0.13 service-configuration@1.0.11 -tprzytula:remember-me@0.1.0 -tracker@1.1.3 +sha@1.0.9 +socket-stream-client@0.2.2 +srp@1.0.12 +tprzytula:remember-me@1.0.0 +tracker@1.2.0 underscore@1.0.10 -webapp@1.4.0 +url@1.2.0 +webapp@1.6.0 webapp-hashing@1.0.9 diff --git a/README.md b/README.md index bfdf5f1..f7b665b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# Meteor - Remember Me +# Meteor - Remember Me [![Build Status](https://travis-ci.org/tprzytula/Meteor-Remember-Me.svg?branch=master)](https://travis-ci.org/tprzytula/Meteor-Remember-Me) [![Coverage Status](https://coveralls.io/repos/github/tprzytula/Meteor-Remember-Me/badge.svg)](https://coveralls.io/github/tprzytula/Meteor-Remember-Me) -### Integrated remember me functionality support for Meteor - +##### RememberMe extension for the Meteor's accounts system As you already know meteor login system is based on login tokens. Each login token have it's own expiry time, until then it will stay active. @@ -31,41 +30,28 @@ active for your phone!. ## Usage -To activate the functionality: - -1. Import the package on server side: - -```js -import RememberMe from 'meteor/tprzytula:remember-me'; -``` - -2. Activate the functionality: - -```js -RememberMe.activate() -``` - -3. Import the package on client side: +1. Import the package on the client side: ```js import RememberMe from 'meteor/tprzytula:remember-me'; ``` -4. Change your login method to this: +2. Replace your login method with this: ```js RememberMe.loginWithPassword(username, password, (error) => { // Your previous logic -}, true) +}, false) ``` If you don't need a callback then you can simply change it to: ```js -RememberMe.loginWithPassword(username, password, true) +RememberMe.loginWithPassword(username, password, false) ``` ## API +###### All methods are client side only `loginWithPassword(string: username, string: password, func: callback, boolean: rememberMe = true)` diff --git a/client/accountsConfigurator.js b/client/accountsConfigurator.js new file mode 100644 index 0000000..46bb3fc --- /dev/null +++ b/client/accountsConfigurator.js @@ -0,0 +1,87 @@ +import { prepareLoginWithPasswordMethod } from './lib/overriding'; + +const IS_CALLBACK_REGISTERED = 'tprzytula:remember-me_callbackRegistered'; +const IS_METHOD_OVERRIDDEN = 'tprzytula:remember-me_methodOverridden'; +const LOGIN_WITH_PASSWORD_METHOD = 'loginWithPassword'; + +/** + * Extends AccountsClient instance with RememberMe functionality. + * @property {AccountsClient} _accounts + */ +class AccountsConfigurator { + constructor(accounts) { + this._accounts = accounts; + } + + /** + * Starts the configuration process. + * @public + */ + configure() { + this._registerLoginMethod(); + this._registerCallback(); + } + + /** + * Extends accounts with a "loginWithPassword" method, which is + * based on the "Meteor.loginWithPassword". + * @private + */ + _registerLoginMethod() { + if (LOGIN_WITH_PASSWORD_METHOD in this._accounts) return; + this._accounts.loginWithPassword = prepareLoginWithPasswordMethod(this._accounts); + } + + /** + * Registers "onLogin" callback. + * After successful login attempt the loginMethod will be overridden. + * @private + */ + _registerCallback() { + if (IS_CALLBACK_REGISTERED in this._accounts) return; + this._accounts.onLogin(this._overrideCallLoginMethod.bind(this)); + this._accounts[IS_CALLBACK_REGISTERED] = true; + } + + /** + * Overrides Meteor's loginMethod. + * From now each time user will perform login/autologin + * the new loginMethod will be invoked. + * @private + */ + _overrideCallLoginMethod() { + if (IS_METHOD_OVERRIDDEN in this._accounts) return; + this._accounts.callLoginMethod = this._getNewCallLoginMethod(); + this._accounts[IS_METHOD_OVERRIDDEN] = true; + } + + /** + * Prepares login method. + * + * Extends the method arguments with "loggedAtLeastOnce". + * This argument will indicate to the server that we were already + * logged in during this device session, so our previously set rememberMe + * option should not affect the outcome of the next login attempt. + * + * Calls original callLoginMethod at the end with extended "methodArguments". + * @returns {Function} callLoginMethod + * @private + */ + _getNewCallLoginMethod() { + const accountsCallLoginMethod = this._accounts.callLoginMethod.bind(this._accounts); + return (options) => { + const preparedOptions = options || {}; + const argument = { loggedAtLeastOnce: true }; + if (preparedOptions.methodArguments) { + preparedOptions.methodArguments.push(argument); + } else { + preparedOptions.methodArguments = [argument]; + } + accountsCallLoginMethod(preparedOptions); + }; + } +} + +export const factory = (...params) => new AccountsConfigurator(...params); + +export default AccountsConfigurator; diff --git a/client/accountsWrapper.js b/client/accountsWrapper.js new file mode 100644 index 0000000..3f2bbfc --- /dev/null +++ b/client/accountsWrapper.js @@ -0,0 +1,59 @@ +import AccountsConfigurator from './accountsConfigurator'; +import * as Alerts from './lib/alerts'; + +const updateFlagMethod = 'tprzytula:rememberMe-update'; + +/** + * Wrapper for currently used accounts system. + * @property {AccountsClient} _accounts + */ +class AccountsWrapper { + constructor(accounts) { + this._accounts = accounts; + } + + /** + * Configures accounts. + * @public + */ + configure() { + this._accountsConfigurator = new AccountsConfigurator(this._accounts); + this._accountsConfigurator.configure(); + } + + /** + * Wraps login method from the accounts instance. + * @param {string} username + * @param {string} password + * @param {boolean} flag + * @param {function} callback + * @public + */ + loginWithPassword({ username, password, flag, callback }) { + this._accounts.loginWithPassword(username, password, (error) => { + if (!error) { + this._updateFlag(flag); + } + callback(error); + }); + } + + /** + * Informs the server of the state update of rememberMe flag. + * @param {boolean} flag + * @private + */ + _updateFlag(flag) { + this._accounts.connection.call(updateFlagMethod, flag, (error) => { + if (error && error.error === 404) { + Alerts.rememberMeNotActive(); + } else if (error) { + Alerts.couldNotUpdateRememberMe(error); + } + }); + } +} + +export const factory = (...params) => new AccountsWrapper(...params); + +export default AccountsWrapper; diff --git a/client/index.js b/client/index.js index 5a1e2b7..1cd2417 100644 --- a/client/index.js +++ b/client/index.js @@ -2,176 +2,61 @@ import { Accounts, AccountsClient } from 'meteor/accounts-base'; - +import AccountsWrapper from './accountsWrapper'; import { exportFlagFromParams, exportCallbackFromParams -} from './helpers'; - -import overrideAccountsLogin from './overrideLogin'; +} from './lib/methodParams'; /** - * RememberMe - * - * @property {Object} remoteConnection - handler to a custom connection. - * @property {string} methodName - unique name for the rememberMe method - * - * @class + * RememberMe client-side + * Extends functionality of Meteor's Accounts system. + * @property {AccountsWrapper} _accountsWrapper */ class RememberMe { constructor() { - this.remoteConnection = null; - this.methodName = 'tprzytula:rememberMe-update'; - overrideAccountsLogin(Accounts); + this._accountsWrapper = null; + this._init(); } /** - * Returns login method either from the main - * connection or remote one if set. - * @returns {function} loginWithPassword - * @private - */ - getLoginWithPasswordMethod = () => { - if (this.remoteConnection) { - return this.remoteConnection.loginWithPassword; - } - - return Meteor.loginWithPassword; - }; - - /** - * Returns call method either from the main - * connection or remote one if set. - * @returns {function} call + * Configures default Accounts system to be used. * @private */ - getCallMethod = () => { - if (this.remoteConnection) { - return this.remoteConnection.call.bind(this.remoteConnection); - } - - return Meteor.call; - }; + _init() { + this._accountsWrapper = new AccountsWrapper(Accounts); + this._accountsWrapper.configure(); + } /** - * Wrapper for the Meteor.loginWithPassword - * Invokes suitable loginMethod and upon results - * passes it to the user's callback and if there - * were no errors then also invokes a method to - * update the rememberMe flag on the server side. + * Login method. + * To be used in the same way as "Meteor.loginWithPassword" except + * of being able to pass the RememberMe flag as the last parameter. + * @param params * @public */ loginWithPassword = (...params) => { - const [user, password, ...rest] = params; + const [username, password, ...rest] = params; const flag = exportFlagFromParams(rest); - const callbackMethod = exportCallbackFromParams(rest); - const loginMethod = this.getLoginWithPasswordMethod(); - loginMethod(user, password, (error) => { - if (!error) { - this.updateFlag(flag); - } - callbackMethod(error); - }); + const callback = exportCallbackFromParams(rest); + this._accountsWrapper.loginWithPassword({ username, password, flag, callback }); }; /** - * Sends request to the server to update - * the remember me setting. - * @param {boolean} flag - * @private - */ - updateFlag = (flag) => { - const callMethod = this.getCallMethod(); - callMethod(this.methodName, flag, (error) => { - if (error && error.error === 404) { - console.warn( - 'Dependency meteor/tprzytula:remember-me is not active!\n', - '\nTo activate it make sure to run "RememberMe.activate()" on the server.' + - 'It is required to be able to access the functionality on the client.' - ); - } else if (error) { - console.error( - 'meteor/tprzytula:remember-me' + - '\nCould not update remember me setting.' + - '\nError:', - error - ); - } - }); - }; - - /** - * Switches from using the current login system to - * a new custom one. After switching each login attempt - * will be performed to new accounts instance. - * @param {AccountsClient} customAccounts - * @returns {boolean} result + * Gives the possibility to change the default Accounts system to a different one. + * The new instance can use different DDP connection or even be on the same one. + * Example of usage is given in the documentation. * @public */ - changeAccountsSystem = (customAccounts) => { - if (customAccounts instanceof AccountsClient && - customAccounts.connection) { - this.remoteConnection = customAccounts.connection; - this.setLoginMethod(customAccounts); - overrideAccountsLogin(customAccounts); - return true; + changeAccountsSystem = (accountsInstance) => { + if (accountsInstance instanceof AccountsClient !== true) { + throw new Meteor.Error(400, 'Provided argument is not a valid instance of AccountsClient'); } - console.error('meteor/tprzytula:remember-me' + - '\nProvided parameter is not a valid AccountsClient.'); - return false; - }; - - /** - * Since freshly created AccountsClients are not having - * this method by default it's required to make sure that - * the set accounts system will contain it. - * @param {AccountsClient} accountsInstance - * @private - */ - setLoginMethod = (accountsInstance) => { - if ('loginWithPassword' in this.remoteConnection) { - // Login method is already present - return; - } - - /* eslint-disable */ - /* istanbul ignore next */ - /* - The method is based on the original one in Accounts: - https://github.com/meteor/meteor/blob/46257bad264bf089e35e0fe35494b51fe5849c7b/packages/accounts-password/password_client.js#L33 - */ - this.remoteConnection.loginWithPassword = function (selector, password, callback) { - if (typeof selector === 'string') { - selector = selector.indexOf('@') === -1 - ? { username: selector } - : { email: selector }; - } - accountsInstance.callLoginMethod({ - methodArguments: [{ - user: selector, - password: Accounts._hashPassword(password) - }], - userCallback: function (error, result) { - if (error && error.error === 400 && - error.reason === 'old password format') { - srpUpgradePath({ - upgradeError: error, - userSelector: selector, - plaintextPassword: password - }, callback); - } else if (error) { - callback && callback(error); - } else { - callback && callback(); - } - } - }); - }; - /* eslint-enable */ - }; + this._accountsWrapper = new AccountsWrapper(accountsInstance); + this._accountsWrapper.configure(); + } } -export default new RememberMe(); +export const factory = () => new RememberMe(); -// Export handle to the class only on TEST environment -export const RememberMeClass = process.env.TEST_METADATA ? RememberMe : null; +export default factory(); diff --git a/client/lib/alerts.js b/client/lib/alerts.js new file mode 100644 index 0000000..9325994 --- /dev/null +++ b/client/lib/alerts.js @@ -0,0 +1,33 @@ +/** + * Informs about the requirement of this functionality + * to be activated on the server before use. + * + * Doesn't throw the error because the login attempt + * succeeded and the inappropriate behaviour of RememberMe dependency + * should not affect the core login behaviour. + */ +export const rememberMeNotActive = () => { + console.warn( + 'Dependency meteor/tprzytula:remember-me is not present on the server!\n', + '\nMake sure you have installed this dependency on the server.', + '\nRememberMe setting will not be taken into account' + ); +}; + +/** + * Prints received error from an attempt to send flag state update + * to the server. + * + * Doesn't throw the error because the login attempt + * succeeded and the inappropriate behaviour of RememberMe dependency + * should not affect the core login behaviour. + * @param {Meteor.Error} error + */ +export const couldNotUpdateRememberMe = (error) => { + console.error( + 'meteor/tprzytula:remember-me' + + '\nCould not update remember me setting.' + + '\nError:', + error + ); +}; diff --git a/client/helpers.js b/client/lib/methodParams.js similarity index 92% rename from client/helpers.js rename to client/lib/methodParams.js index c6c2408..6287d00 100644 --- a/client/helpers.js +++ b/client/lib/methodParams.js @@ -7,8 +7,8 @@ */ export const exportFlagFromParams = (params = []) => { const [ - firstParam = () => {}, - secondParam = true, + firstParam, + secondParam = true ] = params; return (typeof firstParam === 'boolean') ? firstParam diff --git a/client/lib/overriding.js b/client/lib/overriding.js new file mode 100644 index 0000000..7ee3444 --- /dev/null +++ b/client/lib/overriding.js @@ -0,0 +1,39 @@ +import { Accounts } from 'meteor/accounts-base'; + +/* eslint-disable */ +export const prepareLoginWithPasswordMethod = (accountsInstance) => { + /* + The method is based on the original one in Accounts: + https://github.com/meteor/meteor/blob/46257bad264bf089e35e0fe35494b51fe5849c7b/packages/accounts-password/password_client.js#L33 + */ + /* istanbul ignore next */ + // TODO: Maybe try to test it anyways? We changed this method so it would be nice to ensure that it's okay + return function (selector, password, callback) { + if (typeof selector === 'string') { + selector = selector.indexOf('@') === -1 + ? { username: selector } + : { email: selector }; + } + accountsInstance.callLoginMethod({ + methodArguments: [{ + user: selector, + password: Accounts._hashPassword(password) + }], + userCallback: function (error, result) { + if (error && error.error === 400 && + error.reason === 'old password format') { + srpUpgradePath({ + upgradeError: error, + userSelector: selector, + plaintextPassword: password + }, callback); + } else if (error) { + callback && callback(error); + } else { + callback && callback(); + } + } + }); + }; +}; +/* eslint-enable */ diff --git a/client/overrideLogin.js b/client/overrideLogin.js deleted file mode 100644 index 208ab95..0000000 --- a/client/overrideLogin.js +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint no-param-reassign: 0 */ - -const isMethodOverridden = 'tprzytula:remember-me_overridden'; -const isCallbackRegistered = 'tprzytula:remember-me_callbackRegistered'; - -/** - * This function is used to override Account's function called callLoginMethod. - * We are using our custom implementation of remember me functionality where user - * can decide if he want's to be remembered or not. - * - * The problem is that after losing internet connection we don't want to disallow - * login attempt from user no matter if he has set rememberMe or not. - * - * On the server it was not possible to know if the login attempt was from user who - * just started the application or from user reconnecting because it's always new - * connection with type "resume". - * - * Overriding this function after first successful login gives us possibility to - * send another parameter "loggedAtLeastOnce" to the every next login request in the - * current application session. This parameter can be recognized by server - * to perform suitable logic. - * - * This method is internal and is invoked without our logic on every login attempt which is also - * made by Meteor on every reconnect. - * - * Launching the app again means that it will became default again without our additions. - * Then the next successful login will override it again. - */ -const overrideLoginMethod = (accountsClientInstance) => { - const accountsCallLoginMethod = - accountsClientInstance.callLoginMethod.bind(accountsClientInstance); - accountsClientInstance.callLoginMethod = function callLoginMethod(options = {}) { - const preparedOptions = options; - if (preparedOptions) { - if (preparedOptions.methodArguments) { - preparedOptions.methodArguments.push({ loggedAtLeastOnce: true }); - } else { - preparedOptions.methodArguments = [{ loggedAtLeastOnce: true }]; - } - } - accountsCallLoginMethod(preparedOptions); - }; - accountsClientInstance[isMethodOverridden] = true; -}; - -export default (accountsClientInstance) => { - if (isCallbackRegistered in accountsClientInstance) { - // onLogin callback is already registered - return; - } - accountsClientInstance.onLogin(() => { - /* Override meteor accounts callLoginMethod to store information that user logged before */ - if (!(isMethodOverridden in accountsClientInstance)) { - overrideLoginMethod(accountsClientInstance); - } - }); - accountsClientInstance[isCallbackRegistered] = true; -}; diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 95b9be3..ca6fbdb 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.0.0] - 14.08.2018 +* Dependency was refactored + +**Important changes:** +* All log ins made by the default Meteor.loginWithPassword method won't be affected anymore by this dependency. Every client who did not report the rememberMe setting will stay logged in to match the default Meteor's behaviour. + +**Breaking changes:** +* `activate` method was removed. There is no need to activate RememberMe on the server anymore. +* `changeAccountsSystem` will now throw an error when provided parameter is not a valid instance of the AccountsClient + ## [0.2.1] - 10.05.2018 * Change client methods to arrow functions to prevent wrong context issues ([Issue #6](https://github.com/tprzytula/Meteor-Remember-Me/issues/6)) * loginWithPassword method for custom accounts was throwing an error if accounts were not stored in `Meteor.remoteUsers` (whoops!) diff --git a/package-lock.json b/package-lock.json index 407ce66..22cb068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "rememberMe", - "version": "0.2.1", + "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -119,7 +119,7 @@ }, "@sinonjs/formatio": { "version": "2.0.0", - "resolved": "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", "dev": true, "requires": { @@ -133,9 +133,9 @@ "dev": true }, "acorn": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.5.3.tgz", - "integrity": "sha512-jd5MkIUlbbmb07nXH0DT3y7rDVtkzDi4XZOUVWAer8ajmF/DTSSbl5oNFyDOl/OXA33Bl79+ypHhl2pN20VeOQ==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz", + "integrity": "sha512-d+nbxBUGKg7Arpsvbnlq61mc12ek3EY8EQldM3GPAhWJ1UVxC6TDGbIvUMNU6obBX3i1+ptCIzV4vq0gFPEGVQ==", "dev": true }, "acorn-jsx": { @@ -730,8 +730,8 @@ }, "circular-json": { "version": "0.3.3", - "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", - "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "resolved": "https://artifactory.gamesys.co.uk:443/artifactory/api/npm/gamesys-ps-community-npm/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha1-gVyZ6oT2gJUp0vRXkb34JxE1LWY=", "dev": true }, "cli-cursor": { @@ -1115,9 +1115,9 @@ } }, "eslint": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.18.2.tgz", - "integrity": "sha512-qy4i3wODqKMYfz9LUI8N2qYDkHkoieTbiHpMrYUI/WbjhXJQr7lI4VngixTgaG+yHX+NBCv7nW4hA0ShbvaNKw==", + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", "dev": true, "requires": { "ajv": "5.5.2", @@ -1136,7 +1136,7 @@ "functional-red-black-tree": "1.0.1", "glob": "7.1.2", "globals": "11.4.0", - "ignore": "3.3.7", + "ignore": "3.3.8", "imurmurhash": "0.1.4", "inquirer": "3.3.0", "is-resolvable": "1.1.0", @@ -1151,6 +1151,7 @@ "path-is-inside": "1.0.2", "pluralize": "7.0.0", "progress": "2.0.0", + "regexpp": "1.1.0", "require-uncached": "1.0.3", "semver": "5.5.0", "strip-ansi": "4.0.0", @@ -1319,7 +1320,7 @@ "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", "dev": true, "requires": { - "acorn": "5.5.3", + "acorn": "5.7.1", "acorn-jsx": "3.0.1" } }, @@ -1759,9 +1760,9 @@ } }, "ignore": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.7.tgz", - "integrity": "sha512-YGG3ejvBNHRqu0559EOxxNFihD0AjpvHlC/pdGKd3X3ofe+CoJkYazwNJYTNebqpPKN+VVQbh4ZFn1DivMNuHA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.8.tgz", + "integrity": "sha512-pUh+xUQQhQzevjRHHFqqcTy0/dP/kS9I8HSrUydhihjuD09W6ldVWFtIrwhXdUJHis3i2rZNqEHpZH/cbinFbg==", "dev": true }, "ignore-walk": { @@ -2088,9 +2089,9 @@ "dev": true }, "lolex": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.3.2.tgz", - "integrity": "sha512-A5pN2tkFj7H0dGIAM6MFvHKMJcPnjZsOMvR7ujCjfgW5TbV6H9vb1PgxLtHvjqNZTHsUolz+6/WEO0N1xNx2ng==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.0.tgz", + "integrity": "sha512-uJkH2e0BVfU5KOJUevbTOtpDduooSarH5PopO+LfM/vZf8Z9sJzODqKev804JYM2i++ktJfUmC1le4LwFQ1VMg==", "dev": true }, "loose-envify": { @@ -2129,8 +2130,8 @@ }, "mimic-fn": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "resolved": "https://artifactory.gamesys.co.uk:443/artifactory/api/npm/gamesys-ps-community-npm/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha1-ggyGo5M0ZA6ZUWkovQP8qIBX0CI=", "dev": true }, "minimatch": { @@ -2249,14 +2250,14 @@ "dev": true }, "nise": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.3.2.tgz", - "integrity": "sha512-KPKb+wvETBiwb4eTwtR/OsA2+iijXP+VnlSFYJo3EHjm2yjek1NWxHOUQat3i7xNLm1Bm18UA5j5Wor0yO2GtA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.1.tgz", + "integrity": "sha512-9JX3YwoIt3kS237scmSSOpEv7vCukVzLfwK0I0XhocDSHUANid8ZHnLEULbbSkfeMn98B2y5kphIWzZUylESRQ==", "dev": true, "requires": { "@sinonjs/formatio": "2.0.0", "just-extend": "1.1.27", - "lolex": "2.3.2", + "lolex": "2.7.0", "path-to-regexp": "1.7.0", "text-encoding": "0.6.4" } @@ -2705,6 +2706,12 @@ "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "dev": true }, + "regexpp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", + "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", + "dev": true + }, "repeating": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", @@ -2897,18 +2904,29 @@ "dev": true }, "sinon": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-4.5.0.tgz", - "integrity": "sha512-trdx+mB0VBBgoYucy6a9L7/jfQOmvGeaKZT4OOJ+lPAtI8623xyGr8wLiE4eojzBS8G9yXbhx42GHUOVLr4X2w==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.0.0.tgz", + "integrity": "sha512-MatciKXyM5pXMSoqd593MqTsItJNCkSSl53HJYeKR5wfsDdp2yljjUQJLfVwAWLoBNfx1HThteqygGQ0ZEpXpQ==", "dev": true, "requires": { "@sinonjs/formatio": "2.0.0", "diff": "3.5.0", "lodash.get": "4.4.2", - "lolex": "2.3.2", - "nise": "1.3.2", - "supports-color": "5.3.0", + "lolex": "2.7.0", + "nise": "1.4.1", + "supports-color": "5.4.0", "type-detect": "4.0.8" + }, + "dependencies": { + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + } } }, "sinon-chai": { diff --git a/package.js b/package.js index a3f814f..476121b 100644 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'tprzytula:remember-me', - version: '0.2.1', + version: '1.0.0', summary: 'Extension for Meteor account-base package with the implementation of rememberMe', git: 'https://github.com/tprzytulacc/Meteor-RememberMe', documentation: 'README.md' diff --git a/package.json b/package.json index 4ccdfd1..4775890 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rememberMe", - "version": "0.2.1", + "version": "1.0.0", "description": "Extension for Meteor account-base package with the implementation of rememberMe", "license": "MIT", "author": "Tomasz Przytuła ", @@ -30,7 +30,7 @@ "chromedriver": "^2.37.0", "coveralls": "^3.0.1", "cross-env": "^5.1.4", - "eslint": "4.18.2", + "eslint": "^4.19.1", "eslint-config-airbnb-base": "^12.1.0", "eslint-import-resolver-meteor": "^0.4.0", "eslint-plugin-import": "^2.9.0", @@ -38,7 +38,7 @@ "pre-commit": "^1.2.2", "pre-push": "^0.1.1", "selenium-webdriver": "3.0.0-beta-2", - "sinon": "^4.5.0", + "sinon": "^6.0.0", "ultimate-chai": "^4.1.1" }, "pre-commit": [ diff --git a/server/authenticator.js b/server/authenticator.js deleted file mode 100644 index 63b7e41..0000000 --- a/server/authenticator.js +++ /dev/null @@ -1,110 +0,0 @@ -import Crypto from 'crypto'; - -/** - * Gives tools to perform custom authentication. - * - * @typedef {Object} attempt - single user login attempt. - * @type {Authenticator} - * @class - */ -class Authenticator { - constructor(attempt) { - this.loginAttempt = attempt; - } - - /** - * Hashes provided token the same way as Accounts are - * hashing login tokens. - * - * @param {string} token - token to be hashed. - * @return {string} hashedToken - */ - static hashToken(token) { - const hash = Crypto.createHash('sha256'); - hash.update(token); - return hash.digest('base64'); - } - - /** - * Validates custom login attempt. - * - * @returns {Object} result - */ - validateAttempt() { - const isAttemptAllowed = this.loginAttempt.allowed; - if (!isAttemptAllowed) { - return { - result: false, - resultCode: -1, - reason: 'Attempt disallowed by Meteor', - error: this.loginAttempt.error - }; - } - - if (this.loginAttempt.type === 'resume') { - const shouldResume = this.shouldResumeBeAccepted(); - if (!shouldResume) { - return { - result: false, - resultCode: -2, - reason: 'Resume not allowed when user does not have remember me' - }; - } - } - - return { - result: true, - resultCode: 0, - reason: 'Validation passed' - }; - } - - /** - * In case of login attempt being "resume" checks if user is eligible to - * be logged in again. First checks if the loginToken is valid. Then if - * user has flag rememberMe then should be logged in. In case of not having - * rememberMe set there is also check if the user had an active application - * session before, which helps in case of losing internet connection. - * We don't want to logout user because the user lost internet connection for - * a moment, rememberMe should not be a condition in this case. - * - * @returns {boolean} result - */ - shouldResumeBeAccepted() { - const loginTokens = this.getUsersLoginTokens(); - const hashedToken = this.getTokenFromAttempt(); - const userResume = loginTokens.find(item => item.hashedToken === hashedToken); - if (!userResume) { - return false; - } - const methodArguments = this.loginAttempt.methodArguments || []; - const loggedAtLeastOnce = - methodArguments.some(argument => argument.loggedAtLeastOnce === true); - return (userResume.rememberMe || loggedAtLeastOnce); - } - - /** - * Gets hashed resume token send together with the login attempt. - * @returns {string} hashedToken - */ - getTokenFromAttempt() { - const attemptToken = (this.loginAttempt.methodArguments) - ? this.loginAttempt.methodArguments[0].resume - : ''; - return Authenticator.hashToken(attemptToken); - } - - /** - * Get login tokens of user who is trying to log in. - * @returns {Array} loginTokens - */ - getUsersLoginTokens() { - const user = Accounts.findUserByUsername(this.loginAttempt.user.username); - return (user.services && user.services.resume) - ? user.services.resume.loginTokens - : []; - } -} - -export default Authenticator; - diff --git a/server/helpers.js b/server/helpers.js deleted file mode 100644 index fc56690..0000000 --- a/server/helpers.js +++ /dev/null @@ -1,45 +0,0 @@ -const RememberMeHelpers = {}; - -/** - * Returns an user containing provided login token. - * @param userLoginToken - * @returns {Object} user - */ -RememberMeHelpers.findMatchingUser = userLoginToken => Meteor.users.findOne({ - 'services.resume.loginTokens.hashedToken': userLoginToken -}, { - fields: { - 'services.resume.loginTokens': 1 - } -}); - -/** - * Returns all currently stored login token records for - * the user who has also the provided token in parameter. - * @param {string} loginToken - * @returns {Array} loginTokenRecords - */ -RememberMeHelpers.getAllUserTokens = (loginToken) => { - const user = RememberMeHelpers.findMatchingUser(loginToken); - if (!user) return false; - return user.services.resume.loginTokens || []; -}; - -/** - * Updates login token records for an user who match the single token. - * @param {string} loginToken - * @param {Array} newLoginTokens - * @returns {boolean} result - */ -RememberMeHelpers.updateUserTokens = (loginToken, newLoginTokens) => { - const updatedDocuments = Meteor.users.update({ - 'services.resume.loginTokens.hashedToken': loginToken - }, { - $set: { - 'services.resume.loginTokens': newLoginTokens - } - }); - return updatedDocuments === 1; -}; - -export default RememberMeHelpers; diff --git a/server/index.js b/server/index.js index a8e7dd2..b94e1ab 100644 --- a/server/index.js +++ b/server/index.js @@ -1,55 +1,74 @@ -import { Accounts } from 'meteor/accounts-base'; -import methods from './methods'; -import Authenticator from './authenticator'; -import RememberMeHelpers from './helpers'; +import { check } from 'meteor/check'; +import * as integrationMethod from './integration/method'; +import * as integrationAccounts from './integration/accounts'; +import * as integrationError from './integration/error'; +import * as ConnectionLastLoginToken from './lib/connectionLastLoginToken'; +import * as LoginAttemptValidator from './loginAttemptValidator'; /** - * To have the access to this functionality it has to - * be activated on the server. We don't want to interfere - * with users who added the package but don't want to use - * this functionality in specific cases. - * @public + * RememberMe server-side + * Extends functionality of Meteor's Accounts system. */ -export const activate = () => { - methods(); - Accounts.validateLoginAttempt((attempt) => { - const authenticator = new Authenticator(attempt); - const { - result, - resultCode, - reason, - error, - } = authenticator.validateAttempt(); +class RememberMe { + constructor() { + this._activate(); + } + + /** + * Activates the functionality on the server + * @private + */ + _activate() { + this._createMethod(); + integrationAccounts.addValidationStep(RememberMe._validateAttempt.bind(this)); + } + + /** + * Creates Meteor method to listen for requests coming from users. + * Users who will use the rememberMe functionality will pass the setting + * using this method + * @private + */ + _createMethod() { + if (this._updateRememberMeMethod) return; + this._updateRememberMeMethod = integrationMethod.factory({ + name: 'tprzytula:rememberMe-update', + callback: RememberMe._updateRememberMe.bind(this) + }); + this._updateRememberMeMethod.setup(); + } + + /** + * Updates the state of rememberMe setting for requesting connection. + * @param {Object} connection + * @param {boolean} rememberMe + * @returns {boolean} result + * @private + */ + static _updateRememberMe({ connection }, rememberMe) { + check(rememberMe, Boolean); + const lastLoginToken = ConnectionLastLoginToken.factory(connection.id); + return lastLoginToken.updateFields({ rememberMe }); + } + + /** + * Validated login attempt + * @param {Object} attempt + * @returns {boolean} result + * @private + */ + static _validateAttempt(attempt) { + const validator = LoginAttemptValidator.factory(attempt); + const { result, errorCode, reason, error } = validator.validate(); if (error) { throw error; } else if (!result) { - throw new Meteor.Error(resultCode, reason); + throw integrationError.createMeteorError(errorCode, reason); } return true; - }); -}; - -/** - * Updates the state of a rememberMe for the requested connectionId. - * @param {string} connectionId - * @param {boolean} flag - * @returns {boolean} result - * @private - */ -export const updateState = (connectionId, flag) => { - const userLoginToken = Accounts._getLoginToken(connectionId); - const loginTokens = RememberMeHelpers.getAllUserTokens(userLoginToken); - if (!loginTokens) { - return false; } +} - const updatedLoginTokens = loginTokens.map((loginToken) => { - const record = loginToken; - if (loginToken.hashedToken === userLoginToken) { - Object.assign(record, { rememberMe: flag }); - } - return record; - }); +export const factory = () => new RememberMe(); - return RememberMeHelpers.updateUserTokens(userLoginToken, updatedLoginTokens); -}; +export default factory(); diff --git a/server/integration/accounts.js b/server/integration/accounts.js new file mode 100644 index 0000000..6cd180d --- /dev/null +++ b/server/integration/accounts.js @@ -0,0 +1,29 @@ +import { Accounts } from 'meteor/accounts-base'; + +/** + * Adds additional validation step in the Meteor's login process. + * @param {function} callback + */ +export const addValidationStep = callback => Accounts.validateLoginAttempt(callback); + +/** + * Returns last login token associated to the connectionId. + * @param {string} connectionId + * @returns {string} loginToken + */ +export const getConnectionLastLoginToken = connectionId => Accounts._getLoginToken(connectionId); + + +/** + * Returns user's record matching the username. + * @param {string} username + * @returns {Object} user + */ +export const findUserByUsername = username => Accounts.findUserByUsername(username); + +/** + * Hashes login token. + * @param {string} token + * @returns {string} hashedToken + */ +export const hashLoginToken = token => Accounts._hashLoginToken(token); diff --git a/server/integration/collection.js b/server/integration/collection.js new file mode 100644 index 0000000..c187a83 --- /dev/null +++ b/server/integration/collection.js @@ -0,0 +1,24 @@ +/** + * Finds and returns an user containing requested login token. + * @param {string} token + * @returns {Object} user + */ +export const getUserByToken = (token) => { + const query = { 'services.resume.loginTokens.hashedToken': token }; + return Meteor.users.findOne(query, { + 'services.resume.loginTokens': 1 + }); +}; + +/** + * Replaces current user's login tokens. + * // TODO: Ensure that login tokens weren't changed in the meantime + * @param {string} id + * @param {Object[]} tokens + * @returns {number} result + */ +export const replaceUserTokens = (id, tokens) => Meteor.users.update(id, { + $set: { + 'services.resume.loginTokens': tokens + } +}); diff --git a/server/integration/error.js b/server/integration/error.js new file mode 100644 index 0000000..7ea99b2 --- /dev/null +++ b/server/integration/error.js @@ -0,0 +1,6 @@ +/** + * Wrapper for the Meteor.Error + * @param {Array} params + * @returns {Match.Error} Meteor.error + */ +export const createMeteorError = (...params) => new Meteor.Error(...params); diff --git a/server/integration/method.js b/server/integration/method.js new file mode 100644 index 0000000..c4b2c6d --- /dev/null +++ b/server/integration/method.js @@ -0,0 +1,44 @@ +/** + * Higher level wrapper for handling creation + * of new Meteor methods with the possibility + * to easily provide a callback which will be + * triggered when the method is invoked. + * + * @property {string} name + * @property {function} callback + * @class + */ +class Method { + constructor({ name, callback }) { + this.name = name; + this.callback = callback; + } + + /** + * Registers the method in Meteor. + * @public + */ + setup() { + const method = this._constructMethod(); + Meteor.methods(method); + } + + /** + * Prepares object with the method configuration + * in an understandable way for Meteor.methods parser. + * @returns {Object} method + * @private + */ + _constructMethod() { + const { name, callback } = this; + return { + [name](...params) { + return callback(this, ...params); + } + }; + } +} + +export const factory = (...params) => new Method(...params); + +export default Method; diff --git a/server/lib/connectionLastLoginToken.js b/server/lib/connectionLastLoginToken.js new file mode 100644 index 0000000..370d8a3 --- /dev/null +++ b/server/lib/connectionLastLoginToken.js @@ -0,0 +1,65 @@ +import * as helpers from './helpers'; +import * as integrationCollection from '../integration/collection'; +import * as integrationAccounts from '../integration/accounts'; + +/** + * Gives tools to manage the last login token associated to the connectionId. + * @property {string} connectionId + * @property {string} lastToken + * @property {Object} tokenOwner + * @class + */ +class ConnectionLastLoginToken { + constructor(connectionId) { + this.connectionId = connectionId; + this.lastToken = ''; + this.tokenOwner = null; + } + + /** + * Returns all tokens for the user. + * @returns {Object[]} tokens + * @private + */ + _getAllUserTokens() { + return helpers.getValueFromTree(this.tokenOwner, 'services.resume.loginTokens') || []; + } + + /** + * Looks for the last token that matches this connectionId and stores it. + * @private + */ + _fetchLastToken() { + const lastToken = integrationAccounts.getConnectionLastLoginToken(this.connectionId); + const tokenOwner = integrationCollection.getUserByToken(lastToken); + if (!lastToken || !tokenOwner) { + throw new Error(`Could not find tokens for ${this.connectionId}`); + } + Object.assign(this, { lastToken, tokenOwner }); + } + + /** + * Appends/Replaces fields in the loginToken; + * @param {Object} fields + * @returns {boolean} result + */ + updateFields(fields) { + this._fetchLastToken(); + const loginTokens = this._getAllUserTokens(); + const updatedLoginTokens = loginTokens.map((token) => { + if (token.hashedToken === this.lastToken) { + return Object.assign({}, token, fields); + } + return token; + }); + const result = integrationCollection.replaceUserTokens( + this.tokenOwner._id, + updatedLoginTokens + ); + return result === 1; + } +} + +export const factory = (...params) => new ConnectionLastLoginToken(...params); + +export default ConnectionLastLoginToken; diff --git a/server/lib/helpers.js b/server/lib/helpers.js new file mode 100644 index 0000000..c0ecdd6 --- /dev/null +++ b/server/lib/helpers.js @@ -0,0 +1,19 @@ +/** + * Checks if the provided path of properties exists in the object. + * If it does returns the last element from the path. + * @param {Object} obj + * @param {string} path + * @returns {*} value + */ +export const getValueFromTree = (obj = {}, path = '') => { + const pathSplit = path.split('.'); + let currentRoot = obj; + const result = pathSplit.every((step) => { + const isStepAccessible = step in currentRoot; + if (isStepAccessible) { + currentRoot = currentRoot[step]; + } + return isStepAccessible; + }); + return result ? currentRoot : undefined; +}; diff --git a/server/loginAttemptValidator/index.js b/server/loginAttemptValidator/index.js new file mode 100644 index 0000000..f535472 --- /dev/null +++ b/server/loginAttemptValidator/index.js @@ -0,0 +1,48 @@ +import * as IsAllowed from './validators/isAllowed'; +import * as ShouldResumeBeAccepted from './validators/shouldResumeBeAccepted'; + +/** + * The brain of the RememberMe functionality. + * Decides if user's login attempt should be accepted or not. + */ +class LoginAttemptValidator { + constructor(attempt = {}) { + this._loginAttempt = attempt; + this._validators = this._prepareValidators(); + } + + /** + * Creates list of validators + * @returns {Object[]} validators + * @private + */ + _prepareValidators() { + return [ + IsAllowed.factory(this._loginAttempt), + ShouldResumeBeAccepted.factory(this._loginAttempt) + ]; + } + + /** + * Runs all validators and returns the result. + * If any of them failed then the validation did not succeeded. + * @returns {Object} result + */ + validate() { + const failedValidator = this._validators.find(validator => !validator.validate()); + if (failedValidator) { + return failedValidator.getError(); + } + + return { + result: true, + resultCode: 0, + reason: 'Validation passed' + }; + } +} + +export const factory = (...params) => new LoginAttemptValidator(...params); + +export default LoginAttemptValidator; + diff --git a/server/loginAttemptValidator/validators/isAllowed.js b/server/loginAttemptValidator/validators/isAllowed.js new file mode 100644 index 0000000..8815a99 --- /dev/null +++ b/server/loginAttemptValidator/validators/isAllowed.js @@ -0,0 +1,32 @@ +import * as Validator from './validator'; + +/** + * IsAllowed + * + * Validates if the login attempt was already + * disallowed in one of the previous authentication processes. + * + * @class + * @extends Validator + */ +class IsAllowed extends Validator.default { + constructor(...params) { + super(...params); + super.setErrorDetails({ + reason: 'Attempt disallowed by previous validation', + error: this.loginAttempt.error + }); + } + + /** + * Runs the validation. + * @returns {boolean} isValid + */ + validate() { + return !!(this.loginAttempt && this.loginAttempt.allowed); + } +} + +export const factory = (...params) => new IsAllowed(...params); + +export default IsAllowed; diff --git a/server/loginAttemptValidator/validators/shouldResumeBeAccepted.js b/server/loginAttemptValidator/validators/shouldResumeBeAccepted.js new file mode 100644 index 0000000..5366213 --- /dev/null +++ b/server/loginAttemptValidator/validators/shouldResumeBeAccepted.js @@ -0,0 +1,89 @@ +import * as Validator from './validator'; +import * as integrationAccounts from '../../integration/accounts'; + +/** + * ShouldResumeBeAccepted + * + * Validates if the attempt is a resume type. + * Then decides if it should be accepted. + * + * @class + * @extends Validator + */ +class ShouldResumeBeAccepted extends Validator.default { + static _didUserReportSetting(userResume) { + return 'rememberMe' in userResume; + } + + constructor(...params) { + super(...params); + super.setErrorDetails({ + reason: 'Resume not allowed when user does not have remember me', + errorCode: 405 + }); + } + + /** + * Runs the validation. + * @returns {boolean} isValid + */ + validate() { + if (this.loginAttempt.type !== 'resume') { + return true; + } + + const userResume = this._getResume(); + if (!userResume) { + return false; + } + + if (!ShouldResumeBeAccepted._didUserReportSetting(userResume)) { + // User did not report the setting so it should work in a default way + return true; + } + + const methodArguments = this.loginAttempt.methodArguments || []; + const loggedAtLeastOnce = + methodArguments.some(argument => argument.loggedAtLeastOnce === true); + return (userResume.rememberMe || loggedAtLeastOnce); + } + + /** + * Fetches loginToken record associated to this login attempt from the database. + * @returns {Object} loginToken + * @private + */ + _getResume() { + const loginTokens = this._getUsersLoginTokens(); + const hashedToken = this._getTokenFromAttempt(); + return loginTokens.find(item => item.hashedToken === hashedToken); + } + + /** + * Hashes and returns login token passed in the login attempt. + * @returns {string} + * @private + */ + _getTokenFromAttempt() { + const attemptToken = (this.loginAttempt.methodArguments) + ? this.loginAttempt.methodArguments[0].resume + : ''; + return integrationAccounts.hashLoginToken(attemptToken); + } + + /** + * Finds user record in the database and returns all of the user's loginTokens. + * @returns {[Object]} loginTokens + * @private + */ + _getUsersLoginTokens() { + const user = integrationAccounts.findUserByUsername(this.loginAttempt.user.username); + return (user.services && user.services.resume) + ? user.services.resume.loginTokens + : []; + } +} + +export const factory = (...params) => new ShouldResumeBeAccepted(...params); + +export default ShouldResumeBeAccepted; diff --git a/server/loginAttemptValidator/validators/validator.js b/server/loginAttemptValidator/validators/validator.js new file mode 100644 index 0000000..0216344 --- /dev/null +++ b/server/loginAttemptValidator/validators/validator.js @@ -0,0 +1,44 @@ +/** + * Validator + * + * @property {Object} loginAttempt + * @property {Object} errorDetails + * + * @class + * @abstract + */ +class Validator { + constructor(attempt) { + this.loginAttempt = attempt; + this.errorDetails = {}; + } + + /** + * Sets the error message for failures. + * @param {Object} details + */ + setErrorDetails(details) { + this.errorDetails = details; + } + + /** + * Runs the validation. + * @returns {boolean} isValid + */ + validate() { + return !!this.loginAttempt; + } + + /** + * Returns error message for failure. + * @returns {Object} errorMessage + */ + getError() { + return { + result: false, + ...this.errorDetails + }; + } +} + +export default Validator; diff --git a/server/methods.js b/server/methods.js deleted file mode 100644 index 09b7f61..0000000 --- a/server/methods.js +++ /dev/null @@ -1,21 +0,0 @@ -import { check } from 'meteor/check'; -import { updateState } from './index.js'; - -// Name of the method should be unique to not override others -const updateRememberMeMethod = 'tprzytula:rememberMe-update'; - -export default () => { - Meteor.methods({ - /** - * Exposes a meteor method to allow the clients - * to request a change for the rememberMe flag. - * @param {boolean} flag - * @returns {boolean} result - */ - [updateRememberMeMethod](flag = true) { - check(flag, Boolean); - const connectionId = this.connection.id; - return updateState(connectionId, flag); - } - }); -}; diff --git a/test/client/accountsConfigurator.spec.js b/test/client/accountsConfigurator.spec.js new file mode 100644 index 0000000..8a0e742 --- /dev/null +++ b/test/client/accountsConfigurator.spec.js @@ -0,0 +1,123 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; + +import { DDP } from 'meteor/ddp-client'; +import { AccountsClient } from 'meteor/accounts-base'; +import * as AccountsConfigurator from './../../client/accountsConfigurator'; + +describe('Given AccountsConfigurator', () => { + const sandbox = sinon.createSandbox(); + let accountsClient, accountsConfigurator, connection; + + beforeEach(() => { + connection = DDP.connect('localhost:3000'); + accountsClient = new AccountsClient({ connection }); + accountsConfigurator = AccountsConfigurator.factory(accountsClient); + }); + + afterEach(() => { + connection.disconnect(); + sandbox.restore(); + }); + + describe('When configure is invoked', () => { + let onLoginStub, onLoginCallback; + + beforeEach(() => { + onLoginStub = sandbox.stub(accountsClient, 'onLogin') + .callsFake((callback) => { onLoginCallback = callback; }); + accountsConfigurator.configure(); + }); + + it('should register onLogin callback with correct method', () => { + expect(onLoginStub).to.be.calledOnce(); + }); + + it('should set loginWithPassword method to the instance', () => { + expect('loginWithPassword' in accountsClient).to.be.equal(true); + }); + + describe('And configure is invoked again', () => { + beforeEach(() => { + accountsConfigurator.configure(); + }); + + it('should not register additional onLogin callbacks', () => { + expect(onLoginStub).to.be.calledOnce(); + }); + }); + + describe('And the users logs in', () => { + let callLoginMethodStub; + + beforeEach(() => { + callLoginMethodStub = sandbox.stub(accountsClient, 'callLoginMethod'); + onLoginCallback(); + }); + + it('should override callLoginMethod', () => { + expect(accountsClient.callLoginMethod).to.not.be.equal(callLoginMethodStub); + }); + + describe('And the user logs in again', () => { + let overriddenCallLoginMethodStub; + + beforeEach(() => { + overriddenCallLoginMethodStub = sandbox.stub(accountsClient, 'callLoginMethod'); + onLoginCallback(); + }); + + it('should not override callLoginMethod again', () => { + expect(accountsClient.callLoginMethod) + .to.be.equal(overriddenCallLoginMethodStub); + }); + }); + + describe('And the callLoginMethod is invoked without parameter', () => { + beforeEach(() => { + accountsClient.callLoginMethod(); + }); + + it('should call original method with loggedAtLeastOnce in arguments', () => { + const expectedParameter = { methodArguments: [{ loggedAtLeastOnce: true }] }; + expect(callLoginMethodStub).to.be.calledWithExactly(expectedParameter); + }); + }); + + describe('And the callLoginMethod is invoked with sample parameter', () => { + const sampleArguments = [ + { username: 'test-user' }, + { password: 'test-password' } + ]; + + beforeEach(() => { + accountsClient.callLoginMethod({ methodArguments: [...sampleArguments] }); + }); + + it('should call original method with sample parameters with an addition of loggedAtLeastOnce in arguments', () => { + const expectedArguments = [...sampleArguments, { loggedAtLeastOnce: true }]; + expect(callLoginMethodStub) + .to.be.calledWithExactly({ methodArguments: expectedArguments }); + }); + }); + }); + }); +}); + +describe('Given AccountsConfigurator factory', () => { + it('should be a function', () => { + expect(typeof AccountsConfigurator.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = AccountsConfigurator.factory(); + }); + + it('should return an instance of AccountsConfigurator', () => { + expect(result instanceof AccountsConfigurator.default).to.be.equal(true); + }); + }); +}); diff --git a/test/client/accountsWrapper.spec.js b/test/client/accountsWrapper.spec.js new file mode 100644 index 0000000..9db1243 --- /dev/null +++ b/test/client/accountsWrapper.spec.js @@ -0,0 +1,163 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; + +import { DDP } from 'meteor/ddp-client'; +import { AccountsClient } from 'meteor/accounts-base'; +import AccountsConfigurator from './../../client/accountsConfigurator'; +import * as AccountsWrapper from './../../client/accountsWrapper'; +import * as Alerts from './../../client/lib/alerts'; + +describe('Given AccountsWrapper', () => { + const sandbox = sinon.createSandbox(); + const sampleCorrectParams = { + username: 'test-user', + password: 'test-password', + flag: true, + callback: sandbox.stub() + }; + let accountsClient, accountsWrapper, callStub, connection; + + beforeEach(() => { + sampleCorrectParams.callback = sandbox.stub(); + connection = DDP.connect('localhost:3000'); + accountsClient = new AccountsClient({ connection }); + accountsClient.loginWithPassword = sandbox.stub().callsFake((...params) => { + const [username, password, callback] = params; + const attemptValid = username === sampleCorrectParams.username && + password === sampleCorrectParams.password; + if (attemptValid) { + callback(); + return; + } + callback('User not found'); + }); + callStub = sandbox.stub(accountsClient.connection, 'call') + .callsFake((methodName, parameter, callback) => { + callback(); + }); + accountsWrapper = AccountsWrapper.factory(accountsClient); + }); + + afterEach(() => { + connection.disconnect(); + sandbox.restore(); + }); + + describe('When initialised', () => { + let configureSpy; + + beforeEach(() => { + configureSpy = sandbox.stub(AccountsConfigurator.prototype, 'configure'); + accountsWrapper.configure(); + }); + + it('should initiate setup in AccountsConfiguration', () => { + expect(configureSpy).to.be.calledOnce(); + }); + + describe('And loginWithPassword is invoked with correct login details', () => { + beforeEach(() => { + accountsWrapper.loginWithPassword(sampleCorrectParams); + }); + + it('should call loginWithPassword from the accounts instance with correct parameters', () => { + const { username, password } = sampleCorrectParams; + expect(accountsClient.loginWithPassword) + .to.be.calledWith(username, password); + }); + + it('should call callback without any error', () => { + expect(sampleCorrectParams.callback).to.be.calledWith(undefined); + }); + + it('should send request to update the rememberMe flag', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', sampleCorrectParams.flag); + }); + }); + + describe('And loginWithPassword with incorrect login details', () => { + const sampleIncorrectParams = { + username: 'test-user-wrong', + password: 'test-password-wrong', + flag: true, + callback: sinon.stub() + }; + + beforeEach(() => { + accountsWrapper.loginWithPassword(sampleIncorrectParams); + }); + + it('should pass error to the callback', () => { + expect(sampleIncorrectParams.callback).to.be.calledWith('User not found'); + }); + + it('should not send request to update the rememberMe flag', () => { + expect(callStub).not.to.be.calledWith('tprzytula:rememberMe-update'); + }); + }); + + describe('And rememberMe is not activated on the server', () => { + beforeEach(() => { + callStub.callsFake((methodName, parameter, callback) => { + if (methodName === 'tprzytula:rememberMe-update') { + callback(new Meteor.Error(404)); + } + }); + }); + + describe('And loginWithPassword is invoked', () => { + let alertStub; + + beforeEach(() => { + alertStub = sandbox.stub(Alerts, 'rememberMeNotActive'); + accountsWrapper.loginWithPassword(sampleCorrectParams); + }); + + it('should alert that rememberMe is not activated on the server', () => { + expect(alertStub).to.be.calledOnce(); + }); + }); + }); + + describe('And server throws an unexpected error when requested to update rememberMe flag', () => { + beforeEach(() => { + callStub.callsFake((methodName, parameter, callback) => { + if (methodName === 'tprzytula:rememberMe-update') { + callback(new Meteor.Error(401)); + } + }); + }); + + describe('And loginWithPassword is invoked', () => { + let alertStub; + + beforeEach(() => { + alertStub = sandbox.stub(Alerts, 'couldNotUpdateRememberMe'); + accountsWrapper.loginWithPassword(sampleCorrectParams); + }); + + it('should alert that it could not update the rememberMe setting', () => { + expect(alertStub).to.be.calledOnce(); + }); + }); + }); + }); +}); + +describe('Given AccountsWrapper factory', () => { + it('should be a function', () => { + expect(typeof AccountsWrapper.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = AccountsWrapper.factory(); + }); + + it('should return an instance of AccountsWrapper', () => { + expect(result instanceof AccountsWrapper.default).to.be.equal(true); + }); + }); +}); diff --git a/test/client/index.spec.js b/test/client/index.spec.js index b6912bb..a1cf37d 100644 --- a/test/client/index.spec.js +++ b/test/client/index.spec.js @@ -1,354 +1,173 @@ -const RememberMe = require('./../../client/index').RememberMeClass; -const { AccountsClient } = require('meteor/accounts-base'); -const chai = require('ultimate-chai'); -const sinon = require('sinon'); - -const rememberMeMethod = 'tprzytula:rememberMe-update'; -const sampleUsername = 'username'; -const samplePassword = 'password'; -const sampleCallback = () => {}; -const { expect } = chai; - -describe('Given updateRememberMe method', () => { - let callStub, loginWithPasswordStub, rememberMe; +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import { DDP } from 'meteor/ddp-client'; +import { Accounts, AccountsClient } from 'meteor/accounts-base'; +import * as RememberMe from './../../client/index'; + +describe('Given RememberMe', () => { + const sandbox = sinon.createSandbox(); + const sampleCorrectParams = { + username: 'test-user', + password: 'test-password', + flag: true, + callback: sandbox.stub() + }; + let rememberMe; beforeEach(() => { - rememberMe = new RememberMe(); - loginWithPasswordStub = sinon.stub(Meteor, 'loginWithPassword'); - callStub = sinon.stub(Meteor, 'call'); + rememberMe = RememberMe.factory(); }); afterEach(() => { - loginWithPasswordStub.restore(); - callStub.restore(); + sandbox.restore(); }); - describe('When login was unsuccessful', () => { - const notExistingUsername = 'notExistingUser'; - - beforeEach(() => { - loginWithPasswordStub.callsFake((user, password, callback) => callback('User not found')); - rememberMe.loginWithPassword(notExistingUsername, samplePassword); - }); - - it('should not call the updateRememberMe method', () => { - expect(callStub).to.have.callCount(0); - }); - }); + describe('When using loginWithPassword method with sample parameters', () => { + let loginWithPasswordStub; - describe('When login was successful', () => { beforeEach(() => { - loginWithPasswordStub.callsFake((user, password, callback) => { - const error = false; - callback(error); - }); - rememberMe.loginWithPassword(sampleUsername, samplePassword); + loginWithPasswordStub = sandbox.stub(Accounts, 'loginWithPassword'); + rememberMe.loginWithPassword(...Object.values(sampleCorrectParams)); }); - it('should call the updateRememberMe method', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod); + it('should call loginWithPassword with correct arguments', () => { + const { username, password } = sampleCorrectParams; + expect(loginWithPasswordStub).to.be.calledWith(username, password); }); }); -}); -describe('Given rememberMe flag', () => { - let callStub, loginWithPasswordStub, rememberMe, rememberMeFlag; - - beforeEach(() => { - rememberMe = new RememberMe(); - loginWithPasswordStub = sinon.stub( - Meteor, - 'loginWithPassword' - ); - callStub = sinon.stub(Meteor, 'call'); - loginWithPasswordStub.callsFake((user, password, callback) => { - const error = false; - callback(error); - }); - }); - - afterEach(() => { - loginWithPasswordStub.restore(); - callStub.restore(); - }); - - describe('When rememberMe is not activated on the server', () => { - const warningTitle = 'Dependency meteor/tprzytula:remember-me is not active!\n'; - const warningMessage = '\nTo activate it make sure to run "RememberMe.activate()" on the server.' + - 'It is required to be able to access the functionality on the client.'; - let consoleStub; + describe('When switching accounts system', () => { + let connection, accountsClient, loginWithPasswordStub; beforeEach(() => { - callStub.callsFake((method, flag, callback) => { - callback({ error: 404 }); - }); - consoleStub = sinon.stub(console, 'warn'); - }); - - afterEach(() => { - consoleStub.restore(); + connection = DDP.connect('localhost:3000'); + accountsClient = new AccountsClient({ connection }); + rememberMe.changeAccountsSystem(accountsClient); + loginWithPasswordStub = sandbox.stub(accountsClient, 'loginWithPassword'); }); - describe('And loginWithPassword is invoked', () => { + describe('And invoking loginWithPassword method', () => { beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword); + rememberMe.loginWithPassword(...Object.values(sampleCorrectParams)); }); - it('should inform user about the requirement to activate it on server', () => { - expect(consoleStub).to.have.been.calledWith(warningTitle, warningMessage); + it('should call loginWithPassword from the new instance', () => { + expect(loginWithPasswordStub).to.be.calledOnce(); }); }); }); - describe('When rememberMe method fires error', () => { - const errorMessage = 'meteor/tprzytula:remember-me' + - '\nCould not update remember me setting.' + - '\nError:'; - const errorDetails = 'Example details'; - let consoleStub; + describe('When not providing a correct AccountsClient instance to the "changeAccountsSystem"', () => { + let error; beforeEach(() => { - callStub.callsFake((method, flag, callback) => { - callback(errorDetails); - }); - consoleStub = sinon.stub(console, 'error'); + try { + rememberMe.changeAccountsSystem(RememberMe.factory()); + } catch (e) { + console.error(e); + error = e; + } }); - afterEach(() => { - consoleStub.restore(); - }); - - describe('And loginWithPassword is invoked', () => { - beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword); - }); - - it('should inform user about the error', () => { - expect(consoleStub).to.have.been.calledWith(errorMessage, errorDetails); - }); + it('should throw an exception', () => { + expect(error instanceof Meteor.Error).to.be.true(); }); }); - describe('When not provided as a parameter (without callback)', () => { - beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword); - }); + describe('And loginWithPassword method parameters', () => { + let callStub, parameters; - it('should set remember me to true by default (without callback)', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, true); - }); - }); - - describe('When not provided as a parameter (with callback)', () => { - beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword, sampleCallback); - }); - - it('should set remember me to true by default (without callback)', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, true); - }); - }); - - describe('When equals "true"', () => { beforeEach(() => { - rememberMeFlag = true; + parameters = { + username: 'test-user', + password: 'test-password' + }; + callStub = sandbox.stub(Accounts.connection, 'call') + .callsFake((methodName, parameter, callback) => { + callback(); + }); + sandbox.stub(Accounts, 'loginWithPassword') + .callsFake((username, password, callback) => { + callback(); + }); }); - describe('And is passed as a third parameter', () => { + describe('When parameters contains callback', () => { beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword, rememberMeFlag); + Object.assign(parameters, { callback: () => {} }); }); - it('should set rememberMe to true', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, true); - }); - }); - - describe('And is passed as a fourth parameter', () => { - beforeEach(() => { - rememberMe.loginWithPassword( - sampleUsername, - samplePassword, - sampleCallback, - rememberMeFlag - ); - }); + describe('And the method is invoked with rememberMe parameter being true', () => { + beforeEach(() => { + Object.assign(parameters, { flag: true }); + rememberMe.loginWithPassword(...Object.values(parameters)); + }); - it('should set rememberMe to true', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, true); + it('should send RememberMe true request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); + }); }); - }); - }); - describe('When equals "false"', () => { - beforeEach(() => { - rememberMeFlag = false; - }); - - describe('And is passed as a third parameter', () => { - beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword, rememberMeFlag); - }); + describe('And the method is invoked with rememberMe parameter being false', () => { + beforeEach(() => { + Object.assign(parameters, { flag: false }); + rememberMe.loginWithPassword(...Object.values(parameters)); + }); - it('should set rememberMe to false', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, false); + it('should send RememberMe false request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', false); + }); }); - }); - describe('And is passed as a fourth parameter', () => { - beforeEach(() => { - rememberMe.loginWithPassword( - sampleUsername, - samplePassword, - sampleCallback, - rememberMeFlag - ); - }); + describe('And the method is invoked without rememberMe parameter', () => { + beforeEach(() => { + rememberMe.loginWithPassword(...Object.values(parameters)); + }); - it('should set rememberMe to true', () => { - expect(callStub).to.have.been.calledWith(rememberMeMethod, false); + it('should send RememberMe true request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); + }); }); }); - }); -}); - -describe('Given RememberMe.loginWithPassword method', () => { - let callStub, loginWithPasswordStub, rememberMe; - - beforeEach(() => { - rememberMe = new RememberMe(); - loginWithPasswordStub = sinon.stub(Meteor, 'loginWithPassword'); - callStub = sinon.stub(Meteor, 'call'); - }); - - afterEach(() => { - loginWithPasswordStub.restore(); - callStub.restore(); - }); - - describe('When used with sample parameters', () => { - beforeEach(() => { - rememberMe.loginWithPassword(sampleUsername, samplePassword); - }); - - it('should correctly pass parameters to Meteor.loginWithPassword method', () => { - expect(loginWithPasswordStub).calledWith( - sampleUsername, - samplePassword - ); - }); - }); - describe('When accounts system is changed', () => { - let connection, remoteAccounts; - - beforeEach(() => { - connection = DDP.connect('127.0.0.1:3000'); - remoteAccounts = new AccountsClient({ connection }); - rememberMe.changeAccountsSystem(remoteAccounts); - }); - - afterEach(() => { - connection.disconnect(); - }); - - describe('And loginWithPassword is used', () => { - let remoteCallStub, remoteLoginWithPasswordStub; + describe('When parameters do not contain a callback', () => { + describe('And the method is invoked with rememberMe parameter being true', () => { + beforeEach(() => { + Object.assign(parameters, { flag: true }); + rememberMe.loginWithPassword(...Object.values(parameters)); + }); - beforeEach(() => { - remoteLoginWithPasswordStub = sinon.stub( - remoteAccounts.connection, - 'loginWithPassword' - ); - remoteLoginWithPasswordStub.callsFake((user, password, callback) => { - const error = false; - callback(error); + it('should send RememberMe true request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); }); - remoteCallStub = sinon.stub(remoteAccounts.connection, 'call'); - rememberMe.loginWithPassword(sampleUsername, samplePassword); }); - afterEach(() => { - remoteLoginWithPasswordStub.restore(); - remoteCallStub.restore(); - }); + describe('And the method is invoked with rememberMe parameter being false', () => { + beforeEach(() => { + Object.assign(parameters, { flag: false }); + rememberMe.loginWithPassword(...Object.values(parameters)); + }); - it('should use loginWithPassword from the new accounts', () => { - expect(remoteLoginWithPasswordStub).to.have.been.calledWith( - 'username', - 'password' - ); + it('should send RememberMe true request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', false); + }); }); - it('should call rememberMe method for the connection from the new accounts', () => { - expect(remoteCallStub).to.have.been.calledOnce(); + describe('And the method is invoked without rememberMe parameter', () => { + beforeEach(() => { + rememberMe.loginWithPassword(...Object.values(parameters)); + }); + + it('should send RememberMe true request to the server', () => { + expect(callStub).to.be.calledWith('tprzytula:rememberMe-update', true); + }); }); }); }); }); -describe('Given changeAccountsSystem', () => { - let callStub, rememberMe, result; - - beforeEach(() => { - rememberMe = new RememberMe(); - callStub = sinon.stub(Meteor, 'call'); - }); - - afterEach(() => { - callStub.restore(); - }); - - describe('When an incorrect instance is passed', () => { - beforeEach(() => { - const randomInstance = new RememberMe(); - result = rememberMe.changeAccountsSystem(randomInstance); - }); - - it('should not accept the instance', () => { - expect(result).to.be.equal(false); - }); - }); - - describe('When a correct instance is passed', () => { - let connection, remoteAccounts; - - beforeEach(() => { - connection = DDP.connect('127.0.0.1:3000'); - remoteAccounts = new AccountsClient({ connection }); - result = rememberMe.changeAccountsSystem(remoteAccounts); - }); - - afterEach(() => { - connection.disconnect(); - }); - - it('should accept the instance', () => { - expect(result).to.be.equal(true); - }); - - it('should append "loginWithPassword" method to the instance', () => { - expect('loginWithPassword' in remoteAccounts.connection).to.be.equal(true); - }); - }); - - describe('When passed instance already contains loginWithPassword method', () => { - const loginWithPassword = () => {}; - let connection, remoteAccounts; - - beforeEach(() => { - connection = DDP.connect('127.0.0.1:3000'); - remoteAccounts = new AccountsClient({ connection }); - connection.loginWithPassword = loginWithPassword; - result = rememberMe.changeAccountsSystem(remoteAccounts); - }); - - afterEach(() => { - connection.disconnect(); - }); - - it('should not override the already existing loginWithPassword method', () => { - expect(remoteAccounts.connection.loginWithPassword).to.be.equal(loginWithPassword); - }); +describe('Given RememberMe factory', () => { + it('should be a function', () => { + expect(typeof RememberMe.factory).to.be.equal('function'); }); }); diff --git a/test/client/lib/alerts.spec.js b/test/client/lib/alerts.spec.js new file mode 100644 index 0000000..622f508 --- /dev/null +++ b/test/client/lib/alerts.spec.js @@ -0,0 +1,45 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; + +import * as Alerts from './../../../client/lib/alerts'; + +describe('Given alerts', () => { + const sandbox = sinon.createSandbox(); + let consoleWarnSpy, consoleErrorSpy; + + beforeEach(() => { + consoleWarnSpy = sandbox.spy(console, 'warn'); + consoleErrorSpy = sandbox.spy(console, 'error'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When rememberMeNotActive is invoked', () => { + beforeEach(() => { + Alerts.rememberMeNotActive(); + }); + + it('should print warning message', () => { + expect(consoleWarnSpy).to.be.calledOnce(); + }); + }); + + describe('When couldNotUpdateRememberMe is invoked', () => { + const sampleError = 'test'; + + beforeEach(() => { + Alerts.couldNotUpdateRememberMe(sampleError); + }); + + it('should print error message', () => { + expect(consoleErrorSpy).to.be.calledOnce(); + }); + + it('should contain passed error in the message', () => { + const { args } = consoleErrorSpy.getCall(0); + expect(args.includes(sampleError)).to.be.equal(true); + }); + }); +}); diff --git a/test/client/helpers.spec.js b/test/client/lib/methodParams.spec.js similarity index 95% rename from test/client/helpers.spec.js rename to test/client/lib/methodParams.spec.js index 1e8cffa..cd2270c 100644 --- a/test/client/helpers.spec.js +++ b/test/client/lib/methodParams.spec.js @@ -1,7 +1,8 @@ -const { exportFlagFromParams, exportCallbackFromParams } = require('./../../client/helpers'); -const chai = require('ultimate-chai'); - -const { expect } = chai; +import { expect } from 'ultimate-chai'; +import { + exportFlagFromParams, + exportCallbackFromParams +} from './../../../client/lib/methodParams'; describe('Given exportFlagFromParams', () => { const sampleFlag = false; diff --git a/test/client/overrideLogin.spec.js b/test/client/overrideLogin.spec.js deleted file mode 100644 index 79c6a5f..0000000 --- a/test/client/overrideLogin.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -const { AccountsClient } = require('meteor/accounts-base'); -const overrideLogin = require('./../../client/overrideLogin').default; -const chai = require('ultimate-chai'); -const sinon = require('sinon'); - -const { expect } = chai; - -const isMethodOverridden = 'tprzytula:remember-me_overridden'; -const isCallbackRegistered = 'tprzytula:remember-me_callbackRegistered'; - -describe('Given overrideLogin', () => { - describe('When a new AccountsClient instance is created', () => { - let connection, testAccounts, callLoginMethodStub; - - beforeEach(() => { - connection = DDP.connect('127.0.0.1:3000'); - testAccounts = new AccountsClient({ connection }); - callLoginMethodStub = sinon.stub(testAccounts, 'callLoginMethod'); - }); - - afterEach(() => { - connection.disconnect(); - }); - - it('should not have any callbacks', () => { - const { callbacks } = testAccounts._onLoginHook; - const callbacksAmount = Object.keys(callbacks).length; - expect(callbacksAmount).to.be.equal(0); - }); - - it('should not have set a flag for callback registration', () => { - expect(isCallbackRegistered in testAccounts).to.be.equal(false); - }); - - describe('And it is passed to the overrideLogin method', () => { - beforeEach(() => { - overrideLogin(testAccounts); - }); - - it('should register onLogin callback', () => { - const { callbacks } = testAccounts._onLoginHook; - const callbacksAmount = Object.keys(callbacks).length; - expect(callbacksAmount).to.be.equal(1); - }); - - it('should set callback registration flag', () => { - expect(isCallbackRegistered in testAccounts).to.be.equal(true); - }); - - it('should not override the login method', () => { - expect(isMethodOverridden in testAccounts).to.be.equal(false); - }); - - describe('And registered callback is invoked', () => { - beforeEach(() => { - const { callbacks } = testAccounts._onLoginHook; - callbacks['0'](); - }); - - it('should set login method overridden flag', () => { - expect(isMethodOverridden in testAccounts).to.be.equal(true); - }); - - it('should override the login method', () => { - expect(testAccounts.callLoginMethod).to.not.be.equal(callLoginMethodStub); - }); - - describe('And the callLoginMethod is invoked without method arguments', () => { - beforeEach(() => { - testAccounts.callLoginMethod(); - }); - - it('should also call the original method', () => { - expect(callLoginMethodStub).to.be.calledOnce(); - }); - - it('should create methodArguments with loggedAtLeastOnce argument', () => { - expect(callLoginMethodStub).to.be.calledWith({ - methodArguments: [{ - loggedAtLeastOnce: true - }] - }); - }); - }); - - describe('And the callLoginMethod is invoked with sample method arguments', () => { - const sampleMethodArguments = [ - { user: 'testUser' }, - { password: 'testPassword' } - ]; - - beforeEach(() => { - testAccounts.callLoginMethod({ - methodArguments: [...sampleMethodArguments] - }); - }); - - it('should also call the original method', () => { - expect(callLoginMethodStub).to.be.calledOnce(); - }); - - it('should create methodArguments with loggedAtLeastOnce argument', () => { - const expectedArguments = [ - ...sampleMethodArguments, - { loggedAtLeastOnce: true } - ]; - expect(callLoginMethodStub).to.be.calledWith({ - methodArguments: expectedArguments - }); - }); - }); - }); - - describe('And the same Accounts instance is passed several additional times to the method', () => { - beforeEach(() => { - overrideLogin(testAccounts); - overrideLogin(testAccounts); - overrideLogin(testAccounts); - }); - - it('should not register additional callbacks', () => { - const { callbacks } = testAccounts._onLoginHook; - const callbacksAmount = Object.keys(callbacks).length; - expect(callbacksAmount).to.be.equal(1); - }); - }); - }); - }); -}); diff --git a/test/server/authenticator.spec.js b/test/server/authenticator.spec.js deleted file mode 100644 index 00298b7..0000000 --- a/test/server/authenticator.spec.js +++ /dev/null @@ -1,144 +0,0 @@ -const TestUser = require('./utils/testUser'); -const AuthenticatorSpec = require('../../server/authenticator').default; -const LoginAttemptGenerator = require('./utils/loginAttemptGenerator'); -const chai = require('ultimate-chai'); - -const { expect } = chai; -const resume = 'token'; - -describe('Given authenticator', () => { - let result, resultCode, reason; - - describe('When user performs login attempt with loginWithPassword method', () => { - let loginAttempt; - - beforeEach(() => { - const loginAttemptGenerator = new LoginAttemptGenerator({ type: 'password' }); - loginAttempt = loginAttemptGenerator.getLoginAttempt(); - }); - - describe('And the attempt is allowed', () => { - let authenticator; - - beforeEach(() => { - loginAttempt.allowed = true; - authenticator = new AuthenticatorSpec(loginAttempt); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should pass the attempt', () => { - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); - - describe('And the attempt is disallowed', () => { - let authenticator; - - beforeEach(() => { - loginAttempt.allowed = false; - authenticator = new AuthenticatorSpec(loginAttempt); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should not pass the attempt', () => { - expect(result).to.be.equal(false); - expect(resultCode).to.be.equal(-1); - expect(reason).to.be.equal('Attempt disallowed by Meteor'); - }); - }); - }); - - describe('When an resume attempt is received from the client', () => { - let loginAttemptGenerator, testUserInstance; - - beforeEach(() => { - testUserInstance = new TestUser({ - username: 'resume-test', - password: 'resume-test', - }); - testUserInstance.init(); - loginAttemptGenerator = new LoginAttemptGenerator({ - type: 'resume', - user: testUserInstance.getUser() - }); - loginAttemptGenerator.addMethodArgument({ resume }); - }); - - afterEach(() => { - testUserInstance.removeUser(); - }); - - describe('And user was not logged during this device session', () => { - let authenticator; - - beforeEach(() => { - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - authenticator = new AuthenticatorSpec(loginAttempt); - }); - - describe('And the previous login was with rememberMe', () => { - beforeEach(() => { - testUserInstance.setLoginToken({ resume, rememberMe: true }); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should pass the attempt', () => { - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); - - describe('And the previous login was without rememberMe', () => { - beforeEach(() => { - testUserInstance.setLoginToken({ resume, rememberMe: false }); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should not pass the attempt', () => { - expect(result).to.be.equal(false); - expect(resultCode).to.be.equal(-2); - expect(reason).to.be.equal('Resume not allowed when user does not have remember me'); - }); - }); - }); - - describe('And user was already logged during this device session', () => { - let authenticator; - - beforeEach(() => { - loginAttemptGenerator.addMethodArgument({ loggedAtLeastOnce: true }); - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - authenticator = new AuthenticatorSpec(loginAttempt); - }); - - describe('And the previous login was with rememberMe', () => { - beforeEach(() => { - testUserInstance.setLoginToken({ resume, rememberMe: true }); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should pass the attempt', () => { - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); - - describe('And the previous login was without rememberMe', () => { - beforeEach(() => { - testUserInstance.setLoginToken({ resume, rememberMe: false }); - ({ result, resultCode, reason } = authenticator.validateAttempt()); - }); - - it('should pass the attempt', () => { - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); - }); - }); -}); diff --git a/test/server/integration/accounts.spec.js b/test/server/integration/accounts.spec.js new file mode 100644 index 0000000..f6ddae8 --- /dev/null +++ b/test/server/integration/accounts.spec.js @@ -0,0 +1,109 @@ +import { Accounts } from 'meteor/accounts-base'; +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import { + addValidationStep, + findUserByUsername, + getConnectionLastLoginToken, + hashLoginToken +} from '../../../server/integration/accounts'; + +describe('Given addValidationStep method', () => { + const sandbox = sinon.createSandbox(); + let validateLoginAttemptStub; + + beforeEach(() => { + validateLoginAttemptStub = sandbox.stub(Accounts, 'validateLoginAttempt'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When a param is passed', () => { + const sampleMethod = () => {}; + + beforeEach(() => { + addValidationStep(sampleMethod); + }); + + it('should pass the param to the Accounts validateLoginAttempt method', () => { + expect(validateLoginAttemptStub).to.be.calledWith(sampleMethod); + }); + }); +}); + +describe('Given findUserByUsername method', () => { + const sandbox = sinon.createSandbox(); + let findUserByUsernameStub; + + beforeEach(() => { + findUserByUsernameStub = sandbox.stub(Accounts, 'findUserByUsername'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When a param is passed', () => { + const sampleUsername = 'test-user'; + + beforeEach(() => { + findUserByUsername(sampleUsername); + }); + + it('should pass the param to the Accounts findUserByUsername method', () => { + expect(findUserByUsernameStub).to.be.calledWith(sampleUsername); + }); + }); +}); + +describe('Given getConnectionLastLoginToken method', () => { + const sandbox = sinon.createSandbox(); + let _getLoginTokenStub; + + beforeEach(() => { + _getLoginTokenStub = sandbox.stub(Accounts, '_getLoginToken'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When a param is passed', () => { + const sampleConnectionId = 'test-connection-id'; + + beforeEach(() => { + getConnectionLastLoginToken(sampleConnectionId); + }); + + it('should pass the param to the Accounts validateLoginAttempt method', () => { + expect(_getLoginTokenStub).to.be.calledWith(sampleConnectionId); + }); + }); +}); + +describe('Given hashLoginToken method', () => { + const sandbox = sinon.createSandbox(); + let _hashLoginToken; + + beforeEach(() => { + _hashLoginToken = sandbox.stub(Accounts, '_hashLoginToken'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When a param is passed', () => { + const sampleToken = 'sample-token'; + + beforeEach(() => { + hashLoginToken(sampleToken); + }); + + it('should pass the param to the Accounts validateLoginAttempt method', () => { + expect(_hashLoginToken).to.be.calledWith(sampleToken); + }); + }); +}); diff --git a/test/server/integration/collection.spec.js b/test/server/integration/collection.spec.js new file mode 100644 index 0000000..b6fd578 --- /dev/null +++ b/test/server/integration/collection.spec.js @@ -0,0 +1,62 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import { getUserByToken, replaceUserTokens } from '../../../server/integration/collection'; + +describe('Given getUserByToken method', () => { + const sandbox = sinon.createSandbox(); + let findOneStub; + + beforeEach(() => { + findOneStub = sandbox.stub(Meteor.users, 'findOne'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When called with a token', () => { + const sampleToken = 'test-token'; + + beforeEach(() => { + getUserByToken(sampleToken); + }); + + it('should construct a query and pass it to the Meteor findOne method', () => { + const expectedQuery = { + 'services.resume.loginTokens.hashedToken': sampleToken + }; + expect(findOneStub).to.be.calledWith(expectedQuery); + }); + }); +}); + +describe('Given replaceUserTokens method', () => { + const sandbox = sinon.createSandbox(); + let updateStub; + + beforeEach(() => { + updateStub = sandbox.stub(Meteor.users, 'update'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When called with an id and tokens', () => { + const sampleId = 'user-id'; + const sampleTokens = ['token-one', 'token-two']; + + beforeEach(() => { + replaceUserTokens(sampleId, sampleTokens); + }); + + it('should construct a query and pass it to the Meteor update method with id', () => { + const expectedQuery = { + $set: { + 'services.resume.loginTokens': sampleTokens + } + }; + expect(updateStub).to.be.calledWith(sampleId, expectedQuery); + }); + }); +}); diff --git a/test/server/integration/error.spec.js b/test/server/integration/error.spec.js new file mode 100644 index 0000000..3f9a53b --- /dev/null +++ b/test/server/integration/error.spec.js @@ -0,0 +1,37 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import { createMeteorError } from './../../../server/integration/error'; + +describe('Given createMeteorError method', () => { + const sandbox = sinon.createSandbox(); + let errorStub; + + beforeEach(() => { + errorStub = sandbox.spy(Meteor, 'Error'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When invoked with sample parameters', () => { + let createdError; + + beforeEach(() => { + createdError = createMeteorError(500, 'Bad request', + 'Your request is missing required fields'); + }); + + it('should call Meteor.Error with correct parameters', () => { + expect(errorStub).to.be.calledWithExactly(500, 'Bad request', + 'Your request is missing required fields'); + }); + + it('should return an instance of Meteor.Error with correct fields', () => { + expect(createdError instanceof Meteor.Error).to.be.true(); + expect(createdError.error).to.be.equal(500); + expect(createdError.reason).to.be.equal('Bad request'); + expect(createdError.details).to.be.equal('Your request is missing required fields'); + }); + }); +}); diff --git a/test/server/integration/method.spec.js b/test/server/integration/method.spec.js new file mode 100644 index 0000000..02a4a54 --- /dev/null +++ b/test/server/integration/method.spec.js @@ -0,0 +1,77 @@ +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import * as MeteorMethod from '../../../server/integration/method'; + +describe('When an instance is created', () => { + const sandbox = sinon.createSandbox(); + let meteorMethodInstance, callbackSpy; + + beforeEach(() => { + const callbackMock = { callback: () => {} }; + callbackSpy = sandbox.spy(callbackMock, 'callback'); + meteorMethodInstance = MeteorMethod.factory({ + name: 'test', + callback: callbackMock.callback + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('And setup is invoked', () => { + let meteorMethodsStub; + + beforeEach(() => { + meteorMethodsStub = sandbox.stub(Meteor, 'methods'); + meteorMethodInstance.setup(); + }); + + it('should pass a new method configuration to Meteor', () => { + expect(meteorMethodsStub).to.be.calledOnce(); + }); + + describe('And the method configuration', () => { + let receivedConfiguration; + + beforeEach(() => { + [receivedConfiguration] = meteorMethodsStub.getCall(0).args; + }); + + it('should contain a method with provided name', () => { + expect('test' in receivedConfiguration).to.be.equal(true); + expect(typeof receivedConfiguration.test).to.be.equal('function'); + }); + + describe('And when the method is invoked', () => { + const sampleParams = ['1', '2', '3']; + + beforeEach(() => { + receivedConfiguration.test(...sampleParams); + }); + + it('should call provided callback with method context and provided params', () => { + expect(callbackSpy).to.be.calledWith(sinon.match.object, ...sampleParams); + }); + }); + }); + }); +}); + +describe('Given MeteorMethod factory', () => { + it('should be a function', () => { + expect(typeof MeteorMethod.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = MeteorMethod.factory({ name: 'test', callback: () => {} }); + }); + + it('should return an instance of MeteorMethod', () => { + expect(result instanceof MeteorMethod.default).to.be.equal(true); + }); + }); +}); diff --git a/test/server/lib/connectionLastLoginToken.spec.js b/test/server/lib/connectionLastLoginToken.spec.js new file mode 100644 index 0000000..32fad42 --- /dev/null +++ b/test/server/lib/connectionLastLoginToken.spec.js @@ -0,0 +1,74 @@ +import { Accounts } from 'meteor/accounts-base'; +import { expect } from 'ultimate-chai'; +import sinon from 'sinon'; +import * as connectionLastLoginToken from '../../../server/lib/connectionLastLoginToken'; + +describe('Given the connectionLastLoginToken class', () => { + const sandbox = sinon.createSandbox(); + let instance, updateTokensStub, sampleTokens, sampleUser; + + beforeEach(() => { + sampleTokens = [ + { + when: new Date(), + hashedToken: '7Rg3IXBj0hNZJWQa677ILS2jWKtt2rC7o9Nat3+7+zw=' + }, + { + when: new Date(), + hashedToken: '+7+zw=' + } + ]; + sampleUser = { + _id: '6vCQJw5TrRaeb9ZJM', + username: 'test', + services: { + resume: { + loginTokens: sampleTokens + } + } + }; + sandbox.stub(Accounts, '_getLoginToken').returns(sampleTokens[0].hashedToken); + sandbox.stub(Meteor.users, 'findOne').returns(sampleUser); + updateTokensStub = sandbox.stub(Meteor.users, 'update').returns(1); + instance = connectionLastLoginToken.factory('connection-id'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When updateFields is called', () => { + beforeEach(() => { + instance.updateFields({ rememberMe: true }); + }); + + it('should request to replace user tokens with correct params', () => { + const expectedUpdatedToken = Object.assign(sampleTokens[0], { rememberMe: true }); + const expectedQuery = { + $set: { + 'services.resume.loginTokens': [expectedUpdatedToken, sampleTokens[1]] + } + }; + expect(updateTokensStub).to.be + .calledWithExactly(sampleUser._id, expectedQuery); + }); + }); +}); + +describe('Given the connectionLastLoginToken factory', () => { + it('should be a function', () => { + expect(typeof connectionLastLoginToken.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = connectionLastLoginToken.factory(); + }); + + it('should return an instance of ConnectionLastLoginToken', () => { + expect(result instanceof connectionLastLoginToken.default).to.be.equal(true); + }); + }); +}); diff --git a/test/server/lib/helpers.spec.js b/test/server/lib/helpers.spec.js new file mode 100644 index 0000000..de00c7a --- /dev/null +++ b/test/server/lib/helpers.spec.js @@ -0,0 +1,59 @@ +import { expect } from 'ultimate-chai'; +import { getValueFromTree } from '../../../server/lib/helpers'; + +describe('Given getValueFromTree method', () => { + const sampleParam = { + test: { + test: { + treasure: 'congrats!' + }, + treasure: 'that is cheating' + }, + something: { + test: { + treasure: 'bad choice' + } + } + }; + let result; + + describe('When a correct path is provided', () => { + beforeEach(() => { + result = getValueFromTree(sampleParam, 'test.test.treasure'); + }); + + it('should return undefined', () => { + expect(result).to.be.equal('congrats!'); + }); + }); + + describe('When an incorrect path is provided', () => { + beforeEach(() => { + result = getValueFromTree(sampleParam, 'one.two.three.four'); + }); + + it('should return undefined', () => { + expect(result).to.be.equal(undefined); + }); + }); + + describe('When path is not provided', () => { + beforeEach(() => { + result = getValueFromTree(sampleParam); + }); + + it('should return undefined', () => { + expect(result).to.be.equal(undefined); + }); + }); + + describe('When nothing is passed', () => { + beforeEach(() => { + result = getValueFromTree(); + }); + + it('should return undefined', () => { + expect(result).to.be.equal(undefined); + }); + }); +}); diff --git a/test/server/loginAttemptValidator/index.spec.js b/test/server/loginAttemptValidator/index.spec.js new file mode 100644 index 0000000..72cf376 --- /dev/null +++ b/test/server/loginAttemptValidator/index.spec.js @@ -0,0 +1,20 @@ +import { expect } from 'ultimate-chai'; +import * as loginAttemptValidator from '../../../server/loginAttemptValidator/index'; + +describe('Given the loginAttemptValidator factory', () => { + it('should be a function', () => { + expect(typeof loginAttemptValidator.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = loginAttemptValidator.factory(); + }); + + it('should return an instance of LoginAttemptValidator', () => { + expect(result instanceof loginAttemptValidator.default).to.be.equal(true); + }); + }); +}); diff --git a/test/server/loginAttemptValidator/validators/isAllowed.spec.js b/test/server/loginAttemptValidator/validators/isAllowed.spec.js new file mode 100644 index 0000000..b7bfb8f --- /dev/null +++ b/test/server/loginAttemptValidator/validators/isAllowed.spec.js @@ -0,0 +1,89 @@ +import { expect } from 'ultimate-chai'; +import * as isAllowed from '../../../../server/loginAttemptValidator/validators/isAllowed'; + +describe('Given the isAllowed validator', () => { + let instance, loginAttempt, result; + + describe('When login attempt is allowed', () => { + beforeEach(() => { + loginAttempt = { allowed: true }; + instance = isAllowed.factory(loginAttempt); + }); + + describe('And the validation is invoked', () => { + beforeEach(() => { + result = instance.validate(); + }); + + it('should not pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + }); + + describe('When login attempt is disallowed', () => { + beforeEach(() => { + loginAttempt = { allowed: false, error: 'not today' }; + instance = isAllowed.factory(loginAttempt); + }); + + describe('And the validation is invoked', () => { + beforeEach(() => { + result = instance.validate(); + }); + + it('should not pass the validation', () => { + expect(result).to.be.equal(false); + }); + + describe('And the getError is invoked', () => { + beforeEach(() => { + result = instance.getError(); + }); + + it('should return a proper error details', () => { + expect(result).to.be.deep.equal({ + result: false, + reason: 'Attempt disallowed by previous validation', + error: 'not today' + }); + }); + }); + }); + }); + + describe('When login attempt is empty', () => { + beforeEach(() => { + loginAttempt = {}; + instance = isAllowed.factory(loginAttempt); + }); + + describe('And the validation is invoked', () => { + beforeEach(() => { + result = instance.validate(); + }); + + it('should not pass the validation', () => { + expect(result).to.be.equal(false); + }); + }); + }); +}); + +describe('Given the isAllowed factory', () => { + it('should be a function', () => { + expect(typeof isAllowed.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = isAllowed.factory({ error: 'something' }); + }); + + it('should return an instance of IsAllowed validator', () => { + expect(result instanceof isAllowed.default).to.be.equal(true); + }); + }); +}); diff --git a/test/server/loginAttemptValidator/validators/shouldResumeBeAccepted.spec.js b/test/server/loginAttemptValidator/validators/shouldResumeBeAccepted.spec.js new file mode 100644 index 0000000..dfea560 --- /dev/null +++ b/test/server/loginAttemptValidator/validators/shouldResumeBeAccepted.spec.js @@ -0,0 +1,166 @@ +import sinon from 'sinon'; +import { expect } from 'ultimate-chai'; +import * as shouldResumeBeAccepted from '../../../../server/loginAttemptValidator/validators/shouldResumeBeAccepted'; +import * as integrationAccounts from '../../../../server/integration/accounts'; + +describe('Given the shouldResumeBeAccepted validator', () => { + const sandbox = sinon.createSandbox(); + let instance, loginAttemptMock, mockedToken, result; + + beforeEach(() => { + sandbox + .stub(integrationAccounts, 'hashLoginToken') + .callsFake(token => token); + sandbox + .stub(integrationAccounts, 'findUserByUsername') + .callsFake(() => ({ + services: { + resume: { + loginTokens: [mockedToken] + } + } + })); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('When user starts a new device session after being logged in', () => { + beforeEach(() => { + loginAttemptMock = { + type: 'resume', + methodArguments: [{ resume: 'token' }], + user: { username: 'test' } + }; + instance = shouldResumeBeAccepted.factory(loginAttemptMock); + }); + + describe('And rememberMe was true', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token', rememberMe: true }; + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + + describe('And rememberMe was false', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token', rememberMe: false }; + result = instance.validate(); + }); + + it('should not pass the validation', () => { + expect(result).to.be.equal(false); + }); + + describe('And the getError is invoked', () => { + beforeEach(() => { + result = instance.getError(); + }); + + it('should return a proper error details', () => { + expect(result).to.be.deep.equal({ + result: false, + reason: 'Resume not allowed when user does not have remember me', + errorCode: 405 + }); + }); + }); + }); + + describe('And rememberMe was not reported by user', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token' }; + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + }); + + describe('When user reconnects after a connection loss', () => { + beforeEach(() => { + loginAttemptMock = { + type: 'resume', + methodArguments: [{ resume: 'token' }, { loggedAtLeastOnce: true }], + user: { username: 'test' } + }; + instance = shouldResumeBeAccepted.factory(loginAttemptMock); + }); + + describe('And rememberMe was true', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token', rememberMe: true }; + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + + describe('And rememberMe was false', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token', rememberMe: false }; + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + + describe('And rememberMe was not present', () => { + beforeEach(() => { + mockedToken = { hashedToken: 'token' }; + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + }); + + describe('When user logged in using a method', () => { + beforeEach(() => { + loginAttemptMock = { type: 'password' }; + instance = shouldResumeBeAccepted.factory(loginAttemptMock); + }); + + describe('And the validate method is invoked', () => { + beforeEach(() => { + result = instance.validate(); + }); + + it('should pass the validation', () => { + expect(result).to.be.equal(true); + }); + }); + }); +}); + +describe('Given the shouldResumeBeAccepted factory', () => { + it('should be a function', () => { + expect(typeof shouldResumeBeAccepted.factory).to.be.equal('function'); + }); + + describe('When invoked', () => { + let result; + + beforeEach(() => { + result = shouldResumeBeAccepted.factory(); + }); + + it('should return an instance of ShouldResumeBeAccepted validator', () => { + expect(result instanceof shouldResumeBeAccepted.default).to.be.equal(true); + }); + }); +}); + diff --git a/test/server/method.spec.js b/test/server/method.spec.js deleted file mode 100644 index 2904138..0000000 --- a/test/server/method.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -const RememberMe = require('meteor/tprzytula:remember-me'); - -const rememberMeMethod = 'tprzytula:rememberMe-update'; -const chai = require('ultimate-chai'); - -const { expect } = chai; -const getMeteorMethods = () => Meteor.default_server.method_handlers; -const checkIfMeteorMethodExists = name => name in getMeteorMethods(); - -describe('Given a rememberMe method', () => { - /** - * Having this dependency installed should not invoke it. - * The rememberMe method used for the communication client <> server - * should not exist. - */ - it('should not exist by default', () => { - const doesExist = checkIfMeteorMethodExists(rememberMeMethod); - expect(doesExist).to.be.equal(false); - }); - - describe('When rememberMe functionality is activated', () => { - beforeEach(() => { - RememberMe.activate(); - }); - - /** - * After activating the functionality a new meteor method - * should be created. From now user is able to invoke the - * 'tprzytula:rememberMe-update' method. - */ - it('should exist after activating the functionality', () => { - const doesExist = checkIfMeteorMethodExists(rememberMeMethod); - expect(doesExist).to.be.equal(true); - }); - }); -}); diff --git a/test/server/utils/loginAttemptGenerator.js b/test/server/utils/loginAttemptGenerator.js deleted file mode 100644 index daeea58..0000000 --- a/test/server/utils/loginAttemptGenerator.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Generates meteor alike login attempt objects. - * In the running environment the object is received - * on the server side on every login attempt. - * To test unit test this functionality they have to - * be provided in the same form to the methods. - * - * @property {Object} loginAttempt - * @class - */ -class LoginAttemptGenerator { - constructor(options = {}) { - this.createAttempt(options); - } - - /** - * Creates loginAttempt object according to the provided options. - * @param {Object} configuration - */ - createAttempt({ - type = 'password', - allowed = true, - user - }) { - this.loginAttempt = { - type, - allowed, - methodName: 'login', - methodArguments: [], - user: {}, - connection: {}, - }; - - if (type === 'resume') { - this.loginAttempt.user = user || Meteor.users.findOne(); - } - } - - /** - * Returns generated login attempt. - * @returns {Object} loginAttempt - */ - getLoginAttempt() { - return this.loginAttempt; - } - - /** - * Adds a method argument to the attempt. - * On the running meteor environments method arguments - * are being sent by user together with the login attempt. - * They are including data for the authorization. - * @param {Object} argument - */ - addMethodArgument(argument = {}) { - this.loginAttempt.methodArguments.push(argument); - } -} - -module.exports = LoginAttemptGenerator; diff --git a/test/server/utils/testUser.js b/test/server/utils/testUser.js deleted file mode 100644 index ffb681c..0000000 --- a/test/server/utils/testUser.js +++ /dev/null @@ -1,79 +0,0 @@ -const { Accounts } = require('meteor/accounts-base'); -const Authenticator = require('./../../../server/authenticator').default; - -/** - * Manages a test user for testing purposes. - * - * @property {string} username - * @property {string} password - * @class - */ -class TestUser { - constructor(options = {}) { - this.username = options.username || 'test'; - this.password = options.password || 'test'; - } - - /** - * Invoked at the class initialization. - */ - init() { - this.createUser(); - } - - /** - * Returns user document from the collection. - * @returns {Object} user - */ - getUser() { - return Meteor.users.findOne({ username: this.username }); - } - - /** - * Removes user with the same username if exists. - */ - removeUser() { - Meteor.users.remove({ - username: this.username - }); - } - - /** - * Creates username for the configuration credentials. - */ - createUser() { - Accounts.createUser({ - username: this.username, - password: this.password - }); - } - - /** - * Creates and sets login tokens for the user according - * to the provided to the method options. - * @param {Object} options - * @returns {boolean} result - */ - setLoginToken({ resume, rememberMe = true }) { - const loginToken = { - when: new Date(), - hashedToken: Authenticator.hashToken(resume), - rememberMe, - }; - - const user = Meteor.users.findOne({ username: this.username }); - if (!user) { - return false; - } - - const result = Meteor.users.update(user._id, { - $set: { - 'services.resume.loginTokens': [loginToken] - } - }); - - return result === 1; - } -} - -module.exports = TestUser;