mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-19 16:09:18 +05:30
Cover oauth with e2e tests and fix some old and newly introduced bugs
This commit is contained in:
parent
8e95fd835e
commit
d9fc503f9e
@ -5,15 +5,16 @@ import {
|
|||||||
requestToken,
|
requestToken,
|
||||||
logout,
|
logout,
|
||||||
} from 'app/services/api/authentication';
|
} 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 { updateUser, setGuest } from 'app/components/user/actions';
|
||||||
import { setLocale } from 'app/components/i18n/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 logger from 'app/services/logger';
|
||||||
import { ThunkAction } from 'app/reducers';
|
import { ThunkAction } from 'app/reducers';
|
||||||
|
|
||||||
import { Account } from './reducer';
|
import { getActiveAccount, Account } from './reducer';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
@ -296,7 +296,10 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
|||||||
</MeasureHeight>
|
</MeasureHeight>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<div className={helpLinksStyles}>
|
<div
|
||||||
|
className={helpLinksStyles}
|
||||||
|
data-testid="auth-secondary-controls"
|
||||||
|
>
|
||||||
{panels.map(config => this.getLinks(config))}
|
{panels.map(config => this.getLinks(config))}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -20,6 +20,7 @@ import signup from 'app/services/api/signup';
|
|||||||
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
|
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
|
||||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||||
import ContactForm from 'app/components/contact/ContactForm';
|
import ContactForm from 'app/components/contact/ContactForm';
|
||||||
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
import { ThunkAction, Dispatch } from 'app/reducers';
|
import { ThunkAction, Dispatch } from 'app/reducers';
|
||||||
|
|
||||||
import { getCredentials } from './reducer';
|
import { getCredentials } from './reducer';
|
||||||
@ -31,17 +32,8 @@ type ValidationError =
|
|||||||
payload: { [key: string]: any };
|
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 {object} options
|
||||||
* @param {string} options.fallbackUrl - an url to route user to if goBack is not possible
|
* @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');
|
localStorage.removeItem('oauthData');
|
||||||
|
|
||||||
if (resp.redirectUri.startsWith('static_page')) {
|
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=(.+)&/) || [];
|
const [, code] = resp.redirectUri.match(/code=(.+)&/) || [];
|
||||||
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || [];
|
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || [];
|
||||||
@ -516,7 +508,7 @@ export function resetAuth(): ThunkAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SET_OAUTH = 'set_oauth';
|
export const SET_OAUTH = 'set_oauth';
|
||||||
export function setOAuthRequest(oauth: {
|
export function setOAuthRequest(data: {
|
||||||
client_id?: string;
|
client_id?: string;
|
||||||
redirect_uri?: string;
|
redirect_uri?: string;
|
||||||
response_type?: string;
|
response_type?: string;
|
||||||
@ -528,19 +520,19 @@ export function setOAuthRequest(oauth: {
|
|||||||
return {
|
return {
|
||||||
type: SET_OAUTH,
|
type: SET_OAUTH,
|
||||||
payload: {
|
payload: {
|
||||||
clientId: oauth.client_id,
|
clientId: data.client_id,
|
||||||
redirectUrl: oauth.redirect_uri,
|
redirectUrl: data.redirect_uri,
|
||||||
responseType: oauth.response_type,
|
responseType: data.response_type,
|
||||||
scope: oauth.scope,
|
scope: data.scope,
|
||||||
prompt: oauth.prompt,
|
prompt: data.prompt,
|
||||||
loginHint: oauth.loginHint,
|
loginHint: data.loginHint,
|
||||||
state: oauth.state,
|
state: data.state,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SET_OAUTH_RESULT = 'set_oauth_result';
|
export const SET_OAUTH_RESULT = 'set_oauth_result';
|
||||||
export function setOAuthCode(oauth: {
|
export function setOAuthCode(data: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
code: string;
|
code: string;
|
||||||
displayCode: boolean;
|
displayCode: boolean;
|
||||||
@ -548,9 +540,9 @@ export function setOAuthCode(oauth: {
|
|||||||
return {
|
return {
|
||||||
type: SET_OAUTH_RESULT,
|
type: SET_OAUTH_RESULT,
|
||||||
payload: {
|
payload: {
|
||||||
success: oauth.success,
|
success: data.success,
|
||||||
code: oauth.code,
|
code: data.code,
|
||||||
displayCode: oauth.displayCode,
|
displayCode: data.displayCode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -564,7 +556,7 @@ export function requirePermissionsAccept() {
|
|||||||
|
|
||||||
export const SET_SCOPES = 'set_scopes';
|
export const SET_SCOPES = 'set_scopes';
|
||||||
export function setScopes(scopes: Scope[]) {
|
export function setScopes(scopes: Scope[]) {
|
||||||
if (!(scopes instanceof Array)) {
|
if (!Array.isArray(scopes)) {
|
||||||
throw new Error('Scopes must be array');
|
throw new Error('Scopes must be array');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -610,11 +602,11 @@ function needActivation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authHandler(dispatch: Dispatch) {
|
function authHandler(dispatch: Dispatch) {
|
||||||
return (resp: OAuthResponse): Promise<Account> =>
|
return (oAuthResp: OAuthResponse): Promise<Account> =>
|
||||||
dispatch(
|
dispatch(
|
||||||
authenticate({
|
authenticate({
|
||||||
token: resp.access_token,
|
token: oAuthResp.access_token,
|
||||||
refreshToken: resp.refresh_token || null,
|
refreshToken: oAuthResp.refresh_token || null,
|
||||||
}),
|
}),
|
||||||
).then(resp => {
|
).then(resp => {
|
||||||
dispatch(setLogin(null));
|
dispatch(setLogin(null));
|
||||||
@ -626,7 +618,7 @@ function authHandler(dispatch: Dispatch) {
|
|||||||
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
|
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
|
||||||
return resp => {
|
return resp => {
|
||||||
if (resp.errors) {
|
if (resp.errors) {
|
||||||
const firstError = Object.keys(resp.errors)[0];
|
const [firstError] = Object.keys(resp.errors);
|
||||||
const error = {
|
const error = {
|
||||||
type: resp.errors[firstError],
|
type: resp.errors[firstError],
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -44,7 +44,7 @@ class Finish extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{displayCode ? (
|
{displayCode ? (
|
||||||
<div>
|
<div data-testid="oauth-code-container">
|
||||||
<div className={styles.description}>
|
<div className={styles.description}>
|
||||||
<Message {...messages.passCodeToApp} values={{ appName }} />
|
<Message {...messages.passCodeToApp} values={{ appName }} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,7 +42,7 @@ export function Panel(props: {
|
|||||||
|
|
||||||
export function PanelHeader(props: { children: React.ReactNode }) {
|
export function PanelHeader(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.header} {...props}>
|
<div className={styles.header} {...props} data-testid="auth-header">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -50,7 +50,7 @@ export function PanelHeader(props: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function PanelBody(props: { children: React.ReactNode }) {
|
export function PanelBody(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.body} {...props}>
|
<div className={styles.body} {...props} data-testid="auth-body">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -58,7 +58,7 @@ export function PanelBody(props: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function PanelFooter(props: { children: React.ReactNode }) {
|
export function PanelFooter(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.footer} {...props}>
|
<div className={styles.footer} {...props} data-testid="auth-controls">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
import { IntlProvider } from 'app/components/i18n';
|
|
||||||
import logger from 'app/services/logger';
|
import logger from 'app/services/logger';
|
||||||
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
|
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
|
||||||
|
|
||||||
@ -42,7 +41,6 @@ class BSoD extends React.Component<{}, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider>
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<canvas
|
<canvas
|
||||||
className={styles.canvas}
|
className={styles.canvas}
|
||||||
@ -67,7 +65,6 @@ class BSoD extends React.Component<{}, State> {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</IntlProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(<BSoD />, document.getElementById('app'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inject(store, stopLoading) {
|
|
||||||
injectedStore = store;
|
|
||||||
onBsod = stopLoading;
|
|
||||||
}
|
|
41
packages/app/components/ui/bsod/dispatchBsod.tsx
Normal file
41
packages/app/components/ui/bsod/dispatchBsod.tsx
Normal file
@ -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<any>;
|
||||||
|
let onBsod: undefined | (() => void);
|
||||||
|
|
||||||
|
export default function dispatchBsod(
|
||||||
|
store = injectedStore,
|
||||||
|
history = injectedHistory,
|
||||||
|
) {
|
||||||
|
store.dispatch(bsod());
|
||||||
|
onBsod && onBsod();
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<ContextProvider store={store} history={history}>
|
||||||
|
<BSoD />
|
||||||
|
</ContextProvider>,
|
||||||
|
document.getElementById('app'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inject({
|
||||||
|
store,
|
||||||
|
history,
|
||||||
|
stopLoading,
|
||||||
|
}: {
|
||||||
|
store: Store;
|
||||||
|
history: History<any>;
|
||||||
|
stopLoading: () => void;
|
||||||
|
}) {
|
||||||
|
injectedStore = store;
|
||||||
|
injectedHistory = history;
|
||||||
|
onBsod = stopLoading;
|
||||||
|
}
|
@ -1,11 +1,21 @@
|
|||||||
import request from 'app/services/request';
|
import request from 'app/services/request';
|
||||||
import logger from 'app/services/logger';
|
import logger from 'app/services/logger';
|
||||||
|
import { Store } from 'app/reducers';
|
||||||
|
import { History } from 'history';
|
||||||
|
|
||||||
import dispatchBsod, { inject } from './dispatchBsod';
|
import dispatchBsod, { inject } from './dispatchBsod';
|
||||||
import BsodMiddleware from './BsodMiddleware';
|
import BsodMiddleware from './BsodMiddleware';
|
||||||
|
|
||||||
export default function factory(store, stopLoading) {
|
export default function factory({
|
||||||
inject(store, stopLoading);
|
store,
|
||||||
|
history,
|
||||||
|
stopLoading,
|
||||||
|
}: {
|
||||||
|
store: Store;
|
||||||
|
history: History<any>;
|
||||||
|
stopLoading: () => void;
|
||||||
|
}) {
|
||||||
|
inject({ store, history, stopLoading });
|
||||||
|
|
||||||
// do bsod for 500/404 errors
|
// do bsod for 500/404 errors
|
||||||
request.addMiddleware(new BsodMiddleware(dispatchBsod, logger));
|
request.addMiddleware(new BsodMiddleware(dispatchBsod, logger));
|
@ -14,7 +14,7 @@ import history, { browserHistory } from 'app/services/history';
|
|||||||
import i18n from 'app/services/i18n';
|
import i18n from 'app/services/i18n';
|
||||||
import { loadScript, debounce } from 'app/functions';
|
import { loadScript, debounce } from 'app/functions';
|
||||||
|
|
||||||
import App from './shell';
|
import App from './shell/App';
|
||||||
|
|
||||||
const win: { [key: string]: any } = window as any;
|
const win: { [key: string]: any } = window as any;
|
||||||
|
|
||||||
@ -26,7 +26,11 @@ logger.init({
|
|||||||
|
|
||||||
const store = storeFactory();
|
const store = storeFactory();
|
||||||
|
|
||||||
bsodFactory(store, () => loader.hide());
|
bsodFactory({
|
||||||
|
store,
|
||||||
|
history: browserHistory,
|
||||||
|
stopLoading: () => loader.hide(),
|
||||||
|
});
|
||||||
authFlow.setStore(store);
|
authFlow.setStore(store);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@ -77,27 +81,3 @@ function _trackPageView(location) {
|
|||||||
ga('set', 'page', location.pathname + location.search);
|
ga('set', 'page', location.pathname + location.search);
|
||||||
ga('send', 'pageview');
|
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');
|
|
||||||
}
|
|
||||||
|
@ -89,12 +89,12 @@ class RootPage extends React.PureComponent<{
|
|||||||
<Route path="/rules" component={RulesPage} />
|
<Route path="/rules" component={RulesPage} />
|
||||||
<Route path="/dev" component={DevPage} />
|
<Route path="/dev" component={DevPage} />
|
||||||
|
|
||||||
{user.isGuest ? (
|
{!user.isGuest && (
|
||||||
<AuthFlowRoute path="/" component={AuthPage} />
|
|
||||||
) : (
|
|
||||||
<AuthFlowRoute exact path="/" component={ProfilePage} />
|
<AuthFlowRoute exact path="/" component={ProfilePage} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AuthFlowRoute path="/" component={AuthPage} />
|
||||||
|
|
||||||
<Route component={PageNotFound} />
|
<Route component={PageNotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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') {
|
if (resp.statusCode === 400 && resp.error === 'invalid_request') {
|
||||||
resp.userMessage = `Invalid request (${resp.parameter} required).`;
|
userMessage = `Invalid request (${resp.parameter} required).`;
|
||||||
} else if (
|
} else if (
|
||||||
resp.statusCode === 400 &&
|
resp.statusCode === 400 &&
|
||||||
resp.error === 'unsupported_response_type'
|
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') {
|
} 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') {
|
} 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);
|
||||||
}
|
}
|
||||||
|
@ -173,10 +173,11 @@ export default class AuthFlow implements AuthContext {
|
|||||||
const callback = this.onReady;
|
const callback = this.onReady;
|
||||||
this.onReady = () => {};
|
this.onReady = () => {};
|
||||||
|
|
||||||
return resp.then(
|
return resp.then(callback, error => {
|
||||||
callback,
|
logger.error('State transition error', { error });
|
||||||
err => err || logger.warn('State transition error', err),
|
|
||||||
);
|
return error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
|
@ -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 AuthFlow, { ActionsDict, AuthContext as TAuthContext } from './AuthFlow';
|
||||||
|
|
||||||
import * as actions from 'app/components/auth/actions';
|
|
||||||
|
|
||||||
const availableActions = {
|
const availableActions = {
|
||||||
updateUser: actions.updateUser,
|
updateUser,
|
||||||
authenticate: actions.authenticate,
|
authenticate,
|
||||||
activateAccount: actions.activateAccount,
|
activateAccount,
|
||||||
removeAccount: actions.removeAccount,
|
removeAccount,
|
||||||
logout: actions.logout,
|
logout,
|
||||||
goBack: actions.goBack,
|
goBack: actions.goBack,
|
||||||
redirect: actions.redirect,
|
redirect: actions.redirect,
|
||||||
login: actions.login,
|
login: actions.login,
|
||||||
|
@ -1,2 +1 @@
|
|||||||
export { default } from './App';
|
|
||||||
export { default as ContextProvider } from './ContextProvider';
|
export { default as ContextProvider } from './ContextProvider';
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
describe('/dev/applications - user', () => {
|
describe('/dev/applications - user', () => {
|
||||||
before(() => {
|
before(() => {
|
||||||
cy.login({ account: 'default' }).then(({ user }) => {
|
cy.login({ accounts: ['default'] }).then(({ user }) => {
|
||||||
cy.visit('/dev/applications');
|
cy.visit('/dev/applications');
|
||||||
|
|
||||||
// remove all previousely added apps
|
// remove all previously added apps
|
||||||
cy.window().then(async (/** @type {any} */ { oauthApi }) => {
|
cy.window().then(async (/** @type {any} */ { oauthApi }) => {
|
||||||
const apps = await oauthApi.getAppsByUser(user.id);
|
const apps = await oauthApi.getAppsByUser(user.id);
|
||||||
|
|
||||||
|
321
tests-e2e/cypress/integration/oauth/user.test.ts
Normal file
321
tests-e2e/cypress/integration/oauth/user.test.ts
Normal file
@ -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',
|
||||||
|
);
|
||||||
|
}
|
@ -31,7 +31,9 @@ const accountsMap = {
|
|||||||
default2: account1,
|
default2: account1,
|
||||||
};
|
};
|
||||||
|
|
||||||
Cypress.Commands.add('login', async ({ account }) => {
|
Cypress.Commands.add('login', async ({ accounts }) => {
|
||||||
|
const accountsData = await Promise.all(
|
||||||
|
accounts.map(async account => {
|
||||||
let credentials;
|
let credentials;
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
@ -47,24 +49,34 @@ Cypress.Commands.add('login', async ({ account }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
},
|
},
|
||||||
body: `login=${credentials.login}&password=${credentials.password}&rememberMe=1`,
|
body: `${new URLSearchParams({
|
||||||
}).then(resp => resp.json());
|
login: credentials.login,
|
||||||
|
password: credentials.password,
|
||||||
|
rememberMe: '1',
|
||||||
|
})}`,
|
||||||
|
}).then(rawResp => rawResp.json());
|
||||||
|
|
||||||
const state = createState([
|
return {
|
||||||
{
|
|
||||||
id: credentials.id,
|
id: credentials.id,
|
||||||
username: credentials.username,
|
username: credentials.username,
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
token: resp.access_token,
|
token: resp.access_token,
|
||||||
refreshToken: resp.refresh_token,
|
refreshToken: resp.refresh_token,
|
||||||
},
|
};
|
||||||
]);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = createState(accountsData);
|
||||||
|
|
||||||
localStorage.setItem('redux-storage', JSON.stringify(state));
|
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) {
|
function createState(accounts) {
|
||||||
return {
|
return {
|
||||||
accounts: {
|
accounts: {
|
||||||
|
19
tests-e2e/cypress/support/index.d.ts
vendored
19
tests-e2e/cypress/support/index.d.ts
vendored
@ -1,5 +1,15 @@
|
|||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
type AccountAlias = 'default' | 'default2';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
/**
|
/**
|
||||||
@ -8,7 +18,12 @@ declare namespace Cypress {
|
|||||||
* @example cy.login(account)
|
* @example cy.login(account)
|
||||||
*/
|
*/
|
||||||
login(options: {
|
login(options: {
|
||||||
account: 'default' | 'default2';
|
accounts: AccountAlias[];
|
||||||
}): Promise<{ [key: string]: any }>;
|
}): Promise<{ accounts: Account[] }>;
|
||||||
|
|
||||||
|
getByTestId<S = any>(
|
||||||
|
id: string,
|
||||||
|
options?: Partial<Loggable & Timeoutable & Withinable>,
|
||||||
|
): Chainable<S>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,16 +13,9 @@
|
|||||||
// https://on.cypress.io/configuration
|
// https://on.cypress.io/configuration
|
||||||
// ***********************************************************
|
// ***********************************************************
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
|
||||||
import './commands';
|
import './commands';
|
||||||
|
|
||||||
Cypress.on('window:before:load', win => {
|
Cypress.on('window:before:load', win => {
|
||||||
/**
|
// remove fetch to enable correct api mocking with cypress xhr mocks
|
||||||
* define @fetch alias for asserting fetch requests
|
win.fetch = null;
|
||||||
* Example:
|
|
||||||
* cy
|
|
||||||
* .get('@fetch')
|
|
||||||
* .should('be.calledWith', '/api/options');
|
|
||||||
*/
|
|
||||||
cy.spy(win, 'fetch').as('fetch');
|
|
||||||
});
|
});
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"lib": ["es5", "dom"],
|
"lib": ["es6", "dom"],
|
||||||
"types": ["cypress"],
|
"types": ["cypress"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"noImplicitAny": false
|
"noImplicitAny": false
|
||||||
|
Loading…
x
Reference in New Issue
Block a user