diff --git a/.babelrc b/.babelrc index 02f08fb6..cf59a3a3 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,16 @@ { "presets": [ "es2015" - ] -} \ No newline at end of file + ], + "only": [ + "src" + ], + "env": { + "nyc": { + "sourceMaps": "inline", + "plugins": [ + "istanbul" + ] + } + } +} diff --git a/.gitignore b/.gitignore index b4a9ef9b..e0ce9776 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -/node_modules/ +node_modules/ npm-debug.log /docs/ + +/.nyc_output/ +/coverage/ + diff --git a/package.json b/package.json index ed9aa68a..f464e232 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,11 @@ "scripts": { "build": "webpack -p", "doc": "jsdoc src -d docs", - "lint": "eslint src" + "lint": "eslint src", + "test": "ava", + "coverage": "cross-env BABEL_ENV=nyc nyc --reporter=text --reporter=lcov ava", + "coverage:test": "cross-env BABEL_ENV=nyc nyc ava", + "coverage:report": "nyc report --reporter=lcov" }, "main": "dist/amazon-cognito-identity.min.js", "dependencies": { @@ -41,14 +45,37 @@ "sjcl": "^1.0.3" }, "devDependencies": { + "ava": "^0.17.0", "babel-core": "^6.13.2", "babel-loader": "^6.2.4", + "babel-plugin-istanbul": "^3.0.0", "babel-preset-es2015": "^6.13.2", + "babel-register": "^6.14.0", + "cross-env": "^2.0.1", "eslint": "^3.3.1", "eslint-config-airbnb-base": "^5.0.2", "eslint-import-resolver-webpack": "^0.5.1", "eslint-plugin-import": "^1.13.0", "jsdoc": "^3.4.0", + "mock-require": "^1.3.0", + "nyc": "^10.0.0", + "sinon": "^1.17.5", "webpack": "^1.13.1" + }, + "ava": { + "require": [ + "babel-register" + ], + "timeout": "30s" + }, + "nyc": { + "all": true, + "cache": true, + "include": "src", + "require": [ + "babel-register" + ], + "sourceMap": false, + "instrument": false } } diff --git a/src/CognitoUser.authenticateUser.test.js b/src/CognitoUser.authenticateUser.test.js new file mode 100644 index 00000000..bc3b5110 --- /dev/null +++ b/src/CognitoUser.authenticateUser.test.js @@ -0,0 +1,1002 @@ +/* eslint-disable require-jsdoc */ + +import test from 'ava'; +import { stub } from 'sinon'; +import { BigInteger } from 'jsbn'; +import { codec } from 'sjcl'; + +import { + MockClient, + requireDefaultWithModuleMocks, + requestSucceedsWith, + requestFailsWith, + createCallback, + title, +} from './_helpers.test'; + +function hexToBase64(hex) { + return codec.base64.fromBits(codec.hex.toBits(hex)); +} + +const UserPoolId = 'xx-nowhere1_SomeUserPool'; // Constructor validates the format. +const ClientId = 'some-client-id'; +const constructorUsername = 'constructor-username'; +const aliasUsername = 'initiateAuthResponse-username'; +const Password = 'swordfish'; +const IdToken = 'some-id-token'; +const RefreshToken = 'some-refresh-token'; +const AccessToken = 'some-access-token'; +const SrpLargeAHex = '1a'.repeat(32); +const SaltDevicesHex = '5d'.repeat(32); +const SaltDevicesBase64 = hexToBase64(SaltDevicesHex); +const VerifierDevicesHex = 'ed'.repeat(32); +const VerifierDevicesBase64 = hexToBase64(VerifierDevicesHex); +const RandomPasswordHex = 'a0'.repeat(32); +const ValidationData = [ + { Name: 'some-name-1', Value: 'some-value-1' }, + { Name: 'some-name-2', Value: 'some-value-2' }, +]; + +const dateNow = 'Wed Sep 21 07:36:54 UTC 2016'; + +const keyPrefix = `CognitoIdentityServiceProvider.${ClientId}`; +const idTokenKey = `${keyPrefix}.${aliasUsername}.idToken`; +const accessTokenKey = `${keyPrefix}.${aliasUsername}.accessToken`; +const refreshTokenKey = `${keyPrefix}.${aliasUsername}.refreshToken`; +const lastAuthUserKey = `${keyPrefix}.LastAuthUser`; +const deviceKeyKey = `${keyPrefix}.${aliasUsername}.deviceKey`; +const randomPasswordKey = `${keyPrefix}.${aliasUsername}.randomPasswordKey`; +const deviceGroupKeyKey = `${keyPrefix}.${aliasUsername}.deviceGroupKey`; + +const oldDeviceKey = 'old-deviceKey'; + +const cachedDeviceKey = 'cached-deviceKey'; +const cachedRandomPassword = 'cached-randomPassword'; +const cachedDeviceGroupKey = 'cached-deviceGroup'; + +const newDeviceKey = 'new-deviceKey'; +const newDeviceGroupKey = 'new-deviceGroup'; +const deviceName = 'some-device-name'; + +function createExpectedInitiateAuthArgs({ + AuthFlow = 'USER_SRP_AUTH', + extraAuthParameters, +} = {}) { + const args = { + AuthFlow, + ClientId, + AuthParameters: { + USERNAME: constructorUsername, + SRP_A: SrpLargeAHex, + }, + ClientMetadata: ValidationData, + }; + + if (extraAuthParameters) { + Object.assign(args.AuthParameters, extraAuthParameters); + } + + return args; +} + +const initiateAuthResponse = { + ChallengeParameters: { + USER_ID_FOR_SRP: aliasUsername, + SRP_B: 'cb'.repeat(16), + SALT: 'a7'.repeat(16), + SECRET_BLOCK: '0c'.repeat(16), + }, + Session: 'initiateAuth-session', +}; + +function createSrpChallengeResponses(extra) { + const result = { + USERNAME: aliasUsername, + PASSWORD_CLAIM_SECRET_BLOCK: initiateAuthResponse.ChallengeParameters.SECRET_BLOCK, + TIMESTAMP: dateNow, + PASSWORD_CLAIM_SIGNATURE: 'duUyEsqUdolAO+/KMVp9lS/sxTozKH6rNZ2HlWnfLp4=', + }; + + if (extra) { + Object.assign(result, extra); + } + + return result; +} + +function createExpectedRespondToAuthChallengePasswordVerifierArgs( + { extraChallengeResponses } = {} +) { + return { + ChallengeName: 'PASSWORD_VERIFIER', + ClientId, + ChallengeResponses: createSrpChallengeResponses(extraChallengeResponses), + Session: initiateAuthResponse.Session, + }; +} + +function createRespondToAuthChallengeResponseForSuccess({ hasNewDevice } = {}) { + const response = { + AuthenticationResult: { IdToken, AccessToken, RefreshToken }, + }; + + if (hasNewDevice) { + response.AuthenticationResult.NewDeviceMetadata = { + DeviceGroupKey: newDeviceGroupKey, + DeviceKey: newDeviceKey, + }; + } + + return response; +} + +function createRespondToAuthChallengeResponseForChallenge(challengeName) { + return { + ChallengeName: challengeName, + Session: `respondToAuthChallenge-${challengeName}-session`, + }; +} + +function createRespondToAuthChallengeResponseForCustomChallenge() { + return Object.assign( + createRespondToAuthChallengeResponseForChallenge('CUSTOM_CHALLENGE'), + { + ChallengeParameters: { + Name: 'some-custom-challenge-parameter', + }, + } + ); +} + +function assertHasSetSignInSession(t, user) { + const userSession = user.getSignInUserSession(); + t.is(userSession.getIdToken().getJwtToken(), IdToken); + t.is(userSession.getAccessToken().getJwtToken(), AccessToken); + t.is(userSession.getRefreshToken().getToken(), RefreshToken); +} + +function assertHasDeviceState(t, user, { hasOldDevice, hasCachedDevice, hasNewDevice }) { + let expectedDeviceKey; + let expectedRandomPassword; + let expectedDeviceGroupKey; + + if (hasOldDevice) { + expectedDeviceKey = oldDeviceKey; + } + + if (hasCachedDevice) { + expectedDeviceKey = cachedDeviceKey; + expectedRandomPassword = cachedRandomPassword; + expectedDeviceGroupKey = cachedDeviceGroupKey; + } + + if (hasNewDevice) { + expectedDeviceKey = newDeviceKey; + expectedRandomPassword = RandomPasswordHex; + expectedDeviceGroupKey = newDeviceGroupKey; + } + + // FIXME: AuthenticationHelper.getVerifierDevices() returns hex, but CognitoUser expects sjcl bits + t.skip.is(user.verifierDevices, VerifierDevicesBase64); + t.is(user.deviceGroupKey, expectedDeviceGroupKey); + t.is(user.randomPassword, expectedRandomPassword); + t.is(user.deviceKey, expectedDeviceKey); +} + +function assertDidCacheTokens(t, localStorage) { + t.is(localStorage.setItem.withArgs(idTokenKey).callCount, 1); + t.is(localStorage.setItem.withArgs(accessTokenKey).callCount, 1); + t.is(localStorage.setItem.withArgs(refreshTokenKey).callCount, 1); + t.is(localStorage.setItem.withArgs(lastAuthUserKey).callCount, 1); + t.is(localStorage.setItem.withArgs(idTokenKey).args[0][1], IdToken); + t.is(localStorage.setItem.withArgs(accessTokenKey).args[0][1], AccessToken); + t.is(localStorage.setItem.withArgs(refreshTokenKey).args[0][1], RefreshToken); + t.is(localStorage.setItem.withArgs(lastAuthUserKey).args[0][1], aliasUsername); +} + +function assertDidCacheDeviceKeyAndPassword(t, localStorage) { + t.is(localStorage.setItem.withArgs(deviceKeyKey, newDeviceKey).callCount, 1); + t.is(localStorage.setItem.withArgs(randomPasswordKey, RandomPasswordHex).callCount, 1); + t.is(localStorage.setItem.withArgs(deviceGroupKeyKey, newDeviceGroupKey).callCount, 1); +} + +function assertDidNotCacheDeviceKeyAndPassword(t, localStorage) { + t.is(localStorage.setItem.withArgs(deviceKeyKey).callCount, 0); + t.is(localStorage.setItem.withArgs(randomPasswordKey).callCount, 0); + t.is(localStorage.setItem.withArgs(deviceGroupKeyKey).callCount, 0); +} + +class MockUserPool { + constructor() { + this.client = new MockClient(); + } + + getUserPoolId() { + return UserPoolId; + } + + getClientId() { + return ClientId; + } + + getParanoia() { + return 0; + } + + toJSON() { + return '[mock UserPool]'; + } +} + +class MockAuthenticationHelper { + getLargeAValue() { + return new BigInteger(SrpLargeAHex, 16); + } + + getPasswordAuthenticationKey() { + return codec.hex.toBits('a4'.repeat(32)); + } + + generateHashDevice() { + // Can't test this nicely as the instance is local to authenticateUser() + } + + getSaltDevices() { + return SaltDevicesHex; + } + + getVerifierDevices() { + return VerifierDevicesHex; + } + + getRandomPassword() { + return RandomPasswordHex; + } +} + +class MockDateHelper { + getNowString() { + return dateNow; + } +} + +class MockAuthenticationDetails { + getPassword() { + return Password; + } + + getValidationData() { + return ValidationData; + } +} + +class MockResultBase { + constructor(...params) { + this.params = params; + } +} + +class MockCognitoTokenBase extends MockResultBase {} +class MockCognitoAccessToken extends MockCognitoTokenBase { + getJwtToken() { + return this.params[0].AccessToken; + } +} +class MockCognitoIdToken extends MockCognitoTokenBase { + getJwtToken() { + return this.params[0].IdToken; + } +} +class MockCognitoRefreshToken extends MockCognitoTokenBase { + getToken() { + return this.params[0].RefreshToken; + } +} +class MockCognitoUserSession extends MockResultBase { + getAccessToken() { + return this.params[0].AccessToken; + } + + getIdToken() { + return this.params[0].IdToken; + } + + getRefreshToken() { + return this.params[0].RefreshToken; + } +} + +function createUser({ pool = new MockUserPool() } = {}, ...requestConfigs) { + pool.client = new MockClient(...requestConfigs); // eslint-disable-line no-param-reassign + const CognitoUser = requireDefaultWithModuleMocks('./CognitoUser', { + './AuthenticationHelper': MockAuthenticationHelper, + './CognitoAccessToken': MockCognitoAccessToken, + './CognitoIdToken': MockCognitoIdToken, + './CognitoRefreshToken': MockCognitoRefreshToken, + './CognitoUserSession': MockCognitoUserSession, + './DateHelper': MockDateHelper, + }); + return new CognitoUser({ Username: constructorUsername, Pool: pool }); +} + +test.cb('fails on initiateAuth => raises onFailure', t => { + const expectedError = { code: 'InternalServerError' }; + + const user = createUser({}, requestFailsWith(expectedError)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + }, + })); +}); + +// .serial for global.window stompage +test.serial.cb('fails on respondToAuthChallenge => raises onFailure', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedError = { code: 'InternalServerError' }; + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestFailsWith(expectedError)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + }, + })); +}); + +test.serial.cb('with new device state, fails on confirmDevice => raises onFailure', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + global.navigator = { userAgent: deviceName }; + const expectedError = { code: 'InternalServerError' }; + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(createRespondToAuthChallengeResponseForSuccess({ hasNewDevice: true })), + requestFailsWith(expectedError)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + }, + })); +}); + +function deviceStateSuccessMacro( + t, + { hasOldDevice, hasCachedDevice, hasNewDevice, userConfirmationNecessary } +) { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + let extraExpectedChallengeResponses; + + if (hasOldDevice) { + extraExpectedChallengeResponses = { + DEVICE_KEY: oldDeviceKey, + }; + } + + if (hasCachedDevice) { + localStorage.getItem.withArgs(deviceKeyKey).returns(cachedDeviceKey); + localStorage.getItem.withArgs(randomPasswordKey).returns(cachedRandomPassword); + localStorage.getItem.withArgs(deviceGroupKeyKey).returns(cachedDeviceGroupKey); + + extraExpectedChallengeResponses = { + DEVICE_KEY: cachedDeviceKey, + }; + } + + if (hasNewDevice) { + global.navigator = { userAgent: deviceName }; + } + + const expectedInitiateAuthArgs = createExpectedInitiateAuthArgs({ + extraAuthParameters: hasOldDevice ? { DEVICE_KEY: oldDeviceKey } : {}, + }); + + const expectedRespondToAuthChallengeArgs = + createExpectedRespondToAuthChallengePasswordVerifierArgs({ + extraChallengeResponses: extraExpectedChallengeResponses, + }); + + const expectedConfirmDeviceArgs = { + DeviceKey: newDeviceKey, + AccessToken, + DeviceSecretVerifierConfig: { + Salt: SaltDevicesBase64, + PasswordVerifier: VerifierDevicesBase64, + }, + DeviceName: deviceName, + }; + + const requests = [ + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(createRespondToAuthChallengeResponseForSuccess({ hasNewDevice })), + ]; + + if (hasNewDevice) { + requests.push( + requestSucceedsWith({ + UserConfirmationNecessary: userConfirmationNecessary, + }) + ); + } + + const user = createUser({}, ...requests); + + if (hasOldDevice) { + user.deviceKey = oldDeviceKey; + } + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onSuccess(signInUserSessionArg, userConfirmationNecessaryArg) { + t.is(signInUserSessionArg, user.signInUserSession); + if (userConfirmationNecessary) { + t.true(userConfirmationNecessaryArg); + } else { + t.falsy(userConfirmationNecessaryArg); + } + + // check client requests (expanded due to assert string depth limit) + t.is(user.client.requestCallCount, hasNewDevice ? 3 : 2); + t.is(user.client.getRequestCallArgs(0).name, 'initiateAuth'); + t.deepEqual(user.client.getRequestCallArgs(0).args, expectedInitiateAuthArgs); + t.is(user.client.getRequestCallArgs(1).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(1).args, expectedRespondToAuthChallengeArgs); + if (hasNewDevice) { + t.is(user.client.getRequestCallArgs(2).name, 'confirmDevice'); + t.deepEqual(user.client.getRequestCallArgs(2).args, expectedConfirmDeviceArgs); + } + + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + + assertHasDeviceState(t, user, { hasOldDevice, hasCachedDevice, hasNewDevice }); + + assertHasSetSignInSession(t, user); + assertDidCacheTokens(t, localStorage); + + if (hasNewDevice) { + assertDidCacheDeviceKeyAndPassword(t, localStorage); + } else { + assertDidNotCacheDeviceKeyAndPassword(t, localStorage); + } + }, + })); +} +deviceStateSuccessMacro.title = (_, context) => ( + title(null, { context, outcome: 'creates session' }) +); + +for (const hasOldDevice of [false, true]) { + for (const hasCachedDevice of [false, true]) { + for (const hasNewDevice of [false, true]) { + test.serial.cb(deviceStateSuccessMacro, { hasOldDevice, hasCachedDevice, hasNewDevice }); + } + } +} + +test.serial.cb(deviceStateSuccessMacro, { hasNewDevice: true, userConfirmationNecessary: true }); + +test.serial.cb('CUSTOM_AUTH flow, CUSTOM_CHALLENGE challenge => raises customChallenge', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedInitiateAuthArgs = createExpectedInitiateAuthArgs({ + AuthFlow: 'CUSTOM_AUTH', + extraAuthParameters: { + CHALLENGE_NAME: 'SRP_A', + }, + }); + + const expectedRespondToAuthChallengeArgs = + createExpectedRespondToAuthChallengePasswordVerifierArgs(); + + const respondToAuthChallengeResponse = + createRespondToAuthChallengeResponseForCustomChallenge(); + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(respondToAuthChallengeResponse)); + + user.setAuthenticationFlowType('CUSTOM_AUTH'); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + customChallenge(parameters) { + t.deepEqual(parameters, respondToAuthChallengeResponse.ChallengeParameters); + + // check client requests (expanded due to assert string depth limit) + t.is(user.client.requestCallCount, 2); + t.is(user.client.getRequestCallArgs(0).name, 'initiateAuth'); + t.deepEqual(user.client.getRequestCallArgs(0).args, expectedInitiateAuthArgs); + t.is(user.client.getRequestCallArgs(1).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(1).args, expectedRespondToAuthChallengeArgs); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, respondToAuthChallengeResponse.Session); + }, + })); +}); + +test.serial.cb('SMS_MFA challenge => raises mfaRequired', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedInitiateAuthArgs = createExpectedInitiateAuthArgs(); + + const expectedRespondToAuthChallengeArgs = + createExpectedRespondToAuthChallengePasswordVerifierArgs(); + + const respondToAuthChallengeResponse = + createRespondToAuthChallengeResponseForChallenge('SMS_MFA'); + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(respondToAuthChallengeResponse)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + mfaRequired() { + // check client requests (expanded due to assert string depth limit) + t.is(user.client.requestCallCount, 2); + t.is(user.client.getRequestCallArgs(0).name, 'initiateAuth'); + t.deepEqual(user.client.getRequestCallArgs(0).args, expectedInitiateAuthArgs); + t.is(user.client.getRequestCallArgs(1).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(1).args, expectedRespondToAuthChallengeArgs); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, respondToAuthChallengeResponse.Session); + }, + })); +}); + +test.serial.cb('DEVICE_SRP_AUTH challenge, fails on DEVICE_SRP_AUTH => raises onFailure', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedError = { code: 'InternalServerError' }; + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(createRespondToAuthChallengeResponseForChallenge('DEVICE_SRP_AUTH')), + requestFailsWith(expectedError)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + }, + })); +}); + +test.serial.cb( + 'DEVICE_SRP_AUTH challenge, fails on DEVICE_PASSWORD_VERIFIER fails => raises onFailure', + t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedError = { code: 'InternalServerError' }; + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(createRespondToAuthChallengeResponseForChallenge('DEVICE_SRP_AUTH')), + requestSucceedsWith({ + ChallengeParameters: initiateAuthResponse.ChallengeParameters, + }), + requestFailsWith(expectedError)); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + }, + })); + }); + +test.serial.cb('DEVICE_SRP_AUTH challenge, succeeds => creates session', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + // The PASSWORD_CLAIM_SIGNATURE depends on these values + const deviceGroupKey = 'cached-deviceGroupKey'; + const randomPassword = 'cached-randomPassword'; + const deviceKey = 'cached-deviceKey'; + + localStorage.getItem.withArgs(deviceKeyKey).returns(deviceKey); + localStorage.getItem.withArgs(randomPasswordKey).returns(randomPassword); + localStorage.getItem.withArgs(deviceGroupKeyKey).returns(deviceGroupKey); + + const expectedInitiateAuthArgs = createExpectedInitiateAuthArgs(); + + const expectedRespondToAuthChallengePasswordVerifierArgs = + createExpectedRespondToAuthChallengePasswordVerifierArgs({ + extraChallengeResponses: { + DEVICE_KEY: deviceKey, + }, + }); + + const respondToAuthChallengePasswordVerifierResponse = + createRespondToAuthChallengeResponseForChallenge('DEVICE_SRP_AUTH'); + + // FIXME: should be using a separate set of AuthenticationHelper mock results. + const expectedRespondToAuthChallengeDeviceSrpAuthArgs = { + ChallengeName: 'DEVICE_SRP_AUTH', + ClientId, + ChallengeResponses: { + USERNAME: aliasUsername, + DEVICE_KEY: deviceKey, + SRP_A: SrpLargeAHex, + }, + }; + + const respondToAuthChallengeDeviceSrpAuthResponse = { + ChallengeParameters: initiateAuthResponse.ChallengeParameters, + Session: 'respondToAuthChallenge-DEVICE_SRP_AUTH-session', + }; + + const expectedRespondToAuthChallengeDevicePasswordVerifierArgs = { + ChallengeName: 'DEVICE_PASSWORD_VERIFIER', + ClientId, + ChallengeResponses: createSrpChallengeResponses({ + PASSWORD_CLAIM_SIGNATURE: 'ZkW+a3yZRihjvIXY0pKfKzIozqXvsw/2LaOXGDN3vo8=', + DEVICE_KEY: deviceKey, + }), + Session: respondToAuthChallengeDeviceSrpAuthResponse.Session, + }; + + const user = createUser( + {}, + requestSucceedsWith(initiateAuthResponse), + requestSucceedsWith(respondToAuthChallengePasswordVerifierResponse), + requestSucceedsWith(respondToAuthChallengeDeviceSrpAuthResponse), + requestSucceedsWith(createRespondToAuthChallengeResponseForSuccess()) + ); + + user.authenticateUser( + new MockAuthenticationDetails(), + createCallback(t, t.end, { + onSuccess() { + // check client requests (expanded due to assert string depth limit) + t.is(user.client.requestCallCount, 4); + t.is(user.client.getRequestCallArgs(0).name, 'initiateAuth'); + t.deepEqual(user.client.getRequestCallArgs(0).args, expectedInitiateAuthArgs); + t.is(user.client.getRequestCallArgs(1).name, 'respondToAuthChallenge'); + t.deepEqual( + user.client.getRequestCallArgs(1).args, + expectedRespondToAuthChallengePasswordVerifierArgs + ); + t.is(user.client.getRequestCallArgs(2).name, 'respondToAuthChallenge'); + t.deepEqual( + user.client.getRequestCallArgs(2).args, + expectedRespondToAuthChallengeDeviceSrpAuthArgs + ); + t.is(user.client.getRequestCallArgs(3).name, 'respondToAuthChallenge'); + t.deepEqual( + user.client.getRequestCallArgs(3).args, + expectedRespondToAuthChallengeDevicePasswordVerifierArgs + ); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, null); + t.is(user.deviceKey, deviceKey); + t.is(user.randomPassword, randomPassword); + t.is(user.deviceGroupKey, deviceGroupKey); + + assertHasSetSignInSession(t, user); + assertDidCacheTokens(t, localStorage); + assertDidNotCacheDeviceKeyAndPassword(t, localStorage); + }, + })); +}); + +test.cb('sendCustomChallengeAnswer() :: fails => raises onFailure', t => { + const expectedError = { code: 'InternalServerError' }; + const previousChallengeSession = 'previous-challenge-session'; + + const user = createUser({}, requestFailsWith(expectedError)); + user.Session = previousChallengeSession; + + const answerChallenge = 'some-answer-challenge'; + user.sendCustomChallengeAnswer(answerChallenge, createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + + t.is(user.client.requestCallCount, 1); + t.is(user.client.getRequestCallArgs(0).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(0).args, { + ChallengeName: 'CUSTOM_CHALLENGE', + ChallengeResponses: { + USERNAME: constructorUsername, + ANSWER: answerChallenge, + }, + ClientId, + Session: previousChallengeSession, + }); + + t.is(user.getUsername(), constructorUsername); + t.is(user.Session, previousChallengeSession); + }, + })); +}); + +test.cb( + 'sendCustomChallengeAnswer() :: CUSTOM_CHALLENGE challenge => raises customChallenge', + t => { + const respondToAuthChallengeResponse = + createRespondToAuthChallengeResponseForCustomChallenge(); + const previousChallengeSession = 'previous-challenge-session'; + + const user = createUser({}, requestSucceedsWith(respondToAuthChallengeResponse)); + user.Session = previousChallengeSession; + + const answerChallenge = 'some-answer-challenge'; + user.sendCustomChallengeAnswer(answerChallenge, createCallback(t, t.end, { + customChallenge(challengeParameters) { + // Looks like a bug: Uses response `challengeParameters` not `ChallengeParameters` like + // authenticateUser() does. + t.skip.is(challengeParameters, respondToAuthChallengeResponse.ChallengeParameters); + + t.is(user.client.requestCallCount, 1); + t.is(user.client.getRequestCallArgs(0).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(0).args, { + ChallengeName: 'CUSTOM_CHALLENGE', + ChallengeResponses: { + USERNAME: constructorUsername, + ANSWER: answerChallenge, + }, + ClientId, + Session: previousChallengeSession, + }); + + t.is(user.getUsername(), constructorUsername); + t.is(user.Session, respondToAuthChallengeResponse.Session); + }, + })); + }); + +test.serial.cb('sendCustomChallengeAnswer() :: succeeds => creates session', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + const respondToAuthChallengeResponse = + createRespondToAuthChallengeResponseForSuccess(); + + const previousChallengeSession = 'previous-challenge-session'; + + const user = createUser({}, requestSucceedsWith(respondToAuthChallengeResponse)); + user.username = aliasUsername; // assertDidCacheTokens() expects this + user.Session = previousChallengeSession; + + const answerChallenge = 'some-answer-challenge'; + + user.sendCustomChallengeAnswer(answerChallenge, createCallback(t, t.end, { + onSuccess(signInSessionArg) { + t.is(signInSessionArg, user.getSignInUserSession()); + + // check client requests (expanded due to assert string depth limit) + t.is(user.client.requestCallCount, 1); + t.is(user.client.getRequestCallArgs(0).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(0).args, { + ChallengeName: 'CUSTOM_CHALLENGE', + ChallengeResponses: { + USERNAME: aliasUsername, + ANSWER: answerChallenge, + }, + ClientId, + Session: previousChallengeSession, + }); + + // Check user state + t.is(user.getUsername(), aliasUsername); + t.skip.is(user.Session, null); // FIXME: should be clearing Session like authenticateUser() + + assertHasSetSignInSession(t, user); + assertDidCacheTokens(t, localStorage); + assertDidNotCacheDeviceKeyAndPassword(t, localStorage); + }, + })); +}); + +test.cb('sendMFACode() :: fails on respondToAuthChallenge => raises onFailure', t => { + const expectedError = { code: 'InternalServerError' }; + const previousChallengeSession = 'previous-challenge-session'; + + const user = createUser({}, requestFailsWith(expectedError)); + user.Session = previousChallengeSession; + + const confirmationCode = 'some-confirmation-code'; + user.sendMFACode(confirmationCode, createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + + t.is(user.getUsername(), constructorUsername); + t.is(user.Session, previousChallengeSession); + }, + })); +}); + +test.serial.cb('sendMFACode() :: fails on confirmDevice => raises onFailure', t => { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const expectedError = { code: 'InternalServerError' }; + + const respondToAuthChallengeResponse = createRespondToAuthChallengeResponseForSuccess({ + hasNewDevice: true, + }); + + const previousChallengeSession = 'previous-challenge-session'; + + const user = createUser( + {}, + requestSucceedsWith(respondToAuthChallengeResponse), + requestFailsWith(expectedError)); + user.Session = previousChallengeSession; + + const confirmationCode = 'some-confirmation-code'; + user.sendMFACode(confirmationCode, createCallback(t, t.end, { + onFailure(err) { + t.is(err, expectedError); + }, + })); +}); + +function sendMFACodeSucceedsMacro(t, { hasOldDevice, hasNewDevice, userConfirmationNecessary }) { + const localStorage = { + getItem: stub().returns(null), + setItem: stub(), + }; + global.window = { localStorage }; + + const previousChallengeSession = 'previous-challenge-session'; + const confirmationCode = 'some-confirmation-code'; + + const expectedRespondToAuthChallengeArgs = { + ChallengeName: 'SMS_MFA', + ChallengeResponses: { + USERNAME: aliasUsername, + SMS_MFA_CODE: confirmationCode, + }, + ClientId, + Session: previousChallengeSession, + }; + + if (hasOldDevice) { + expectedRespondToAuthChallengeArgs.ChallengeResponses.DEVICE_KEY = oldDeviceKey; + } + + const user = createUser( + {}, + requestSucceedsWith(createRespondToAuthChallengeResponseForSuccess({ hasNewDevice })), + requestSucceedsWith({ + UserConfirmationNecessary: userConfirmationNecessary, + })); + + user.username = aliasUsername; // assertDidCacheTokens() expects this + user.Session = previousChallengeSession; + + if (hasOldDevice) { + user.deviceKey = oldDeviceKey; + } + + user.sendMFACode(confirmationCode, createCallback(t, t.end, { + onSuccess(signInUserSessionArg, userConfirmationNecessaryArg) { + t.is(signInUserSessionArg, user.getSignInUserSession()); + if (userConfirmationNecessary) { + t.true(userConfirmationNecessaryArg); + } else { + t.falsy(userConfirmationNecessaryArg); + } + + t.is(user.client.requestCallCount, hasNewDevice ? 2 : 1); + t.is(user.client.getRequestCallArgs(0).name, 'respondToAuthChallenge'); + t.deepEqual(user.client.getRequestCallArgs(0).args, expectedRespondToAuthChallengeArgs); + if (hasNewDevice) { + t.is(user.client.getRequestCallArgs(1).name, 'confirmDevice'); + t.deepEqual(user.client.getRequestCallArgs(1).args, { + DeviceKey: newDeviceKey, + AccessToken, + DeviceSecretVerifierConfig: { + Salt: SaltDevicesBase64, + PasswordVerifier: VerifierDevicesBase64, + }, + DeviceName: deviceName, + }); + } + + t.is(user.getUsername(), aliasUsername); + t.is(user.Session, previousChallengeSession); + + assertHasDeviceState(t, user, { hasOldDevice, hasNewDevice }); + + assertDidCacheTokens(t, localStorage); + if (hasNewDevice) { + assertDidCacheDeviceKeyAndPassword(t, localStorage); + } else { + assertDidNotCacheDeviceKeyAndPassword(t, localStorage); + } + }, + })); +} +sendMFACodeSucceedsMacro.title = (_, context) => ( + title('sendMFACode', { context, outcome: 'creates session' }) +); + +for (const hasOldDevice of [false, true]) { + for (const hasNewDevice of [false, true]) { + test.serial.cb(sendMFACodeSucceedsMacro, { hasOldDevice, hasNewDevice }); + } +} +test.serial.cb( + sendMFACodeSucceedsMacro, + { hasNewDevice: true, userConfirmationNecessary: true } +); diff --git a/src/CognitoUser.test.js b/src/CognitoUser.test.js new file mode 100644 index 00000000..0cc0876e --- /dev/null +++ b/src/CognitoUser.test.js @@ -0,0 +1,477 @@ +/* eslint-disable require-jsdoc */ + +import test from 'ava'; +import { stub } from 'sinon'; + +import { + MockClient, + requireDefaultWithModuleMocks, + requestCalledOnceWith, + createCallback, + createBasicCallback, + title, +} from './_helpers.test'; + +// Valid property values: constructor, request props, etc... +const Username = 'some-username'; +const ClientId = 'some-client-id'; +const AccessToken = 'some-access-token'; +const CodeDeliveryDetails = { + Destination: 'some-destination', + DeliveryMedium: 'some-medium', + AttributeName: 'some-attribute', +}; + +// Valid arguments +const attributeName = 'some-attribute-name'; +const confirmationCode = '123456'; +const attributes = [ + { Name: 'some-attribute-name-1', Value: 'some-attribute-value-1' }, + { Name: 'some-attribute-name-2', Value: 'some-attribute-value-2' }, +]; + +class MockUserPool { + constructor() { + this.client = new MockClient(); + } + + getClientId() { + return ClientId; + } + + toJSON() { + return '[mock UserPool]'; + } +} + +class MockSession { + isValid() { + return true; + } + + getAccessToken() { + return { + getJwtToken() { + return AccessToken; + }, + }; + } +} + +function createUser({ pool = new MockUserPool(), session, mocks } = {}, ...requestConfigs) { + pool.client = new MockClient(...requestConfigs); // eslint-disable-line no-param-reassign + const CognitoUser = requireDefaultWithModuleMocks('./CognitoUser', mocks); + const user = new CognitoUser({ Username, Pool: pool }); + user.signInUserSession = session; + return user; +} + +function createSignedInUserWithMocks(mocks, ...requests) { + return createUser({ session: new MockSession(), mocks }, ...requests); +} + +function createSignedInUser(...requests) { + return createUser({ session: new MockSession() }, ...requests); +} + +function createSignedInUserWithExpectedError(expectedError) { + return createSignedInUser([expectedError]); +} + +function createExpectedErrorFromSuccess(succeeds) { + return succeeds ? null : { code: 'InternalServerException' }; +} + + +function testSpec({ + title: macroTitle, + cb = true, + serial = false, + macro, + cases = [ + [false], + [true], + ], +}) { + if (typeof macroTitle === 'string') { + // eslint-disable-next-line no-param-reassign + macro.title = (_, succeeds) => title(macroTitle, { succeeds }); + } else if (typeof macroTitle === 'function') { + // eslint-disable-next-line no-param-reassign + macro.title = (_, ...args) => macroTitle(...args); + } else { + // eslint-disable-next-line no-param-reassign + macro.title = (_, succeeds, ...values) => ( + title(macroTitle.name, { + succeeds, + args: macroTitle.args && macroTitle.args(...values), + context: macroTitle.context && macroTitle.context(...values), + }) + ); + } + let testMethod = test; + if (cb) { + testMethod = testMethod.cb; + } + if (serial) { + testMethod = testMethod.serial; + } + for (const testCase of cases) { + testMethod(macro, ...testCase); + } +} + + +testSpec({ + title(data) { + return title('constructor', { args: data, outcome: 'throws "required"' }); + }, + cb: false, + macro(t, data) { + const CognitoUser = requireDefaultWithModuleMocks('./CognitoUser'); + t.throws(() => new CognitoUser(data), /required/); + }, + cases: [ + [null], + [{}], + [{ Username: null, Pool: null }], + [{ Username: null, Pool: new MockUserPool() }], + [{ Username, Pool: null }], + ], +}); + +test('constructor() :: valid => creates expected instance', t => { + const CognitoUser = requireDefaultWithModuleMocks('./CognitoUser'); + const pool = new MockUserPool(); + + const user = new CognitoUser({ Username, Pool: pool }); + + t.is(user.getSignInUserSession(), null); + t.is(user.getUsername(), Username); + t.is(user.getAuthenticationFlowType(), 'USER_SRP_AUTH'); + t.is(user.pool, pool); +}); + +test('setAuthenticationFlowType() => sets authentication flow type', t => { + const user = createUser(); + const flowType = 'CUSTOM_AUTH'; + + user.setAuthenticationFlowType(flowType); + + t.is(user.getAuthenticationFlowType(), flowType); +}); + +// See CognitoUser.authenticateUser.test.js for authenticateUser() and the challenge responses + +testSpec({ + title(forceAliasCreation, succeeds) { + return title('confirmRegistration', { succeeds, args: { forceAliasCreation } }); + }, + macro(t, forceAliasCreation, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createUser({}, [expectedError]); + + user.confirmRegistration(confirmationCode, forceAliasCreation, err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'confirmSignUp', { + ClientId, + ConfirmationCode: confirmationCode, + Username, + ForceAliasCreation: forceAliasCreation, + }); + t.end(); + }); + }, + cases: [ + [false, false], + [true, false], + [false, true], + [true, true], + ], +}); + +testSpec({ + title: 'changePassword', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + const oldUserPassword = 'swordfish'; + const newUserPassword = 'slaughterfish'; + user.changePassword(oldUserPassword, newUserPassword, err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'changePassword', { + PreviousPassword: oldUserPassword, + ProposedPassword: newUserPassword, + AccessToken, + }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'enableMFA', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + user.enableMFA(err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'setUserSettings', { + MFAOptions: [ + { DeliveryMedium: 'SMS', AttributeName: 'phone_number' }, + ], + AccessToken, + }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'disableMFA', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + user.disableMFA(err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'setUserSettings', { + MFAOptions: [], + AccessToken, + }); + t.end(); + }); + }, +}); + + +testSpec({ + title: 'deleteUser', + serial: true, + macro(t, succeeds) { + const localStorage = { + removeItem: stub(), + }; + global.window = { localStorage }; + + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + user.deleteUser(err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'deleteUser', { AccessToken }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'updateAttributes', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + user.updateAttributes(attributes, err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'updateUserAttributes', { + UserAttributes: attributes, + AccessToken, + }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'getUserAttributes', + macro(t, succeeds) { + class MockCognitoUserAttribute { + constructor(...params) { + this.params = params; + } + } + + const expectedError = createExpectedErrorFromSuccess(succeeds); + const responseResult = succeeds ? { UserAttributes: attributes } : null; + + const user = createSignedInUserWithMocks( + { + './CognitoUserAttribute': MockCognitoUserAttribute, + }, + [expectedError, responseResult]); + + user.getUserAttributes((err, result) => { + t.is(err, expectedError); + if (succeeds) { + t.true(Array.isArray(result) && result.every(i => i instanceof MockCognitoUserAttribute)); + t.deepEqual(result.map(i => i.params[0]), attributes); + } else { + t.falsy(result); + } + requestCalledOnceWith(t, user.client, 'getUser', { AccessToken }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'deleteUserAttributes', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + const attributeList = attributes.map(a => a.Name); + + user.deleteAttributes(attributeList, err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'deleteUserAttributes', { + UserAttributeNames: attributeList, + AccessToken, + }); + t.end(); + }); + }, +}); + +testSpec({ + title: 'resendConfirmationCode', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + user.resendConfirmationCode(err => { + t.is(err, expectedError); + requestCalledOnceWith(t, user.client, 'resendConfirmationCode', { ClientId, Username }); + t.end(); + }); + }, +}); + +testSpec({ + title: { + name: 'forgotPassword', + context(usingInputVerificationCode) { + return { usingInputVerificationCode }; + }, + }, + macro(t, succeeds, usingInputVerificationCode) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const expectedData = !succeeds ? null : { CodeDeliveryDetails }; + const request = [expectedError, expectedData]; + const user = createSignedInUser(request); + + function done() { + requestCalledOnceWith(t, user.client, 'forgotPassword', { ClientId, Username }); + t.end(); + } + + const callback = !usingInputVerificationCode ? + createBasicCallback(t, succeeds, expectedError, done) : + createCallback(t, done, { + onFailure(err) { + t.false(succeeds); + t.is(err, expectedError); + }, + inputVerificationCode(data) { + t.true(succeeds); + t.is(data, expectedData); + }, + }); + + user.forgotPassword(callback); + }, + cases: [ + [false, false], + [true, false], + [false, true], + [true, true], + ], +}); + +testSpec({ + title: 'confirmPassword', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + const newPassword = 'swordfish'; + const callback = createBasicCallback(t, succeeds, expectedError, () => { + requestCalledOnceWith(t, user.client, 'confirmForgotPassword', { + ClientId, + Username, + ConfirmationCode: confirmationCode, + Password: newPassword, + }); + t.end(); + }); + user.confirmPassword(confirmationCode, newPassword, callback); + }, +}); + +testSpec({ + title: 'getAttributeVerificationCode', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const expectedData = !succeeds ? null : { CodeDeliveryDetails }; + const user = createSignedInUser([expectedError, expectedData]); + + function done() { + requestCalledOnceWith(t, user.client, 'getUserAttributeVerificationCode', { + AttributeName: attributeName, + AccessToken, + }); + t.end(); + } + user.getAttributeVerificationCode( + attributeName, + createCallback(t, done, { + onFailure(err) { + t.false(succeeds); + t.is(err, expectedError); + }, + inputVerificationCode(data) { + t.true(succeeds); + t.is(data, expectedData); + }, + })); + }, +}); + +testSpec({ + title: 'verifyAttribute', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + + const callback = createBasicCallback(t, succeeds, expectedError, () => { + requestCalledOnceWith(t, user.client, 'verifyUserAttribute', { + AttributeName: attributeName, + Code: confirmationCode, + AccessToken, + }); + t.end(); + }); + user.verifyAttribute(attributeName, confirmationCode, callback); + }, +}); + +testSpec({ + title: 'getDevice', + macro(t, succeeds) { + const expectedError = createExpectedErrorFromSuccess(succeeds); + const user = createSignedInUserWithExpectedError(expectedError); + const expectedDeviceKey = 'some-device-key'; + user.deviceKey = expectedDeviceKey; + + const callback = createBasicCallback(t, succeeds, expectedError, () => { + requestCalledOnceWith(t, user.client, 'getDevice', { + AccessToken, + DeviceKey: expectedDeviceKey, + }); + t.end(); + }); + user.getDevice(callback); + }, +}); diff --git a/src/CognitoUserPool.test.js b/src/CognitoUserPool.test.js new file mode 100644 index 00000000..625938e4 --- /dev/null +++ b/src/CognitoUserPool.test.js @@ -0,0 +1,133 @@ +/* eslint-disable require-jsdoc */ + +import test from 'ava'; +import { stub } from 'sinon'; + +import { + MockClient, + requireDefaultWithModuleMocks, + requestFailsWith, + requestSucceedsWith, +} from './_helpers.test'; + +class MockCognitoUser { + constructor({ Username, Pool }) { + this.username = Username; + this.pool = Pool; + } +} + +function requireCognitoUserPool() { + return requireDefaultWithModuleMocks('./CognitoUserPool', { + './CognitoUser': MockCognitoUser, + }); +} + +const UserPoolId = 'xx-nowhere1_SomeUserPool'; // Constructor validates the format. +const ClientId = 'some-client-id'; + +function createPool(extraData = {}) { + const CognitoUserPool = requireCognitoUserPool(); + return new CognitoUserPool(Object.assign({}, { UserPoolId, ClientId }, extraData)); +} + +function createPoolWithClient(...requestConfigs) { + const pool = createPool(); + pool.client = new MockClient(...requestConfigs); + return pool; +} + +function constructorThrowsRequired(t, data) { + const CognitoUserPool = requireCognitoUserPool(); + t.throws(() => new CognitoUserPool(data), /required/); +} +constructorThrowsRequired.title = (originalTitle, data) => ( + `constructor( ${JSON.stringify(data)} ) => throws with "required"` +); +test(constructorThrowsRequired, null); +test(constructorThrowsRequired, {}); +test(constructorThrowsRequired, { UserPoolId: null, ClientId: null }); +test(constructorThrowsRequired, { UserPoolId, ClientId: null }); +test(constructorThrowsRequired, { UserPoolId: null, ClientId }); + +test('constructor :: invalid UserPoolId => throws with "Invalid UserPoolId"', t => { + const CognitoUserPool = requireCognitoUserPool(); + const data = { UserPoolId: 'invalid-user-pool-id', ClientId }; + t.throws(() => new CognitoUserPool(data), /Invalid UserPoolId/); +}); + +test('constructor => creates instance with expected values', t => { + const pool = createPool(); + t.truthy(pool); + t.is(pool.getUserPoolId(), UserPoolId); + t.is(pool.getClientId(), ClientId); + t.is(pool.getParanoia(), 0); + t.true(pool.client instanceof MockClient); +}); + +test('constructor({ Paranoia }) => sets paranoia', t => { + const paranoia = 7; + const pool = createPool({ Paranoia: paranoia }); + t.is(pool.getParanoia(), paranoia); +}); + +test('setParanoia() => sets paranoia', t => { + const pool = createPoolWithClient(); + const paranoia = 7; + pool.setParanoia(paranoia); + t.is(pool.getParanoia(), paranoia); +}); + +test.cb('signUp() :: fails => callback gets error', t => { + const expectedError = { code: 'SomeError' }; + const pool = createPoolWithClient(requestFailsWith(expectedError)); + pool.signUp('username', 'password', null, null, err => { + t.is(err, expectedError); + t.end(); + }); +}); + +test.cb('signUp() :: success => callback gets user and confirmed', t => { + const expectedUsername = 'username'; + const pool = createPoolWithClient(requestSucceedsWith({ UserConfirmed: true })); + pool.signUp(expectedUsername, 'password', null, null, (err, result) => { + t.true(result.user instanceof MockCognitoUser); + t.is(result.user.username, expectedUsername); + t.is(result.user.pool, pool); + t.true(result.userConfirmed); + t.end(); + }); +}); + +test('getCurrentUser() :: no last user => returns null', t => { + const pool = createPoolWithClient(); + const localStorage = { + getItem: stub().returns(null), + }; + global.window = { localStorage }; + + t.is(pool.getCurrentUser(), null); + + t.true(localStorage.getItem.calledOnce); + t.true(localStorage.getItem.calledWithExactly( + `CognitoIdentityServiceProvider.${pool.getClientId()}.LastAuthUser` + )); +}); + +test('getCurrentUser() :: with last user => returns user instance', t => { + const pool = createPoolWithClient(); + const username = 'username'; + const localStorage = { + getItem: stub().returns(username), + }; + global.window = { localStorage }; + + const currentUser = pool.getCurrentUser(); + t.true(currentUser instanceof MockCognitoUser); + t.is(currentUser.username, username); + + t.true(localStorage.getItem.calledOnce); + t.true(localStorage.getItem.calledWithExactly( + `CognitoIdentityServiceProvider.${pool.getClientId()}.LastAuthUser` + )); +}); diff --git a/src/_helpers.test.js b/src/_helpers.test.js new file mode 100644 index 00000000..5515af0a --- /dev/null +++ b/src/_helpers.test.js @@ -0,0 +1,140 @@ +/* eslint-disable */ + +import test from 'ava'; +import { stub } from 'sinon'; +import mockRequire, { reRequire } from 'mock-require'; + +export class MockClient { + constructor(...requestConfigs) { + this.requestConfigs = requestConfigs; + this.nextRequestIndex = 0; + this.requestCallArgs = []; + } + + get requestCallCount() { + return this.requestCallArgs.length; + } + + getRequestCallArgs(call) { + return this.requestCallArgs[call]; + } + + makeUnauthenticatedRequest(name, args, cb) { + if (typeof cb !== 'function') { + throw new TypeError('MockClient requires cb arg.') + } + this.requestCallArgs.push({ name, args }); + if (this.nextRequestIndex >= this.requestConfigs.length) { + throw new Error(`No config for request ${this.nextRequestIndex}: '${name}'(${JSON.stringify(args)}).`); + } + const requestConfig = this.requestConfigs[this.nextRequestIndex++]; + cb(...requestConfig); + } +} + +export function requestFailsWith(err) { + return [err, null]; +} + +export function requestSucceedsWith(result) { + return [null, result]; +} + +export function requestCalledWithOnCall(t, client, callIndex, expectedName, expectedArgs) { + const { name, args } = client.getRequestCallArgs(callIndex); + t.is(name, expectedName); + t.deepEqual(args, expectedArgs); +} + +export function requestCalledOnceWith(t, client, expectedName, expectedArgs) { + t.true(client.requestCallCount === 1); + requestCalledWithOnCall(t, client, 0, expectedName, expectedArgs); +} + +export function createCallback(t, done, callbackTests) { + const callback = {}; + Object.keys(callbackTests).forEach(key => { + callback[key] = function testCallbackMethod(...args) { + t.is(this, callback); + callbackTests[key](...args); + done(); + }; + }); + return callback; +} + +export function createBasicCallback(t, succeeds, expectedError, done) { + return createCallback(t, done, { + onFailure(err) { + t.false(succeeds); + t.is(err, expectedError); + }, + onSuccess() { + t.true(succeeds); + }, + }); +} + +test.afterEach.always(t => { + mockRequire.stopAll(); + delete global.window; +}); + +const defaultMocks = { + 'aws-sdk/clients/cognitoidentityserviceprovider': MockClient, + './AuthenticationDetails': null, + './AuthenticationHelper': null, + './CognitoAccessToken': null, + './CognitoIdToken': null, + './CognitoRefreshToken': null, + './CognitoUser': null, + './CognitoUserAttribute': null, + './CognitoUserPool': null, + './CognitoUserSession': null, + './DateHelper': null, +}; + +function requireWithModuleMocks(request, moduleMocks = {}) { + const allModuleMocks = Object.assign({}, defaultMocks, moduleMocks); + Object.keys(allModuleMocks).forEach(mockRequest => { + if (mockRequest !== request) { + mockRequire(mockRequest, allModuleMocks[mockRequest]); + } + }); + + return reRequire(request); +} + +export function requireDefaultWithModuleMocks(request, moduleMocks) { + return requireWithModuleMocks(request, moduleMocks).default; +} + +function titleMapString(value) { + return value && typeof value === 'object' + ? Object.keys(value).map(key => `${key}: ${JSON.stringify(value[key])}`).join(', ') + : value || ''; +} + +export function title( + fn, + { args, context, succeeds, outcome = succeeds ? 'succeeds' : 'fails' } +) { + const fnString = typeof fn === 'function' ? fn.name.replace(/Macro$/, '') : fn; + const callString = fn || args ? `${fnString}(${titleMapString(args)})` : ''; + const contextString = titleMapString(context); + const prefixString = callString && contextString + ? `${callString} :: ${contextString}` + : callString || contextString; + return `${prefixString} => ${outcome}`; +} + +export function addSimpleTitle(macro, { args, context } = {}) { + // eslint-disable-next-line no-param-reassign + macro.title = (_, succeeds, ...values) => ( + title(macro, { + succeeds, + args: args && args(...values), + context: context && context(...values), + }) + ); +}