From d9fc503f9e2e0908f14619fe4b3d2b18d56c821f Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Thu, 26 Dec 2019 14:18:58 +0200 Subject: [PATCH] Cover oauth with e2e tests and fix some old and newly introduced bugs --- packages/app/components/accounts/actions.ts | 9 +- .../app/components/auth/PanelTransition.tsx | 5 +- packages/app/components/auth/actions.ts | 48 ++- .../app/components/auth/finish/Finish.tsx | 2 +- packages/app/components/ui/Panel.tsx | 6 +- packages/app/components/ui/bsod/BSoD.tsx | 45 ++- .../app/components/ui/bsod/dispatchBsod.js | 20 -- .../app/components/ui/bsod/dispatchBsod.tsx | 41 +++ .../ui/bsod/{factory.js => factory.ts} | 14 +- packages/app/index.tsx | 32 +- packages/app/pages/root/RootPage.tsx | 6 +- packages/app/services/api/oauth.ts | 29 +- packages/app/services/authFlow/AuthFlow.ts | 9 +- packages/app/services/authFlow/index.ts | 21 +- packages/app/shell/index.ts | 1 - .../cypress/integration/dev/user.test.ts | 4 +- .../cypress/integration/oauth/user.test.ts | 321 ++++++++++++++++++ tests-e2e/cypress/support/commands.js | 62 ++-- tests-e2e/cypress/support/index.d.ts | 19 +- tests-e2e/cypress/support/index.js | 11 +- tests-e2e/tsconfig.json | 2 +- 21 files changed, 538 insertions(+), 169 deletions(-) delete mode 100644 packages/app/components/ui/bsod/dispatchBsod.js create mode 100644 packages/app/components/ui/bsod/dispatchBsod.tsx rename packages/app/components/ui/bsod/{factory.js => factory.ts} (52%) create mode 100644 tests-e2e/cypress/integration/oauth/user.test.ts diff --git a/packages/app/components/accounts/actions.ts b/packages/app/components/accounts/actions.ts index c8b8ba1..7f524a8 100644 --- a/packages/app/components/accounts/actions.ts +++ b/packages/app/components/accounts/actions.ts @@ -5,15 +5,16 @@ import { requestToken, logout, } from 'app/services/api/authentication'; -import { relogin as navigateToLogin } from 'app/components/auth/actions'; +import { + relogin as navigateToLogin, + setAccountSwitcher, +} from 'app/components/auth/actions'; import { updateUser, setGuest } from 'app/components/user/actions'; import { setLocale } from 'app/components/i18n/actions'; -import { setAccountSwitcher } from 'app/components/auth/actions'; -import { getActiveAccount } from 'app/components/accounts/reducer'; import logger from 'app/services/logger'; import { ThunkAction } from 'app/reducers'; -import { Account } from './reducer'; +import { getActiveAccount, Account } from './reducer'; import { add, remove, diff --git a/packages/app/components/auth/PanelTransition.tsx b/packages/app/components/auth/PanelTransition.tsx index e4a6e16..9186302 100644 --- a/packages/app/components/auth/PanelTransition.tsx +++ b/packages/app/components/auth/PanelTransition.tsx @@ -296,7 +296,10 @@ class PanelTransition extends React.PureComponent { -
+
{panels.map(config => this.getLinks(config))}
diff --git a/packages/app/components/auth/actions.ts b/packages/app/components/auth/actions.ts index 1ee0e3e..38048ba 100644 --- a/packages/app/components/auth/actions.ts +++ b/packages/app/components/auth/actions.ts @@ -20,6 +20,7 @@ import signup from 'app/services/api/signup'; import dispatchBsod from 'app/components/ui/bsod/dispatchBsod'; import { create as createPopup } from 'app/components/ui/popup/actions'; import ContactForm from 'app/components/contact/ContactForm'; +import { Account } from 'app/components/accounts/reducer'; import { ThunkAction, Dispatch } from 'app/reducers'; import { getCredentials } from './reducer'; @@ -31,17 +32,8 @@ type ValidationError = payload: { [key: string]: any }; }; -export { updateUser } from 'app/components/user/actions'; -export { - authenticate, - logoutAll as logout, - remove as removeAccount, - activate as activateAccount, -} from 'app/components/accounts/actions'; -import { Account } from 'app/components/accounts/reducer'; - /** - * Reoutes user to the previous page if it is possible + * Routes user to the previous page if it is possible * * @param {object} options * @param {string} options.fallbackUrl - an url to route user to if goBack is not possible @@ -427,7 +419,7 @@ export function oAuthComplete(params: { accept?: boolean } = {}) { localStorage.removeItem('oauthData'); if (resp.redirectUri.startsWith('static_page')) { - const displayCode = resp.redirectUri === 'static_page_with_code'; + const displayCode = /static_page_with_code/.test(resp.redirectUri); const [, code] = resp.redirectUri.match(/code=(.+)&/) || []; [, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || []; @@ -516,7 +508,7 @@ export function resetAuth(): ThunkAction { } export const SET_OAUTH = 'set_oauth'; -export function setOAuthRequest(oauth: { +export function setOAuthRequest(data: { client_id?: string; redirect_uri?: string; response_type?: string; @@ -528,19 +520,19 @@ export function setOAuthRequest(oauth: { return { type: SET_OAUTH, payload: { - clientId: oauth.client_id, - redirectUrl: oauth.redirect_uri, - responseType: oauth.response_type, - scope: oauth.scope, - prompt: oauth.prompt, - loginHint: oauth.loginHint, - state: oauth.state, + clientId: data.client_id, + redirectUrl: data.redirect_uri, + responseType: data.response_type, + scope: data.scope, + prompt: data.prompt, + loginHint: data.loginHint, + state: data.state, }, }; } export const SET_OAUTH_RESULT = 'set_oauth_result'; -export function setOAuthCode(oauth: { +export function setOAuthCode(data: { success: boolean; code: string; displayCode: boolean; @@ -548,9 +540,9 @@ export function setOAuthCode(oauth: { return { type: SET_OAUTH_RESULT, payload: { - success: oauth.success, - code: oauth.code, - displayCode: oauth.displayCode, + success: data.success, + code: data.code, + displayCode: data.displayCode, }, }; } @@ -564,7 +556,7 @@ export function requirePermissionsAccept() { export const SET_SCOPES = 'set_scopes'; export function setScopes(scopes: Scope[]) { - if (!(scopes instanceof Array)) { + if (!Array.isArray(scopes)) { throw new Error('Scopes must be array'); } @@ -610,11 +602,11 @@ function needActivation() { } function authHandler(dispatch: Dispatch) { - return (resp: OAuthResponse): Promise => + return (oAuthResp: OAuthResponse): Promise => dispatch( authenticate({ - token: resp.access_token, - refreshToken: resp.refresh_token || null, + token: oAuthResp.access_token, + refreshToken: oAuthResp.refresh_token || null, }), ).then(resp => { dispatch(setLogin(null)); @@ -626,7 +618,7 @@ function authHandler(dispatch: Dispatch) { function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) { return resp => { if (resp.errors) { - const firstError = Object.keys(resp.errors)[0]; + const [firstError] = Object.keys(resp.errors); const error = { type: resp.errors[firstError], payload: { diff --git a/packages/app/components/auth/finish/Finish.tsx b/packages/app/components/auth/finish/Finish.tsx index 201ef9c..5ba01e2 100644 --- a/packages/app/components/auth/finish/Finish.tsx +++ b/packages/app/components/auth/finish/Finish.tsx @@ -44,7 +44,7 @@ class Finish extends React.Component { />
{displayCode ? ( -
+
diff --git a/packages/app/components/ui/Panel.tsx b/packages/app/components/ui/Panel.tsx index 619f5d5..c77cc67 100644 --- a/packages/app/components/ui/Panel.tsx +++ b/packages/app/components/ui/Panel.tsx @@ -42,7 +42,7 @@ export function Panel(props: { export function PanelHeader(props: { children: React.ReactNode }) { return ( -
+
{props.children}
); @@ -50,7 +50,7 @@ export function PanelHeader(props: { children: React.ReactNode }) { export function PanelBody(props: { children: React.ReactNode }) { return ( -
+
{props.children}
); @@ -58,7 +58,7 @@ export function PanelBody(props: { children: React.ReactNode }) { export function PanelFooter(props: { children: React.ReactNode }) { return ( -
+
{props.children}
); diff --git a/packages/app/components/ui/bsod/BSoD.tsx b/packages/app/components/ui/bsod/BSoD.tsx index 5728fc6..3cd2397 100644 --- a/packages/app/components/ui/bsod/BSoD.tsx +++ b/packages/app/components/ui/bsod/BSoD.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { FormattedMessage as Message } from 'react-intl'; -import { IntlProvider } from 'app/components/i18n'; import logger from 'app/services/logger'; import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json'; @@ -42,32 +41,30 @@ class BSoD extends React.Component<{}, State> { } return ( - -
- el && new BoxesField(el)} - /> +
+ el && new BoxesField(el)} + /> -
-
- -
-
- -
-
- -
- - support@ely.by - -
- -
+
+
+ +
+
+ +
+
+ +
+ + support@ely.by + +
+
- +
); } } diff --git a/packages/app/components/ui/bsod/dispatchBsod.js b/packages/app/components/ui/bsod/dispatchBsod.js deleted file mode 100644 index 854d5f6..0000000 --- a/packages/app/components/ui/bsod/dispatchBsod.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { bsod } from './actions'; -import BSoD from 'app/components/ui/bsod/BSoD'; - -let injectedStore; -let onBsod; - -export default function dispatchBsod(store = injectedStore) { - store.dispatch(bsod()); - onBsod && onBsod(); - - ReactDOM.render(, document.getElementById('app')); -} - -export function inject(store, stopLoading) { - injectedStore = store; - onBsod = stopLoading; -} diff --git a/packages/app/components/ui/bsod/dispatchBsod.tsx b/packages/app/components/ui/bsod/dispatchBsod.tsx new file mode 100644 index 0000000..e76b293 --- /dev/null +++ b/packages/app/components/ui/bsod/dispatchBsod.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ContextProvider } from 'app/shell'; +import { Store } from 'app/reducers'; +import { History } from 'history'; + +import { bsod } from './actions'; +import BSoD from './BSoD'; + +let injectedStore: Store; +let injectedHistory: History; +let onBsod: undefined | (() => void); + +export default function dispatchBsod( + store = injectedStore, + history = injectedHistory, +) { + store.dispatch(bsod()); + onBsod && onBsod(); + + ReactDOM.render( + + + , + document.getElementById('app'), + ); +} + +export function inject({ + store, + history, + stopLoading, +}: { + store: Store; + history: History; + stopLoading: () => void; +}) { + injectedStore = store; + injectedHistory = history; + onBsod = stopLoading; +} diff --git a/packages/app/components/ui/bsod/factory.js b/packages/app/components/ui/bsod/factory.ts similarity index 52% rename from packages/app/components/ui/bsod/factory.js rename to packages/app/components/ui/bsod/factory.ts index 42124ba..ea79c62 100644 --- a/packages/app/components/ui/bsod/factory.js +++ b/packages/app/components/ui/bsod/factory.ts @@ -1,11 +1,21 @@ import request from 'app/services/request'; import logger from 'app/services/logger'; +import { Store } from 'app/reducers'; +import { History } from 'history'; import dispatchBsod, { inject } from './dispatchBsod'; import BsodMiddleware from './BsodMiddleware'; -export default function factory(store, stopLoading) { - inject(store, stopLoading); +export default function factory({ + store, + history, + stopLoading, +}: { + store: Store; + history: History; + stopLoading: () => void; +}) { + inject({ store, history, stopLoading }); // do bsod for 500/404 errors request.addMiddleware(new BsodMiddleware(dispatchBsod, logger)); diff --git a/packages/app/index.tsx b/packages/app/index.tsx index 7165d76..6203b34 100644 --- a/packages/app/index.tsx +++ b/packages/app/index.tsx @@ -14,7 +14,7 @@ import history, { browserHistory } from 'app/services/history'; import i18n from 'app/services/i18n'; import { loadScript, debounce } from 'app/functions'; -import App from './shell'; +import App from './shell/App'; const win: { [key: string]: any } = window as any; @@ -26,7 +26,11 @@ logger.init({ const store = storeFactory(); -bsodFactory(store, () => loader.hide()); +bsodFactory({ + store, + history: browserHistory, + stopLoading: () => loader.hide(), +}); authFlow.setStore(store); Promise.all([ @@ -77,27 +81,3 @@ function _trackPageView(location) { ga('set', 'page', location.pathname + location.search); ga('send', 'pageview'); } - -/* global process: false */ -if (process.env.NODE_ENV !== 'production') { - // some shortcuts for testing on localhost - win.testOAuth = (loginHint = '') => - (location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&login_hint=${loginHint}`); - win.testOAuthPermissions = () => - (location.href = - '/oauth2/v1/tlauncher?client_id=tlauncher&redirect_uri=http%3A%2F%2Flocalhost%3A8080&response_type=code&scope=account_info,account_email'); - win.testOAuthPromptAccount = () => - (location.href = - '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account'); - win.testOAuthPromptPermissions = (loginHint = '') => - (location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=consent&login_hint=${loginHint}`); - win.testOAuthPromptAll = () => - (location.href = - '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account,consent'); - win.testOAuthStatic = () => - (location.href = - '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page_with_code&response_type=code&scope=account_info%2Caccount_email'); - win.testOAuthStaticCode = () => - (location.href = - '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page&response_type=code&scope=account_info%2Caccount_email'); -} diff --git a/packages/app/pages/root/RootPage.tsx b/packages/app/pages/root/RootPage.tsx index 6e01fca..bd0d9b1 100644 --- a/packages/app/pages/root/RootPage.tsx +++ b/packages/app/pages/root/RootPage.tsx @@ -89,12 +89,12 @@ class RootPage extends React.PureComponent<{ - {user.isGuest ? ( - - ) : ( + {!user.isGuest && ( )} + +
diff --git a/packages/app/services/api/oauth.ts b/packages/app/services/api/oauth.ts index 5bb99fc..ace35da 100644 --- a/packages/app/services/api/oauth.ts +++ b/packages/app/services/api/oauth.ts @@ -182,19 +182,36 @@ function getOAuthRequest(oauthData: OauthData): OauthRequestData { }; } -function handleOauthParamsValidation(resp: { [key: string]: any } = {}) { +function handleOauthParamsValidation( + resp: + | { [key: string]: any } + | { + statusCode: number; + success: false; + error: + | 'invalid_request' + | 'unsupported_response_type' + | 'invalid_scope' + | 'invalid_client'; + parameter: string; + } = {}, +) { + let userMessage: string | null = null; + if (resp.statusCode === 400 && resp.error === 'invalid_request') { - resp.userMessage = `Invalid request (${resp.parameter} required).`; + userMessage = `Invalid request (${resp.parameter} required).`; } else if ( resp.statusCode === 400 && resp.error === 'unsupported_response_type' ) { - resp.userMessage = `Invalid response type '${resp.parameter}'.`; + userMessage = `Invalid response type '${resp.parameter}'.`; } else if (resp.statusCode === 400 && resp.error === 'invalid_scope') { - resp.userMessage = `Invalid scope '${resp.parameter}'.`; + userMessage = `Invalid scope '${resp.parameter}'.`; } else if (resp.statusCode === 401 && resp.error === 'invalid_client') { - resp.userMessage = 'Can not find application you are trying to authorize.'; + userMessage = 'Can not find application you are trying to authorize.'; } - return Promise.reject(resp); + return userMessage + ? Promise.reject({ ...resp, userMessage }) + : Promise.reject(resp); } diff --git a/packages/app/services/authFlow/AuthFlow.ts b/packages/app/services/authFlow/AuthFlow.ts index 03f9045..f16977a 100644 --- a/packages/app/services/authFlow/AuthFlow.ts +++ b/packages/app/services/authFlow/AuthFlow.ts @@ -173,10 +173,11 @@ export default class AuthFlow implements AuthContext { const callback = this.onReady; this.onReady = () => {}; - return resp.then( - callback, - err => err || logger.warn('State transition error', err), - ); + return resp.then(callback, error => { + logger.error('State transition error', { error }); + + return error; + }); } return resp; diff --git a/packages/app/services/authFlow/index.ts b/packages/app/services/authFlow/index.ts index 56ccf0e..60bc02a 100644 --- a/packages/app/services/authFlow/index.ts +++ b/packages/app/services/authFlow/index.ts @@ -1,13 +1,20 @@ +import * as actions from 'app/components/auth/actions'; +import { updateUser } from 'app/components/user/actions'; +import { + authenticate, + logoutAll as logout, + remove as removeAccount, + activate as activateAccount, +} from 'app/components/accounts/actions'; + import AuthFlow, { ActionsDict, AuthContext as TAuthContext } from './AuthFlow'; -import * as actions from 'app/components/auth/actions'; - const availableActions = { - updateUser: actions.updateUser, - authenticate: actions.authenticate, - activateAccount: actions.activateAccount, - removeAccount: actions.removeAccount, - logout: actions.logout, + updateUser, + authenticate, + activateAccount, + removeAccount, + logout, goBack: actions.goBack, redirect: actions.redirect, login: actions.login, diff --git a/packages/app/shell/index.ts b/packages/app/shell/index.ts index 8df1f26..cab2d0d 100644 --- a/packages/app/shell/index.ts +++ b/packages/app/shell/index.ts @@ -1,2 +1 @@ -export { default } from './App'; export { default as ContextProvider } from './ContextProvider'; diff --git a/tests-e2e/cypress/integration/dev/user.test.ts b/tests-e2e/cypress/integration/dev/user.test.ts index b6e93ab..dfba4b2 100644 --- a/tests-e2e/cypress/integration/dev/user.test.ts +++ b/tests-e2e/cypress/integration/dev/user.test.ts @@ -1,9 +1,9 @@ describe('/dev/applications - user', () => { before(() => { - cy.login({ account: 'default' }).then(({ user }) => { + cy.login({ accounts: ['default'] }).then(({ user }) => { cy.visit('/dev/applications'); - // remove all previousely added apps + // remove all previously added apps cy.window().then(async (/** @type {any} */ { oauthApi }) => { const apps = await oauthApi.getAppsByUser(user.id); diff --git a/tests-e2e/cypress/integration/oauth/user.test.ts b/tests-e2e/cypress/integration/oauth/user.test.ts new file mode 100644 index 0000000..286baba --- /dev/null +++ b/tests-e2e/cypress/integration/oauth/user.test.ts @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/camelcase */ +const defaults = { + client_id: 'ely', + redirect_uri: 'http://ely.by/authorization/oauth', + response_type: 'code', + scope: 'account_info,account_email', +}; + +it('should complete oauth', () => { + cy.login({ accounts: ['default'] }); + + cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`); + + cy.url().should('equal', 'https://ely.by/'); +}); + +it('should ask to choose an account if user has multiple', () => { + cy.login({ accounts: ['default', 'default2'] }).then( + ({ accounts: [account] }) => { + cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`); + + cy.url().should('include', '/oauth/choose-account'); + + cy.getByTestId('auth-header').should('contain', 'Choose an account'); + + cy.getByTestId('auth-body') + .contains(account.email) + .click(); + + cy.url().should('equal', 'https://ely.by/'); + }, + ); +}); + +// TODO: remove api mocks, when we will be able to revoke permissions +it('should prompt for permissions', () => { + cy.server(); + + cy.route({ + method: 'POST', + // NOTE: can not use cypress glob syntax, because it will break due to + // '%2F%2F' (//) in redirect_uri + // url: '/api/oauth2/v1/complete/*', + url: new RegExp('/api/oauth2/v1/complete'), + response: { + statusCode: 401, + error: 'accept_required', + }, + status: 401, + }).as('complete'); + + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'http://localhost:8080', + })}`, + ); + + cy.wait('@complete'); + + assertPermissions(); + + cy.server({ enable: false }); + + cy.getByTestId('auth-controls') + .contains('Approve') + .click(); + + cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/); +}); + +// TODO: enable, when backend api will return correct response on auth decline +xit('should redirect to error page, when permission request declined', () => { + cy.server(); + + cy.route({ + method: 'POST', + // NOTE: can not use cypress glob syntax, because it will break due to + // '%2F%2F' (//) in redirect_uri + // url: '/api/oauth2/v1/complete/*', + url: new RegExp('/api/oauth2/v1/complete'), + response: { + statusCode: 401, + error: 'accept_required', + }, + status: 401, + }).as('complete'); + + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'http://localhost:8080', + })}`, + ); + + cy.wait('@complete'); + + assertPermissions(); + + cy.server({ enable: false }); + + cy.getByTestId('auth-secondary-controls') + .contains('Decline') + .click(); + + cy.url().should('include', 'error=access_denied'); +}); + +describe('login_hint', () => { + it('should automatically choose account, when id in login_hint is present', () => { + cy.login({ accounts: ['default', 'default2'] }).then( + ({ accounts: [account] }) => { + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + // suggest preferred username + // https://docs.ely.by/ru/oauth.html#id3 + login_hint: account.id, + })}`, + ); + + cy.url().should('equal', 'https://ely.by/'); + }, + ); + }); + + it('should automatically choose account, when email in login_hint is present', () => { + cy.login({ accounts: ['default', 'default2'] }).then( + ({ accounts: [account] }) => { + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + // suggest preferred username + // https://docs.ely.by/ru/oauth.html#id3 + login_hint: account.email, + })}`, + ); + + cy.url().should('equal', 'https://ely.by/'); + }, + ); + }); + + it('should automatically choose account, when username in login_hint is present and it is not an active account', () => { + cy.login({ accounts: ['default2', 'default'] }).then( + ({ + // try to authenticate with an account, that is not currently active one + accounts: [, account], + }) => { + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + // suggest preferred username + // https://docs.ely.by/ru/oauth.html#id3 + login_hint: account.username, + })}`, + ); + + cy.url().should('equal', 'https://ely.by/'); + }, + ); + }); +}); + +describe('prompts', () => { + it('should prompt for account', () => { + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + prompt: 'select_account', + })}`, + ); + + cy.url().should('include', '/oauth/choose-account'); + + cy.getByTestId('auth-header').should('contain', 'Choose an account'); + }); + + it('should prompt for permissions', () => { + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'http://localhost:8080', + prompt: 'consent', + })}`, + ); + + assertPermissions(); + + cy.getByTestId('auth-controls') + .contains('Approve') + .click(); + + cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/); + }); + + // TODO: enable, when backend api will return correct response on auth decline + xit('should redirect to error page, when permission request declined', () => { + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'http://localhost:8080', + prompt: 'consent', + })}`, + ); + + cy.url().should('include', '/oauth/permissions'); + + cy.getByTestId('auth-secondary-controls') + .contains('Decline') + .click(); + + cy.url().should('include', 'error=access_denied'); + }); + + it('should prompt for both account and permissions', () => { + cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => { + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'http://localhost:8080', + prompt: 'select_account,consent', + })}`, + ); + + cy.url().should('include', '/oauth/choose-account'); + + cy.getByTestId('auth-header').should('contain', 'Choose an account'); + + cy.getByTestId('auth-body') + .contains(account.email) + .click(); + + assertPermissions(); + + cy.getByTestId('auth-controls') + .contains('Approve') + .click(); + + cy.url().should( + 'match', + /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/, + ); + }); + }); +}); + +describe('static pages', () => { + it('should authenticate using static page', () => { + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'static_page', + })}`, + ); + + cy.url().should('include', 'oauth/finish#{%22auth_code%22:'); + }); + + it('should authenticate using static page with code', () => { + cy.login({ accounts: ['default'] }); + + cy.visit( + `/oauth2/v1/ely?${new URLSearchParams({ + ...defaults, + client_id: 'tlauncher', + redirect_uri: 'static_page_with_code', + })}`, + ); + + cy.url().should('include', 'oauth/finish#{%22auth_code%22:'); + + cy.getByTestId('oauth-code-container').should( + 'contain', + 'provide the following code', + ); + + // just click on copy, but we won't assert if the string was copied + // because it is a little bit complicated + // https://github.com/cypress-io/cypress/issues/2752 + cy.getByTestId('oauth-code-container') + .contains('Copy') + .click({ + // TODO: forcing, because currently we have needless re-renders, that causing + // button to disappear for some time and to be unclickable + force: true, + }); + }); +}); + +function assertPermissions() { + cy.url().should('include', '/oauth/permissions'); + + cy.getByTestId('auth-header').should('contain', 'Application permissions'); + cy.getByTestId('auth-body').should( + 'contain', + 'Access to your profile data (except E‑mail)', + ); + cy.getByTestId('auth-body').should( + 'contain', + 'Access to your E‑mail address', + ); +} diff --git a/tests-e2e/cypress/support/commands.js b/tests-e2e/cypress/support/commands.js index 2b00e03..96d0f37 100644 --- a/tests-e2e/cypress/support/commands.js +++ b/tests-e2e/cypress/support/commands.js @@ -31,40 +31,52 @@ const accountsMap = { default2: account1, }; -Cypress.Commands.add('login', async ({ account }) => { - let credentials; +Cypress.Commands.add('login', async ({ accounts }) => { + const accountsData = await Promise.all( + accounts.map(async account => { + let credentials; - if (account) { - credentials = accountsMap[account]; + if (account) { + credentials = accountsMap[account]; - if (!credentials) { - throw new Error(`Unknown account name: ${account}`); - } - } + if (!credentials) { + throw new Error(`Unknown account name: ${account}`); + } + } - const resp = await fetch('/api/authentication/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body: `login=${credentials.login}&password=${credentials.password}&rememberMe=1`, - }).then(resp => resp.json()); + const resp = await fetch('/api/authentication/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: `${new URLSearchParams({ + login: credentials.login, + password: credentials.password, + rememberMe: '1', + })}`, + }).then(rawResp => rawResp.json()); - const state = createState([ - { - id: credentials.id, - username: credentials.username, - email: credentials.email, - token: resp.access_token, - refreshToken: resp.refresh_token, - }, - ]); + return { + id: credentials.id, + username: credentials.username, + email: credentials.email, + token: resp.access_token, + refreshToken: resp.refresh_token, + }; + }), + ); + + const state = createState(accountsData); localStorage.setItem('redux-storage', JSON.stringify(state)); - return state; + return { accounts: accountsData }; }); +Cypress.Commands.add('getByTestId', (id, options) => + cy.get(`[data-testid=${id}]`, options), +); + function createState(accounts) { return { accounts: { diff --git a/tests-e2e/cypress/support/index.d.ts b/tests-e2e/cypress/support/index.d.ts index 76d1eba..d6a1eb9 100644 --- a/tests-e2e/cypress/support/index.d.ts +++ b/tests-e2e/cypress/support/index.d.ts @@ -1,5 +1,15 @@ /// +type AccountAlias = 'default' | 'default2'; + +interface Account { + id: string; + username: string; + email: string; + token: string; + refreshToken: string; +} + declare namespace Cypress { interface Chainable { /** @@ -8,7 +18,12 @@ declare namespace Cypress { * @example cy.login(account) */ login(options: { - account: 'default' | 'default2'; - }): Promise<{ [key: string]: any }>; + accounts: AccountAlias[]; + }): Promise<{ accounts: Account[] }>; + + getByTestId( + id: string, + options?: Partial, + ): Chainable; } } diff --git a/tests-e2e/cypress/support/index.js b/tests-e2e/cypress/support/index.js index 8736e0c..a1338d0 100644 --- a/tests-e2e/cypress/support/index.js +++ b/tests-e2e/cypress/support/index.js @@ -13,16 +13,9 @@ // https://on.cypress.io/configuration // *********************************************************** -// Import commands.js using ES2015 syntax: import './commands'; Cypress.on('window:before:load', win => { - /** - * define @fetch alias for asserting fetch requests - * Example: - * cy - * .get('@fetch') - * .should('be.calledWith', '/api/options'); - */ - cy.spy(win, 'fetch').as('fetch'); + // remove fetch to enable correct api mocking with cypress xhr mocks + win.fetch = null; }); diff --git a/tests-e2e/tsconfig.json b/tests-e2e/tsconfig.json index 25ed8b5..cc442b1 100644 --- a/tests-e2e/tsconfig.json +++ b/tests-e2e/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "noEmit": true, "moduleResolution": "node", - "lib": ["es5", "dom"], + "lib": ["es6", "dom"], "types": ["cypress"], "resolveJsonModule": true, "noImplicitAny": false