diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c4d1328 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,37 @@ +{ + "extends": [ + "airbnb-base", + "plugin:meteor/recommended" + ], + "plugins": [ + "meteor" + ], + "env": { + "meteor": true + }, + "settings": { + "import/resolver": "meteor" + }, + "parser": "babel-eslint", + "globals": { + "Meteor": true, + "it": true, + "describe": true + }, + "rules": { + "comma-dangle": [ + 0 + ], + "indent": [ + 2, + 4, + { + "SwitchCase": 1 + } + ], + "import/extensions": [ + "off", + "never" + ] + } +} diff --git a/.gitignore b/.gitignore index bcebb00..b6a664c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea .npm .vscode -node_modules \ No newline at end of file +node_modules +package-lock.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c146f2..1f78c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,24 @@ # Changelog +## [0.2.0] - 13.03.2018 +New feature: +* Add support for custom AccountsClient ([introduction](./CUSTOM_ACCOUNTS.md)) + +Related improvements: +* Check if onLogin callback from the dependency is already present +* Check if loginAttempt method was already overridden in provided instance + ## [0.1.3] - 04.02.2018 * Inform client if the functionality was not activated on the server * Client side unit tests ## [0.1.2] - 01.02.2018 -* Remove 'lodash' dependency, replace usages using ES6 +* Remove 'lodash' dependency, replace usages with ES6 * Remove 'crypto-js' dependency, use 'crypto' instead * Decrease the server bundle size significantly by the above changes ## [0.1.1] - 27.01.2018 -* Print correct error in case of already dissallowed attempt +* Print correct error in case of already disallowed attempt * Add server side tests ## [0.1.0] - 19.01.2018 diff --git a/CUSTOM_ACCOUNTS.md b/CUSTOM_ACCOUNTS.md new file mode 100644 index 0000000..41a7381 --- /dev/null +++ b/CUSTOM_ACCOUNTS.md @@ -0,0 +1,73 @@ +# AccountsClient + +## Custom AccountsClient in Meteor Apps + +#### Wait why? + +Imagine an example situation where you decided to split your Meteor server into two separate ones to reduce the overload. You found it pretty convenient to use one of them only for serving HCP where the separate one will handle all the logic. + +Another example could be an android app in the Google Play store where upon start you could decide between connecting to the *NA* or *EU* server. + +#### How ? +To achieve the first example you will point all the client apps to the HCP server and connect to the separate one using: + +```js +const remoteConnection = DDP.connect('127.0.0.1:4000'); +``` +By now you can easily start using methods from the new connection the same way as you are doing it on the main: +```js +remoteConnection.call('delayApocalypse', 1000, (error) => { + if (error) { + console.error('Could not delay the apocalypse:', error); + } +}) +``` + +However migrating the accounts system is a bit more tricky. Upon using the default accounts methods the app's clients will try to log in to the main server by the default (which was supposed to be HCP only). + +The first step for migration is to create a new instance of AccountsClient + +```js +const accountsClient = new AccountsClient({ connection: remoteConnection }); +``` + +Unfortunately if you want to have a method like `loginWithPassword` then you have to implement it yourself the same way as it's done for the main accounts system [Source](https://github.com/meteor/meteor/blob/46257bad264bf089e35e0fe35494b51fe5849c7b/packages/accounts-password/password_client.js#L33) + +But don't worry! Using tprzytula:remember-me you don't have to worry about that. + +## Switching to the custom AccountsClient in tprzytula:remember-me + +Using this dependency the login logic always stays the same no matter of which AccountsClient system you are currently using. You can switch the accounts system at any point during your app lifetime. After you will be done with the AccountsClient configuration the only thing you need to do is to pass the instance to *changeAccountsSystem* method and voila! + +### Example: + +##### Configuration: + +To let the dependency know that you have and want to use a separate custom account system you need to pass the instance to the `changeAccountsSystem` method. + +```js +import { AccountsClient } from 'meteor/accounts-base'; +import RememberMe from 'meteor/tprzytula:remember-me'; + +Meteor.remoteConnection = DDP.connect('127.0.0.1:4000'); // Meteor's server for accounts +Meteor.remoteUsers = new AccountsClient({ connection: Meteor.remoteConnection }); + +RememberMe.changeAccountsSystem(Meteor.remoteUsers); + +``` + +##### Usage: + +After the configuration you can use the newly set accounts system in the same way you were doing it previously. + +```js +import RememberMe from 'meteor/tprzytula:remember-me'; + +RememberMe.loginWithPassword('username', 'password', (error) => { + if (error) { + console.error(error); + return; + } + // success! +}, true); +``` diff --git a/README.md b/README.md index a757f92..f104f69 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Wrapper for a Meteor.loginWithPassword with an addition of rememberMe as a last The default for rememberMe is true to match the behaviour of Meteor. +`changeAccountsSystem(AccountsClient: customAccounts)` + +Gives the possibility to set a custom accounts instance to be used for the login system ([more details](./CUSTOM_ACCOUNTS.md)) + ## Testing You can test this dependency by running `npm run test` \ No newline at end of file diff --git a/client/helpers.js b/client/helpers.js new file mode 100644 index 0000000..c6c2408 --- /dev/null +++ b/client/helpers.js @@ -0,0 +1,30 @@ +/** + * Checks which param is the rememberMe flag + * and returns it. If it's not present then + * returns "true" by default. + * @param {Array} params + * @returns {boolean} flag + */ +export const exportFlagFromParams = (params = []) => { + const [ + firstParam = () => {}, + secondParam = true, + ] = params; + return (typeof firstParam === 'boolean') + ? firstParam + : secondParam; +}; + +/** + * Checks if the first provided param is the callback + * function. If it's not present then returns an + * empty method instead. + * @param {Array} params + * @returns {function} callback + */ +export const exportCallbackFromParams = (params = []) => { + const [firstParam] = params; + return (typeof firstParam === 'function') + ? firstParam + : () => {}; +}; diff --git a/client/index.js b/client/index.js index bf5499d..cec6230 100644 --- a/client/index.js +++ b/client/index.js @@ -1,38 +1,171 @@ +import { + Accounts, + AccountsClient +} from 'meteor/accounts-base'; + +import { + exportFlagFromParams, + exportCallbackFromParams +} from './helpers'; + import overrideAccountsLogin from './overrideLogin'; -overrideAccountsLogin(); - -const RememberMe = {}; -const updateRememberMe = 'tprzytula:rememberMe-update'; - -RememberMe.loginWithPassword = (user, password, callback = () => {}, rememberMe = true) => { - const flag = (typeof callback === 'boolean') - ? callback - : rememberMe; - - const callbackMethod = (typeof callback === 'function') - ? callback - : () => {}; - - Meteor.loginWithPassword(user, password, (error) => { - if (!error) { - Meteor.call(updateRememberMe, 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 - ); + +/** + * RememberMe + * + * @property {Object} remoteConnection - handler to a custom connection. + * @property {string} methodName - unique name for the rememberMe method + * + * @class + */ +class RememberMe { + constructor() { + this.remoteConnection = null; + this.methodName = 'tprzytula:rememberMe-update'; + overrideAccountsLogin(Accounts); + } + + /** + * Returns login method either from the main + * connection or remote one if set. + * @returns {function} loginWithPassword + * @private + */ + getLoginWithPasswordMethod() { + return this.remoteConnection + ? this.remoteConnection.loginWithPassword + : Meteor.loginWithPassword; + } + + /** + * Returns call method either from the main + * connection or remote one if set. + * @returns {function} call + * @private + */ + getCallMethod() { + return this.remoteConnection + ? this.remoteConnection.call.bind(this.remoteConnection) + : Meteor.call; + } + + /** + * 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. + * @public + */ + loginWithPassword(...params) { + const [user, 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); + }); + } + + /** + * 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 + * @public + */ + changeAccountsSystem(customAccounts) { + if (customAccounts instanceof AccountsClient && + customAccounts.connection) { + this.remoteConnection = customAccounts.connection; + this.setLoginMethod(); + overrideAccountsLogin(customAccounts); + return true; + } + 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. + * @private + */ + setLoginMethod() { + if ('loginWithPassword' in this.remoteConnection) { + // Login method is already present + return; + } + + /* eslint-disable */ + /* + 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 }; + } + Meteor.remoteUsers.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(); + } } }); - } - callbackMethod(error); - }); -}; + }; + /* eslint-enable */ + } +} + +export default new RememberMe(); -export default RememberMe; +// Export handle to the class only on TEST environment +export const RememberMeClass = process.env.TEST_METADATA ? RememberMe : null; diff --git a/client/overrideLogin.js b/client/overrideLogin.js index bdf082f..f82f1fe 100644 --- a/client/overrideLogin.js +++ b/client/overrideLogin.js @@ -1,4 +1,5 @@ -import { Accounts } from 'meteor/accounts-base'; +const isMethodOverridden = 'tprzytula:remember-me_overridden'; +const isCallbackRegistered = 'tprzytula:remember-me_callbackRegistered'; /** * This function is used to override Account's function called callLoginMethod. @@ -21,9 +22,9 @@ import { Accounts } from 'meteor/accounts-base'; * 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 = () => { - const accountsCallLoginMethod = Accounts.callLoginMethod.bind(Accounts); - Accounts.callLoginMethod = function (options = {}) { +const overrideLoginMethod = (accountsClientInstance) => { + const accountsCallLoginMethod = accountsClientInstance.callLoginMethod.bind(accountsClientInstance); + accountsClientInstance.callLoginMethod = function callLoginMethod(options = {}) { const preparedOptions = options; if (preparedOptions) { if (preparedOptions.methodArguments) { @@ -34,15 +35,19 @@ const overrideLoginMethod = () => { } accountsCallLoginMethod(preparedOptions); }; + accountsClientInstance[isMethodOverridden] = true; }; -export default () => { - let loginOverridden = false; - Accounts.onLogin(() => { +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 (!loginOverridden) { - overrideLoginMethod(); - loginOverridden = true; + if (!(isMethodOverridden in accountsClientInstance)) { + overrideLoginMethod(accountsClientInstance); } }); + accountsClientInstance[isCallbackRegistered] = true; }; diff --git a/package.js b/package.js index 6a0efc4..7d25823 100644 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'tprzytula:remember-me', - version: '0.1.3', + version: '0.2.0', summary: 'Extension for Meteor account-base package with the implementation of rememberMe', git: 'https://github.com/tprzytulacc/Meteor-RememberMe', documentation: 'README.md' @@ -20,14 +20,14 @@ Package.onTest((api) => { api.use('accounts-base'); api.use('accounts-password'); api.use('coffeescript'); + api.use('ddp'); api.use('meteortesting:mocha'); api.use('tprzytula:remember-me'); - api.use('practicalmeteor:chai'); api.use('practicalmeteor:sinon'); api.mainModule('test/client/index.js', 'client'); api.mainModule('test/server/index.js', 'server'); Npm.depends({ - sinon: '4.2.2' + sinon: '4.2.2', + 'ultimate-chai': '4.1.1' }); - -}); \ No newline at end of file +}); diff --git a/package.json b/package.json index 9d45d36..9cf28dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rememberMe", - "version": "0.1.3", + "version": "0.2.0", "description": "Extension for Meteor account-base package with the implementation of rememberMe", "license": "MIT", "author": "Tomasz Przytuła ", @@ -16,7 +16,17 @@ ], "homepage": "https://github.com/tprzytulacc/Meteor-Remember-Me", "scripts": { + "eslint": "eslint ./client ./server ./test || true", + "eslint-fix": "eslint ./client ./server ./test --fix || true", "test": "meteor test-packages ./ --once --driver-package meteortesting:mocha", "test-watch": "TEST_WATCH=1 meteor test-packages ./ --full-app --driver-package meteortesting:mocha" + }, + "devDependencies": { + "babel-eslint": "^8.2.2", + "eslint": "4.18.2", + "eslint-config-airbnb-base": "^12.1.0", + "eslint-import-resolver-meteor": "^0.4.0", + "eslint-plugin-import": "^2.9.0", + "eslint-plugin-meteor": "^4.2.0" } -} \ No newline at end of file +} diff --git a/server/authenticator.js b/server/authenticator.js index 71382c9..c82ec53 100644 --- a/server/authenticator.js +++ b/server/authenticator.js @@ -73,16 +73,12 @@ class Authenticator { shouldResumeBeAccepted() { const loginTokens = this.getUsersLoginTokens(); const hashedToken = this.getTokenFromAttempt(); - const userResume = loginTokens.find( - item => item.hashedToken === hashedToken - ); + 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 - ); + const loggedAtLeastOnce = methodArguments.some(argument => argument.loggedAtLeastOnce === true); return (userResume.rememberMe || loggedAtLeastOnce); } diff --git a/server/helpers.js b/server/helpers.js index 79a1000..fc56690 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -5,15 +5,13 @@ const RememberMeHelpers = {}; * @param userLoginToken * @returns {Object} user */ -RememberMeHelpers.findMatchingUser = (userLoginToken) => { - return Meteor.users.findOne({ - 'services.resume.loginTokens.hashedToken': userLoginToken - }, { - fields: { - 'services.resume.loginTokens': 1 - } - }); -}; +RememberMeHelpers.findMatchingUser = userLoginToken => Meteor.users.findOne({ + 'services.resume.loginTokens.hashedToken': userLoginToken +}, { + fields: { + 'services.resume.loginTokens': 1 + } +}); /** * Returns all currently stored login token records for diff --git a/server/index.js b/server/index.js index c43a3aa..a8e7dd2 100644 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,12 @@ export const activate = () => { methods(); Accounts.validateLoginAttempt((attempt) => { const authenticator = new Authenticator(attempt); - const { result, resultCode, reason, error } = authenticator.validateAttempt(); + const { + result, + resultCode, + reason, + error, + } = authenticator.validateAttempt(); if (error) { throw error; } else if (!result) { @@ -31,7 +36,7 @@ export const activate = () => { * @returns {boolean} result * @private */ -export const _updateState = (connectionId, flag) => { +export const updateState = (connectionId, flag) => { const userLoginToken = Accounts._getLoginToken(connectionId); const loginTokens = RememberMeHelpers.getAllUserTokens(userLoginToken); if (!loginTokens) { diff --git a/server/methods.js b/server/methods.js index 97dc89a..09b7f61 100644 --- a/server/methods.js +++ b/server/methods.js @@ -1,5 +1,5 @@ import { check } from 'meteor/check'; -import { _updateState } from './index.js'; +import { updateState } from './index.js'; // Name of the method should be unique to not override others const updateRememberMeMethod = 'tprzytula:rememberMe-update'; @@ -15,7 +15,7 @@ export default () => { [updateRememberMeMethod](flag = true) { check(flag, Boolean); const connectionId = this.connection.id; - return _updateState(connectionId, flag); + return updateState(connectionId, flag); } }); }; diff --git a/test/client/index.js b/test/client/index.js index b8df722..f89322a 100644 --- a/test/client/index.js +++ b/test/client/index.js @@ -1,5 +1,7 @@ const method = require('./tests/method'); const rememberMe = require('./tests/rememberMe'); +const customAccountsClient = require('./tests/customAccountsClient'); +const overrideLogin = require('./tests/overrideLogin'); /** * Client-side test cases. @@ -7,4 +9,6 @@ const rememberMe = require('./tests/rememberMe'); describe('client', () => { method(); rememberMe(); -}); \ No newline at end of file + customAccountsClient(); + overrideLogin(); +}); diff --git a/test/client/tests/customAccountsClient.js b/test/client/tests/customAccountsClient.js new file mode 100644 index 0000000..c4b4dc0 --- /dev/null +++ b/test/client/tests/customAccountsClient.js @@ -0,0 +1,134 @@ +const AccountsClient = require('meteor/accounts-base').AccountsClient; +const RememberMe = require('meteor/tprzytula:remember-me').RememberMeClass; + +const sinon = require('sinon'); +const chai = require('ultimate-chai'); +const expect = chai.expect; + +const rememberMeMethod = 'tprzytula:rememberMe-update'; + +module.exports = () => { + /** + * If the accounts system is being managed not on the + * Meteor server from the main connection then it's required + * to create a new instance of AccountsClient with provided + * DDP connection pointing to the desired server. + */ + describe('Support for custom AccountsClient', () => { + + /** + * From the release 0.2.0 a new API method "changeAccountsSystem" + * is provided. Using this method it's possible to switch from + * using the default Accounts from the main Meteor server to + * a custom one. + */ + describe('Changing the default accounts system', () => { + + /** + * It's important to only accept instanced of the AccountsClient. + * All invalid parameters should not be accepted, which means + * that the accounts should not change and the method should + * return "false". + */ + it('should not accept invalid AccountsClient', () => { + const rememberMe = new RememberMe(); + const randomInstance = new RememberMe(); + const result = rememberMe.changeAccountsSystem(randomInstance); + expect(result).to.be.equal(false); + }); + + /** + * Upon providing correct AccountsClient instance the method + * should return "true". + */ + it('should switch to new AccountsClient', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + const result = rememberMe.changeAccountsSystem(preparedAccounts); + expect(result).to.be.equal(true); + }); + }); + + describe('Adjusting received AccountsClient', () => { + + it('should append "loginWithPassword" method if there is none', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + expect('loginWithPassword' in preparedAccounts.connection).to.be.equal(false); + rememberMe.changeAccountsSystem(preparedAccounts); + expect('loginWithPassword' in preparedAccounts.connection).to.be.equal(true); + }); + + }); + + describe('Should start using the passed custom AccountsClient', () => { + + /** + * Each time the wrapper is called it + * should also internally call the Meteor's login method. + */ + it('should call loginWithPassword', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + rememberMe.changeAccountsSystem(preparedAccounts); + + const loginWithPassword = sinon.stub( + preparedAccounts.connection, + 'loginWithPassword' + ); + + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); + expect(loginWithPassword).to.have.been.calledWith( + 'username', + 'password' + ); + + rememberMe.loginWithPassword('username_two', 'password_two'); + expect(loginWithPassword).to.have.been.calledTwice(); + expect(loginWithPassword).to.have.been.calledWith( + 'username_two', + 'password_two' + ); + + loginWithPassword.restore(); + }); + + /** + * If the login performed successfully then method for updating + * the state of remember me should be invoked. This way server + * will be informed about requested change for this setting. + */ + it('should call updateRememberMe method if logged in successfully', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + rememberMe.changeAccountsSystem(preparedAccounts); + + const loginWithPassword = sinon.stub( + preparedAccounts.connection, + 'loginWithPassword' + ); + const call = sinon.stub(connection, 'call'); + loginWithPassword.callsFake((user, password, callback) => { + const error = false; + callback(error); + }); + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); + expect(loginWithPassword).to.have.been.calledWith( + 'username', + 'password' + ); + expect(call).to.have.been.calledOnce(); + expect(call).to.have.been.calledWith(rememberMeMethod); + + loginWithPassword.restore(); + call.restore(); + }); + }); + }); +}; diff --git a/test/client/tests/method.js b/test/client/tests/method.js index 8a11e93..b9e7abe 100644 --- a/test/client/tests/method.js +++ b/test/client/tests/method.js @@ -1,6 +1,10 @@ -const RememberMe = require('meteor/tprzytula:remember-me').default; +const RememberMe = require('meteor/tprzytula:remember-me').RememberMeClass; + const rememberMeMethod = 'tprzytula:rememberMe-update'; const sinon = require('sinon'); +const chai = require('ultimate-chai'); + +const expect = chai.expect; module.exports = () => { /** @@ -13,20 +17,21 @@ module.exports = () => { * should also internally call the Meteor's login method. */ it('should call Meteor.loginWithPassword', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' ); - RememberMe.loginWithPassword('username', 'password'); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - RememberMe.loginWithPassword('username_two', 'password_two'); - expect(loginWithPassword).to.have.been.calledTwice; + rememberMe.loginWithPassword('username_two', 'password_two'); + expect(loginWithPassword).to.have.been.calledTwice(); expect(loginWithPassword).to.have.been.calledWith( 'username_two', 'password_two' @@ -41,14 +46,15 @@ module.exports = () => { * this request to the server. */ it('should not call updateRememberMe method if login failed', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub(Meteor, 'loginWithPassword'); const call = sinon.stub(Meteor, 'call'); loginWithPassword.callsFake((user, password, callback) => { const error = 'Invalid user'; callback(error); }); - RememberMe.loginWithPassword('username', 'password'); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); expect(call).to.have.callCount(0); loginWithPassword.restore(); @@ -61,6 +67,7 @@ module.exports = () => { * will be informed about requested change for this setting. */ it('should call updateRememberMe method if logged in successfully', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -70,13 +77,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password'); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWith(rememberMeMethod); loginWithPassword.restore(); @@ -91,6 +98,7 @@ module.exports = () => { * a parameter (if encountered). */ it('should correctly pass callback to "Meteor.loginWithPassword"', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -101,20 +109,20 @@ module.exports = () => { callback(error); }); const obj = {}; - obj.callback = (error) => error; + obj.callback = error => error; const callbackSpy = sinon.spy(obj, 'callback'); - RememberMe.loginWithPassword('username', 'password', obj.callback); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', obj.callback); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(callbackSpy).to.have.been.calledOnce; + expect(callbackSpy).to.have.been.calledOnce(); expect(callbackSpy).to.have.been.calledWith(error); loginWithPassword.restore(); call.restore(); }); }); -}; \ No newline at end of file +}; diff --git a/test/client/tests/overrideLogin.js b/test/client/tests/overrideLogin.js new file mode 100644 index 0000000..ba818de --- /dev/null +++ b/test/client/tests/overrideLogin.js @@ -0,0 +1,85 @@ +const AccountsClient = require('meteor/accounts-base').AccountsClient; +const RememberMe = require('meteor/tprzytula:remember-me').RememberMeClass; + +const isMethodOverridden = 'tprzytula:remember-me_overridden'; +const isCallbackRegistered = 'tprzytula:remember-me_callbackRegistered'; +const chai = require('ultimate-chai'); + +const expect = chai.expect; + +module.exports = () => { + /** + * Overriding internal login method 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. + */ + describe('Overriding internal login method', () => { + + /** + * By default freshly created instance of AccountsClient should + * not have any onLogin callbacks set yet. Upon creating + * RememberMe instance and giving to it the preparedAccounts + * a new callback should be registered. + * + * The callback will be used to override the login method + * after only the first successful login attempt during + * the device session. + */ + it('should register onLogin callback on initialization', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + const callbacks = preparedAccounts._onLoginHook.callbacks; + + const beforeInitialization = Object.keys(callbacks).length; + expect(beforeInitialization).to.be.equal(0); + expect(isCallbackRegistered in preparedAccounts).to.be.equal(false); + + rememberMe.changeAccountsSystem(preparedAccounts); + + const afterInitialization = Object.keys(callbacks).length; + expect(afterInitialization).to.be.equal(1); + expect(isCallbackRegistered in preparedAccounts).to.be.equal(true); + }); + + /** + * Given conditions in above tests it's important to + * make sure that the same onLogin callback won't be + * registered/duplicated no matter of how many times we + * will register the same AccountsClient to the RememberMe. + */ + it('should not register more than one callback to the same AccountsClient', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + const callbacks = preparedAccounts._onLoginHook.callbacks; + + rememberMe.changeAccountsSystem(preparedAccounts); + rememberMe.changeAccountsSystem(preparedAccounts); + rememberMe.changeAccountsSystem(preparedAccounts); + rememberMe.changeAccountsSystem(preparedAccounts); + rememberMe.changeAccountsSystem(preparedAccounts); + expect(Object.keys(callbacks).length).to.be.equal(1); + }); + + /** + * After successful change of accounts there should be one + * onLogin callback registered which will have the purpose + * to override the login method. To ensure this it's ensured + * if the method was overwritten only after invoking the callback. + */ + it('should override login method after first successful login', () => { + const rememberMe = new RememberMe(); + const connection = DDP.connect('127.0.0.1:3000'); + const preparedAccounts = new AccountsClient({ connection }); + rememberMe.changeAccountsSystem(preparedAccounts); + expect(isMethodOverridden in preparedAccounts).to.be.equal(false); + + const callbacks = preparedAccounts._onLoginHook.callbacks; + callbacks['0'](); + expect(isMethodOverridden in preparedAccounts).to.be.equal(true); + }); + }); +}; diff --git a/test/client/tests/rememberMe.js b/test/client/tests/rememberMe.js index 5f77011..8528cdb 100644 --- a/test/client/tests/rememberMe.js +++ b/test/client/tests/rememberMe.js @@ -1,20 +1,23 @@ -const RememberMe = require('meteor/tprzytula:remember-me').default; +const RememberMe = require('meteor/tprzytula:remember-me').RememberMeClass; + const rememberMeMethod = 'tprzytula:rememberMe-update'; const sinon = require('sinon'); +const chai = require('ultimate-chai'); + +const expect = chai.expect; module.exports = () => { /** * Remember me setting should be correctly set for each case. */ describe('Remember me flag', () => { - /** * There is no requirement for passing remember me parameter. * To match default behaviour of Meteor it's true by default. */ describe('not provided as a parameter', () => { - it('should set remember me to true by default (without callback)', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -24,13 +27,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password'); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password'); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWith(rememberMeMethod, true); loginWithPassword.restore(); @@ -38,6 +41,7 @@ module.exports = () => { }); it('should set remember me to true by default (with callback)', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -47,13 +51,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password', () => {}); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', () => {}); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWith(rememberMeMethod, true); loginWithPassword.restore(); @@ -66,8 +70,8 @@ module.exports = () => { * in case where user does not need to provide a callback. */ describe('provided as a third parameter', () => { - it('should set remember me to true if equals "true"', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -77,13 +81,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password', true); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', true); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWithMatch(rememberMeMethod, true); loginWithPassword.restore(); @@ -91,6 +95,7 @@ module.exports = () => { }); it('should set remember me to false if equals "false"', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -100,13 +105,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password', false); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', false); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWithMatch(rememberMeMethod, false); loginWithPassword.restore(); @@ -119,8 +124,8 @@ module.exports = () => { * in case where wants to provide callback as a third one. */ describe('provided as a fourth parameter', () => { - it('should set remember me to true if equals "true"', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -130,13 +135,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password', () => {}, true); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', () => {}, true); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWithMatch(rememberMeMethod, true); loginWithPassword.restore(); @@ -144,6 +149,7 @@ module.exports = () => { }); it('should set remember me to false if equals "false"', () => { + const rememberMe = new RememberMe(); const loginWithPassword = sinon.stub( Meteor, 'loginWithPassword' @@ -153,13 +159,13 @@ module.exports = () => { const error = false; callback(error); }); - RememberMe.loginWithPassword('username', 'password', () => {}, false); - expect(loginWithPassword).to.have.been.calledOnce; + rememberMe.loginWithPassword('username', 'password', () => {}, false); + expect(loginWithPassword).to.have.been.calledOnce(); expect(loginWithPassword).to.have.been.calledWith( 'username', 'password' ); - expect(call).to.have.been.calledOnce; + expect(call).to.have.been.calledOnce(); expect(call).to.have.been.calledWithMatch(rememberMeMethod, false); loginWithPassword.restore(); @@ -167,4 +173,4 @@ module.exports = () => { }); }); }); -}; \ No newline at end of file +}; diff --git a/test/server/index.js b/test/server/index.js index 988d654..338d568 100644 --- a/test/server/index.js +++ b/test/server/index.js @@ -1,12 +1,12 @@ -const login = require('./tests/login'); -const method = require('./tests/method'); -const resume = require('./tests/resume'); - -/** - * Server-side test cases. - */ -describe('server', () => { - login(); - method(); - resume(); -}); \ No newline at end of file +const login = require('./tests/login'); +const method = require('./tests/method'); +const resume = require('./tests/resume'); + +/** + * Server-side test cases. + */ +describe('server', () => { + login(); + method(); + resume(); +}); diff --git a/test/server/tests/login.js b/test/server/tests/login.js index 5c62203..976fac2 100644 --- a/test/server/tests/login.js +++ b/test/server/tests/login.js @@ -1,50 +1,50 @@ -const { chai } = require('meteor/practicalmeteor:chai'); -const Authenticator = require('../../../server/authenticator').default; -const LoginAttemptGenerator = require('../utils/loginAttemptGenerator'); - -const expect = chai.expect; -const type = 'password'; - -module.exports = () => { - /** - * Those tests are covering a basic user login attempt. - * The attempt is invoked if the user is logging for the first - * time by using methods such as Meteor.loginWithPassword. - */ - describe('login attempt', () => { - /** - * The attempt can be disallowed already from the previously - * ran validators. It can be a validator directly from the Meteor core - * saying that the password is wrong but also another one created by the developer. - * - * In this case there is no need to validate the attempt anymore. - * It should be instantly disallowed again. - */ - it('should not pass if the attempt is already disallowed', () => { - const loginAttemptGenerator = new LoginAttemptGenerator({ type }); - let loginAttempt = loginAttemptGenerator.getLoginAttempt(); - loginAttempt.allowed = false; - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - expect(result).to.be.equal(false); - expect(resultCode).to.be.equal(-1); - expect(reason).to.be.equal('Attempt disallowed by Meteor'); - }); - - /** - * The dependency logic should not affect normal login attempts. - * Because of that if the previously ran validations succeeded the - * dependency should also let it pass further. - */ - it('should pass if the attempt is allowed', () => { - const loginAttemptGenerator = new LoginAttemptGenerator({ type }); - let loginAttempt = loginAttemptGenerator.getLoginAttempt(); - loginAttempt.allowed = true; - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); -}; \ No newline at end of file +const Authenticator = require('../../../server/authenticator').default; +const LoginAttemptGenerator = require('../utils/loginAttemptGenerator'); +const chai = require('ultimate-chai'); + +const expect = chai.expect; +const type = 'password'; + +module.exports = () => { + /** + * Those tests are covering a basic user login attempt. + * The attempt is invoked if the user is logging for the first + * time by using methods such as Meteor.loginWithPassword. + */ + describe('login attempt', () => { + /** + * The attempt can be disallowed already from the previously + * ran validators. It can be a validator directly from the Meteor core + * saying that the password is wrong but also another one created by the developer. + * + * In this case there is no need to validate the attempt anymore. + * It should be instantly disallowed again. + */ + it('should not pass if the attempt is already disallowed', () => { + const loginAttemptGenerator = new LoginAttemptGenerator({ type }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + loginAttempt.allowed = false; + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + expect(result).to.be.equal(false); + expect(resultCode).to.be.equal(-1); + expect(reason).to.be.equal('Attempt disallowed by Meteor'); + }); + + /** + * The dependency logic should not affect normal login attempts. + * Because of that if the previously ran validations succeeded the + * dependency should also let it pass further. + */ + it('should pass if the attempt is allowed', () => { + const loginAttemptGenerator = new LoginAttemptGenerator({ type }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + loginAttempt.allowed = true; + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + expect(result).to.be.equal(true); + expect(resultCode).to.be.equal(0); + expect(reason).to.be.equal('Validation passed'); + }); + }); +}; diff --git a/test/server/tests/method.js b/test/server/tests/method.js index dc2c0a9..f3a281d 100644 --- a/test/server/tests/method.js +++ b/test/server/tests/method.js @@ -1,39 +1,41 @@ -const RememberMe = require('meteor/tprzytula:remember-me'); -const { chai } = require('meteor/practicalmeteor:chai'); - -const rememberMeMethod = 'tprzytula:rememberMe-update'; -const getMeteorMethods = () => Meteor.default_server.method_handlers; -const checkIfMeteorMethodExists = name => name in getMeteorMethods(); - -module.exports = () => { - /** - * The dependency is significantly affecting how the login system works. - * I'm against the idea of dependencies which are running automatically - * without the developer knowledge. There are situations where developers - * are leaving not used dependencies in the list. In this case it can be - * hard for them to debug and find the reason for their login system - * to work differently than the normal one should. - */ - describe('remember-me method', () => { - /** - * Having this dependency installed should not invoke it. - * The main 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); - }); - - /** - * After activating the functionality a new meteor method - * should be created. From now all users are being able to - * invoke the 'tprzytula:rememberMe-update' method. - */ - it('should exist after activating the functionality', () => { - RememberMe.activate(); - const doesExist = checkIfMeteorMethodExists(rememberMeMethod); - expect(doesExist).to.be.equal(true); - }); - }); -}; \ No newline at end of file +const RememberMe = require('meteor/tprzytula:remember-me'); + +const rememberMeMethod = 'tprzytula:rememberMe-update'; +const chai = require('ultimate-chai'); + +const expect = chai.expect; +const getMeteorMethods = () => Meteor.default_server.method_handlers; +const checkIfMeteorMethodExists = name => name in getMeteorMethods(); + +module.exports = () => { + /** + * The dependency is significantly affecting how the login system works. + * I'm against the idea of dependencies which are running automatically + * without the developer knowledge. There are situations where developers + * are leaving not used dependencies in the list. In this case it can be + * hard for them to debug and find the reason for their login system + * to work differently than the normal one should. + */ + describe('remember-me method', () => { + /** + * Having this dependency installed should not invoke it. + * The main 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); + }); + + /** + * After activating the functionality a new meteor method + * should be created. From now all users are being able to + * invoke the 'tprzytula:rememberMe-update' method. + */ + it('should exist after activating the functionality', () => { + RememberMe.activate(); + const doesExist = checkIfMeteorMethodExists(rememberMeMethod); + expect(doesExist).to.be.equal(true); + }); + }); +}; diff --git a/test/server/tests/resume.js b/test/server/tests/resume.js index c0a0ec6..a07abdb 100644 --- a/test/server/tests/resume.js +++ b/test/server/tests/resume.js @@ -1,105 +1,105 @@ -const { chai } = require('meteor/practicalmeteor:chai'); -const TestUser = require('../utils/testUser'); -const Authenticator = require('../../../server/authenticator').default; -const LoginAttemptGenerator = require('../utils/loginAttemptGenerator') - -const expect = chai.expect; -const type = 'resume'; -const resume = 'token'; -const testUser = new TestUser({ - username: 'resume-test', - password: 'resume-test' -}); - -module.exports = () => { - /** - * Those tests are covering the autologin attempt. - * The attempt is invoked by the core of the meteor accounts. - * Every time a previously logged in user reconnects to the system - * there is a "resume" attempt sent. - * - * This dependency did allow the user to decide during the login - * if he want to have the rememberMe flag set on true or false. - * This setting will have an importance of the decision being made during resume. - */ - describe('resume attempt', () => { - /** - * In case of an user logging in with rememberMe the resume - * attempt should be allowed. This covers a situation where user - * is being logged with rememberMe and then restarts the application. - * The user should stay logged in. - */ - it('should pass if user does have rememberMe', () => { - testUser.setLoginToken({ resume, rememberMe: true }); - const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - - /** - * In case of an user logging in without rememberMe the resume - * attempt should not be allowed. This covers a situation where user - * is being logged without rememberMe and then restarts the application. - * The user should be logged out. - */ - it('should not pass if user does not have rememberMe', () => { - testUser.setLoginToken({ resume, rememberMe: false }); - const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - 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'); - }); - - /** - * Important thing to keep in mind is that Meteor's login system does not know - * when the user is starting the app from the scratch or just lost the internet. - * It's not intended to logout an user without rememberMe every time he will lose - * the internet connection. - * - * To avoid this situation the user from now is sending also 'loggedAtLeastOnce: true' - * flag if he already logged once in ongoing device session. - */ - describe('connection loss', () => { - /** - * If the user already had a successfull login attempt during his device session - * then he should stay logged in no matter the rememberMe setting after the reconnect. - * Validates if user stays online with rememberMe being set to 'false'. - */ - it('should pass for the same session as previous login when without rememberMe', () => { - testUser.setLoginToken({ resume, rememberMe: false }); - const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); - loginAttemptGenerator.addMethodArgument({ loggedAtLeastOnce: true }); - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - - /** - * If the user already had a successfull login attempt during his device session - * then he should stay logged in no matter the rememberMe setting after the reconnect. - * Validates if user stays online with rememberMe being set to 'true'. - */ - it('should pass for the same session as previous login when with rememberMe', () => { - testUser.setLoginToken({ resume, rememberMe: true }); - const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); - loginAttemptGenerator.addMethodArgument({ loggedAtLeastOnce: true }); - const loginAttempt = loginAttemptGenerator.getLoginAttempt(); - const authenticator = new Authenticator(loginAttempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - expect(result).to.be.equal(true); - expect(resultCode).to.be.equal(0); - expect(reason).to.be.equal('Validation passed'); - }); - }); - }); -}; \ No newline at end of file +const TestUser = require('../utils/testUser'); +const Authenticator = require('../../../server/authenticator').default; +const LoginAttemptGenerator = require('../utils/loginAttemptGenerator'); +const chai = require('ultimate-chai'); + +const expect = chai.expect; +const type = 'resume'; +const resume = 'token'; +const testUser = new TestUser({ + username: 'resume-test', + password: 'resume-test', +}); + +module.exports = () => { + /** + * Those tests are covering the autologin attempt. + * The attempt is invoked by the core of the meteor accounts. + * Every time a previously logged in user reconnects to the system + * there is a "resume" attempt sent. + * + * This dependency did allow the user to decide during the login + * if he want to have the rememberMe flag set on true or false. + * This setting will have an importance of the decision being made during resume. + */ + describe('resume attempt', () => { + /** + * In case of an user logging in with rememberMe the resume + * attempt should be allowed. This covers a situation where user + * is being logged with rememberMe and then restarts the application. + * The user should stay logged in. + */ + it('should pass if user does have rememberMe', () => { + testUser.setLoginToken({ resume, rememberMe: true }); + const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + expect(result).to.be.equal(true); + expect(resultCode).to.be.equal(0); + expect(reason).to.be.equal('Validation passed'); + }); + + /** + * In case of an user logging in without rememberMe the resume + * attempt should not be allowed. This covers a situation where user + * is being logged without rememberMe and then restarts the application. + * The user should be logged out. + */ + it('should not pass if user does not have rememberMe', () => { + testUser.setLoginToken({ resume, rememberMe: false }); + const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + 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'); + }); + + /** + * Important thing to keep in mind is that Meteor's login system does not know + * when the user is starting the app from the scratch or just lost the internet. + * It's not intended to logout an user without rememberMe every time he will lose + * the internet connection. + * + * To avoid this situation the user from now is sending also 'loggedAtLeastOnce: true' + * flag if he already logged once in ongoing device session. + */ + describe('connection loss', () => { + /** + * If the user already had a successfull login attempt during his device session + * then he should stay logged in no matter the rememberMe setting after the reconnect. + * Validates if user stays online with rememberMe being set to 'false'. + */ + it('should pass for the same session as previous login when without rememberMe', () => { + testUser.setLoginToken({ resume, rememberMe: false }); + const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); + loginAttemptGenerator.addMethodArgument({ loggedAtLeastOnce: true }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + expect(result).to.be.equal(true); + expect(resultCode).to.be.equal(0); + expect(reason).to.be.equal('Validation passed'); + }); + + /** + * If the user already had a successfull login attempt during his device session + * then he should stay logged in no matter the rememberMe setting after the reconnect. + * Validates if user stays online with rememberMe being set to 'true'. + */ + it('should pass for the same session as previous login when with rememberMe', () => { + testUser.setLoginToken({ resume, rememberMe: true }); + const loginAttemptGenerator = new LoginAttemptGenerator({ type, resume }); + loginAttemptGenerator.addMethodArgument({ loggedAtLeastOnce: true }); + const loginAttempt = loginAttemptGenerator.getLoginAttempt(); + const authenticator = new Authenticator(loginAttempt); + const { result, resultCode, reason } = authenticator.validateAttempt(); + expect(result).to.be.equal(true); + expect(resultCode).to.be.equal(0); + expect(reason).to.be.equal('Validation passed'); + }); + }); + }); +}; diff --git a/test/server/utils/loginAttemptGenerator.js b/test/server/utils/loginAttemptGenerator.js index fa0a157..aa36a94 100644 --- a/test/server/utils/loginAttemptGenerator.js +++ b/test/server/utils/loginAttemptGenerator.js @@ -1,59 +1,59 @@ -/** - * 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 {*} param0 - */ - createAttempt({ type = 'password', allowed = true, resume }) { - this.loginAttempt = { - type, - allowed, - methodName: 'login', - methodArguments: [], - user: {}, - connection: {} - }; - - if (type === 'resume') { - this.loginAttempt.user = Meteor.users.findOne();; - } - - if (resume) { - this.loginAttempt.methodArguments.push({ resume }); - } - } - - /** - * 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; \ No newline at end of file +/** + * 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 {*} param0 + */ + createAttempt({ type = 'password', allowed = true, resume }) { + this.loginAttempt = { + type, + allowed, + methodName: 'login', + methodArguments: [], + user: {}, + connection: {}, + }; + + if (type === 'resume') { + this.loginAttempt.user = Meteor.users.findOne(); + } + + if (resume) { + this.loginAttempt.methodArguments.push({ resume }); + } + } + + /** + * 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 index 3ae37e8..fb7f2f2 100644 --- a/test/server/utils/testUser.js +++ b/test/server/utils/testUser.js @@ -1,72 +1,72 @@ -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'; - this.init(); - } - - /** - * Invoked at the class initialization. - */ - init() { - this.removeUser(); - this.createUser(); - } - - /** - * 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; \ No newline at end of file +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'; + this.init(); + } + + /** + * Invoked at the class initialization. + */ + init() { + this.removeUser(); + this.createUser(); + } + + /** + * 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;