diff --git a/ci-environment-test.yml b/ci-environment-test.yml index 21debaec655..cfc3a2e3da9 100644 --- a/ci-environment-test.yml +++ b/ci-environment-test.yml @@ -15,6 +15,7 @@ services: - selenium manager-e2e: environment: + - CRED_STORE_MODE=${CRED_STORE_MODE} - DOCKER=true - REACT_APP_APP_ROOT=${REACT_APP_APP_ROOT} - REACT_APP_API_ROOT=${REACT_APP_API_ROOT} @@ -32,3 +33,6 @@ services: entrypoint: ["./scripts/wait-for-it.sh", "-t", "250", "-s", "selenium:4444", "--", "yarn","e2e", "--log"] depends_on: - chrome + - mongodb + mongodb: + image: mongo:latest diff --git a/e2e/config/custom-commands.js b/e2e/config/custom-commands.js index cd9742a6eb7..36cc1971a94 100644 --- a/e2e/config/custom-commands.js +++ b/e2e/config/custom-commands.js @@ -27,7 +27,7 @@ const { loadImposter, } = require('../utils/mb-utils'); -const { readToken } = require('../utils/config-utils'); +const { getToken } = require('../utils/config-utils'); exports.browserCommands = () => { /* Overwrite the native getText function @@ -73,7 +73,7 @@ exports.browserCommands = () => { }); browser.addCommand('readToken', function(username) { - const token = readToken(username); + const token = getToken(username); return token; }); diff --git a/e2e/config/wdio.axe.conf.js b/e2e/config/wdio.axe.conf.js index fafadfdf738..a3d67acbf7c 100644 --- a/e2e/config/wdio.axe.conf.js +++ b/e2e/config/wdio.axe.conf.js @@ -3,9 +3,12 @@ const { merge } = require('ramda'); const { argv } = require('yargs'); const wdioMaster = require('./wdio.conf.js'); const { browserConf } = require('./browser-config'); + +// TODO - update to use credstore interface for login instead of config-utils const { login } = require('../utils/config-utils'); + const { browserCommands } = require('./custom-commands'); const selectedBrowser = () => { diff --git a/e2e/config/wdio.conf.js b/e2e/config/wdio.conf.js index fdd19363e50..a120e874a8d 100644 --- a/e2e/config/wdio.conf.js +++ b/e2e/config/wdio.conf.js @@ -2,20 +2,12 @@ require('dotenv').config(); const { readFileSync, unlinkSync } = require('fs'); const { argv } = require('yargs'); -const { - login, - generateCreds, - checkoutCreds, - checkInCreds, - removeCreds, - cleanupAccounts, -} = require('../utils/config-utils'); -const { resetAccounts } = require('../setup/cleanup'); +const FSCredStore = require('../utils/fs-cred-store'); +const MongoCredStore = require('../utils/mongo-cred-store') const { browserCommands } = require('./custom-commands'); const { browserConf } = require('./browser-config'); -const { constants } = require('../constants'); const { keysIn } = require('lodash'); const selectedBrowser = argv.browser ? browserConf[argv.browser] : browserConf['chrome']; @@ -51,6 +43,24 @@ const getRunnerCount = () => { } const parallelRunners = getRunnerCount(); +console.log("parallel runners: " + parallelRunners); + +// NOTE: credStore provides a promise-based API. In order to work correctly with WDIO, any calls in +// lifecycle methods *other than* onPrepare and onComplete should be wrapped using WDIO's browser.call +// method. This blocks execution until any promises within the function passed to call are resolved. +// See more at: +// https://webdriver.io/docs/api/browser/call.html +// +// to use mongo cred store, set MONGO_HOST to either localhost (e.g., for local testing) or mongodb (for docker) +// if it's not set the filesystem cred store will be used +let MONGO_HOST = false; + +if (process.env.MONGO_HOST) { + MONGO_HOST = process.env.MONGO_HOST == 'mongodb' ? 'mongodb' : false +} + +console.log("mongo host set to: " + MONGO_HOST); +const credStore = MONGO_HOST ? new MongoCredStore(MONGO_HOST) : new FSCredStore('./e2e/creds.js'); exports.config = { // Selenium Host/Port @@ -204,6 +214,7 @@ exports.config = { }, testUser: '', // SET IN THE BEFORE HOOK PRIOR TO EACH TEST + // // ===== // Hooks @@ -218,8 +229,9 @@ exports.config = { * @param {Array.} capabilities list of capabilities details */ onPrepare: function (config, capabilities, user) { - // Generate our temporary test credentials file - generateCreds('./e2e/creds.js', config, parallelRunners); + console.log("onPrepare"); + // Generate temporary test credentials and store for use across tests + credStore.generateCreds(config, parallelRunners); }, /** * Gets executed just before initialising the webdriver session and test framework. It allows you @@ -237,6 +249,7 @@ exports.config = { * @param {Array.} specs List of spec file paths that are to be run */ before: function (capabilities, specs) { + console.log("before"); // Load up our custom commands require('@babel/register'); @@ -264,12 +277,23 @@ exports.config = { browser.windowHandleMaximize(); } - /* Get test credentials from temporary creds file - Set "inUse:true" for account under test - */ - const testCreds = checkoutCreds('./e2e/creds.js', specs[0]); + // inject browser object into credstore for login and a few other functions + credStore.setBrowser(browser); + + // inject credStore into browser so it can be easily accessed from test cases + // and utility code + browser.credStore = credStore; - login(testCreds.username, testCreds.password, './e2e/creds.js'); + let creds = null; + browser.call(() => { + return credStore.checkoutCreds(specs[0]) + .then((testCreds) => { + creds = testCreds; + }).catch((err) => console.log(err)); + }); + console.log("creds are"); + console.log(creds); + credStore.login(creds.username, creds.password, false); }, /** * Runs before a WebdriverIO command gets executed. @@ -347,7 +371,9 @@ exports.config = { } // Set "inUse:false" on the account under test in the credentials file - checkInCreds('./e2e/creds.js', specs[0]); + browser.call( + () => credStore.checkinCreds(specs[0]).then((creds) => console.log(creds)) + ); }, /** * Gets executed right after terminating the webdriver session. @@ -364,13 +390,9 @@ exports.config = { * @param {Array.} capabilities list of capabilities details */ onComplete: function(exitCode, config, capabilities) { - // Run delete all, on every test account - - /* We wait an arbitrary amount of time here for linodes to be removed - Otherwise, attempting to remove attached volumes will fail - */ - return resetAccounts(JSON.parse(readFileSync('./e2e/creds.js')), './e2e/creds.js') - .then(res => resolve(res)) - .catch(error => console.error('Error:', error)); + console.log("onComplete"); + // delete all data created during the test and remove test credentials from + // the underlying store + return credStore.cleanupAccounts(); } } diff --git a/e2e/setup/cleanup.js b/e2e/setup/cleanup.js index c5d3fcd48b7..13ee794ffa1 100644 --- a/e2e/setup/cleanup.js +++ b/e2e/setup/cleanup.js @@ -7,7 +7,12 @@ const { isEmpty } = require('lodash'); const { readFileSync, unlink } = require('fs'); -const getAxiosInstance = function(token) { +function removeEntity(token, entity, endpoint) { + return getAxiosInstance(token) + .delete(`${endpoint}/${entity.id}`) + .then((res) => entity.label + " - " + res.status + " " + res.statusText); +} +const getAxiosInstance = (token) => { const axiosInstance = axios.create({ httpsAgent: new https.Agent({ rejectUnauthorized: false @@ -23,35 +28,19 @@ const getAxiosInstance = function(token) { } exports.removeAllLinodes = token => { - return new Promise((resolve, reject) => { - const linodesEndpoint = '/linode/instances'; - - - const removeInstance = (linode, endpoint) => { - return getAxiosInstance(token).delete(`${endpoint}/${linode.id}`) - .then(res => { - - }) - .catch((error) => { - console.error('Error was', error); - reject(error); - }); + + const linodesEndpoint = '/linode/instances'; + + return getAxiosInstance(token).get(linodesEndpoint) + .then(res => { + linodes = res.data.data; + if (linodes.length > 0) { + return Promise.all( + res.data.data.map(linode => removeEntity(token, linode, linodesEndpoint)) + ); + } else { + return ["No Linodes"]; } - - return getAxiosInstance(token).get(linodesEndpoint) - .then(res => { - const promiseArray = - res.data.data.map(l => removeInstance(l, linodesEndpoint)); - - Promise.all(promiseArray) - .then(function(res) { - resolve(res); - }).catch(error => { - console.error(`error was`, error); - reject(error); - }) - }) - .catch(error => console.log(error.response.status)); }); } @@ -66,144 +55,97 @@ exports.pause = (volumesResponse) => { } exports.removeAllVolumes = token => { - return new Promise((resolve, reject) => { - const endpoint = '/volumes'; + + const endpoint = '/volumes'; + + return getAxiosInstance(token).get(endpoint).then(volumesResponse => { + return exports.pause(volumesResponse).then(res => { + volumes = res.data.data; + if (volumes.length > 0) { + return Promise.all( + res.data.data.map(v => removeEntity(token, v, endpoint)) + ); + } else { + return ["No Volumes"]; + } - const removeVolume = (res, endpoint) => { - return getAxiosInstance(token).delete(`${endpoint}/${res.id}`) - .then(response => { - resolve(response.status); - }) - .catch(error => { - if (error.includes('This volume must be detached before it can be deleted.')) { - - } - reject(`Removing Volume ${res.id} failed due to ${JSON.stringify(error.response.data)}`); - }); - } - - return getAxiosInstance(token).get(endpoint).then(volumesResponse => { - return exports.pause(volumesResponse).then(res => { - const removeVolumesArray = - res.data.data.map(v => removeVolume(v, endpoint)); - - Promise.all(removeVolumesArray).then(function(res) { - resolve(res); - }).catch(error => { - console.log(error.data); - reject(error.data) - }); - }) }) - .catch(error => console.log(error.response.status)); }); } exports.deleteAll = (token, user) => { - return new Promise((resolve, reject) => { - const endpoints = [ - '/domains', - '/nodebalancers', - '/images', - '/account/users', - '/account/oauth-clients' - ]; - - const getEndpoint = (endpoint, user) => { - return getAxiosInstance(token).get(`${API_ROOT}${endpoint}`) - .then(res => { - if (endpoint.includes('images')) { - privateImages = res.data.data.filter(i => i['is_public'] === false); - res.data['data'] = privateImages; - return res.data; - } - - if (endpoint.includes('oauth-clients')) { - const appClients = res.data.data.filter(client => !client['id'] === process.env.REACT_APP_CLIENT_ID); - res.data['data'] = appClients; - return res.data; - } + const endpoints = [ + '/domains', + '/nodebalancers', + '/images', + '/account/users', + '/account/oauth-clients' + ]; + + const getEndpoint = (endpoint, user) => { + return getAxiosInstance(token).get(`${API_ROOT}${endpoint}`) + .then(res => { + if (endpoint.includes('images')) { + privateImages = res.data.data.filter(i => i['is_public'] === false); + res.data.data = privateImages; + } + + if (endpoint.includes('oauth-clients')) { + const appClients = res.data.data.filter(client => client['id'] !== process.env.REACT_APP_CLIENT_ID); + res.data.data = appClients; + } + + if (endpoint.includes('users')) { + const nonRootUsers = res.data.data.filter(u => u.username !== user); + // tack on an id and label so the general-purpose removeEntity function works + // (it expects all entities to have an id and a label) + nonRootUsers.forEach((user) => { + user.id = user.username; + user.label = user.username; + }) + res.data.data = nonRootUsers; + } + return res; + }); + } - if (endpoint.includes('users')) { - const nonRootUsers = res.data.data.filter(u => u.username !== user); - res.data['data'] = nonRootUsers; - return res.data; + const iterateEndpointsAndRemove = () => { + return Promise.all(endpoints.map(ep => { + return getEndpoint(ep, user) + .then(res => { + if (res.data.data.length > 0) { + return Promise.all(res.data.data.map(entity => removeEntity(token, entity, ep))); + } else { + return ["No entities for " + ep]; } - return res.data; - }) - .catch((error) => { - console.log('Error', error); - // reject(error); }); - } - - const removeInstance = (res, endpoint, token) => { - return getAxiosInstance(token).delete(`${endpoint}/${res.id ? res.id : res.username}`) - .then(res => res) - .catch((error) => { - // reject(error); - console.error(error); - }); - } + })); + } - const iterateEndpointsAndRemove = () => { - return endpoints.map(ep => { - return getEndpoint(ep, user) - .then(res => { - if (res.results > 0) { - res.data.forEach(i => { - removeInstance(i, ep, token) - .then(res => { - return res; - }); - }); - } - }) - .catch(error => { - console.error(error); - reject(error); - }); - }); - } - - // Remove linodes, then remove all instances - return Promise.all(iterateEndpointsAndRemove()) - .then((values) => resolve(values)) - .catch(error => { - console.error('Error', reject(error)); - }); - }); + // Remove linodes, then remove all instances + return iterateEndpointsAndRemove(); } -exports.resetAccounts = (credsArray, credsPath) => { - return new Promise((resolve, reject) => { - credsArray.forEach((cred, i) => { - return exports.removeAllLinodes(cred.token) - .then(res => { - console.log(`Removing all data from ${cred.username}`); +exports.resetAccounts = (credsArray) => { + return Promise.all( + credsArray.map((cred) => { + return exports.removeAllLinodes(cred.token) + .then((res) => { + console.log("removed all linodes") + console.log(res); return exports.removeAllVolumes(cred.token) - .then(res => { - return exports.deleteAll(cred.token, cred.username) - .then(res => { - if (i === credsArray.length -1) { - unlink(credsPath, (err) => { - return res; - }); - } - }) - .catch(error => { - console.log('error', error); - }); - }) - .catch(error => { - console.log('error', error) - }) - }) - .catch(error => { - console.log(error.data) - reject(error) - }); - }); - }); + }) + .then((res) => { + console.log("removed all volumes") + console.log(res); + return exports.deleteAll(cred.token, cred.username) + }) + .then((res) => { + console.log("removed everything else"); + console.log(res); + return res; + }) + }) + ); } diff --git a/e2e/utils/config-utils.js b/e2e/utils/config-utils.js index ab7c6fb5e30..71a036be1b3 100644 --- a/e2e/utils/config-utils.js +++ b/e2e/utils/config-utils.js @@ -1,176 +1,20 @@ -const moment = require('moment'); -const { existsSync, statSync, writeFileSync, readFileSync } = require('fs'); -const { constants } = require('../constants'); -const { deleteAll } = require('../setup/setup'); -/* -* Get localStorage after landing on homepage -* and write them out to a file for use in other tests -* @returns { String } stringified local storage object -*/ -exports.storeToken = (credFilePath, username) => { - let credCollection = JSON.parse(readFileSync(credFilePath)); - - const getTokenFromLocalStorage = () => { - const browserLocalStorage = browser.execute(function() { - return JSON.stringify(localStorage); - }); - const parsedLocalStorage = JSON.parse(browserLocalStorage.value); - return parsedLocalStorage['authentication/oauth-token']; - } - - let currentUser = credCollection.find( cred => cred.username === username ); - - if ( !currentUser.isPresetToken ){ - currentUser.token = getTokenFromLocalStorage(); - } - - writeFileSync(credFilePath, JSON.stringify(credCollection)); -} - -exports.readToken = (username) => { - const credCollection = JSON.parse(readFileSync('./e2e/creds.js')); - const currentUserCreds = credCollection.find(cred => cred.username === username); - return currentUserCreds['token']; -} - -/* -* Navigates to baseUrl, inputs username and password -* And attempts to login -* @param { String } username -* @param { String } password -* @returns {null} returns nothing -*/ -exports.login = (username, password, credFilePath) => { - - browser.url(constants.routes.linodes); - try { - browser.waitForVisible('#username', constants.wait.long); - } catch (err) { - console.log(browser.getSource()); - } - - browser.waitForVisible('#password', constants.wait.long); - browser.jsClick('#username'); - browser.trySetValue('#username', username); - browser.jsClick('#password'); - browser.trySetValue('#password', password); - - const loginButton = browser.getUrl().includes('dev') ? '.btn#submit' : '[data-qa-sign-in] input'; - const letsGoButton = browser.getUrl().includes('dev') ? '.btn#submit' : '[data-qa-welcome-button]'; - - // Helper to check if on the Authorize 3rd Party App - const isOauthAuthPage = () => { - /** - * looking to determine if we're on the oauth/auth page - */ - try { - browser.waitForVisible('.oauthauthorize-page', constants.wait.short); - return true; - } catch (err) { - console.log('Not on the Oauth Page, continuing'); - return false; - } - } - - // Helper to check if CSRF error is displayed on the page - const csrfErrorExists = () => { - const sourceIncludesCSRF = browser.getSource().includes('CSRF'); - return sourceIncludesCSRF; - }; - - // Click the Login button - browser.click(loginButton); - - const onOauthPage = isOauthAuthPage(); - const csrfError = csrfErrorExists(); - - // If on the authorize page, click the authorize button - if (onOauthPage) { - $('.form-actions>.btn').click(); - } - - // If still on the login page, check for a form error - if (csrfError) { - // Attempt to Login after encountering the CSRF Error - browser.trySetValue('#password', password); - browser.trySetValue('#username', username); - $(loginButton).click(); - } - - // Wait for the add entity menu to exist - try { - browser.waitForExist('[data-qa-add-new-menu-button]', constants.wait.normal); - } catch (err) { - console.log('Add an entity menu failed to exist', 'Failed to login to the Manager for some reason.'); - console.error(`Current URL is:\n${browser.getUrl()}`); - console.error(`Page source: \n ${browser.getSource()}`); - } - - // Wait for the welcome modal to display, click it once it appears - if (browser.waitForVisible('[role="dialog"]')) { - browser.click(letsGoButton); - browser.waitForVisible('[role="dialog"]', constants.wait.long, true) - } - - browser.waitForVisible('[data-qa-add-new-menu-button]', constants.wait.long); - - if (credFilePath) { - exports.storeToken(credFilePath, username); - } -} - -exports.checkoutCreds = (credFilePath, specFile) => { - let credCollection = JSON.parse(readFileSync(credFilePath)); - return credCollection.find((cred, i) => { - if (!cred.inUse) { - credCollection[i].inUse = true; - credCollection[i].spec = specFile; - browser.options.testUser = credCollection[i].username; - writeFileSync(credFilePath, JSON.stringify(credCollection)); - return cred; - } - }); -} - -exports.checkInCreds = (credFilePath, specFile) => { - let credCollection = JSON.parse(readFileSync(credFilePath)); - return credCollection.find((cred, i) => { - if (cred.spec === specFile) { - credCollection[i].inUse = false; - credCollection[i].spec = ''; - // credCollection[i].token = ''; - writeFileSync(credFilePath, JSON.stringify(credCollection)); - return cred; - } - return; +module.exports.readToken = () => { + let token = null; + browser.call(() => { + return browser.credStore.readToken(browser.options.testUser).then((t) => token = t); }); + console.log(`token is ${token}`); + return token; } -exports.generateCreds = (credFilePath, config, userCount) => { - const credCollection = []; - - const setCredCollection = (userKey, userIndex) => { - const setEnvToken = process.env[`MANAGER_OAUTH${userIndex}`]; - const token = !!setEnvToken ? setEnvToken : ''; - const tokenFlag = !!token - credCollection.push({username: process.env[`${userKey}${userIndex}`], password: process.env[`MANAGER_PASS${userIndex}`], inUse: false, token: token, spec: '', isPresetToken: tokenFlag}); - } - - setCredCollection('MANAGER_USER', ''); - if ( userCount > 1 ) { - for( i = 2; i <= userCount; i++ ){ - setCredCollection('MANAGER_USER', `_${i}`); - } - } - writeFileSync(credFilePath, JSON.stringify(credCollection)); -} - -exports.cleanupAccounts = (credFilePath) => { - const credCollection = JSON.parse(readFileSync(credFilePath)); - credCollection.forEach(cred => { - return deleteAll(cred.token).then(() => {}); +module.exports.getToken = (username) => { + let token = null; + browser.call(() => { + return browser.credStore.readToken(username).then((t) => token = t); }); + console.log(`token is ${token}`); + return token; } /* diff --git a/e2e/utils/cred-store.js b/e2e/utils/cred-store.js new file mode 100644 index 00000000000..d5bc7f04b40 --- /dev/null +++ b/e2e/utils/cred-store.js @@ -0,0 +1,146 @@ +const { resetAccounts } = require('../setup/cleanup'); +const { constants } = require('../constants'); + +class CredStore { + + // default browser object is a mock to support testing outside the context + // of running the e2e tests. when running under e2e browser is passed in via + // web driver code that bootstraps the tests. + constructor(shouldCleanupUsingAPI = true, browser = { options: {} }) { + this.browser = browser; + this.shouldCleanupUsingAPI = shouldCleanupUsingAPI; + } + + setBrowser(browser) { + //console.log("setting browser to:"); + //console.log(browser); + this.browser = browser; + } + + // not sure if es6 supports abstract methods so using this as a hack to + // let implementor know that child class must provide an impl for this + // method. + getAllCreds() { + throw "CredStore.getAllCreds() needs to be implemented in child class"; + } + + cleanupAccounts() { + if (this.shouldCleanupUsingAPI) { + console.log("cleaning up user resources via API"); + return this.getAllCreds().then((credCollection) => { + console.log(credCollection); + return resetAccounts(credCollection); + }); + } else { + console.log("not cleaning up resources via API"); + return Promise.resolve(false); + } + } + + login(username, password, shouldStoreToken = false) { + console.log("logging in for user: " + username); + + let browser = this.browser; + + browser.url(constants.routes.linodes); + try { + browser.waitForVisible('#username', constants.wait.long); + } catch (err) { + console.log(browser.getSource()); + } + + browser.waitForVisible('#password', constants.wait.long); + browser.jsClick('#username'); + browser.trySetValue('#username', username); + browser.jsClick('#password'); + browser.trySetValue('#password', password); + + + let url = browser.getUrl(); + + const loginButton = url.includes('dev') ? '.btn#submit' : '[data-qa-sign-in] input'; + const letsGoButton = url.includes('dev') ? '.btn#submit' : '[data-qa-welcome-button]'; + + // Helper to check if on the Authorize 3rd Party App + const isOauthAuthPage = () => { + /** + * looking to determine if we're on the oauth/auth page + */ + try { + browser.waitForVisible('.oauthauthorize-page', constants.wait.short); + return true; + } catch (err) { + console.log('Not on the Oauth Page, continuing'); + return false; + } + } + + // Helper to check if CSRF error is displayed on the page + const csrfErrorExists = () => { + const sourceIncludesCSRF = browser.getSource().includes('CSRF'); + return sourceIncludesCSRF; + }; + + // Click the Login button + browser.click(loginButton); + + const onOauthPage = isOauthAuthPage(); + const csrfError = csrfErrorExists(); + + // If on the authorize page, click the authorize button + if (onOauthPage) { + $('.form-actions>.btn').click(); + } + + // If still on the login page, check for a form error + if (csrfError) { + // Attempt to Login after encountering the CSRF Error + browser.trySetValue('#password', password); + browser.trySetValue('#username', username); + $(loginButton).click(); + } + + // Wait for the add entity menu to exist + try { + browser.waitForExist('[data-qa-add-new-menu-button]', constants.wait.normal); + } catch (err) { + console.log('Add an entity menu failed to exist', 'Failed to login to the Manager for some reason.'); + console.error(`Current URL is:\n${browser.getUrl()}`); + console.error(`Page source: \n ${browser.getSource()}`); + } + + // Wait for the welcome modal to display, click it once it appears + if (browser.waitForVisible('[role="dialog"]')) { + browser.click(letsGoButton); + browser.waitForVisible('[role="dialog"]', constants.wait.long, true) + } + + browser.waitForVisible('[data-qa-add-new-menu-button]', constants.wait.long); + + // TODO fix storeToken implementation + //if (shouldStoreToken) { + // this.storeToken(username); + //} + } + + + /* + not currently working + + localStorage is a global var that's only available in the context of web driver. + + this would replace personal access tokens provided via env vars in favor of the access token + created when logging in. + + we can rework token handling in a followup feature branch. + */ + getTokenFromLocalStorage() { + const browserLocalStorage = browser.execute(function() { + return JSON.stringify(localStorage); // where does localStorage come from? + }); + const parsedLocalStorage = JSON.parse(browserLocalStorage.value); + return parsedLocalStorage['authentication/oauth-token']; + } + +} +module.exports = CredStore; \ No newline at end of file diff --git a/e2e/utils/fs-cred-store.js b/e2e/utils/fs-cred-store.js new file mode 100644 index 00000000000..c0b63f3822b --- /dev/null +++ b/e2e/utils/fs-cred-store.js @@ -0,0 +1,185 @@ +const moment = require('moment'); +const { existsSync, statSync, writeFile, readFile, unlink } = require('fs'); + +const CredStore = require('./cred-store'); + +/** + * Takes test user creds from environment variables and manages them in a json file + * on the local filesystem. + */ +class FSCredStore extends CredStore { + + constructor(credsFile, shouldCleanupUsingAPI, browser) { + super(shouldCleanupUsingAPI, browser); + this.credsFile = credsFile; + console.log(this); + } + + _readCredsFile() { + return new Promise((resolve, reject) => { + readFile(this.credsFile, (err, data) => { + if (err) { + reject(err); + } else { + resolve(JSON.parse(data)); + } + }); + }); + } + + _writeCredsFile(credCollection) { + return new Promise((resolve, reject) => { + writeFile(this.credsFile, JSON.stringify(credCollection), (err) => { + if (err) { + reject(err); + } else { + resolve(true); + } + }); + }); + } + + /* + * Not currently used, see comments in utils/cred-store.js + * + * Get localStorage after landing on homepage + * and write them out to a file for use in other tests + * @returns { String } stringified local storage object + */ + storeToken(username) { + // this needs to be fixed. won't actually update anything given current impl + // because it's not updating the token in credCollection. + return this._readCredsFile().then((credCollection) => { + let currentUser = credCollection.find(cred => cred.username === username); + + if (!currentUser.isPresetToken) { + currentUser.token = this.getTokenFromLocalStorage(); + } + + return this._writeCredsFile(credCollection); + }) + } + + readToken(username) { + return this._readCredsFile().then((credCollection) => { + const currentUserCreds = credCollection.find(cred => cred.username === username); + return currentUserCreds['token']; + }); + } + + + checkoutCreds(specFile) { + console.log("checkoutCreds: " + specFile); + return this._readCredsFile().then((credCollection) => { + const creds = credCollection.find((cred, i) => { + if (!cred.inUse) { + credCollection[i].inUse = true; + credCollection[i].spec = specFile; + + this.browser.options.testUser = credCollection[i].username; + return true; + } + }); + return this._writeCredsFile(credCollection).then(() => creds); + }); + } + + checkinCreds(specFile) { + console.log("checkinCreds: " + specFile); + + return this._readCredsFile().then((credCollection) => { + const creds = credCollection.find((cred, i) => { + if (cred.spec === specFile) { + credCollection[i].inUse = false; + credCollection[i].spec = ''; + // credCollection[i].token = ''; + + return true; + } + }); + return this._writeCredsFile(credCollection).then(() => creds); + }); + } + + generateCreds(config, userCount) { + const credCollection = []; + + const setCredCollection = (userKey, userIndex) => { + const setEnvToken = process.env[`MANAGER_OAUTH${userIndex}`]; + const token = !!setEnvToken ? setEnvToken : ''; + const tokenFlag = !!token + credCollection.push({username: process.env[`${userKey}${userIndex}`], password: process.env[`MANAGER_PASS${userIndex}`], inUse: false, token: token, spec: '', isPresetToken: tokenFlag}); + } + + setCredCollection('MANAGER_USER', ''); + if ( userCount > 1 ) { + for( let i = 2; i <= userCount; i++ ){ + setCredCollection('MANAGER_USER', `_${i}`); + } + } + + console.log("adding users:"); + console.log(credCollection); + return this._writeCredsFile(credCollection); + } + + getAllCreds() { + return this._readCredsFile(); + } + + cleanupAccounts() { + return super.cleanupAccounts() + .catch((err) => console.log(err)) + .then(() => { + return new Promise((resolve, reject) => { + unlink(this.credsFile, (err) => { + if (err) { + reject(err); + } else { + resolve(this.credsFile); + } + }) + }); + }) + } +} + +if (process.argv[2] == "test-fs") { + + console.log("running fs credential tests"); + + let mockTestConfig = {"host":"selenium","port":4444,"sync":true,"specs":["./e2e/specs/search/smoke-search.spec.js"],"suites":{},"exclude":["./e2e/specs/accessibility/*.spec.js"],"logLevel":"silent","coloredLogs":true,"deprecationWarnings":false,"baseUrl":"http://localhost:3000","bail":0,"waitforInterval":500,"waitforTimeout":30000,"framework":"jasmine","reporters":["spec","junit"],"reporterOptions":{"junit":{"outputDir":"./e2e/test-results"}},"maxInstances":1,"maxInstancesPerCapability":100,"connectionRetryTimeout":90000,"connectionRetryCount":3,"debug":false,"execArgv":null,"mochaOpts":{"timeout":10000},"jasmineNodeOpts":{"defaultTimeoutInterval":600000},"before":[null],"beforeSession":[],"beforeSuite":[],"beforeHook":[],"beforeTest":[],"beforeCommand":[],"afterCommand":[],"afterTest":[],"afterHook":[],"afterSuite":[],"afterSession":[],"after":[null],"onError":[],"onReload":[],"beforeFeature":[],"beforeScenario":[],"beforeStep":[],"afterFeature":[],"afterScenario":[],"afterStep":[],"mountebankConfig":{"proxyConfig":{"imposterPort":"8088","imposterProtocol":"https","imposterName":"Linode-API","proxyHost":"https://api.linode.com/v4","mutualAuth":true}},"testUser":"","watch":false}; + + let credStore = new FSCredStore("/tmp/e2e-users.js", false); + + // assumes env var config for 2 test users, see .env or .env.example + credStore.generateCreds(mockTestConfig, 2) + .then((r) => { + console.log("checking out creds"); + return credStore.checkoutCreds("spec1") + }) + .then((creds) => { + console.log("checked out creds are:"); + console.log(creds); + return credStore.checkinCreds("spec1"); + }) + .then((creds) => { + console.log("checked in creds are:"); + console.log(creds); + return credStore.readToken(creds.username); + }) + .then((token) => { + console.log("token is: " + token); + return credStore.getAllCreds(); + }).then((allCreds) => { + console.log("got all creds:"); + console.log(allCreds); + return credStore.cleanupAccounts(); + }) + .catch((err) => { + console.log("fs cred store test failed somewhere"); + console.log(err); + }); + +} +module.exports = FSCredStore; \ No newline at end of file diff --git a/e2e/utils/mongo-cred-store.js b/e2e/utils/mongo-cred-store.js new file mode 100644 index 00000000000..dba33548213 --- /dev/null +++ b/e2e/utils/mongo-cred-store.js @@ -0,0 +1,234 @@ +const MongoClient = require('mongodb').MongoClient; +const assert = require('assert'); +const CredStore = require('./cred-store'); + +/** + * Takes test user creds from environment variables and manages them in via a mongodb. + * + * Designed to be used when running e2e tests via docker compose (see integration-test.yml). + */ +class MongoCredStore extends CredStore { + + constructor(dbHost, shouldCleanupUsingAPI, browser) { + super(shouldCleanupUsingAPI, browser); + console.log("connecting to mongodb host: " + dbHost); + + // Connection URL + this.dbUrl = 'mongodb://' + dbHost + ':27017'; + + // Database Name + this.dbName = 'test-credentials'; + this.collectionName = 'users'; + console.log(this); + } + + // return MongoClient for use in chained promises + _connect() { + return MongoClient.connect(this.dbUrl, { useNewUrlParser: true }) + .catch((err) => { + console.log("error connecting to mongo"); + console.log(err); + }); + } + + generateCreds(config, userCount) { + // stores connection to mongo for use in chained promises + let mongo = null; + + return this._connect().then((mongoClient) => { + + console.log("populating creds"); + mongo = mongoClient; + + const collection = mongoClient.db(this.dbName).collection(this.collectionName); + return collection.createIndexes( + [{key: {inUse: 1, username: 1, spec: 1}}] + ); + + }).then((result) => { + console.log("initiailized users index"); + + const setCredCollection = (userKey, userIndex) => { + + const setEnvToken = process.env[`MANAGER_OAUTH${userIndex}`]; + const token = setEnvToken ? setEnvToken : ''; + const tokenFlag = token !== ''; + + let userRecord = { + username: process.env[`${userKey}${userIndex}`], + password: process.env[`MANAGER_PASS${userIndex}`], + inUse: false, + token: token, + spec: '', + isPresetToken: tokenFlag + }; + + const collection = mongo.db(this.dbName).collection(this.collectionName); + return collection.insertOne(userRecord).then(() => userRecord); + } + + const users = [setCredCollection('MANAGER_USER', '')]; + + if (userCount > 1) { + for (let i = 2; i <= userCount; i++ ) { + users.push(setCredCollection('MANAGER_USER', `_${i}`)); + } + } + return Promise.all(users); + }) + .then((users) => { + console.log("adding " + users.length + " users:"); + users.forEach((user) => { + console.log(user); + }); + console.log("closing mongo client for populating creds"); + return mongo.close(); + }) + } + + // fetches all available creds + getAllCreds() { + let mongo = null; + return this._connect().then((mongoClient) => { + mongo = mongoClient; + return mongo.db(this.dbName).collection(this.collectionName).find({}); + }).then((allCreds) => { + let credsCollection = allCreds.toArray(); + return mongo.close().then((r) => { return credsCollection; }); + }); + } + + checkoutCreds(specToRun) { + let mongo = null; + return this._connect().then((mongoClient) => { + mongo = mongoClient; + return mongo.db(this.dbName).collection(this.collectionName) + .findOneAndUpdate( + { inUse: false }, + { $set: { inUse: true, spec: specToRun } }, + { returnOriginal: false } + ) + + }) + .then((result) => { + console.log("checked out creds"); + const creds = result.value; + + this.browser.options.testUser = creds.username; + + return mongo.close().then((r) => { return creds; }); + }) + .catch((err) => { + console.log("error checking out creds for spec: " + specToRun); + console.log(err); + }); + } + + checkinCreds(specThatRan) { + // get the cred that's in use for the given spec + // then mark it as not in use (and available for the next spec) + let mongo = null; + return this._connect().then((mongoClient) => { + mongo = mongoClient; + + return mongo.db(this.dbName).collection(this.collectionName) + .findOneAndUpdate( + { spec: specThatRan }, + { $set: { inUse: false, spec: '' } }, + { returnOriginal: false } + ); + + }) + .then((result) => { + console.log("checked in creds"); + const creds = result.value; + return mongo.close().then((r) => { return creds; }); + }) + .catch((err) => { + console.log("error checking in creds for spec: " + specThatRan); + console.log(err); + }); + } + + readToken(username) { + let mongo = null; + return this._connect().then((mongoClient) => { + mongo = mongoClient; + return mongo.db(this.dbName).collection(this.collectionName).findOne( + { "username": username } + ); + }) + .then((creds) => { + console.log("read token for user: " + username); + return mongo.close().then((r) => creds.token); + }); + } + + storeToken(username) { + throw "MongoCredStore.storeToken not implemented. See comments in utils/cred-store.js."; + } + + cleanupAccounts() { + return super.cleanupAccounts() + .catch((err) => console.log(err)) + .then((users) => { + let mongo = null; + return this._connect().then((mongoClient) => { + console.log("dropping mongo creds collection"); + mongo = mongoClient; + return mongo.db(this.dbName).collection(this.collectionName).drop() + }).then((result) => { + console.log("closing mongo client for cleanup"); + return mongo.close(); + }); + }) + .catch((err) => console.log(err)); + } +} + +// runs test in if block below if this script is called directly with a "test-mongo" arg +// for example: +// source .env && node e2e/utils/mongo-cred-store.js test-mongo +// +// otherwise export a mongo credstore object instance for use in e2e tests when running via +// docker componse +if (process.argv[2] == "test-mongo") { + + // NOTE: test below requires mongo to be running locally via + // docker run -d -p 27017:27017 mongo + console.log("running mongo credential tests"); + + let mockTestConfig = {"host":"selenium","port":4444,"sync":true,"specs":["./e2e/specs/search/smoke-search.spec.js"],"suites":{},"exclude":["./e2e/specs/accessibility/*.spec.js"],"logLevel":"silent","coloredLogs":true,"deprecationWarnings":false,"baseUrl":"http://localhost:3000","bail":0,"waitforInterval":500,"waitforTimeout":30000,"framework":"jasmine","reporters":["spec","junit"],"reporterOptions":{"junit":{"outputDir":"./e2e/test-results"}},"maxInstances":1,"maxInstancesPerCapability":100,"connectionRetryTimeout":90000,"connectionRetryCount":3,"debug":false,"execArgv":null,"mochaOpts":{"timeout":10000},"jasmineNodeOpts":{"defaultTimeoutInterval":600000},"before":[null],"beforeSession":[],"beforeSuite":[],"beforeHook":[],"beforeTest":[],"beforeCommand":[],"afterCommand":[],"afterTest":[],"afterHook":[],"afterSuite":[],"afterSession":[],"after":[null],"onError":[],"onReload":[],"beforeFeature":[],"beforeScenario":[],"beforeStep":[],"afterFeature":[],"afterScenario":[],"afterStep":[],"mountebankConfig":{"proxyConfig":{"imposterPort":"8088","imposterProtocol":"https","imposterName":"Linode-API","proxyHost":"https://api.linode.com/v4","mutualAuth":true}},"testUser":"","watch":false}; + let mongoCredStore = new MongoCredStore("localhost", false); + + // assumes env var config for 2 test users, see .env or .env.example + mongoCredStore.generateCreds(mockTestConfig, 2) + .then((r) => { + console.log("checking out creds"); + return mongoCredStore.checkoutCreds("spec1") + }) + .then((creds) => { + console.log("checked out creds are:"); + console.log(creds); + return mongoCredStore.checkinCreds("spec1"); + }) + .then((creds) => { + console.log("checked in creds are:"); + console.log(creds); + return mongoCredStore.readToken(creds.username); + }).then((token) => { + console.log("token for username is: " + token); + return mongoCredStore.getAllCreds(); + }).then((allCreds) => { + console.log("got all creds:"); + console.log(allCreds); + return mongoCredStore.cleanupAccounts(); + }) + .catch((err) => { + console.log("mongo cred store test failed somewhere"); + console.log(err); + }); + +} + +module.exports = MongoCredStore; \ No newline at end of file diff --git a/integration-test.yml b/integration-test.yml index 8cd16716787..3820740ff69 100644 --- a/integration-test.yml +++ b/integration-test.yml @@ -31,6 +31,7 @@ services: - chrome manager-e2e: environment: + - CRED_STORE_MODE=${CRED_STORE_MODE} - DOCKER=true - REACT_APP_APP_ROOT=${REACT_APP_APP_ROOT} - REACT_APP_API_ROOT=${REACT_APP_API_ROOT} @@ -45,6 +46,9 @@ services: dockerfile: Dockerfile volumes: - ./e2e/test-results:/src/e2e/test-results - entrypoint: ["./scripts/wait-for-it.sh", "-t", "500", "-s", "manager-local:3000", "--", "yarn","e2e", "--log"] + entrypoint: ["./scripts/wait-for-it.sh", "-t", "500", "-s", "manager-local:3000", "--", "yarn", "e2e", "--log"] depends_on: - manager-local + - mongodb + mongodb: + image: mongo:latest diff --git a/package.json b/package.json index 7ae62dd1924..26026f4233a 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,7 @@ "lint-staged": "^8.1.0", "madge": "^3.4.3", "mini-css-extract-plugin": "^0.5.0", + "mongodb": "^3.1.13", "mountebank": "^1.14.1", "otplib": "^10.0.1", "postcss-flexbugs-fixes": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index c393aaad98e..3d243d9005c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4221,6 +4221,11 @@ bser@^2.0.0: dependencies: node-int64 "^0.4.0" +bson@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.1.tgz#4330f5e99104c4e751e7351859e2d408279f2f13" + integrity sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg== + buffer-alloc-unsafe@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" @@ -10320,6 +10325,11 @@ memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: errno "^0.1.3" readable-stream "^2.0.1" +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + meow@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" @@ -10601,6 +10611,25 @@ moment-timezone@^0.5.16: resolved "https://registry.yarnpkg.com/moment/-/moment-2.23.0.tgz#759ea491ac97d54bac5ad776996e2a58cc1bc225" integrity sha512-3IE39bHVqFbWWaPOMHZF98Q9c3LDKGTmypMiTM2QygGXXElkFWIH7GxfmlwmY2vwa+wmNsoYZmG2iusf1ZjJoA== +mongodb-core@3.1.11: + version "3.1.11" + resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.1.11.tgz#b253038dbb4d7329f3d1c2ee5400bb0c9221fde5" + integrity sha512-rD2US2s5qk/ckbiiGFHeu+yKYDXdJ1G87F6CG3YdaZpzdOm5zpoAZd/EKbPmFO6cQZ+XVXBXBJ660sSI0gc6qg== + dependencies: + bson "^1.1.0" + require_optional "^1.0.1" + safe-buffer "^5.1.2" + optionalDependencies: + saslprep "^1.0.0" + +mongodb@^3.1.13: + version "3.1.13" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.1.13.tgz#f8cdcbb36ad7a08b570bd1271c8525753f75f9f4" + integrity sha512-sz2dhvBZQWf3LRNDhbd30KHVzdjZx9IKC0L+kSZ/gzYquCF5zPOgGqRz6sSCqYZtKP2ekB4nfLxhGtzGHnIKxA== + dependencies: + mongodb-core "3.1.11" + safe-buffer "^5.1.2" + moo@^0.4.3: version "0.4.3" resolved "https://registry.yarnpkg.com/moo/-/moo-0.4.3.tgz#3f847a26f31cf625a956a87f2b10fbc013bfd10e" @@ -13124,6 +13153,14 @@ require-uncached@^1.0.3: caller-path "^0.1.0" resolve-from "^1.0.0" +require_optional@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.1.tgz#4cf35a4247f64ca3df8c2ef208cc494b1ca8fc2e" + integrity sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g== + dependencies: + resolve-from "^2.0.0" + semver "^5.1.0" + requirejs-config-file@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/requirejs-config-file/-/requirejs-config-file-3.1.1.tgz#ccb8efbe2301c24ac0cf34dd3f3c862741750f5c" @@ -13180,6 +13217,11 @@ resolve-from@^1.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" integrity sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY= +resolve-from@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" + integrity sha1-lICrIOlP+h2egKgEx+oUdhGWa1c= + resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" @@ -13364,6 +13406,13 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" +saslprep@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.2.tgz#da5ab936e6ea0bbae911ffec77534be370c9f52d" + integrity sha512-4cDsYuAjXssUSjxHKRe4DTZC0agDwsCqcMqtJAQPzC74nJ7LfAJflAtC1Zed5hMzEQKj82d3tuzqdGNRsLJ4Gw== + dependencies: + sparse-bitfield "^3.0.3" + sass-lookup@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-3.0.0.tgz#3b395fa40569738ce857bc258e04df2617c48cac" @@ -13851,6 +13900,13 @@ space-separated-tokens@^1.0.0: dependencies: trim "0.0.1" +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" + integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE= + dependencies: + memory-pager "^1.0.2" + spawn-promise@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/spawn-promise/-/spawn-promise-0.1.8.tgz#a5bea98814c48f52cbe02720e7fe2d6fc3b5119a"