diff --git a/packages/accounts-base/accounts_login_options_client_tests.js b/packages/accounts-base/accounts_login_options_client_tests.js new file mode 100644 index 00000000000..7d1e28cdd32 --- /dev/null +++ b/packages/accounts-base/accounts_login_options_client_tests.js @@ -0,0 +1,70 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +const LOGIN_TYPE = '__test'; + +Tinytest.addAsync( + 'accounts - login - successful attempt with options', + async test => { + // --SETUP-- + const testUserId = await Meteor.callAsync('registerTestLoginHandler'); + const validateResultHandler = new MockFunction(); + const onLoginHandler = new MockFunction(); + const onLoginHook = Accounts.onLogin(onLoginHandler); + + // --TEST-- + const result = await callLoginMethodAsync({ + methodArguments: [{ [LOGIN_TYPE]: { userId: testUserId } }], + validateResult: validateResultHandler, + }); + + // `result` is the `userCallback` result. + expectResult(test, result, testUserId); + + // Verify the `validateResult` parameter. + const validateResultCallsCount = validateResultHandler.mock.calls.length; + test.equal(validateResultCallsCount, 1); + + if (validateResultCallsCount === 1) { + const [result] = validateResultHandler.mock.calls[0]; + expectResult(test, result, testUserId); + } + + // Verify the `onLogin` parameter. + const onLoginHandlerCallsCount = onLoginHandler.mock.calls.length; + test.equal(onLoginHandlerCallsCount, 1); + + if (onLoginHandlerCallsCount === 1) { + const [result] = onLoginHandler.mock.calls[0]; + expectResult(test, result, testUserId); + } + + // --TEARDOWN-- + onLoginHook.stop(); + await Meteor.callAsync('removeTestLoginHandler', testUserId); + }, +); + +function callLoginMethodAsync({ methodArguments, validateResult }) { + return new Promise((resolve, reject) => { + Accounts.callLoginMethod({ + methodArguments, + validateResult(result) { + validateResult?.(result); + }, + userCallback(error, result) { + if (error) { + reject(error); + } else { + resolve(result); + } + }, + }); + }); +} + +function expectResult(test, result, userId) { + test.equal(result.type, LOGIN_TYPE); + test.equal(result.id, userId); + test.equal(result.foo, 'bar'); // comes from `options` +} diff --git a/packages/accounts-base/accounts_login_options_server_tests.js b/packages/accounts-base/accounts_login_options_server_tests.js new file mode 100644 index 00000000000..42b31079f62 --- /dev/null +++ b/packages/accounts-base/accounts_login_options_server_tests.js @@ -0,0 +1,200 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +const LOGIN_TYPE = '__test'; + +/** + * Registers a test log-in handler. + * @param {(object) => object} getResult A function that returns the desired results, given the options. + */ +export function registerTestLoginHandler(getResult) { + Accounts.registerLoginHandler(LOGIN_TYPE, options => { + if (!options[LOGIN_TYPE]) return; + return getResult(options[LOGIN_TYPE]); + }); +} + +/** + * Removes the test log-in handler. + */ +export function removeTestLoginHandler() { + Accounts._loginHandlers = Accounts._loginHandlers.filter(h => h.name !== LOGIN_TYPE); +} + +Tinytest.add( + 'accounts - login - successful attempt with options', + test => { + // --SETUP-- + const userId = Accounts.insertUserDoc({}); + registerTestLoginHandler(() => ({ + userId, + options: { foo: 'bar' }, + })); + + const hooks = registerLifecycleHooks(); + const { + validateLoginAttemptHandler, + onLoginHandler, + onLoginFailureHandler, + } = hooks; + + // --TEST-- + const conn = DDP.connect(Meteor.absoluteUrl()); + const result = conn.call('login', { [LOGIN_TYPE]: true }); + + test.equal(result.id, userId); + test.equal(result.type, LOGIN_TYPE); + test.equal(result.foo, 'bar'); + + expectHandlerCalledWithAttempt(test, validateLoginAttemptHandler, { allowed: true }) + expectHandlerCalledWithAttempt(test, onLoginHandler, { allowed: true }); + + test.length(onLoginFailureHandler.mock.calls, 0); + + // --TEARDOWN-- + conn.call('logout'); + conn.disconnect(); + + hooks.stop(); + + Meteor.users.remove(userId); + removeTestLoginHandler(); + }, +); + +Tinytest.add( + 'accounts - login - failed attempt with options and no user', + test => { + // --SETUP-- + registerTestLoginHandler(() => ({ + error: new Meteor.Error('log-in-error'), + options: { foo: 'bar' }, + })); + + const hooks = registerLifecycleHooks(); + const { + validateLoginAttemptHandler, + onLoginHandler, + onLoginFailureHandler, + } = hooks; + + // --TEST-- + const conn = DDP.connect(Meteor.absoluteUrl()); + test.throws( + () => conn.call('login', { [LOGIN_TYPE]: true }), + 'log-in-error', + ); + + expectHandlerCalledWithAttempt(test, validateLoginAttemptHandler, { allowed: false }); + expectHandlerCalledWithAttempt(test, onLoginFailureHandler, { allowed: false }); + + test.length(onLoginHandler.mock.calls, 0); + + // --TEARDOWN-- + conn.call('logout'); + conn.disconnect(); + + hooks.stop(); + + removeTestLoginHandler(); + }, +); + +Tinytest.add( + 'accounts - login - failed attempt with options and a user', + test => { + // --SETUP-- + const userId = Accounts.insertUserDoc({}); + registerTestLoginHandler(() => ({ + error: new Meteor.Error('log-in-error'), + userId, + options: { foo: 'bar' }, + })); + + const hooks = registerLifecycleHooks(); + const { + validateLoginAttemptHandler, + onLoginHandler, + onLoginFailureHandler, + } = hooks; + + // --TEST-- + const conn = DDP.connect(Meteor.absoluteUrl()); + test.throws( + () => conn.call('login', { [LOGIN_TYPE]: true }), + 'log-in-error', + ); + + expectHandlerCalledWithAttempt(test, validateLoginAttemptHandler, { allowed: false, hasUser: true }); + expectHandlerCalledWithAttempt(test, onLoginFailureHandler, { allowed: false, hasUser: true }); + + test.length(onLoginHandler.mock.calls, 0); + + // --TEARDOWN-- + conn.call('logout'); + conn.disconnect(); + + hooks.stop(); + + Meteor.users.remove(userId); + removeTestLoginHandler(); + }, +); + +function registerLifecycleHooks() { + const validateLoginAttemptHandler = new MockFunction(() => true); + const validateLoginAttemptHook = Accounts.validateLoginAttempt(validateLoginAttemptHandler); + + const onLoginHandler = new MockFunction(); + const onLoginHook = Accounts.onLogin(onLoginHandler); + + const onLoginFailureHandler = new MockFunction(); + const onLoginFailureHook = Accounts.onLoginFailure(onLoginFailureHandler); + + return { + validateLoginAttemptHandler, + onLoginHandler, + onLoginFailureHandler, + stop() { + validateLoginAttemptHook.stop(); + onLoginHook.stop(); + onLoginFailureHook.stop(); + }, + }; +} + + +function expectHandlerCalledWithAttempt(test, handler, { allowed, hasUser }) { + const callCount = handler.mock.calls.length; + test.equal(callCount, 1); + + if (callCount === 1) { + const [ attempt ] = handler.mock.calls[0]; + expectLoginAttempt(test, attempt, { allowed, hasUser }); + } +} + +function expectLoginAttempt(test, attempt, { allowed, hasUser = allowed }) { + test.isTrue(attempt); + + if (!attempt) return; + + test.equal(attempt.type, LOGIN_TYPE); + test.equal(attempt.allowed, allowed); + test.equal(attempt.methodName, 'login'); + + if (allowed) { + test.isFalse(attempt.error); + } else { + test.isTrue(attempt.error); + } + + if (hasUser) { + test.isTrue(attempt.user); + } else { + test.isFalse(attempt.user); + } + + test.isTrue(attempt.options); + test.equal(attempt.options.foo, 'bar'); +} diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index e23b02913cc..73305e635e3 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -463,6 +463,9 @@ export class AccountsServer extends AccountsCommon { if (result.error) { attempt.error = result.error; } + if (result.options) { + attempt.options = result.options; + } if (user) { attempt.user = user; } @@ -1818,4 +1821,3 @@ const generateCasePermutationsForString = string => { } return permutations; } - diff --git a/packages/accounts-base/accounts_tests_setup.js b/packages/accounts-base/accounts_tests_setup.js index bd79562fe0c..1ab599a60cf 100644 --- a/packages/accounts-base/accounts_tests_setup.js +++ b/packages/accounts-base/accounts_tests_setup.js @@ -1,3 +1,7 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { registerTestLoginHandler, removeTestLoginHandler } from './accounts_login_options_server_tests'; + const getTokenFromSecret = ({ selector, secret: secretParam }) => { let secret = secretParam; @@ -33,4 +37,18 @@ Meteor.methods({ return getTokenFromSecret({ selector, secret }); }, getTokenFromSecret, + // Helpers for `accounts_login_options_client_tests.js` + registerTestLoginHandler() { + registerTestLoginHandler(({ userId }) => ({ + userId, + options: { foo: 'bar' }, + })); + // Insert a test user so the client doesn't have to deal with it. + return Accounts.insertUserDoc({}); + }, + removeTestLoginHandler(userId) { + removeTestLoginHandler(); + // Remove the test user. + Meteor.users.remove(userId); + } }); diff --git a/packages/accounts-base/client_tests.js b/packages/accounts-base/client_tests.js index 79974638677..0039cc3607a 100644 --- a/packages/accounts-base/client_tests.js +++ b/packages/accounts-base/client_tests.js @@ -1,3 +1,4 @@ import "./accounts_url_tests.js"; import "./accounts_reconnect_tests.js"; import "./accounts_client_tests.js"; +import "./accounts_login_options_client_tests.js"; diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 43df32c1197..1a166322cf0 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: 'A user account system', - version: '2.2.10', + version: '2.2.11', }); Package.onUse(api => { diff --git a/packages/accounts-base/server_tests.js b/packages/accounts-base/server_tests.js index 71169a35af2..132fcc4cc81 100644 --- a/packages/accounts-base/server_tests.js +++ b/packages/accounts-base/server_tests.js @@ -1,2 +1,3 @@ import "./accounts_tests.js"; import "./accounts_reconnect_tests.js"; +import './accounts_login_options_server_tests.js'; diff --git a/packages/test-helpers/mock_function.js b/packages/test-helpers/mock_function.js new file mode 100644 index 00000000000..c5661f9218c --- /dev/null +++ b/packages/test-helpers/mock_function.js @@ -0,0 +1,23 @@ +/** + * A simple call-recording mock function. + * @type {MockFunction} + */ +MockFunction = class MockFunction { + calls = []; + + constructor(fn = () => {}) { + const self = this; + const mocked = function (...args) { + self.calls.push(args); + return fn.call(this, ...args); + }; + + mocked.mock = this; + + return mocked; + } + + reset() { + this.calls.length = 0; + } +} diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js index 399e768cbea..f45e2d11a66 100644 --- a/packages/test-helpers/package.js +++ b/packages/test-helpers/package.js @@ -24,13 +24,25 @@ Package.onUse(function (api) { api.export([ - 'pollUntil', 'try_all_permutations', - 'SeededRandom', 'clickElement', 'blurElement', - 'focusElement', 'simulateEvent', 'getStyleProperty', 'canonicalizeHtml', - 'renderToDiv', 'clickIt', - 'withCallbackLogger', 'testAsyncMulti', - 'simplePoll', 'runAndThrowIfNeeded', - 'makeTestConnection', 'DomUtils']); + 'blurElement', + 'canonicalizeHtml', + 'clickElement', + 'clickIt', + 'DomUtils', + 'focusElement', + 'getStyleProperty', + 'makeTestConnection', + 'MockFunction', + 'pollUntil', + 'renderToDiv', + 'runAndThrowIfNeeded', + 'SeededRandom', + 'simplePoll', + 'simulateEvent', + 'testAsyncMulti', + 'try_all_permutations', + 'withCallbackLogger', + ]); api.addFiles('try_all_permutations.js'); api.addFiles('async_multi.js'); @@ -40,6 +52,7 @@ Package.onUse(function (api) { api.addFiles('render_div.js'); api.addFiles('current_style.js'); api.addFiles('callback_logger.js'); + api.addFiles('mock_function.js'); api.addFiles('domutils.js', 'client'); api.addFiles('connection.js', 'server'); });