diff --git a/.gitignore b/.gitignore index 723ef36..bcebb00 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.idea \ No newline at end of file +.idea +.npm +.vscode +node_modules \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c35c5de --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +.idea +.npm +.git +.vscode +node_modules +test \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 061b5ca..8a78128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,7 @@ ## [0.1.0] - 19.01.2018 * Initial release + +## [0.1.1] - 27.01.2018 +* Print correct error in case of already dissallowed attempt +* Add server side tests \ No newline at end of file diff --git a/README.md b/README.md index a31def9..a757f92 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,6 @@ 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. +## Testing -## TODO: -- support multi server accounts -- tests +You can test this dependency by running `npm run test` \ No newline at end of file diff --git a/client/index.js b/client/index.js index f9ec70f..1f5dbdc 100644 --- a/client/index.js +++ b/client/index.js @@ -2,8 +2,8 @@ import { Accounts } from 'meteor/accounts-base'; import overrideAccountsLogin from './overrideLogin'; const RememberMe = {}; - const updateRememberMe = 'tprzytula:rememberMe-update'; + RememberMe.loginWithPassword = (user, password, callback = () => {}, rememberMe = true) => { let flag = rememberMe; let callbackMethod = () => {}; diff --git a/package.js b/package.js index 3ee3848..d8c97a2 100644 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'tprzytula:remember-me', - version: '0.1.0', + version: '0.1.1', summary: 'Extension for Meteor account-base package with the implementation of rememberMe', git: 'https://github.com/tprzytulacc/Meteor-RememberMe', documentation: 'README.md' @@ -10,10 +10,22 @@ Package.onUse((api) => { api.versionsFrom('1.5.2.2'); api.use('ecmascript'); api.use('accounts-base'); + api.use('accounts-password'); api.mainModule('client/index.js', 'client'); api.mainModule('server/index.js', 'server'); }); +Package.onTest((api) => { + api.use('ecmascript'); + api.use('accounts-base'); + api.use('accounts-password'); + api.use('coffeescript'); + api.use('meteortesting:mocha'); + api.use('tprzytula:remember-me'); + api.use('practicalmeteor:chai'); + api.mainModule('test/server/index.js', 'server'); +}); + Npm.depends({ 'crypto-js': '3.1.9-1', lodash: '4.17.4' diff --git a/package.json b/package.json index e433454..e00c1ba 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,21 @@ { - "name": "tprzytula:remember-me", - "version": "0.1.0", - "description": "Extension for Meteor account-base package with the implementation of rememberMe", - "license": "MIT", - "author": "Tomasz Przytuła ", - "repository": { - "type": "git", - "url": "https://github.com/tprzytulacc/Meteor-Remember-Me" - }, - "keywords": [ - "meteor", - "rememberme", - "autologin", - "login" - ], - "homepage": "https://github.com/tprzytulacc/Meteor-Remember-Me" -} \ No newline at end of file + "name": "rememberMe", + "version": "0.1.1", + "description": "Extension for Meteor account-base package with the implementation of rememberMe", + "license": "MIT", + "author": "Tomasz Przytuła ", + "repository": { + "type": "git", + "url": "https://github.com/tprzytulacc/Meteor-Remember-Me" + }, + "keywords": [ + "meteor", + "rememberme", + "autologin", + "login" + ], + "homepage": "https://github.com/tprzytulacc/Meteor-Remember-Me", + "scripts": { + "test": "meteor test-packages ./ --driver-package meteortesting:mocha" + } +} diff --git a/server/authenticator.js b/server/authenticator.js index 13cb4b9..a4fce25 100644 --- a/server/authenticator.js +++ b/server/authenticator.js @@ -37,7 +37,8 @@ class Authenticator { return { result: false, resultCode: -1, - reason: 'Attempt disallowed by Meteor' + reason: 'Attempt disallowed by Meteor', + error: this.loginAttempt.error }; } diff --git a/server/index.js b/server/index.js index 473f1d8..c43a3aa 100644 --- a/server/index.js +++ b/server/index.js @@ -14,8 +14,10 @@ export const activate = () => { methods(); Accounts.validateLoginAttempt((attempt) => { const authenticator = new Authenticator(attempt); - const { result, resultCode, reason } = authenticator.validateAttempt(); - if (!result) { + const { result, resultCode, reason, error } = authenticator.validateAttempt(); + if (error) { + throw error; + } else if (!result) { throw new Meteor.Error(resultCode, reason); } return true; @@ -32,6 +34,9 @@ export const activate = () => { 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; diff --git a/test/server/index.js b/test/server/index.js new file mode 100644 index 0000000..988d654 --- /dev/null +++ b/test/server/index.js @@ -0,0 +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 diff --git a/test/server/tests/login.js b/test/server/tests/login.js new file mode 100644 index 0000000..5c62203 --- /dev/null +++ b/test/server/tests/login.js @@ -0,0 +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 diff --git a/test/server/tests/method.js b/test/server/tests/method.js new file mode 100644 index 0000000..dc2c0a9 --- /dev/null +++ b/test/server/tests/method.js @@ -0,0 +1,39 @@ +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 diff --git a/test/server/tests/resume.js b/test/server/tests/resume.js new file mode 100644 index 0000000..c0a0ec6 --- /dev/null +++ b/test/server/tests/resume.js @@ -0,0 +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 diff --git a/test/server/utils/loginAttemptGenerator.js b/test/server/utils/loginAttemptGenerator.js new file mode 100644 index 0000000..fa0a157 --- /dev/null +++ b/test/server/utils/loginAttemptGenerator.js @@ -0,0 +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 diff --git a/test/server/utils/testUser.js b/test/server/utils/testUser.js new file mode 100644 index 0000000..3ae37e8 --- /dev/null +++ b/test/server/utils/testUser.js @@ -0,0 +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