diff --git a/bin/cleanup-test-users.js b/bin/cleanup-test-users.js index d97bfb0d9..38a11cc28 100644 --- a/bin/cleanup-test-users.js +++ b/bin/cleanup-test-users.js @@ -2,7 +2,7 @@ require('dotenv').config(); const rp = require('request-promise').defaults({ - url: `https://graph.facebook.com/v3.3/${process.env.FACEBOOK_CLIENT_ID}/accounts/test-users`, + url: `https://graph.facebook.com/v4.0/${process.env.FACEBOOK_CLIENT_ID}/accounts/test-users`, json: true, qs: { access_token: process.env.FACEBOOK_APP_TOKEN, @@ -12,7 +12,7 @@ const rp = require('request-promise').defaults({ async function fetchUsers(url = '') { const { data, paging } = await rp.get(url); for (const user of data) { - await rp.delete(`https://graph.facebook.com/v3.3/${user.id}`); + await rp.delete(`https://graph.facebook.com/v4.0/${user.id}`); process.stdout.write('.'); } diff --git a/rfcs/facebook_oauth_tests_throttling_fix.md b/rfcs/facebook_oauth_tests_throttling_fix.md new file mode 100644 index 000000000..5b14cfe47 --- /dev/null +++ b/rfcs/facebook_oauth_tests_throttling_fix.md @@ -0,0 +1,129 @@ +# Facebook OAuth tests change + +## Overview and Motivation +After some updates Facebook Graph API throttling mechanisms, OAuth tests started failing randomly with 403 code or +`invalid credentials` ms-users error. +Graph API limits requests by a sliding window algorithm, which allows some(not all) requests even after block. +The changes listed here will provide better stability in OAuth error handling and overall test suites stability. + +## Facebook Throttling +After some changes in Facebook GraphAPI `throttling` behavior became much stronger. +On Every GraphAPI request, facebook response contains special header `X-App-Usage`: +```javascript +const XAppUsage = { + 'call_count': 28, //Percentage of calls made + 'total_time': 15, //Percentage of total time + 'total_cputime' : 24 //Percentage of total CPU time +} +``` +If the value of any field becomes greater than 100 causes `throttling`. +This header helps to determine how soon `throttling` will happen. + +### OAuth API v3.3 v4.0 Update +Updated OAuth API. There is no braking changes and there's no additional work to upgrade version. +API 3.3 is not available for new Applications and all requests made to this version are redirected to v4.0. + +### Tests `Missing Credentials` Error +Mishandling `@hapi/boom` error causes the OAuth strategy to continue its execution but without mandatory `credentials` and this caused the random tests to fail. + +##### Solution +Implement additional error conversion from `@boom/error` to general `HTTPError`. +This return OAuth's error processing to normal behavior. + +### Tests `403 - Application throttled` Error +Before every test, some direct GraphApi calls made. In some `throttling` conditions requests rejected with `403` Error, +this happens because of Facebook call Throttling. + +### @hapi/bell error handling +When the user declines an application to access the 500 Error returned from `@hapi/bell`. +When application throttled or request `200 < statusCode > 299` the same 500 Error returned with Response as `data` field. + +On catch block after `http.auth.test` call added additional error processing on @hapi/boom error: +* Cleans references to `http.IncomingMessage` from error, otherwise further error serialization shows all contents of `http.IncommingMessage` class. +IMHO: That's weird to show Buffers and other internal stuff. +* If error message contains information that user Rejected App access, returns Error.AuthenticationRequiredError` with `OAuth App rejected: Permissions error` . to save current behavior and return 401 StatusCode. +* Other errors are thrown as @hapi/Bomm error. + +Removed `isError` check and function from `auth/oauth/index.js` because it's impossible to receive a response with statusCode other than 200 from `@hapi/bell`. +According to current source code, only `redirects` and `h.unauthenticated(internal_errors)`(used to report OAuth request errors) thrown from `hapi.auth.test`. + +#### Overall Graph API over-usage +Over-usage of Graph API request time produced by `createTestUser` API calls, this API call uses a lot of API's execution time and increments Facebook's counters fast. +Before every test, we execute `createTestUser` 3*testCount times, so each suite execution performs about 39 long-running calls. +Facebook Graph API starts throttling requests after 4-6 suite runs. + +##### Solution +Exclude long-running `createTestUser` operation from tests and reuse test users. +Attempt to reduce API call count. + +## Test logic changes + +### Test Users Create +Test users will be created once before all tests and reused. +After each test run, users Application permissions revoked depending on user context: +* For `testUserInstalledPartial` test revokes only `email` permission. +* For `testUser` test revokes all permissions. + +When the test suite finishes users deleted, this saves us from hitting the Facebook TestUser count limit. + +### Test Users +There are 3 types of Facebook users used In the current test suites: +```javascript +const createTestUser = (localCache = cache) => Promise.props({ + testUser: createTestUserAPI(), + testUserInstalled: createTestUserAPI({ installed: true }), + testUserInstalledPartial: createTestUserAPI({ permissions: 'public_profile' }), +}) +``` + +#### testUserInstalled +**`testUserInstalled`** not used in tests. After review of all commit history for `suites/oauth/facebook.js`, found that +this user was defined in [this commit](https://github.com/makeomatic/ms-users/blob/733aba371b62d90935c42087ca6d3912152cb63b/test/suites/oauth/facebook.js) +and never used. + +According to the users' props, it defined for `sign-in` tests without creating the new user, +but these tests use `testUser` in all suite and behave like other sign-in/sign-up tests. + +Startup logic and checks for these tests is almost the same and works like: +1. Call `https://mservice/users/oauth/facebook` +2. The Service redirects to the Facebook Login form. +3. Puppeter fills in Login and password. +4. Puppeter Confirms/Rejects Application request. + +`testUser` totally responds to our needs and `testUserInstalled` looks unused. + +**!!!** _Assuming that we can remove the `testUserInstalled` user._ + +#### testUserInstalledPartial +**`testUserPartial`** used in tests that checking partial permissions in registration/login and used as the previous scope, +but users prop `installed` false. + +In current partial permission tests, Facebook asks 2 permissions(public_profile, email), this indicates that +partial `permissions` ignored on the user creation process. So we can safely deauthorize application using 'DELETE /uid/permissions' request. + +#### Changes: +To make tests more readable and a bit easier to read some methods moved to separate files: +* WebExecuter class - contains all actions performed with the Facebook page using `Puppeter`. +* GraphApi Class - contains all calls to the Facebook Graph API. + +Repeating `asserts` moved outside of tests into functions. +Tests regrouped by User type. Some duplicate actions moved into `hooks`. + +### Additional tests/functionality? +Current tests cover all possible situations, even for the user with Application `installed === true` property. +All test cases for this type of user look the same as previous tests and code duplicates because the same API used. +One difference in them, when the user already has some permissions provided to the application, +'Facebook Permission Access' screen shows a smaller permission list +and only `signInAndNavigate` method changed in 1 row(index of the checkbox to click). + + +### GDPR NOTE +In our case, GDPR not applied inside the scope of the 'Facebook Login' feature. +Facebook is a Data Provider for us and using own privacy policy that allows us to use +data provided from Facebook. +From our side, we must add `Cookie and Data collection notification` - already exists. + +Some changes should be made if We use Android or IOS Facebook SDK in event tracking functions. +For Detailed description visit [this page](https://www.facebook.com/business/m/one-sheeters/gdpr-developer-faqs) + +Additional info can be found [here](https://developers.facebook.com/docs/app-events/best-practices/gdpr-compliance) diff --git a/src/auth/oauth/index.js b/src/auth/oauth/index.js index 92dcfecae..ecd0571b9 100644 --- a/src/auth/oauth/index.js +++ b/src/auth/oauth/index.js @@ -1,5 +1,6 @@ const Promise = require('bluebird'); const Errors = require('common-errors'); +const Boom = require('@hapi/boom'); const get = require('../../utils/get-value'); const getUid = require('./utils/uid'); @@ -13,10 +14,31 @@ const { USERS_ID_FIELD, ErrorTotpRequired } = require('../../constants'); // helpers const isRedirect = ({ statusCode }) => statusCode === 301 || statusCode === 302; -const isError = ({ statusCode }) => statusCode >= 400; const is404 = ({ statusCode }) => statusCode === 404; const isHTMLRedirect = ({ statusCode, source }) => statusCode === 200 && source; +/** + * Cleanup boom error and return matching error; + * @param @hapi/boom error + * @returns {error} + */ +function checkBoomError(error) { + const { data: errData } = error; + const { message } = error; + + // can contain another Boom error + if (errData !== null && typeof errData === 'object') { + // delete reference to http.IncommingMessage + delete errData.data.res; + } + + if (message.startsWith('App rejected')) { + return Errors.AuthenticationRequiredError(`OAuth ${error.message}`, error); + } + + return error; +} + /** * Authentication handler * @param {HttpClientResponse} response @@ -31,7 +53,7 @@ function oauthVerification(response, credentials) { }, 'service oauth verification'); if (response) { - if (isError(response) || isHTMLRedirect(response)) { + if (isHTMLRedirect(response)) { return Promise.reject(response); } @@ -149,6 +171,11 @@ module.exports = async function authHandler({ action, transportRequest }) { const { credentials } = await http.auth.test(strategy, transportRequest); response = [null, credentials]; } catch (err) { + // No need to go further if Oauth Error happened + if (Boom.isBoom(err)) { + throw checkBoomError(err); + } + // continue if redirect response = [err]; } diff --git a/src/auth/oauth/utils/fb-urls.js b/src/auth/oauth/utils/fb-urls.js index d9d5aaf75..273656466 100644 --- a/src/auth/oauth/utils/fb-urls.js +++ b/src/auth/oauth/utils/fb-urls.js @@ -1,5 +1,5 @@ module.exports = class Urls { - static DEFAULT_API_VERSION = 'v3.3'; + static DEFAULT_API_VERSION = 'v4.0'; static self = null; diff --git a/test/configs/core.js b/test/configs/core.js index b20057a73..320405cfd 100644 --- a/test/configs/core.js +++ b/test/configs/core.js @@ -34,7 +34,7 @@ module.exports = { clientSecret: process.env.FACEBOOK_CLIENT_SECRET, location: 'https://ms-users.local', password: 'lB4wlZByzpp2R9mGefiLeaZUwVooUuX7G7uctaoeNgxvUs3W', - apiVersion: 'v3.3', + apiVersion: 'v4.0', }, }, }, diff --git a/test/helpers/oauth/facebook/graph-api.js b/test/helpers/oauth/facebook/graph-api.js new file mode 100644 index 000000000..39547306a --- /dev/null +++ b/test/helpers/oauth/facebook/graph-api.js @@ -0,0 +1,70 @@ +const request = require('request-promise'); + +/** + * Class wraps Facebook Graph API requests + */ +class GraphAPI { + static graphApi = request.defaults({ + baseUrl: 'https://graph.facebook.com/v4.0', + headers: { + Authorization: `OAuth ${process.env.FACEBOOK_APP_TOKEN}`, + }, + json: true, + }); + + /** + * Creates test user with passed `props` + * @param props + * @returns {*} + */ + static createTestUser(props = {}) { + return this.graphApi({ + uri: `/${process.env.FACEBOOK_CLIENT_ID}/accounts/test-users`, + method: 'POST', + body: { + installed: false, + ...props, + }, + }).promise(); + } + + /** + * Deletes test user by id + * @param userId + * @returns {*} + */ + static deleteTestUser(userId) { + return this.graphApi({ + uri: `${userId}`, + method: 'DELETE', + }).promise(); + } + + /** + * Removes all Application permissions. + * This only the way to De Authorize Application from user. + * @param userId + * @returns {Promise} + */ + static async deAuthApplication(userId) { + return this.graphApi({ + uri: `/${userId}/permissions`, + method: 'DELETE', + }); + } + + /** + * Delete any Application permission from user. + * @param userId + * @param permission + * @returns {Promise} + */ + static async deletePermission(userId, permission) { + return this.graphApi({ + uri: `/${userId}/permissions/${permission}`, + method: 'DELETE', + }); + } +} + +module.exports = GraphAPI; diff --git a/test/helpers/oauth/facebook/index.js b/test/helpers/oauth/facebook/index.js new file mode 100644 index 000000000..7b9a1ee64 --- /dev/null +++ b/test/helpers/oauth/facebook/index.js @@ -0,0 +1,7 @@ +const GraphApi = require('./graph-api'); +const WebExecuter = require('./web-executer'); + +module.exports = { + GraphApi, + WebExecuter, +}; diff --git a/test/helpers/oauth/facebook/web-executer.js b/test/helpers/oauth/facebook/web-executer.js new file mode 100644 index 000000000..d64f6ef61 --- /dev/null +++ b/test/helpers/oauth/facebook/web-executer.js @@ -0,0 +1,237 @@ +const vm = require('vm'); +const cheerio = require('cheerio'); +const puppeteer = require('puppeteer'); +const Promise = require('bluebird'); +const assert = require('assert'); + +/** + * Wrap for all `puppeter` actions needed to test Facebook Login process + */ +class WebExecuter { + static get serviceLink() { return this._serviceLink; } + + static set serviceLink(v) { this._serviceLink = v; } + + constructor() { + this._serviceLink = WebExecuter.serviceLink; + } + + async stop() { + const { page, chrome } = this; + if (page) await page.close(); + if (chrome) await chrome.close(); + } + + async start() { + this.chrome = await puppeteer.launch({ + executablePath: '/usr/bin/chromium-browser', + ignoreHTTPSErrors: true, + args: ['--no-sandbox'], + }); + + const page = this.page = await this.chrome.newPage(); + // rewrite window.close() + await page.exposeFunction('close', () => ( + console.info('triggered window.close()') + )); + + page.on('requestfinished', (req) => { + this.lastRequestResponse = req.response(); + }); + } + + /** + * Navigates chrome to service oauth endpoint + * Waits until the facebook login page appears + * Enters users credentials and presses on login button + */ + async initiateAuth(user) { + const { _serviceLink, page } = this; + const executeLink = `${_serviceLink}/users/oauth/facebook`; + + try { + await page.goto(executeLink, { waitUntil: 'networkidle2' }); + await page.screenshot({ fullPage: true, path: './ss/1.png' }); + await page.waitForSelector('input#email'); + await page.type('input#email', user.email, { delay: 100 }); + await page.screenshot({ fullPage: true, path: './ss/2.png' }); + await page.waitForSelector('input#pass'); + await page.type('input#pass', user.password, { delay: 100 }); + await page.screenshot({ fullPage: true, path: './ss/3.png' }); + await page.click('button[name=login]', { delay: 100 }); + } catch (e) { + console.error('failed to initiate auth', e); + await page.screenshot({ fullPage: true, path: `./ss/initiate-auth-${Date.now()}.png` }); + throw e; + } + } + + /** + * Passes authentication process and simulates that user revokes some permission + * + * @param user + * @param predicate + * @param permissionIndex - Index of the item in the facebook permission access request to be clicked + * @returns {Promise<*>} + */ + async signInAndNavigate(user, predicate, permissionIndex = 2) { + await this.initiateAuth(user); + const { page } = this; + let response; + try { + await page.waitForSelector('#platformDialogForm a[id]', { visible: true }); + await page.screenshot({ fullPage: true, path: `./ss/sandnav-initial-${Date.now()}.png` }); + await page.click('#platformDialogForm a[id]', { delay: 100 }); + await Promise.delay(300); + await page.screenshot({ fullPage: true, path: `./ss/sandnav-before-${Date.now()}.png` }); + await page.waitForSelector(`#platformDialogForm label:nth-child(${permissionIndex})`, { visible: true }); + await page.click(`#platformDialogForm label:nth-child(${permissionIndex})`, { delay: 100 }); + await Promise.delay(300); + await page.screenshot({ fullPage: true, path: `./ss/sandnav-after-${Date.now()}.png` }); + await page.waitForSelector('button[name=__CONFIRM__]', { visible: true }); + await page.click('button[name=__CONFIRM__]', { delay: 100 }); + response = await page.waitForResponse(predicate); + } catch (e) { + console.error('failed to navigate', e); + await page.screenshot({ fullPage: true, path: `./ss/sandnav-${Date.now()}.png` }); + throw e; + } + + return response; + } + + /** + * When login succeeded, Facebook shows 'Application access' request form + * Pressing `Confirm` + * @param user + * @returns {Promise} + */ + async authenticate(user) { + await this.initiateAuth(user); + await Promise.delay(1000); + + const { page } = this; + try { + await page.waitForSelector('button[name=__CONFIRM__]'); + await page.screenshot({ fullPage: true, path: `./ss/authenticate-accept-${Date.now()}.png` }); + await page.click('button[name=__CONFIRM__]', { delay: 100 }); + } catch (e) { + await page.screenshot({ fullPage: true, path: `./ss/authenticate-${Date.now()}.png` }); + throw e; + } + } + + /** + * Simulates situation when user declines `Application access` request form + * @param user + * @returns {Promise<*>} + */ + async rejectAuth(user) { + await this.initiateAuth(user); + const { page } = this; + try { + await this.page.waitForSelector('button[name=__CANCEL__]'); + await this.page.click('button[name=__CANCEL__]'); + return await this.navigatePage(); + } catch (e) { + await page.screenshot({ fullPage: true, path: `./ss/declined-${Date.now()}.png` }); + throw e; + } + } + + /** + * Gets Results from `ms-users.oauth` endpoint after successful Facebook Login + * @param user + * @returns {Promise<{body: *, token: *}>} + */ + async getToken(user) { + const { page } = this; + + await this.authenticate(user); + await Promise.all([ + this.navigatePage(), // so that refresh works, etc + page.waitForSelector('.no-js > body > script'), + ]); + + try { + const body = await this.extractMsUsersPostMessage(); + + assert(body.payload.token, JSON.stringify(body)); + + return { + body, + token: body.payload.token, + }; + } catch (e) { + await page.screenshot({ fullPage: true, path: `./ss/token-${Date.now()}.png` }); + throw e; + } + } + + /** + * Executes sign-in process for active page using provided token. + * Assuming that Auth process passed before. + * @param token + * @returns {Promise<{body: *, url: *, status: *}>} + */ + async signInWithToken(token) { + const executeLink = `${this._serviceLink}/users/oauth/facebook?jwt=${token}`; + return this.navigatePage({ href: executeLink }); + } + + /** + * Get ms-users oauth result variable + * @returns {Promise<*>} + */ + async extractMsUsersPostMessage() { + return this.page.evaluate('window.$ms_users_inj_post_message'); + } + + /** + * Navigates Chrome page to provided url or waits for redirect occurred + * @param href + * @param waitUntil + * @returns {Promise<{body: *, url: *, status: *}>} + */ + async navigatePage({ href, waitUntil = 'networkidle0' } = {}) { + const { page } = this; + + if (href) { + await page.goto(href, { waitUntil, timeout: 30000 }); + } else { + await page.waitForNavigation({ waitUntil, timeout: 30000 }); + } + + // just to be sure + await Promise.delay(1500); + + // maybe this is the actual request status code + const status = this.lastRequestResponse.status(); + const url = page.url(); + let body; + try { + body = await page.content(); + } catch (e) { + body = e.message; + } + + console.info('%s - %s', status, url); + + return { body, status, url }; + } + + /** + * Executes provided HTML and returns resulting Window Context. + * @param body + * @returns {Context} + */ + static getJavascriptContext(body) { + const $ = cheerio.load(body); + const vmScript = new vm.Script($('.no-js > body > script').html()); + const context = vm.createContext({ window: { close: () => {} } }); + vmScript.runInContext(context); + return context; + } +} + +module.exports = WebExecuter; diff --git a/test/suites/oauth/facebook.js b/test/suites/oauth/facebook.js index ff60fb157..c9e8a5771 100644 --- a/test/suites/oauth/facebook.js +++ b/test/suites/oauth/facebook.js @@ -4,56 +4,54 @@ const authenticator = require('otplib/authenticator'); const { inspectPromise } = require('@makeomatic/deploy'); const Promise = require('bluebird'); -const vm = require('vm'); -const cheerio = require('cheerio'); + const assert = require('assert'); const forEach = require('lodash/forEach'); -const request = require('request-promise'); -const puppeteer = require('puppeteer'); - -const serviceLink = 'https://ms-users.local'; -const graphApi = request.defaults({ - baseUrl: 'https://graph.facebook.com/v3.3', - headers: { - Authorization: `OAuth ${process.env.FACEBOOK_APP_TOKEN}`, - }, - json: true, -}); -const cache = {}; +const { GraphApi, WebExecuter } = require('../../helpers/oauth/facebook'); + +/* Set our service url */ +WebExecuter.serviceLink = 'https://ms-users.local'; const defaultAudience = '*.localhost'; -const createTestUserAPI = (props = {}) => graphApi({ - uri: `/${process.env.FACEBOOK_CLIENT_ID}/accounts/test-users`, - method: 'POST', - body: { - installed: false, - ...props, - }, -}).promise(); - -const createTestUser = (localCache = cache) => Promise.props({ - testUser: createTestUserAPI(), - testUserInstalled: createTestUserAPI({ installed: true }), - testUserInstalledPartial: createTestUserAPI({ permissions: 'public_profile' }), -}).then((data) => { - Object.assign(localCache, data); -}); +/** + * Checking whether user successfully logged-in or registered + * @param payload + */ +function checkServiceOkResponse(payload) { + assert(payload.hasOwnProperty('jwt')); + assert(payload.hasOwnProperty('user')); + assert(payload.user.hasOwnProperty('metadata')); + assert(payload.user.metadata.hasOwnProperty(defaultAudience)); + assert(payload.user.metadata[defaultAudience].hasOwnProperty('facebook')); + assert.ifError(payload.user.password); + assert.ifError(payload.user.audience); +} -function parseHTML(body) { - const $ = cheerio.load(body); - const vmScript = new vm.Script($('.no-js > body > script').html()); - const context = vm.createContext({ window: { close: () => {} } }); - vmScript.runInContext(context); - return context; +/** + * Check whether service responded with 'missing permissions' + * Used in tests checking partial permission access + * @param context + */ +function checkServiceMissingPermissionsResponse(context) { + assert.ok(context.$ms_users_inj_post_message); + assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:attached'); + assert.equal(context.$ms_users_inj_post_message.error, false); + assert.deepEqual(context.$ms_users_inj_post_message.missingPermissions, ['email']); + assert.ok(context.$ms_users_inj_post_message.payload.token, 'missing token'); + assert.equal(context.$ms_users_inj_post_message.payload.provider, 'facebook'); } describe('#facebook', function oauthFacebookSuite() { - let chrome; - let page; let service; - let lastRequestResponse; + /** + * Creates new account in `ms-users` service. + * Function slightly different from `helpers/registerUser`. + * @param token + * @param overwrite + * @returns {Promise | * | Thenable | PromiseLike | Promise} + */ function createAccount(token, overwrite = {}) { const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64')); const opts = { @@ -72,493 +70,419 @@ describe('#facebook', function oauthFacebookSuite() { .then(inspectPromise(true)); } - async function initiateAuth(_user) { - const user = _user || cache.testUser; - const executeLink = `${serviceLink}/users/oauth/facebook`; - - try { - await page.goto(executeLink, { waitUntil: 'networkidle2' }); - await page.screenshot({ fullPage: true, path: './ss/1.png' }); - await page.waitForSelector('input#email'); - await page.type('input#email', user.email, { delay: 100 }); - await page.screenshot({ fullPage: true, path: './ss/2.png' }); - await page.waitForSelector('input#pass'); - await page.type('input#pass', user.password, { delay: 100 }); - await page.screenshot({ fullPage: true, path: './ss/3.png' }); - await page.click('button[name=login]', { delay: 100 }); - } catch (e) { - console.error('failed to initiate auth', e); - await page.screenshot({ fullPage: true, path: `./ss/initiate-auth-${Date.now()}.png` }); - throw e; - } - } - - async function authenticate() { - await initiateAuth(); - await Promise.delay(1000); - - try { - await page.waitForSelector('button[name=__CONFIRM__]'); - await page.click('button[name=__CONFIRM__]', { delay: 100 }); - } catch (e) { - await page.screenshot({ fullPage: true, path: `./ss/authenticate-${Date.now()}.png` }); - throw e; - } - } - - async function extractBody() { - return page.evaluate('window.$ms_users_inj_post_message'); - } - - async function navigate({ href, waitUntil = 'networkidle0' } = {}) { - if (href) { - await page.goto(href, { waitUntil, timeout: 30000 }); - } else { - await page.waitForNavigation({ waitUntil, timeout: 30000 }); - } - - // just to be sure - await Promise.delay(1000); - // maybe this is the actual request status code - const status = lastRequestResponse.status(); - const url = page.url(); - let body; - try { - body = await page.content(); - } catch (e) { - body = e.message; - } - - console.info('%s - %s', status, url); - - return { body, status, url }; - } - - async function getFacebookToken() { - await authenticate(); - await Promise.all([ - navigate(), // so that refresh works, etc - page.waitForSelector('.no-js > body > script'), - ]); - - try { - const body = await extractBody(); - - assert(body.payload.token, JSON.stringify(body)); - - return { - body, - token: body.payload.token, - }; - } catch (e) { - await page.screenshot({ fullPage: true, path: `./ss/token-${Date.now()}.png` }); - throw e; - } - } - - async function signInAndNavigate(predicate) { - await initiateAuth(cache.testUserInstalledPartial); - - let response; - try { - await page.waitForSelector('#platformDialogForm a[id]', { visible: true }); - await page.click('#platformDialogForm a[id]', { delay: 100 }); - await Promise.delay(300); - await page.waitForSelector('#platformDialogForm label:nth-child(2)', { visible: true }); - await page.click('#platformDialogForm label:nth-child(2)', { delay: 100 }); - await Promise.delay(300); - await page.waitForSelector('button[name=__CONFIRM__]', { visible: true }); - await page.click('button[name=__CONFIRM__]', { delay: 100 }); - response = await page.waitForResponse(predicate); - } catch (e) { - console.error('failed to navigate', e); - await page.screenshot({ fullPage: true, path: `./ss/sandnav-${Date.now()}.png` }); - throw e; - } - - return response; - } - - // need to relaunch each time for clean contexts - beforeEach('init Chrome', async () => { - chrome = await puppeteer.launch({ - executablePath: '/usr/bin/chromium-browser', - ignoreHTTPSErrors: true, - args: ['--no-sandbox'], - }); - page = await chrome.newPage(); - - // rewrite window.close() - await page.exposeFunction('close', () => ( - console.info('triggered window.close()') - )); - - page.on('requestfinished', (req) => { - lastRequestResponse = req.response(); - }); - }); - + /* Restart service before each test to achieve clean database. */ beforeEach('start', async () => { service = await global.startService(this.testConfig); }); - beforeEach('create user', createTestUser); - afterEach(async () => { - if (page) await page.close(); - if (chrome) await chrome.close(); - await global.clearRedis(); - }); - it('should able to retrieve faceboook profile', async () => { - const { token, body } = await getFacebookToken(); - assert(token, `did not get token - ${token} - ${body}`); + afterEach('stop', async () => { + await global.clearRedis(); }); - it('should able to handle declined authentication', async () => { - await initiateAuth(); - - try { - await page.waitForSelector('button[name=__CANCEL__]'); - await page.click('button[name=__CANCEL__]'); + /** + * Suite works with 'Fresh' user. + * Application has any access to the users Facebook profile. + * This suite don't need to recreate user for each test and we can use one AuthToken in all tests. + */ + describe('new user', async () => { + let generalUser; - const { status, url } = await navigate(); - assert(status === 401, `statusCode is ${status}, url is ${url}`); - } catch (e) { - await page.screenshot({ fullPage: true, path: `./ss/declined-${Date.now()}.png` }); - throw e; - } - }); + before('create test user', async () => { + generalUser = await GraphApi.createTestUser(); + }); - it('should be able to register via facebook', async () => { - const { token } = await getFacebookToken(); - const registered = await createAccount(token); - - assert(registered.hasOwnProperty('jwt')); - assert(registered.hasOwnProperty('user')); - assert(registered.user.hasOwnProperty('metadata')); - assert(registered.user.metadata.hasOwnProperty(defaultAudience)); - assert(registered.user.metadata[defaultAudience].hasOwnProperty('facebook')); - assert.ifError(registered.user.password); - assert.ifError(registered.user.audience); - }); + after('delete test user', async () => { + await GraphApi.deleteTestUser(generalUser.id); + }); - it('can get info about registered fb account through getInternalData & getMetadata', async () => { - const { token } = await getFacebookToken(); - const { user } = await createAccount(token); - const [internalData, metadata] = await Promise - .all([ - service.amqp.publishAndWait('users.getInternalData', { - username: user.metadata[defaultAudience].facebook.uid, - }), - service.amqp.publishAndWait('users.getMetadata', { - username: user.metadata[defaultAudience].facebook.uid, - audience: defaultAudience, - }), - ]) - .reflect() - .then(inspectPromise()); - - // verify internal data - assert.ok(internalData.facebook, 'facebook data not present'); - assert.ok(internalData.facebook.id, 'fb id is not present'); - assert.ok(internalData.facebook.email, 'fb email is not present'); - assert.ok(internalData.facebook.token, 'fb token is not present'); - assert.ifError(internalData.facebook.username, 'fb returned real username'); - assert.ifError(internalData.facebook.refreshToken, 'fb returned refresh token'); - - // verify metadata - assert.ok(metadata[defaultAudience].facebook, 'facebook profile not present'); - assert.ok(metadata[defaultAudience].facebook.id, 'facebook scoped is not present'); - assert.ok(metadata[defaultAudience].facebook.displayName, 'fb display name not present'); - assert.ok(metadata[defaultAudience].facebook.name, 'fb name not present'); - assert.ok(metadata[defaultAudience].facebook.uid, 'internal fb uid not present'); - }); + /** + * Checking general functionality just to be ensured that we can receive `token` or handle `Declined` Facebook Auth Request + */ + describe('general checks', async () => { + let fb; - it('should attach facebook profile to existing user', async () => { - const username = 'facebookuser@me.com'; - const databag = { service }; + beforeEach('start WebExecuter', async () => { + fb = new WebExecuter(); + await fb.start(); + }); - await globalRegisterUser(username).call(databag); - await globalAuthUser(username).call(databag); + afterEach('stop WebExecuter / deauth App', async () => { + await GraphApi.deAuthApplication(generalUser.id); + await fb.stop(); + }); - // pre-auth user - await getFacebookToken(); - await Promise.delay(1000); + it('should able to handle declined authentication', async () => { + const { status, url } = await fb.rejectAuth(generalUser); + console.log('Decline', status, url); + assert(status === 401, `statusCode is ${status}, url is ${url}`); + }); - const executeLink = `${serviceLink}/users/oauth/facebook?jwt=${databag.jwt}`; - console.info('opening %s', executeLink); + it('should able to retrieve faceboook profile', async () => { + const { token: resToken, body } = await fb.getToken(generalUser); + assert(resToken, `did not get token - ${resToken} - ${body}`); + }); + }); - const { status, url, body } = await navigate({ href: executeLink }); + /** + * Suite checks general service behavior. + * Token retrieved once and all tests use it. + */ + describe('service register/create/detach', () => { + let fb; + let token; + /* Should be 'before' hook, but Mocha executes it before starting our service. */ + beforeEach('start WebExecuter and get Facebook token', async () => { + if (!token || typeof token === 'undefined') { + fb = new WebExecuter(); + await fb.start(); + ({ token } = await fb.getToken(generalUser)); + await fb.stop(); + } + }); - assert(status === 200, `Page is ${url} and status is ${status}`); + /* Cleanup App permissions for further user reuse */ + after('deauth application', async () => { + await GraphApi.deAuthApplication(generalUser.id); + }); - const context = parseHTML(body); + it('should be able to register via facebook', async () => { + const registered = await createAccount(token); + checkServiceOkResponse(registered); + }); - assert(context.$ms_users_inj_post_message, `post message not present: ${body}`); - assert(context.$ms_users_inj_post_message.type === 'ms-users:attached', `type wrong -> ${body}`); - assert(Object.keys(context.$ms_users_inj_post_message.payload).length); - }); + it('can get info about registered fb account through getInternalData & getMetadata', async () => { + const { user } = await createAccount(token); + + const [internalData, metadata] = await Promise + .all([ + service.amqp.publishAndWait('users.getInternalData', { + username: user.metadata[defaultAudience].facebook.uid, + }), + service.amqp.publishAndWait('users.getMetadata', { + username: user.metadata[defaultAudience].facebook.uid, + audience: defaultAudience, + }), + ]) + .reflect() + .then(inspectPromise()); + + /* verify internal data */ + assert.ok(internalData.facebook, 'facebook data not present'); + assert.ok(internalData.facebook.id, 'fb id is not present'); + assert.ok(internalData.facebook.email, 'fb email is not present'); + assert.ok(internalData.facebook.token, 'fb token is not present'); + assert.ifError(internalData.facebook.username, 'fb returned real username'); + assert.ifError(internalData.facebook.refreshToken, 'fb returned refresh token'); + + /* verify metadata */ + assert.ok(metadata[defaultAudience].facebook, 'facebook profile not present'); + assert.ok(metadata[defaultAudience].facebook.id, 'facebook scoped is not present'); + assert.ok(metadata[defaultAudience].facebook.displayName, 'fb display name not present'); + assert.ok(metadata[defaultAudience].facebook.name, 'fb name not present'); + assert.ok(metadata[defaultAudience].facebook.uid, 'internal fb uid not present'); + }); - it('should reject attaching already attached profile to a new user', async () => { - const username = 'facebookuser@me.com'; - const databag = { service }; - - const { token } = await getFacebookToken(); - await createAccount(token); - await globalRegisterUser(username).call(databag); - await globalAuthUser(username).call(databag); - await Promise.delay(1000); - - const executeLink = `${serviceLink}/users/oauth/facebook?jwt=${databag.jwt}`; - console.info('opening %s', executeLink); - const { status, url, body } = await navigate({ href: executeLink }); - - assert(status === 412, `Page is ${url} and status is ${status}`); - - const context = parseHTML(body); - - assert.ok(context.$ms_users_inj_post_message); - assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:attached'); - assert.equal(context.$ms_users_inj_post_message.error, true); - assert.deepEqual(context.$ms_users_inj_post_message.payload, { - status: 412, - statusCode: 412, - status_code: 412, - name: 'HttpStatusError', - message: 'profile is linked', + it('should detach facebook profile', async () => { + const registered = await createAccount(token); + + checkServiceOkResponse(registered); + + const uid = `facebook:${registered.user.metadata[defaultAudience].facebook.id}`; + const { username } = registered.user.metadata['*.localhost']; + let response; + + response = await service + .dispatch('oauth.detach', { + params: { + username, + provider: 'facebook', + }, + }) + .reflect() + .then(inspectPromise(true)); + + assert(response.success, 'werent able to detach'); + + /* verify that related account has been pruned from metadata */ + response = await service + .dispatch('getMetadata', { + params: { + username, + audience: Object.keys(registered.user.metadata), + }, + }) + .reflect() + .then(inspectPromise(true)); + + forEach(response.metadata, (audience) => { + assert.ifError(audience.facebook); + }); + + /* verify that related account has been pruned from internal data */ + response = await service + .dispatch('getInternalData', { params: { username } }) + .reflect() + .then(inspectPromise(true)); + + assert.ifError(response.facebook, 'did not detach fb'); + + /* verify that related account has been dereferenced */ + const error = await service + .dispatch('getInternalData', { params: { username: uid } }) + .reflect() + .then(inspectPromise(false)); + + assert.equal(error.statusCode, 404); + }); }); - }); - - it('should be able to sign in with facebook account', async function test() { - const username = 'facebookuser@me.com'; - const databag = { service }; - await globalRegisterUser(username).call(databag); - await globalAuthUser(username).call(databag); - await getFacebookToken(); - await Promise.delay(1000); + /** + * Suite Checks Login/Attach profile possibility + * In this suite, FacebookAuth process performed once and token saved in memory. + * Service users created before tests to remove code deduplication. + * Previous version was restarting Auth process and getting new token before each test. + * This version repeats same behavior but without repeating auth and get token processes. + */ + describe('service login/attach', () => { + let fb; + let token; + let dataBag; + const username = 'facebookuser@me.com'; + /* Should be 'before' hook, but Mocha executes it before starting our service. */ + beforeEach('init WebExecuter, get Facebook token, register user', async () => { + if (!fb || typeof fb === 'undefined') { + fb = new WebExecuter(); + await fb.start(); + ({ token } = await fb.getToken(generalUser)); + } + + dataBag = { service }; + await globalRegisterUser(username).call(dataBag); + await globalAuthUser(username).call(dataBag); + }); - const executeLink = `${serviceLink}/users/oauth/facebook`; - /* initial request for attaching account */ - const preRequest = await navigate({ href: `${executeLink}?jwt=${databag.jwt}` }); - assert(preRequest.status === 200, `attaching account failed - ${preRequest.status} - ${preRequest.url}`); + after('stop executer', async () => { + await fb.stop(); + }); - const { status, url, body } = await navigate({ href: executeLink }); - assert(status === 200, `signing in failed - ${status} - ${url}`); + /* IF test reordering occurs this going to save us from headache */ + after('deauth application', async () => { + await GraphApi.deAuthApplication(generalUser.id); + }); - const context = parseHTML(body); + it('should reject attaching already attached profile to a new user', async () => { + await createAccount(token); + await Promise.delay(1000); + + const { status, url } = await fb.signInWithToken(dataBag.jwt); + assert(status === 412, `Page is ${url} and status is ${status}`); + + const message = await fb.extractMsUsersPostMessage(); + assert.ok(message); + assert.equal(message.type, 'ms-users:attached'); + assert.equal(message.error, true); + assert.deepEqual(message.payload, { + status: 412, + statusCode: 412, + status_code: 412, + name: 'HttpStatusError', + message: 'profile is linked', + }); + }); - assert.ok(context.$ms_users_inj_post_message); - assert.equal(context.$ms_users_inj_post_message.error, false); - assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:logged-in'); + it('should attach facebook profile to existing user', async () => { + await Promise.delay(1000); + const { status, url, body } = await fb.signInWithToken(dataBag.jwt); + assert(status === 200, `Page is ${url} and status is ${status}`); - const { payload } = context.$ms_users_inj_post_message; - assert(payload.hasOwnProperty('jwt')); - assert(payload.hasOwnProperty('user')); - assert(payload.user.hasOwnProperty('metadata')); - assert(payload.user.metadata.hasOwnProperty(defaultAudience)); - assert(payload.user.metadata[defaultAudience].hasOwnProperty('facebook')); - assert.ifError(payload.user.password); - assert.ifError(payload.user.audience); - }); + const message = await fb.extractMsUsersPostMessage(); - it('should detach facebook profile', async () => { - const { token } = await getFacebookToken(); - const registered = await createAccount(token); + assert(message, `post message not present: ${body}`); + assert(message.type === 'ms-users:attached', `type wrong -> ${body}`); + assert(Object.keys(message.payload).length); + }); - assert(registered.hasOwnProperty('jwt')); - assert(registered.hasOwnProperty('user')); - assert(registered.user.hasOwnProperty('metadata')); - assert(registered.user.metadata.hasOwnProperty(defaultAudience)); - assert(registered.user.metadata[defaultAudience].hasOwnProperty('facebook')); - assert.ifError(registered.user.password); - assert.ifError(registered.user.audience); + it('should be able to sign in with facebook account', async () => { + await Promise.delay(1000); + const executeLink = `${fb._serviceLink}/users/oauth/facebook`; - const uid = `facebook:${registered.user.metadata[defaultAudience].facebook.id}`; - const { username } = registered.user.metadata['*.localhost']; - let response; + /* initial request for attaching account */ + const preRequest = await fb.signInWithToken(dataBag.jwt); + assert(preRequest.status === 200, `attaching account failed - ${preRequest.status} - ${preRequest.url}`); - response = await service - .dispatch('oauth.detach', { params: { username, provider: 'facebook' } }) - .reflect() - .then(inspectPromise(true)); + const { status, url } = await fb.navigatePage({ href: executeLink }); + assert(status === 200, `signing in failed - ${status} - ${url}`); - assert(response.success, 'werent able to detach'); + const message = await fb.extractMsUsersPostMessage(); - /* verify that related account has been pruned from metadata */ - response = await service - .dispatch('getMetadata', { - params: { username, audience: Object.keys(registered.user.metadata) }, - }) - .reflect() - .then(inspectPromise(true)); + assert.ok(message); + assert.equal(message.error, false); + assert.equal(message.type, 'ms-users:logged-in'); - forEach(response.metadata, (audience) => { - assert.ifError(audience.facebook); - }); + const { payload } = message; + checkServiceOkResponse(payload); + }); - /* verify that related account has been pruned from internal data */ - response = await service - .dispatch('getInternalData', { params: { username } }) - .reflect() - .then(inspectPromise(true)); + it('should be able to sign in with facebook account if mfa is enabled', async function test() { + await Promise.delay(1000); + /* enable mfa */ + const { secret } = await service.dispatch('mfa.generate-key', { params: { username, time: Date.now() } }); + await service.dispatch('mfa.attach', { params: { username, secret, totp: authenticator.generate(secret) } }); - assert.ifError(response.facebook, 'did not detach fb'); + const executeLink = `${fb._serviceLink}/users/oauth/facebook`; - /* verify that related account has been dereferenced */ - const error = await service - .dispatch('getInternalData', { params: { username: uid } }) - .reflect() - .then(inspectPromise(false)); + /* initial request for attaching account */ + const preRequest = await fb.signInWithToken(dataBag.jwt); + assert(preRequest.status === 200, `attaching account failed - ${preRequest.status} - ${preRequest.url}`); - assert.equal(error.statusCode, 404); - }); + const { status, url } = await fb.navigatePage({ href: executeLink }); + assert(status === 403, `mfa was not requested - ${status} - ${url}`); - it('should reject when signing in with partially returned scope and report it', async () => { - const data = await signInAndNavigate((response) => { - return response.url().startsWith(serviceLink) && response.status() === 401; - }); + const message = await fb.extractMsUsersPostMessage(); - const status = data.status(); - const url = data.url(); - const body = await data.text(); + assert.ok(message); + assert.equal(message.error, true); + assert.equal(message.type, 'ms-users:totp_required'); - assert(status === 401, `did not reject partial sign in - ${status} - ${url} - ${body}`); + const { payload: { userId, token: localToken } } = message; + const login = await service.dispatch( + 'login', + { + params: { username: userId, password: localToken, isOAuthFollowUp: true, audience: defaultAudience }, + headers: { 'x-auth-totp': authenticator.generate(secret) }, + } + ); - const context = parseHTML(body); - assert.ok(context.$ms_users_inj_post_message); - assert.deepEqual(context.$ms_users_inj_post_message.payload, { - args: { 0: 'missing permissions - email' }, - message: 'An attempt was made to perform an operation without authentication: missing permissions - email', - name: 'AuthenticationRequiredError', - missingPermissions: ['email'], + checkServiceOkResponse(login); + }); }); }); - describe('should re-request partially returned scope endlessly', () => { - before('apply', () => { - this.testConfig = { - oauth: { providers: { facebook: { retryOnMissingPermissions: true } } }, - }; - }); - - it('should re-request partially returned scope endlessly', async () => { - const pageResponse = await signInAndNavigate((response) => { - return /dialog\/oauth\?auth_type=rerequest/.test(response.url()); + /** + * Suite works with 'Partial' user. + * Application must be granted with some permissions and not installed, + * but In this case the Facebook permission request showing full permissions (partial permissions ignored when the test user created). + * All tests perform Facebook Auth -> Uncheck 1 permission on Facebook App Access request -> clicking "Confirm" button + * After each test Deletes all application permissions this uninstalls application from user. + * NOTE: + * We don't need to test same behavior for user with app `installed`. + * OAuth API endpoint behavior is same, and tests code will be copied from this suite. + */ + describe('partial user', async () => { + let fb; + let partialUser; + + before('create test users', async () => { + partialUser = await GraphApi.createTestUser({ + permissions: 'public_profile', }); + }); - const url = pageResponse.url(); - const status = pageResponse.status(); - - assert(/dialog\/oauth\?auth_type=rerequest/.test(url), `failed to redirect back - ${status} - ${url}`); + after('delete test user', async () => { + await GraphApi.deleteTestUser(partialUser.id); }); - after('remove', () => { - delete this.testConfig; + beforeEach('start WebExecuter', async () => { + fb = new WebExecuter(); + await fb.start(); }); - }); - describe('should login with partially returned scope and report it', () => { - before('apply', () => { - this.testConfig = { - oauth: { providers: { facebook: { retryOnMissingPermissions: false } } }, - }; + afterEach('stop WebExecuter', async () => { + await GraphApi.deAuthApplication(partialUser.id); + await fb.stop(); }); - it('should login with partially returned scope and report it', async () => { - const data = await signInAndNavigate((response) => { - return response.url().startsWith(serviceLink) && response.status() === 200; + it('should reject when signing in with partially returned scope and report it', async () => { + const data = await fb.signInAndNavigate(partialUser, (response) => { + return response.url().startsWith(fb._serviceLink) && response.status() === 401; }); + const status = data.status(); + const url = data.url(); const body = await data.text(); - const context = parseHTML(body); + + assert(status === 401, `did not reject partial sign in - ${status} - ${url} - ${body}`); + + const context = WebExecuter.getJavascriptContext(body); assert.ok(context.$ms_users_inj_post_message); - assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:attached'); - assert.equal(context.$ms_users_inj_post_message.error, false); - assert.deepEqual(context.$ms_users_inj_post_message.missingPermissions, ['email']); - assert.ok(context.$ms_users_inj_post_message.payload.token, 'missing token'); - assert.equal(context.$ms_users_inj_post_message.payload.provider, 'facebook'); + assert.deepEqual(context.$ms_users_inj_post_message.payload, { + args: { 0: 'missing permissions - email' }, + message: 'An attempt was made to perform an operation without authentication: missing permissions - email', + name: 'AuthenticationRequiredError', + missingPermissions: ['email'], + }); }); - it('should register with partially returned scope and require email verification', async () => { - const data = await signInAndNavigate((response) => { - return response.url().startsWith(serviceLink) && response.status() === 200; + describe('should re-request partially returned scope endlessly', () => { + before('apply', () => { + this.testConfig = { + oauth: { providers: { facebook: { retryOnMissingPermissions: true } } }, + }; }); - const status = data.status(); - const url = data.url(); - const body = await data.text(); + it('should re-request partially returned scope endlessly', async () => { + const pageResponse = await fb.signInAndNavigate(partialUser, (response) => { + return /dialog\/oauth\?auth_type=rerequest/.test(response.url()); + }); - assert(status === 200, `failed to redirect back - ${status} - ${url} - ${body}`); + const url = pageResponse.url(); + const status = pageResponse.status(); - const context = parseHTML(body); + assert(/dialog\/oauth\?auth_type=rerequest/.test(url), `failed to redirect back - ${status} - ${url}`); + }); - assert.ok(context.$ms_users_inj_post_message); - assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:attached'); - assert.equal(context.$ms_users_inj_post_message.error, false); - assert.deepEqual(context.$ms_users_inj_post_message.missingPermissions, ['email']); - assert.ok(context.$ms_users_inj_post_message.payload.token, 'missing token'); - assert.equal(context.$ms_users_inj_post_message.payload.provider, 'facebook'); - - const { requiresActivation, id } = await createAccount( - context.$ms_users_inj_post_message.payload.token, - { username: 'unverified@makeomatic.ca' } - ); - - assert.equal(requiresActivation, true); - assert.ok(id); + after('remove', () => { + delete this.testConfig; + }); }); - after('remove', () => { - delete this.testConfig; - }); - }); + describe('should login/register with partially returned scope and report it', () => { + before('apply', () => { + this.testConfig = { + oauth: { providers: { facebook: { retryOnMissingPermissions: false } } }, + }; + }); + + it('should login with partially returned scope and report it', async () => { + const data = await fb.signInAndNavigate(partialUser, (response) => { + return response.url().startsWith(fb._serviceLink) && response.status() === 200; + }); + + const body = await data.text(); + const context = WebExecuter.getJavascriptContext(body); - it('should be able to sign in with facebook account if mfa is enabled', async function test() { - const username = 'facebookuser@me.com'; - const databag = { service }; - - await globalRegisterUser(username).call(databag); - await globalAuthUser(username).call(databag); - await getFacebookToken(); - await Promise.delay(1000); - - // enable mfa - const { secret } = await service.dispatch('mfa.generate-key', { params: { username, time: Date.now() } }); - await service.dispatch('mfa.attach', { params: { username, secret, totp: authenticator.generate(secret) } }); - - const executeLink = `${serviceLink}/users/oauth/facebook`; - - /* initial request for attaching account */ - const preRequest = await navigate({ href: `${executeLink}?jwt=${databag.jwt}` }); - assert(preRequest.status === 200, `attaching account failed - ${preRequest.status} - ${preRequest.url}`); - - const { status, url, body } = await navigate({ href: executeLink }); - assert(status === 403, `mfa was not requested - ${status} - ${url}`); - - const context = parseHTML(body); - - assert.ok(context.$ms_users_inj_post_message); - assert.equal(context.$ms_users_inj_post_message.error, true); - assert.equal(context.$ms_users_inj_post_message.type, 'ms-users:totp_required'); - - const { payload: { userId, token } } = context.$ms_users_inj_post_message; - const login = await service.dispatch( - 'login', - { - params: { username: userId, password: token, isOAuthFollowUp: true, audience: defaultAudience }, - headers: { 'x-auth-totp': authenticator.generate(secret) }, - } - ); - - assert(login.hasOwnProperty('jwt')); - assert(login.hasOwnProperty('user')); - assert(login.hasOwnProperty('mfa')); - assert(login.user.hasOwnProperty('metadata')); - assert(login.user.metadata.hasOwnProperty(defaultAudience)); - assert(login.user.metadata[defaultAudience].hasOwnProperty('facebook')); - assert.ifError(login.user.password); - assert.ifError(login.user.audience); + checkServiceMissingPermissionsResponse(context); + }); + + it('should register with partially returned scope and require email verification', async () => { + const data = await fb.signInAndNavigate(partialUser, (response) => { + return response.url().startsWith(fb._serviceLink) && response.status() === 200; + }); + + const status = data.status(); + const url = data.url(); + const body = await data.text(); + + assert(status === 200, `failed to redirect back - ${status} - ${url} - ${body}`); + + const context = WebExecuter.getJavascriptContext(body); + + checkServiceMissingPermissionsResponse(context); + + const { requiresActivation, id } = await createAccount( + context.$ms_users_inj_post_message.payload.token, + { username: 'unverified@makeomatic.ca' } + ); + + assert.equal(requiresActivation, true); + assert.ok(id); + }); + + after('remove', () => { + delete this.testConfig; + }); + }); }); });