lirantal / cypress-social-logins

Cypress authentication flows using social network providers
Apache License 2.0
248 stars 78 forks source link

Options Object new Function Property isn't getting Invoked When Called Upon in baseLoginConnect Function #66

Closed InvisibleExo closed 3 years ago

InvisibleExo commented 3 years ago

I'm having trouble trying to invoke a function I created as a property for the options Object. I'm trying to add and test a check for an additional check whether user has added a property called: additionalSteps in the options Object. The purpose for additionalSteps property is for the User to define an async functions , which passes in the page and options objects as parameters, then can be used to define whatever steps a User may need to do after the typePassword function. (Ex: Some sign ins steps might require a PIN, Security Question, etc.) Currently, I have the check for options.additionalSteps right after typePassword function. If condition is true, I would then invoke the additonalSteps function. Problem is that it doesn't appear the function is getting invoked and code moves on to the options.optSecret check. I put a boolean variable within the if condition to determine if the function did get invoked, and the actions are being performed within a second before Chromium session closes, the results show that its not getting invoked. Initially I attempted to invoke the function as options.additionalSteps({page, options}) without luck. Second attempt was to declare an additionalSteps variable, if options does contain an additionalSteps property, assign addtionalSteps = options.additionalSteps which is then passed into parameters for baseLoginConnect function. Is there a factor I'm missing when trying to invoke a function from a property? Looking up on topic of invoking functions, I can't see what I'm doing wrong. (Note: I would rather try to invoke the function as options.additionalSteps instead of passing it in as another parameter, to avoid letting baseLoginConnect function have too many parameters.) Any advise would be helpful.

Plugins.js (Pieces of Code to review would be CustomizedLogin and additionSteps check in baseLoginConnect -

/* eslint-disable no-undef */
'use strict'

const puppeteer = require('puppeteer')
const authenticator = require('otplib').authenticator

/**
 *
 * @param {options.username} string username
 * @param {options.password} string password
 * @param {options.loginUrl} string password
 * @param {options.args} array[string] string array which allows providing further arguments to puppeteer
 * @param {options.loginSelector} string a selector on the loginUrl page for the social provider button
 * @param {options.loginSelectorDelay} number delay a specific amount of time before clicking on the login button, defaults to 250ms. Pass a boolean false to avoid completely.
 * @param {options.postLoginSelector} string a selector on the app's post-login return page to assert that login is successful
 * @param {options.preLoginSelector} string a selector to find and click on before clicking on the login button (useful for accepting cookies)
 * @param {options.otpSecret} string Secret for generating a otp based on OTPLIB
 * @param {options.headless} boolean launch puppeteer in headless more or not
 * @param {options.logs} boolean whether to log cookies and other metadata to console
 * @param {options.getAllBrowserCookies} boolean whether to get all browser cookies instead of just for the loginUrl
 * @param {options.isPopup} boolean is your google auth displayed like a popup
 * @param {options.popupDelay} number delay a specific milliseconds before popup is shown. Pass a falsy (false, 0, null, undefined, '') to avoid completely
 * @param {options.cookieDelay} number delay a specific milliseconds before get a cookies. Pass a falsy (false, 0, null, undefined, '') to avoid completely.
 * @param {options.postLoginClick} string a selector to find and click on after clicking on the login button
 * @param {options.usernameField} string selector for the username field
 * @param {options.usernameSubmitBtn} string selector for the username button
 * @param {options.passwordField} string selector for the username field
 * @param {options.passwordSubmitBtn} string selector submit button
 * @param {options.additionalSteps} function any additional func which may be required for signin step after username and password
 */

function delay(time) {
  return new Promise(function(resolve) {
    setTimeout(resolve, time)
  })
}

function validateOptions(options) {
  if (!options.username || !options.password) {
    throw new Error('Username or Password missing for social login')
  }
}

async function login({page, options} = {}) {
  if (options.preLoginSelector) {
    await page.waitForSelector(options.preLoginSelector)
    await page.click(options.preLoginSelector)
  }

  await page.waitForSelector(options.loginSelector)

  if (options.loginSelectorDelay !== false) {
    await delay(options.loginSelectorDelay)
  }

  await page.click(options.loginSelector)
}

async function getCookies({page, options} = {}) {
  await page.waitForSelector(options.postLoginSelector)

  const cookies = options.getAllBrowserCookies
    ? await getCookiesForAllDomains(page)
    : await page.cookies(options.loginUrl)

  if (options.logs) {
    console.log(cookies)
  }

  return cookies
}

async function getLocalStorageData({page, options} = {}) {
  await page.waitForSelector(options.postLoginSelector)

  const localStorageData = await page.evaluate(() => {
    let json = {}
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i)
      json[key] = localStorage.getItem(key)
    }
    return json
  })
  if (options.logs) {
    console.log(localStorageData)
  }

  return localStorageData
}

async function getSessionStorageData({page, options} = {}) {
  await page.waitForSelector(options.postLoginSelector)

  const sessionStorageData = await page.evaluate(() => {
    let json = {}
    for (let i = 0; i < sessionStorage.length; i++) {
      const key = sessionStorage.key(i)
      json[key] = sessionStorage.getItem(key)
    }
    return json
  })
  if (options.logs) {
    console.log(sessionStorageData)
  }

  return sessionStorageData
}

async function getCookiesForAllDomains(page) {
  const cookies = await page._client.send('Network.getAllCookies', {})
  return cookies.cookies
}

async function finalizeSession({page, browser, options} = {}) {
  await browser.close()
}

async function waitForMultipleSelectors(selectors, options, page) {
  const navigationOutcome = await racePromises(
    selectors.map(selector => page.waitForSelector(selector, options))
  )
  return selectors[parseInt(navigationOutcome)]
}

async function racePromises(promises) {
  const wrappedPromises = []
  let resolved = false
  promises.map((promise, index) => {
    wrappedPromises.push(
      new Promise(resolve => {
        promise.then(
          () => {
            resolve(index)
          },
          error => {
            if (!resolved) {
              throw error
            }
          }
        )
      })
    )
  })
  return Promise.race(wrappedPromises).then(index => {
    resolved = true
    return index
  })
}

async function baseLoginConnect(typeUsername, typePassword, otpApp, authorizeApp, additionalSteps, postLogin, options) {
  validateOptions(options)

  const launchOptions = {headless: !!options.headless}

  if (options.args && options.args.length) {
    launchOptions.args = options.args
  }

  const browser = await puppeteer.launch(launchOptions)
  let page = await browser.newPage()
  let originalPageIndex = 1
  await page.setViewport({width: 1280, height: 800})
  await page.setExtraHTTPHeaders({
    'Accept-Language': 'en-USq=0.9,enq=0.8'
  })
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0 Win64 x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36'
  )

  await page.goto(options.loginUrl)
  await login({page, options})

  // Switch to Popup Window
  if (options.isPopup) {
    if (options.popupDelay) {
      await delay(options.popupDelay)
    }
    const pages = await browser.pages()
    // remember original window index
    originalPageIndex = pages.indexOf(
      pages.find(p => page._target._targetId === p._target._targetId)
    )
    page = pages[pages.length - 1]
  }

  await typeUsername({page, options})
  await typePassword({page, options})
  let additionsFound = false
  if (options.additionalSteps && additionalSteps != null) {
    console.log('perform additional steps')
    await additionalSteps({page, options})
    additionsFound = true
  }

  if (options.otpSecret && otpApp) {
    await otpApp({page, options})
  }

  if (options.authorize) {
    await authorizeApp({page, options})
  }

  // Switch back to Original Window
  if (options.isPopup) {
    if (options.popupDelay) {
      await delay(options.popupDelay)
    }
    const pages = await browser.pages()
    page = pages[originalPageIndex]
  }

  if (options.postLoginClick) {
    await postLogin({page, options})
  }

  if (options.cookieDelay) {
    await delay(options.cookieDelay)
  }

  const cookies = await getCookies({page, options})
  const lsd = await getLocalStorageData({page, options})
  const ssd = await getSessionStorageData({page, options})
  await finalizeSession({page, browser, options})

  return {
    cookies,
    lsd,
    ssd,
    additionsFound
  }
}

module.exports.baseLoginConnect = baseLoginConnect

module.exports.GoogleSocialLogin = async function GoogleSocialLogin(options = {}) {
  const typeUsername = async function({page, options} = {}) {
    await page.waitForSelector('input#identifierId[type="email"]')
    await page.type('input#identifierId[type="email"]', options.username)
    await page.click('#identifierNext')
  }

  const typePassword = async function({page, options} = {}) {
    let buttonSelectors = ['#signIn', '#passwordNext', '#submit']

    await page.waitForSelector('input[type="password"]', {visible: true})
    await page.type('input[type="password"]', options.password)

    const buttonSelector = await waitForMultipleSelectors(buttonSelectors, {visible: true}, page)
    await page.click(buttonSelector)
  }

  const postLogin = async function ({page, options} = {}) {
    await page.waitForSelector(options.postLoginClick)
    await page.click(options.postLoginClick)
  }

  return baseLoginConnect(typeUsername, typePassword, null, null, postLogin, options)
}

module.exports.GitHubSocialLogin = async function GitHubSocialLogin(options = {}) {
  const typeUsername = async function({page, options} = {}) {
    await page.waitForSelector('input#login_field')
    await page.type('input#login_field', options.username)
  }

  const typePassword = async function({page, options} = {}) {
    await page.waitForSelector('input#password', {visible: true})
    await page.type('input#password', options.password)
    await page.click('input[type="submit"]')
  }

  const authorizeApp = async function({page, options} = {}) {
    await page.waitForSelector('button#js-oauth-authorize-btn', {visible: true})
    await page.click('button#js-oauth-authorize-btn', options.password)
  }

  const postLogin = async function ({page, options} = {}) {
    await page.waitForSelector(options.postLoginClick)
    await page.click(options.postLoginClick)
  }

  return baseLoginConnect(typeUsername, typePassword, null, authorizeApp, postLogin, options)
}

module.exports.MicrosoftSocialLogin = async function MicrosoftSocialLogin(options = {}) {
  const typeUsername = async function({page, options} = {}) {
    await page.waitForSelector('input[type="email"]')
    await page.type('input[type="email"]', options.username)
    await page.click('input[type="submit"]')
  }

  const typePassword = async function({page, options} = {}) {
    await delay(5000)

    await page.waitForSelector('input[type="password"]', {visible: true})
    await page.type('input[type="password"]', options.password)
    await page.click('input[type="submit"]')
  }

  const authorizeApp = async function({page, options} = {}) {
    await page.waitForSelector('button#js-oauth-authorize-btn', {visible: true})
    await page.click('button#js-oauth-authorize-btn', options.password)
  }

  const postLogin = async function ({page, options} = {}) {
    await page.waitForSelector(options.postLoginClick)
    await page.click(options.postLoginClick)
  }

  return baseLoginConnect(typeUsername, typePassword, null, authorizeApp, postLogin, options)
}

module.exports.AmazonSocialLogin = async function AmazonSocialLogin(options = {}) {
  const typeUsername = async function({page, options} = {}) {
    await page.waitForSelector('#ap_email', {visible: true})
    await page.type('#ap_email', options.username)
  }

  const typePassword = async function({page, options} = {}) {
    let buttonSelectors = ['#signInSubmit']

    await page.waitForSelector('input[type="password"]', {visible: true})
    await page.type('input[type="password"]', options.password)

    const buttonSelector = await waitForMultipleSelectors(buttonSelectors, {visible: true}, page)
    await page.click(buttonSelector)
  }

  const otpApp = async function({page, options} = {}) {
    let buttonSelectors = ['#auth-signin-button']

    await page.waitForSelector('#auth-mfa-otpcode', {visible: true})
    await page.type('#auth-mfa-otpcode', authenticator.generate(options.otpSecret))

    const buttonSelector = await waitForMultipleSelectors(buttonSelectors, {visible: true}, page)
    await page.click(buttonSelector)
  }

  return baseLoginConnect(typeUsername, typePassword, otpApp, null, null, options)
}

module.exports.CustomizedLogin = async function CustomizedLogin(options = {}) {
  if (options.usernameField && options.passwordField) {
    const typeUsername = async function({page, options} = {}) {
      await page.waitForSelector(options.usernameField, {visible: true})
      await page.type(options.usernameField, options.username)
      if (options.usernameSubmitBtn) {
        await page.click(options.usernameSubmitBtn)
      }
    }
    const typePassword = async function({page, options} = {}) {
      await page.waitForSelector(options.passwordField, {visible: true})
      await page.type(options.passwordField, options.password)
      if (options.passwordSubmitBtn) {
        await page.click(options.passwordSubmitBtn)
      }
    }
    let additionalSteps = null
    if (options.additionalSteps) {
      additionalSteps = options.additionalSteps
    }
    const postLogin = async function ({page, options} = {}) {
      await page.waitForSelector(options.postLoginClick)
      await page.click(options.postLoginClick)
    }

    return baseLoginConnect(typeUsername, typePassword, null, null, additionalSteps, postLogin, options)
  } else {
    throw new Error('Please review your option properties. Propeties usernameField and passwordField are required as type String.')
  }
}

test code -

it.only('Login with custom function', () => {
    const username = Cypress.env('steamUserName')
    const password = Cypress.env('steamUserNamePW')
    const loginUrl = Cypress.env('loginUrl')
    const loginSelector = Cypress.env('loginSelector')
    const cookieName = Cypress.env('cookieName')
    const socialLoginOptions = {
      username,
      password,
      loginUrl,
      // Add username/pw fields and buttons and addtional steps
      usernameField: '#input_username',
      passwordField: '#input_password',
      passwordSubmitBtn: '#login_btn_signin',
      additionalSteps: async function({page, options} = {}) {
        console.log('This is the addtional step...')
        await page.waitForSelector('#header_notification_link', {visible: true})
        await page.click('#header_notification_link')
      },
      isPopup: true,
      popupDelay: 6000,
      logs: true,
      headless: false,
      loginSelector: loginSelector,
      postLoginClick: '#account_pulldown',
      postLoginSelector: '#account_dropdown div.popup_menu a.popup_menu_item:first-of-type'
    }

    cy.log(socialLoginOptions)

    return cy.task('customizedLogin', socialLoginOptions, {timeout: 300000}).then(({cookies, lsd, ssd, additionsFound}) => {
      cy.log(additionsFound)
      cy.clearCookies()
      const cookie = cookies.filter(cookie => cookie.name === cookieName).pop()
      if (cookie) {
        cy.setCookie(cookie.name, cookie.value, {
          domain: cookie.domain,
          expiry: cookie.expires,
          httpOnly: cookie.httpOnly,
          path: cookie.path,
          secure: cookie.secure
        })

        cy.log(cookie)

        Cypress.Cookies.defaults({
          preserve: cookieName
        })
      }
    })
  })
InvisibleExo commented 3 years ago

Solved. The problem was that I defined my options.additionalSteps inside my test spec file. If I added and defined the addtionalSteps function in the index file for Cypress plugins, options.additionSteps was invoked and operated as expected(depending on how the function was defined.

This will allow the function work as expected. I would add a condition to check whether or not I want to include addtionalSteps. (./cypress/plugins/index.js):

 const {customizedLogin} = require('../../src/Plugins').CustomizedLogin

async function fewMoreSteps({page, options} = {}) {
  console.log('This is the addtional step...')
  await page.waitForSelector('#header_notification_link', {visible: true, timeout: 6000})
  await page.click('#header_notification_link')
}

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  on('task', {
    customizedLogin: (options) => {
      if (options.moreSteps) {
        options.additionalSteps = fewMoreSteps
      }
      return CustomizedLogin(options)
    }
  }
  )
}

I don't think its possible for an object with functions to be passed through Cypress.task(), though I would have to do some research to determine whether or not my initial approach was even possible.

lirantal commented 3 years ago

Thanks for sharing @InvisibleExo ❤️ Any chance you want to update the README.md file with a pull request to include that example? I'm sure it'll be helpful for others.

InvisibleExo commented 3 years ago

@lirantal sure. I have the additions to Plugins.js I worked and tested on, as well added information for ReadMe.md all ready to be committed, but I'm getting denied when pushing my branch with changes.

lirantal commented 3 years ago

@InvisibleExo Did you create a fork of this repo? if you do and you push your branch there then you can open a pull request to this one.

InvisibleExo commented 3 years ago

@lirantal I forked my own branch, and was able to push it with the changes. Thanks, used to using git only my own projects.