Skip to content
Snippets Groups Projects
Unverified Commit 32dd9ab4 authored by Jack Reed's avatar Jack Reed Committed by GitHub
Browse files

Merge pull request #3254 from ProjectMirador/auth-refactor

Consolidate IIIF auth behaviors
parents b15cbb61 6653ec3d
No related branches found
No related tags found
No related merge requests found
Showing
with 996 additions and 750 deletions
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import * as actions from '../../../src/state/actions'; import * as actions from '../../../src/state/actions';
import ActionTypes from '../../../src/state/actions/action-types'; import ActionTypes from '../../../src/state/actions/action-types';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('auth actions', () => { describe('auth actions', () => {
describe('addAuthenticationRequest', () => { describe('addAuthenticationRequest', () => {
it('requests an authentication attempt from given a url', () => { it('requests an authentication attempt from given a url', () => {
const id = 'abc123'; const id = 'abc123';
const windowId = 'windowId'; const windowId = 'windowId';
const infoId = 'infoId';
const expectedAction = { const expectedAction = {
id, id,
infoId,
type: ActionTypes.ADD_AUTHENTICATION_REQUEST, type: ActionTypes.ADD_AUTHENTICATION_REQUEST,
windowId, windowId,
}; };
expect(actions.addAuthenticationRequest(windowId, infoId, id)).toEqual(expectedAction); expect(actions.addAuthenticationRequest(windowId, id)).toEqual(expectedAction);
}); });
}); });
describe('resolveAuthenticationRequest', () => { describe('resolveAuthenticationRequest', () => {
let store = null; it('markes the auth request as resolved (pending fetching access tokens to mark it a success)', () => {
beforeEach(() => {
store = mockStore({});
});
it('triggers an access token fetch', () => {
const authId = 'abc123'; const authId = 'abc123';
const infoId = 'x'; const tokenServiceId = 'xyz';
const serviceId = 'xyz';
store = mockStore({ const expectedAction = {
auth: { id: authId,
[authId]: { tokenServiceId,
infoId: [infoId], type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST,
}, };
},
infoResponses: { expect(actions.resolveAuthenticationRequest(authId, tokenServiceId)).toEqual(expectedAction);
[infoId]: {
json: {
service: {
'@id': authId,
service: {
'@id': serviceId,
profile: 'http://iiif.io/api/auth/1/token',
},
},
},
},
},
}); });
it('can be marked as failed', () => {
const authId = 'abc123';
const tokenServiceId = 'xyz';
const expectedAction = { const expectedAction = {
authId, id: authId,
infoIds: [infoId], ok: false,
serviceId, tokenServiceId,
type: ActionTypes.REQUEST_ACCESS_TOKEN, type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST,
}; };
store.dispatch(actions.resolveAuthenticationRequest(authId)); expect(
expect(store.getActions()).toEqual([expectedAction]); actions.resolveAuthenticationRequest(authId, tokenServiceId, { ok: false }),
).toEqual(expectedAction);
}); });
}); });
describe('requestAccessToken', () => { describe('requestAccessToken', () => {
it('requests an infoResponse from given a url', () => { it('requests an infoResponse from given a url', () => {
const authId = 'abc123'; const authId = 'abc123';
const infoIds = ['x'];
const serviceId = 'xyz'; const serviceId = 'xyz';
const expectedAction = { const expectedAction = {
authId, authId,
infoIds,
serviceId, serviceId,
type: ActionTypes.REQUEST_ACCESS_TOKEN, type: ActionTypes.REQUEST_ACCESS_TOKEN,
}; };
expect(actions.requestAccessToken(serviceId, authId, infoIds)).toEqual(expectedAction); expect(actions.requestAccessToken(serviceId, authId)).toEqual(expectedAction);
}); });
}); });
describe('receiveAccessToken', () => { describe('receiveAccessToken', () => {
it('recieves an access token', () => { it('recieves an access token', () => {
const authId = 'auth';
const serviceId = 'abc123'; const serviceId = 'abc123';
const json = { const json = {
content: 'image information request', content: 'image information request',
...@@ -92,76 +69,54 @@ describe('auth actions', () => { ...@@ -92,76 +69,54 @@ describe('auth actions', () => {
}; };
const expectedAction = { const expectedAction = {
authId,
json, json,
serviceId, serviceId,
type: ActionTypes.RECEIVE_ACCESS_TOKEN, type: ActionTypes.RECEIVE_ACCESS_TOKEN,
}; };
expect(actions.receiveAccessToken(serviceId, json)).toEqual(expectedAction); expect(actions.receiveAccessToken(authId, serviceId, json)).toEqual(expectedAction);
}); });
}); });
describe('receiveAccessTokenFailure', () => { describe('receiveAccessTokenFailure', () => {
it('fails to receive an access token', () => { it('fails to receive an access token', () => {
const authId = 'auth';
const serviceId = 'abc123'; const serviceId = 'abc123';
const error = 'some error'; const error = 'some error';
const expectedAction = { const expectedAction = {
authId,
error, error,
serviceId, serviceId,
type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE,
}; };
expect(actions.receiveAccessTokenFailure(serviceId, error)).toEqual(expectedAction); expect(actions.receiveAccessTokenFailure(authId, serviceId, error)).toEqual(expectedAction);
}); });
}); });
describe('resolveAccessTokenRequest', () => { describe('resolveAccessTokenRequest', () => {
let store = null;
beforeEach(() => {
store = mockStore({});
});
it('resolves the auth request, receives the access token, and re-dispatches fetching info responses', () => { it('resolves the auth request, receives the access token, and re-dispatches fetching info responses', () => {
const authId = 'abc123'; const authId = 'auth';
const infoId = 'x'; const serviceId = 'abc123';
const messageId = 'xyz';
const json = { accessToken: 1 }; const json = { accessToken: 1 };
store = mockStore({ expect(actions.resolveAccessTokenRequest(authId, serviceId, json)).toEqual(
accessTokens: { {
[messageId]: { authId, json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN,
authId,
infoIds: [infoId],
},
}, },
}); );
store.dispatch(actions.resolveAccessTokenRequest({ messageId, ...json }));
expect(store.getActions()).toEqual([
{ id: authId, ok: true, type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST },
{ json, serviceId: messageId, type: ActionTypes.RECEIVE_ACCESS_TOKEN },
]);
}); });
it('without an access token, resolves the auth request unsuccessfully', () => { it('without an access token, resolves the auth request unsuccessfully', () => {
const authId = 'abc123'; const authId = 'auth';
const infoId = 'x'; const serviceId = 'abc123';
const messageId = 'xyz';
const json = { error: 'xyz' }; const json = { error: 'xyz' };
store = mockStore({ expect(actions.resolveAccessTokenRequest(authId, serviceId, json)).toEqual(
accessTokens: { {
[messageId]: { authId, error: json, serviceId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE,
authId,
infoIds: [infoId],
},
}, },
}); );
store.dispatch(actions.resolveAccessTokenRequest({ messageId, ...json }));
expect(store.getActions()).toEqual([
{ id: authId, ok: false, type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST },
{ error: json, serviceId: messageId, type: ActionTypes.RECEIVE_ACCESS_TOKEN_FAILURE },
]);
}); });
}); });
}); });
...@@ -2,8 +2,6 @@ import React from 'react'; ...@@ -2,8 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import PluginProvider from '../../../src/extend/PluginProvider'; import PluginProvider from '../../../src/extend/PluginProvider';
import AppProviders from '../../../src/containers/AppProviders'; import AppProviders from '../../../src/containers/AppProviders';
import AccessTokenSender from '../../../src/containers/AccessTokenSender';
import AuthenticationSender from '../../../src/containers/AuthenticationSender';
import { App } from '../../../src/components/App'; import { App } from '../../../src/components/App';
/** */ /** */
...@@ -21,7 +19,5 @@ describe('App', () => { ...@@ -21,7 +19,5 @@ describe('App', () => {
expect(wrapper.find(PluginProvider).length).toBe(1); expect(wrapper.find(PluginProvider).length).toBe(1);
expect(wrapper.find(AppProviders).length).toBe(1); expect(wrapper.find(AppProviders).length).toBe(1);
expect(wrapper.find('Suspense').length).toBe(1); expect(wrapper.find('Suspense').length).toBe(1);
expect(wrapper.find(AuthenticationSender).length).toBe(1);
expect(wrapper.find(AccessTokenSender).length).toBe(1);
}); });
}); });
import React from 'react';
import { shallow } from 'enzyme';
import Fab from '@material-ui/core/Fab';
import { AuthenticationLogout } from '../../../src/components/AuthenticationLogout';
/**
* Helper function to create a shallow wrapper around AuthenticationLogout
*/
function createWrapper(props) {
return shallow(
<AuthenticationLogout
authServiceId="http://example.com/auth"
label="Log out now!"
logoutServiceId="http://example.com/logout"
status="ok"
t={key => key}
windowId="w"
{...props}
/>,
);
}
describe('AuthenticationLogout', () => {
it('when status is not ok, render fragment', () => {
const wrapper = createWrapper({ status: 'fail' });
expect(wrapper.matchesElement(<></>)).toBe(true);
});
it('renders Fab with logout label', () => {
const wrapper = createWrapper();
expect(wrapper.find(Fab).length).toBe(1);
expect(wrapper.find(Fab).text()).toBe('Log out now!');
});
it('click opens a new window to logout and resets state', () => {
const mockWindow = {};
const open = jest.fn(() => mockWindow);
const reset = jest.fn(() => {});
const wrapper = createWrapper({ depWindow: { open }, resetAuthenticationState: reset });
expect(wrapper.find(Fab).props().onClick());
expect(open).toHaveBeenCalledWith('http://example.com/logout');
expect(reset).toHaveBeenCalledWith({ authServiceId: 'http://example.com/auth' });
});
});
import React from 'react';
import { shallow } from 'enzyme';
import { NewWindow } from '../../../src/components/NewWindow';
import { AuthenticationSender } from '../../../src/components/AuthenticationSender';
/**
* Helper function to create a shallow wrapper around ErrorDialog
*/
function createWrapper(props) {
return shallow(
<AuthenticationSender
t={key => key}
handleInteraction={() => {}}
{...props}
/>,
);
}
describe('AuthenticationSender', () => {
let wrapper;
it('renders nothing if there is no url', () => {
wrapper = createWrapper({});
expect(wrapper.matchesElement(<></>)).toBe(true);
});
it('renders properly', () => {
Object.defineProperty(window, 'origin', {
value: 'http://localhost',
writable: true,
});
wrapper = createWrapper({ url: 'http://example.com' });
expect(wrapper.find(NewWindow).length).toBe(1);
expect(wrapper.find(NewWindow).props().url).toBe('http://example.com?origin=http://localhost');
});
it('triggers an action when the window is unloaded', () => {
const handleInteraction = jest.fn();
wrapper = createWrapper({ handleInteraction, url: 'http://example.com' });
wrapper.find(NewWindow).simulate('close');
expect(handleInteraction).toHaveBeenCalledWith('http://example.com');
});
});
import React from 'react';
import { shallow } from 'enzyme';
import WindowAuthenticationBar from '../../../src/containers/WindowAuthenticationBar';
import { NewWindow } from '../../../src/components/NewWindow';
import { AccessTokenSender } from '../../../src/components/AccessTokenSender';
import { IIIFAuthentication } from '../../../src/components/IIIFAuthentication';
/**
* Helper function to create a shallow wrapper around IIIFAuthentication
*/
function createWrapper(props) {
return shallow(
<IIIFAuthentication
accessTokenServiceId="http://example.com/token"
authServiceId="http://example.com/auth"
failureDescription="... and this is why."
failureHeader="Login failed"
handleAuthInteraction={() => {}}
isInteractive
logoutServiceId="http://example.com/logout"
resetAuthenticationState={() => {}}
resolveAccessTokenRequest={() => {}}
resolveAuthenticationRequest={() => {}}
t={key => key}
windowId="w"
{...props}
/>,
);
}
describe('IIIFAuthentication', () => {
describe('without an auth service', () => {
it('renders nothing', () => {
const wrapper = createWrapper({ authServiceId: null });
expect(wrapper.isEmptyRender()).toBe(true);
});
});
describe('with an available auth service', () => {
it('renders a login bar', () => {
const handleAuthInteraction = jest.fn();
const wrapper = createWrapper({ handleAuthInteraction });
expect(wrapper.find(WindowAuthenticationBar).length).toBe(1);
expect(wrapper.find(WindowAuthenticationBar).simulate('confirm'));
expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'http://example.com/auth');
});
it('renders nothing for a non-interactive login', () => {
const wrapper = createWrapper({ isInteractive: false });
expect(wrapper.isEmptyRender()).toBe(true);
});
});
describe('with a failed authentication', () => {
it('renders with an error message', () => {
const handleAuthInteraction = jest.fn();
const wrapper = createWrapper({ handleAuthInteraction, status: 'failed' });
expect(wrapper.find(WindowAuthenticationBar).length).toBe(1);
expect(wrapper.find(WindowAuthenticationBar).prop('confirmButton')).toEqual('retry');
expect(wrapper.find(WindowAuthenticationBar).prop('status')).toEqual('failed');
expect(wrapper.find(WindowAuthenticationBar).prop('header')).toEqual('Login failed');
expect(wrapper.find(WindowAuthenticationBar).prop('description')).toEqual('... and this is why.');
expect(wrapper.find(WindowAuthenticationBar).simulate('confirm'));
expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'http://example.com/auth');
});
});
describe('in the middle of authenicating', () => {
it('does the IIIF access cookie behavior', () => {
const wrapper = createWrapper({ status: 'cookie' });
expect(wrapper.find(WindowAuthenticationBar).length).toBe(1);
expect(wrapper.find(NewWindow).length).toBe(1);
expect(wrapper.find(NewWindow).prop('url')).toContain('http://example.com/auth?origin=');
});
it('does the IIIF access token behavior', () => {
const wrapper = createWrapper({ status: 'token' });
expect(wrapper.find(WindowAuthenticationBar).length).toBe(1);
expect(wrapper.find(AccessTokenSender).length).toBe(1);
expect(wrapper.find(AccessTokenSender).prop('url')).toEqual('http://example.com/token');
});
});
describe('when logged in', () => {
it('renders a logout button', () => {
const openWindow = jest.fn();
const resetAuthenticationState = jest.fn();
const wrapper = createWrapper({ openWindow, resetAuthenticationState, status: 'ok' });
expect(wrapper.find(WindowAuthenticationBar).length).toBe(1);
expect(wrapper.find(WindowAuthenticationBar).prop('confirmButton')).toEqual('logout');
expect(wrapper.find(WindowAuthenticationBar).prop('hasLogoutService')).toEqual(true);
wrapper.find(WindowAuthenticationBar).simulate('confirm');
expect(openWindow).toHaveBeenCalledWith('http://example.com/logout', undefined, 'centerscreen');
expect(resetAuthenticationState).toHaveBeenCalledWith({
authServiceId: 'http://example.com/auth', tokenServiceId: 'http://example.com/token',
});
});
});
});
...@@ -3,7 +3,7 @@ import { shallow } from 'enzyme'; ...@@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
import { Window } from '../../../src/components/Window'; import { Window } from '../../../src/components/Window';
import WindowTopBar from '../../../src/containers/WindowTopBar'; import WindowTopBar from '../../../src/containers/WindowTopBar';
import PrimaryWindow from '../../../src/containers/PrimaryWindow'; import PrimaryWindow from '../../../src/containers/PrimaryWindow';
import WindowAuthenticationControl from '../../../src/containers/WindowAuthenticationControl'; import IIIFAuthentication from '../../../src/containers/IIIFAuthentication';
import ErrorContent from '../../../src/containers/ErrorContent'; import ErrorContent from '../../../src/containers/ErrorContent';
/** create wrapper */ /** create wrapper */
...@@ -34,9 +34,9 @@ describe('Window', () => { ...@@ -34,9 +34,9 @@ describe('Window', () => {
wrapper = createWrapper(); wrapper = createWrapper();
expect(wrapper.find(PrimaryWindow)).toHaveLength(1); expect(wrapper.find(PrimaryWindow)).toHaveLength(1);
}); });
it('renders <WindowAuthenticationControl>', () => { it('renders <WindowAuthenticationBar>', () => {
wrapper = createWrapper(); wrapper = createWrapper();
expect(wrapper.find(WindowAuthenticationControl)).toHaveLength(1); expect(wrapper.find(IIIFAuthentication)).toHaveLength(1);
}); });
it('renders manifest error', () => { it('renders manifest error', () => {
wrapper = createWrapper({ manifestError: 'Invalid JSON' }); wrapper = createWrapper({ manifestError: 'Invalid JSON' });
......
import React from 'react';
import { shallow } from 'enzyme';
import Button from '@material-ui/core/Button';
import Collapse from '@material-ui/core/Collapse';
import DialogActions from '@material-ui/core/DialogActions';
import SanitizedHtml from '../../../src/containers/SanitizedHtml';
import { WindowAuthenticationBar } from '../../../src/components/WindowAuthenticationBar';
/**
* Helper function to create a shallow wrapper around AuthenticationLogout
*/
function createWrapper(props) {
return shallow(
<WindowAuthenticationBar
classes={{}}
hasLogoutService
confirmButton="Click here"
label="Log in to see more"
onConfirm={() => {}}
status="ok"
t={key => key}
windowId="w"
{...props}
/>,
);
}
describe('AuthenticationControl', () => {
it('renders nothing if the user is logged in and there is no logout service', () => {
const wrapper = createWrapper({ hasLogoutService: false });
expect(wrapper.isEmptyRender()).toBe(true);
});
it('renders a non-collapsing version if there is no description', () => {
const wrapper = createWrapper({ description: undefined, header: undefined });
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('Log in to see more');
expect(wrapper.find(Button).children().text()).toEqual('Click here');
});
it('renders a collapsable version if there is a description', () => {
const onConfirm = jest.fn();
const wrapper = createWrapper({ description: 'long description', header: 'header', onConfirm });
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('Log in to see more');
expect(wrapper.find(Button).at(0).find('span').text()).toEqual('continue');
// is expandable
expect(wrapper.find(Collapse).prop('in')).toEqual(false);
wrapper.find(Button).at(0).simulate('click');
expect(wrapper.find(Collapse).prop('in')).toEqual(true);
// has more information
expect(wrapper.find(Collapse).find(SanitizedHtml).at(0).props().htmlString).toEqual('header');
expect(wrapper.find(Collapse).find(SanitizedHtml).at(1).props().htmlString).toEqual('long description');
// is recollapsable
wrapper.find(DialogActions).find(Button).at(0).simulate('click');
expect(wrapper.find(Collapse).prop('in')).toEqual(false);
wrapper.find(Button).at(0).simulate('click');
// starts the auth process
wrapper.find(DialogActions).find(Button).at(1).simulate('click');
expect(onConfirm).toHaveBeenCalled();
});
it('triggers an action when the confirm button is clicked', () => {
const onConfirm = jest.fn();
const wrapper = createWrapper({
onConfirm,
});
wrapper.find(Button).simulate('click');
expect(onConfirm).toHaveBeenCalled();
});
});
import React from 'react';
import { shallow } from 'enzyme';
import Button from '@material-ui/core/Button';
import Collapse from '@material-ui/core/Collapse';
import DialogActions from '@material-ui/core/DialogActions';
import SanitizedHtml from '../../../src/containers/SanitizedHtml';
import { WindowAuthenticationControl } from '../../../src/components/WindowAuthenticationControl';
import AuthenticationLogout from '../../../src/containers/AuthenticationLogout';
/**
* Helper function to create a shallow wrapper around WindowAuthenticationControl
*/
function createWrapper(props) {
return shallow(
<WindowAuthenticationControl
t={key => key}
classes={{}}
degraded
handleAuthInteraction={() => {}}
label="authenticate"
windowId="w"
profile="http://iiif.io/api/auth/1/login"
{...props}
/>,
);
}
describe('WindowAuthenticationControl', () => {
let wrapper;
it('renders AuthenticationLogout if it is not degraded', () => {
wrapper = createWrapper({ degraded: false });
expect(wrapper.find(AuthenticationLogout).length).toBe(1);
});
describe('with a non-interactive login', () => {
it('renders failure messages', () => {
wrapper = createWrapper({
degraded: true,
failureDescription: 'failure description',
failureHeader: 'failure header',
profile: 'http://iiif.io/api/auth/1/external',
status: 'failed',
});
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header');
expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description');
expect(wrapper.find(DialogActions)).toHaveLength(1);
expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2);
expect(wrapper.find(DialogActions).find(Button).at(1).children()
.text()).toEqual('retry');
});
});
it('renders properly', () => {
wrapper = createWrapper({ confirmLabel: 'some confirm label', description: 'some description' });
expect(wrapper.find(SanitizedHtml).at(2).props().htmlString).toEqual('some description');
expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2);
expect(wrapper.find(DialogActions).find(Button).at(1).children()
.text()).toEqual('some confirm label');
});
it('hides the cancel button if there is nothing to collapose', () => {
wrapper = createWrapper({ classes: { topBar: 'topBar' }, confirmLabel: 'some confirm label' });
expect(wrapper.find('.topBar').children().find(Button).at(0)
.children()
.text()).toEqual('some confirm label');
expect(wrapper.find(DialogActions).find(Button)).toHaveLength(0);
});
it('shows the auth dialog when the login button is clicked', () => {
wrapper = createWrapper({ classes: { topBar: 'topBar' }, description: 'some description' });
wrapper.find('.topBar').props().onClick();
expect(wrapper.find(Collapse).props().in).toEqual(true);
});
it('triggers an action when the confirm button is clicked', () => {
const handleAuthInteraction = jest.fn();
wrapper = createWrapper({
confirmLabel: 'some confirm label',
description: 'some description',
handleAuthInteraction,
infoId: 'i',
serviceId: 's',
});
wrapper.instance().setState({ open: true });
expect(wrapper.find(Collapse).props().in).toEqual(true);
wrapper.find(DialogActions).find(Button).at(1).simulate('click');
expect(handleAuthInteraction).toHaveBeenCalledWith('w', 'i', 's');
});
it('displays a failure message if the login has failed', () => {
wrapper = createWrapper({
failureDescription: 'failure description',
failureHeader: 'failure header',
status: 'failed',
});
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header');
expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description');
expect(wrapper.find(DialogActions).find(Button)).toHaveLength(2);
expect(wrapper.find(DialogActions).find(Button).at(1).children()
.text()).toEqual('login');
});
it('displays the login messages if the user dismisses the failure messages', () => {
wrapper = createWrapper({
failureDescription: 'failure description',
failureHeader: 'failure header',
status: 'failed',
});
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('failure header');
expect(wrapper.find(SanitizedHtml).at(1).props().htmlString).toEqual('failure description');
wrapper.find(DialogActions).find(Button).at(0).simulate('click');
expect(wrapper.find(SanitizedHtml).at(0).props().htmlString).toEqual('authenticate');
});
});
...@@ -2,83 +2,15 @@ import { accessTokensReducer } from '../../../src/state/reducers/accessTokens'; ...@@ -2,83 +2,15 @@ import { accessTokensReducer } from '../../../src/state/reducers/accessTokens';
import ActionTypes from '../../../src/state/actions/action-types'; import ActionTypes from '../../../src/state/actions/action-types';
describe('access tokens response reducer', () => { describe('access tokens response reducer', () => {
describe('should handle RECEIVE_DEGRADED_INFO_RESPONSE', () => {
it('does nothing for a kiosk service', () => {
expect(accessTokensReducer({}, {
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
profile: 'http://iiif.io/api/auth/1/kiosk',
},
},
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
})).toEqual({});
});
it('does nothing if a external request for that token service is in flight', () => {
expect(accessTokensReducer(
{
token: {
isFetching: true,
},
},
{
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
'@id': 'auth',
profile: 'http://iiif.io/api/auth/1/external',
service: {
'@id': 'token',
profile: 'http://iiif.io/api/auth/1/token',
},
},
},
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
},
)).toEqual({ token: { isFetching: true } });
});
it('adds an entry for an external auth token service', () => {
expect(accessTokensReducer(
{},
{
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
'@id': 'auth',
profile: 'http://iiif.io/api/auth/1/external',
service: {
'@id': 'token',
profile: 'http://iiif.io/api/auth/1/token',
},
},
},
ok: false,
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
},
)).toEqual({
token: {
authId: 'auth',
id: 'token',
infoIds: ['http://example.com'],
isFetching: true,
},
});
});
});
it('should handle REQUEST_ACCESS_TOKEN', () => { it('should handle REQUEST_ACCESS_TOKEN', () => {
expect(accessTokensReducer({}, { expect(accessTokensReducer({}, {
authId: 'auth123', authId: 'auth123',
infoIds: [1, 2, 3],
serviceId: 'abc123', serviceId: 'abc123',
type: ActionTypes.REQUEST_ACCESS_TOKEN, type: ActionTypes.REQUEST_ACCESS_TOKEN,
})).toEqual({ })).toEqual({
abc123: { abc123: {
authId: 'auth123', authId: 'auth123',
id: 'abc123', id: 'abc123',
infoIds: [1, 2, 3],
isFetching: true, isFetching: true,
}, },
}); });
......
...@@ -2,74 +2,18 @@ import { authReducer } from '../../../src/state/reducers/auth'; ...@@ -2,74 +2,18 @@ import { authReducer } from '../../../src/state/reducers/auth';
import ActionTypes from '../../../src/state/actions/action-types'; import ActionTypes from '../../../src/state/actions/action-types';
describe('auth response reducer', () => { describe('auth response reducer', () => {
describe('should handle RECEIVE_DEGRADED_INFO_RESPONSE', () => {
it('does nothing for a login service', () => {
expect(authReducer({}, {
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
profile: 'http://iiif.io/api/auth/1/login',
},
},
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
})).toEqual({});
});
it('does nothing if a kiosk/external request for that service is in flight', () => {
expect(authReducer(
{
auth: {
isFetching: true,
},
},
{
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
'@id': 'auth',
profile: 'http://iiif.io/api/auth/1/kiosk',
},
},
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
},
)).toEqual({ auth: { isFetching: true } });
});
it('adds an entry for a kiosk/external service', () => {
expect(authReducer(
{},
{
infoId: 'http://example.com',
infoJson: {
'@id': 'http://example.com',
service: {
'@id': 'auth',
profile: 'http://iiif.io/api/auth/1/kiosk',
},
},
ok: false,
type: ActionTypes.RECEIVE_DEGRADED_INFO_RESPONSE,
},
)).toEqual({
auth: {
id: 'auth',
infoId: ['http://example.com'],
isFetching: true,
profile: 'http://iiif.io/api/auth/1/kiosk',
},
});
});
});
it('should handle ADD_AUTHENTICATION_REQUEST', () => { it('should handle ADD_AUTHENTICATION_REQUEST', () => {
expect(authReducer({}, { expect(authReducer({}, {
id: 'abc123', id: 'abc123',
infoId: 1, profile: 'iiif/login',
type: ActionTypes.ADD_AUTHENTICATION_REQUEST, type: ActionTypes.ADD_AUTHENTICATION_REQUEST,
windowId: 'main',
})).toEqual({ })).toEqual({
abc123: { abc123: {
id: 'abc123', id: 'abc123',
infoId: [1],
isFetching: true, isFetching: true,
profile: 'iiif/login',
windowId: 'main',
}, },
}); });
}); });
...@@ -94,6 +38,14 @@ describe('auth response reducer', () => { ...@@ -94,6 +38,14 @@ describe('auth response reducer', () => {
}, },
}); });
}); });
describe('should handle RECEIVE_ACCESS_TOKEN', () => {
it('does nothing if id is not present', () => {
expect(authReducer({ foo: {} }, {
authId: 'foo',
type: ActionTypes.RECEIVE_ACCESS_TOKEN,
})).toMatchObject({ foo: { ok: true } });
});
});
describe('should handle RESET_AUTHENTICATION_STATE', () => { describe('should handle RESET_AUTHENTICATION_STATE', () => {
it('does nothing if id is not present', () => { it('does nothing if id is not present', () => {
expect(authReducer({}, { expect(authReducer({}, {
......
...@@ -92,27 +92,4 @@ describe('info response reducer', () => { ...@@ -92,27 +92,4 @@ describe('info response reducer', () => {
type: ActionTypes.IMPORT_MIRADOR_STATE, type: ActionTypes.IMPORT_MIRADOR_STATE,
})).toEqual({}); })).toEqual({});
}); });
it('should handle RESET_AUTHENTICATION_STATE', () => {
expect(infoResponsesReducer(
{
abc123: {
stuff: 'foo',
tokenServiceId: 'abc123',
},
def456: {
stuff: 'foo',
tokenServiceId: 'def456',
},
},
{
tokenServiceId: 'abc123',
type: ActionTypes.RESET_AUTHENTICATION_STATE,
},
)).toEqual({
def456: {
stuff: 'foo',
tokenServiceId: 'def456',
},
});
});
}); });
import { call, select } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
import { Utils } from 'manifesto.js/dist-esmodule/Utils';
import serviceFixture from '../../fixtures/version-2/canvasService.json';
import settings from '../../../src/config/settings';
import ActionTypes from '../../../src/state/actions/action-types';
import {
refetchInfoResponses,
refetchInfoResponsesOnLogout,
doAuthWorkflow,
rerequestOnAccessTokenFailure,
invalidateInvalidAuth,
} from '../../../src/state/sagas/auth';
import {
fetchInfoResponse,
} from '../../../src/state/sagas/iiif';
import {
getAccessTokens,
getWindows,
selectInfoResponses,
getVisibleCanvases,
getAuth,
getConfig,
} from '../../../src/state/selectors';
describe('IIIF Authentication sagas', () => {
describe('refetchInfoResponsesOnLogout', () => {
it('delays and then refetches info responses', () => {
const tokenServiceId = 'whatever';
/** stub out delay... ugh. */
const provideDelay = ({ fn }, next) => ((fn.name === 'delayP') ? null : next());
return expectSaga(refetchInfoResponsesOnLogout, { tokenServiceId })
.provide([
{ call: provideDelay },
[call(refetchInfoResponses, { serviceId: tokenServiceId }), {}],
])
.call(refetchInfoResponses, { serviceId: tokenServiceId })
.run();
});
});
describe('refetchInfoResponses', () => {
it('discards info responses that could hvae used the new access token', () => {
const serviceId = 'https://authentication.example.org/token';
const tokenService = { id: serviceId };
const authStanza = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.org/login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
{
'@id': serviceId,
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const x = {
id: 'x',
json: {
...authStanza,
},
};
const y = {
id: 'y',
json: {
...authStanza,
},
};
return expectSaga(refetchInfoResponses, { serviceId })
.provide([
[select(getAccessTokens), { [serviceId]: tokenService }],
[select(getWindows), {}],
[select(selectInfoResponses), { x, y }],
])
.put({ infoId: 'x', type: ActionTypes.REMOVE_INFO_RESPONSE })
.put({ infoId: 'y', type: ActionTypes.REMOVE_INFO_RESPONSE })
.run();
});
it('ignores info responses that would not use the token', () => {
const serviceId = 'https://authentication.example.org/token';
const tokenService = { id: serviceId };
const authStanza = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.org/login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
{
'@id': 'https://authentication.example.org/some-other-token-service',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const wrongService = {
id: 'wrongService',
json: {
...authStanza,
},
};
const noAuth = {
id: 'noAuth',
json: {},
};
const noJson = {
id: 'noJson',
};
return expectSaga(refetchInfoResponses, { serviceId })
.provide([
[select(getAccessTokens), { [serviceId]: tokenService }],
[select(getWindows), {}],
[select(selectInfoResponses), { noAuth, noJson, wrongService }],
])
.not.put({ infoId: 'noAuth', type: ActionTypes.REMOVE_INFO_RESPONSE })
.not.put({ infoId: 'noJson', type: ActionTypes.REMOVE_INFO_RESPONSE })
.not.put({ infoId: 'wrongService', type: ActionTypes.REMOVE_INFO_RESPONSE })
.run();
});
it('re-requests info responses for visible canvases', () => {
const serviceId = 'https://authentication.example.org/token';
const tokenService = { id: serviceId };
const authStanza = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.org/login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
{
'@id': serviceId,
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const window = {};
const canvases = [
Utils.parseManifest(serviceFixture).getSequences()[0].getCanvases()[0],
];
const iiifInfoId = 'https://api.digitale-sammlungen.de/iiif/image/v2/bsb00122140_00001';
const infoResponse = {
id: iiifInfoId,
json: {
...authStanza,
},
};
return expectSaga(refetchInfoResponses, { serviceId })
.provide([
[select(getAccessTokens), { [serviceId]: tokenService }],
[select(getWindows), { window }],
[select(getVisibleCanvases, { windowId: 'window' }), canvases],
[select(selectInfoResponses), { [iiifInfoId]: infoResponse }],
[call(fetchInfoResponse, { infoId: iiifInfoId }), {}],
])
.call(fetchInfoResponse, { infoId: iiifInfoId })
.run();
});
});
describe('doAuthWorkflow', () => {
it('kicks off the first external auth from the info.json', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/external',
profile: 'http://iiif.io/api/auth/1/external',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
return expectSaga(doAuthWorkflow, { infoJson, windowId })
.provide([
[select(getAuth), {}],
[select(getConfig), { auth: settings.auth }],
])
.put({
id: 'https://authentication.example.com/external',
tokenServiceId: 'https://authentication.example.com/token',
type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST,
})
.put({
authId: 'https://authentication.example.com/external',
serviceId: 'https://authentication.example.com/token',
type: ActionTypes.REQUEST_ACCESS_TOKEN,
})
.run();
});
it('does nothing if the auth service has been tried already', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/external',
profile: 'http://iiif.io/api/auth/1/external',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
return expectSaga(doAuthWorkflow, { infoJson, windowId })
.provide([
[select(getAuth), { 'https://authentication.example.com/external': { ok: false } }],
[select(getConfig), { auth: settings.auth }],
])
.not.put.like({ type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST })
.not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN })
.run();
});
it('does nothing if the auth service is "interactive"', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
return expectSaga(doAuthWorkflow, { infoJson, windowId })
.provide([
[select(getAuth), {}],
[select(getConfig), { auth: settings.auth }],
])
.not.put.like({ type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST })
.not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN })
.run();
});
it('kicks off the kiosk auth from the info.json', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
return expectSaga(doAuthWorkflow, { infoJson, windowId })
.provide([
[select(getAuth), {}],
[select(getConfig), { auth: settings.auth }],
])
.put({
id: 'https://authentication.example.com/kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
type: ActionTypes.ADD_AUTHENTICATION_REQUEST,
windowId,
})
.run();
});
});
describe('rerequestOnAccessTokenFailure', () => {
it('does nothing if no access token was used', () => {
const infoJson = {};
const windowId = 'window';
const tokenServiceId = undefined;
return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId })
.provide([
[select(getAccessTokens), {}],
])
.not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN })
.run();
});
it('does nothing if the access token has never worked', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
const tokenServiceId = 'https://authentication.example.com/token';
return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId })
.provide([
[select(getAccessTokens), { [tokenServiceId]: { success: false } }],
])
.not.put.like({ type: ActionTypes.REQUEST_ACCESS_TOKEN })
.run();
});
it('re-requests the access token if it might be reneweable', () => {
const infoJson = {
service: [{
'@context': 'http://iiif.io/api/auth/1/context.json',
'@id': 'https://authentication.example.com/kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
service: [
{
'@id': 'https://authentication.example.com/token',
profile: 'http://iiif.io/api/auth/1/token',
},
],
}],
};
const windowId = 'window';
const tokenServiceId = 'https://authentication.example.com/token';
return expectSaga(rerequestOnAccessTokenFailure, { infoJson, tokenServiceId, windowId })
.provide([
[select(getAccessTokens), { [tokenServiceId]: { success: true } }],
])
.put({
authId: 'https://authentication.example.com/kiosk',
serviceId: 'https://authentication.example.com/token',
type: ActionTypes.REQUEST_ACCESS_TOKEN,
})
.run();
});
});
describe('invalidateInvalidAuth', () => {
it('resets the auth service if the auth cookie might have expired', () => {
const authId = 'authId';
const serviceId = 'serviceId';
return expectSaga(invalidateInvalidAuth, { serviceId })
.provide([
[select(getAccessTokens), { [serviceId]: { authId, id: serviceId, success: true } }],
[select(getAuth), { [authId]: { id: authId } }],
])
.put({
id: authId,
tokenServiceId: serviceId,
type: ActionTypes.RESET_AUTHENTICATION_STATE,
})
.run();
});
it('marks the auth service as failed if the auth token was not successfully used', () => {
const authId = 'authId';
const serviceId = 'serviceId';
return expectSaga(invalidateInvalidAuth, { serviceId })
.provide([
[select(getAccessTokens), { [serviceId]: { authId, id: serviceId } }],
[select(getAuth), { [authId]: { id: authId } }],
])
.put({
id: authId,
ok: false,
tokenServiceId: serviceId,
type: ActionTypes.RESOLVE_AUTHENTICATION_REQUEST,
})
.run();
});
});
});
import { call, select } from 'redux-saga/effects'; import { select } from 'redux-saga/effects';
import { expectSaga, testSaga } from 'redux-saga-test-plan'; import { expectSaga } from 'redux-saga-test-plan';
import { import {
fetchAnnotation, fetchAnnotation,
fetchManifest, fetchManifest,
fetchSearchResponse, fetchSearchResponse,
fetchInfoResponse, fetchInfoResponse,
refetchInfoResponses,
fetchResourceManifest, fetchResourceManifest,
} from '../../../src/state/sagas/iiif'; } from '../../../src/state/sagas/iiif';
import { import {
...@@ -167,6 +166,7 @@ describe('IIIF sagas', () => { ...@@ -167,6 +166,7 @@ describe('IIIF sagas', () => {
const action = { const action = {
imageResource: {}, imageResource: {},
infoId: 'infoId', infoId: 'infoId',
windowId: 'window',
}; };
return expectSaga(fetchInfoResponse, action) return expectSaga(fetchInfoResponse, action)
...@@ -176,6 +176,7 @@ describe('IIIF sagas', () => { ...@@ -176,6 +176,7 @@ describe('IIIF sagas', () => {
ok: true, ok: true,
tokenServiceId: undefined, tokenServiceId: undefined,
type: 'mirador/RECEIVE_DEGRADED_INFO_RESPONSE', type: 'mirador/RECEIVE_DEGRADED_INFO_RESPONSE',
windowId: 'window',
}) })
.run(); .run();
}); });
...@@ -217,24 +218,6 @@ describe('IIIF sagas', () => { ...@@ -217,24 +218,6 @@ describe('IIIF sagas', () => {
}); });
}); });
describe('refetchInfoResponses', () => {
it('refetches info responses when a new access token is available', () => {
const serviceId = 'serviceId';
const tokenService = { id: serviceId, infoIds: ['x', 'y'] };
testSaga(refetchInfoResponses, { serviceId })
.next()
.select(getAccessTokens)
.next({ serviceId: tokenService })
.all([
call(fetchInfoResponse, { infoId: 'x', tokenService }),
call(fetchInfoResponse, { infoId: 'y', tokenService }),
])
.next()
.put({ serviceId, type: 'mirador/CLEAR_ACCESS_TOKEN_QUEUE' });
});
});
describe('fetchSearchResponse', () => { describe('fetchSearchResponse', () => {
it('fetches a IIIF search', () => { it('fetches a IIIF search', () => {
fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); fetch.mockResponseOnce(JSON.stringify({ data: '12345' }));
......
import manifestFixture001 from '../../fixtures/version-2/001.json';
import manifestFixture019 from '../../fixtures/version-2/019.json';
import settings from '../../../src/config/settings';
import { import {
getAccessTokens, getAccessTokens,
selectCurrentAuthServices,
} from '../../../src/state/selectors/auth'; } from '../../../src/state/selectors/auth';
describe('getAccessTokens', () => { describe('getAccessTokens', () => {
...@@ -16,3 +20,178 @@ describe('getAccessTokens', () => { ...@@ -16,3 +20,178 @@ describe('getAccessTokens', () => {
expect(accessTokens).toEqual(state.accessTokens); expect(accessTokens).toEqual(state.accessTokens);
}); });
}); });
describe('selectCurrentAuthServices', () => {
const resource = {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
},
],
};
const externalOnly = {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
],
};
const state = {
auth: {},
config: { auth: settings.auth },
infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: resource,
},
'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44': {
json: externalOnly,
},
},
manifests: {
a: {
json: manifestFixture001,
},
b: {
json: manifestFixture019,
},
},
windows: {
noCanvas: {
manifestId: 'a',
},
w: {
manifestId: 'a',
visibleCanvases: [
'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json',
],
},
x: {
manifestId: 'b',
visibleCanvases: [
'http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json',
],
},
y: {
manifestId: 'b',
visibleCanvases: [
'https://purl.stanford.edu/fr426cg9537/iiif/canvas/fr426cg9537_1',
],
},
},
};
it('returns undefined if there is no current canvas', () => {
expect(selectCurrentAuthServices({ config: { auth: settings.auth }, manifests: {} }, { windowId: 'noCanvas' })[0]).toBeUndefined();
});
it('returns the next auth service to try', () => {
expect(selectCurrentAuthServices(state, { windowId: 'w' })[0].id).toEqual('external');
});
it('returns the service if the next auth service is interactive', () => {
const auth = { external: { isFetching: false, ok: false } };
expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'w' })[0].id).toEqual('login');
});
it('returns the last attempted auth service if all of them have been tried', () => {
const auth = {
external: { isFetching: false, ok: false },
login: { isFetching: false, ok: false },
};
expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'w' })[0].id).toEqual('login');
expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'x' })[0].id).toEqual('external');
expect(selectCurrentAuthServices({ ...state, auth }, { windowId: 'y' })[0]).toBeUndefined();
});
describe('proscribed order', () => {
let auth = {};
const orderedState = {
config: { auth: settings.auth },
infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
{
'@id': 'kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
},
{
'@id': 'clickthrough',
profile: 'http://iiif.io/api/auth/1/clickthrough',
},
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
},
{
'@id': 'login2',
profile: 'http://iiif.io/api/auth/1/login',
},
],
},
},
},
manifests: {
a: {
json: manifestFixture001,
},
},
windows: {
w: {
manifestId: 'a',
visibleCanvases: [
'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json',
],
},
},
};
it('returns external first', () => {
auth = {};
expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('external');
});
it('returns kiosk next', () => {
auth = { external: { isFetching: false, ok: false } };
expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('kiosk');
});
it('returns clickthrough next', () => {
auth = {
external: { isFetching: false, ok: false },
kiosk: { isFetching: false, ok: false },
};
expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('clickthrough');
});
it('returns logins last', () => {
auth = {
clickthrough: { isFetching: false, ok: false },
external: { isFetching: false, ok: false },
kiosk: { isFetching: false, ok: false },
};
expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('login');
});
it('returns services within a given type using the order from the manifest', () => {
auth = {
clickthrough: { isFetching: false, ok: false },
external: { isFetching: false, ok: false },
kiosk: { isFetching: false, ok: false },
login: { isFetching: false, ok: false },
};
expect(selectCurrentAuthServices({ ...orderedState, auth }, { windowId: 'w' })[0].id).toEqual('login2');
});
});
});
...@@ -2,6 +2,7 @@ import manifestFixture001 from '../../fixtures/version-2/001.json'; ...@@ -2,6 +2,7 @@ import manifestFixture001 from '../../fixtures/version-2/001.json';
import manifestFixture019 from '../../fixtures/version-2/019.json'; import manifestFixture019 from '../../fixtures/version-2/019.json';
import minimumRequired from '../../fixtures/version-2/minimumRequired.json'; import minimumRequired from '../../fixtures/version-2/minimumRequired.json';
import minimumRequired3 from '../../fixtures/version-3/minimumRequired.json'; import minimumRequired3 from '../../fixtures/version-3/minimumRequired.json';
import settings from '../../../src/config/settings';
import { import {
getVisibleCanvases, getVisibleCanvases,
...@@ -9,11 +10,8 @@ import { ...@@ -9,11 +10,8 @@ import {
getPreviousCanvasGrouping, getPreviousCanvasGrouping,
getCanvas, getCanvas,
getCanvasLabel, getCanvasLabel,
selectCanvasAuthService,
selectNextAuthService,
selectInfoResponse, selectInfoResponse,
getVisibleCanvasNonTiledResources, getVisibleCanvasNonTiledResources,
selectLogoutAuthService,
getVisibleCanvasIds, getVisibleCanvasIds,
} from '../../../src/state/selectors/canvases'; } from '../../../src/state/selectors/canvases';
...@@ -251,185 +249,13 @@ describe('getCanvasLabel', () => { ...@@ -251,185 +249,13 @@ describe('getCanvasLabel', () => {
}); });
}); });
describe('selectNextAuthService', () => {
const auth = {};
const resource = {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
{
'@id': 'kiosk',
profile: 'http://iiif.io/api/auth/1/kiosk',
},
{
'@id': 'clickthrough',
profile: 'http://iiif.io/api/auth/1/clickthrough',
},
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
},
{
'@id': 'login2',
profile: 'http://iiif.io/api/auth/1/login',
},
],
};
const noAuthResource = {};
it('returns external first', () => {
expect(selectNextAuthService({ auth }, resource).id).toEqual('external');
});
it('returns kiosk next', () => {
auth.external = { isFetching: false, ok: false };
expect(selectNextAuthService({ auth }, resource).id).toEqual('kiosk');
});
it('returns clickthrough next', () => {
auth.external = { isFetching: false, ok: false };
auth.kiosk = { isFetching: false, ok: false };
expect(selectNextAuthService({ auth }, resource).id).toEqual('clickthrough');
});
it('returns logins last', () => {
auth.external = { isFetching: false, ok: false };
auth.kiosk = { isFetching: false, ok: false };
auth.clickthrough = { isFetching: false, ok: false };
expect(selectNextAuthService({ auth }, resource).id).toEqual('login');
auth.login = { isFetching: false, ok: false };
expect(selectNextAuthService({ auth }, resource).id).toEqual('login2');
});
it('returns null if there are no services', () => {
expect(selectNextAuthService({ auth }, noAuthResource)).toBeNull();
});
it('returns null if a service is currently in-flight', () => {
auth.external = { isFetching: true };
expect(selectNextAuthService({ auth }, resource)).toBeNull();
});
});
describe('selectCanvasAuthService', () => {
const resource = {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
},
],
};
const externalOnly = {
service: [
{
'@id': 'external',
profile: 'http://iiif.io/api/auth/1/external',
},
],
};
const state = {
auth: {},
infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: resource,
},
'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44': {
json: externalOnly,
},
},
manifests: {
a: {
json: manifestFixture001,
},
b: {
json: manifestFixture019,
},
},
};
it('returns undefined if there is no current canvas', () => {
expect(selectCanvasAuthService({ manifests: {} }, { manifestId: 'a' })).toBeUndefined();
});
it('returns the next auth service to try', () => {
expect(selectCanvasAuthService(state, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('external');
});
it('returns the service if the next auth service is interactive', () => {
const auth = { external: { isFetching: false, ok: false } };
expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('login');
});
it('returns the last attempted auth service if all of them have been tried', () => {
const auth = {
external: { isFetching: false, ok: false },
login: { isFetching: false, ok: false },
};
expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' }).id).toEqual('login');
expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'http://iiif.io/api/presentation/2.0/example/fixtures/canvas/24/c1.json', manifestId: 'b' }).id).toEqual('external');
expect(selectCanvasAuthService({ ...state, auth }, { canvasId: 'https://purl.stanford.edu/fr426cg9537/iiif/canvas/fr426cg9537_1', manifestId: 'b' })).toBeUndefined();
});
});
describe('selectLogoutAuthService', () => {
it('returns a logout auth service if one exists', () => {
const logout = {
'@id': 'http://foo/logout',
profile: 'http://iiif.io/api/auth/1/logout',
};
const resource = {
service: [
{
'@id': 'login',
profile: 'http://iiif.io/api/auth/1/login',
service: [
logout,
],
},
],
};
const state = {
auth: {
login: {
ok: true,
},
},
infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: resource,
},
},
manifests: {
a: {
json: manifestFixture001,
},
},
};
expect(
selectLogoutAuthService(
state,
{ canvasId: 'https://iiif.bodleian.ox.ac.uk/iiif/canvas/9cca8fdd-4a61-4429-8ac1-f648764b4d6d.json', manifestId: 'a' },
).id,
)
.toBe(logout['@id']);
});
});
describe('selectInfoResponse', () => { describe('selectInfoResponse', () => {
it('returns in the info response for the first canvas resource', () => { it('returns in the info response for the first canvas resource', () => {
const resource = { some: 'resource' }; const resource = { some: 'resource' };
const state = { const state = {
auth: {}, auth: {},
config: { auth: settings.auth },
infoResponses: { infoResponses: {
'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': { 'https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d': {
json: resource, json: resource,
......
...@@ -30,7 +30,13 @@ export class AccessTokenSender extends Component { ...@@ -30,7 +30,13 @@ export class AccessTokenSender extends Component {
*/ */
return ( return (
<IComCom <IComCom
attributes={{ src: `${url}?origin=${window.origin}&messageId=${url}` }} attributes={{
'aria-hidden': true,
height: 1,
src: `${url}?origin=${window.origin}&messageId=${url}`,
style: { visibility: 'hidden' },
width: 1,
}}
handleReceiveMessage={this.onReceiveAccessTokenMessage} handleReceiveMessage={this.onReceiveAccessTokenMessage}
/> />
); );
......
...@@ -2,8 +2,6 @@ import React, { Component, lazy, Suspense } from 'react'; ...@@ -2,8 +2,6 @@ import React, { Component, lazy, Suspense } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import PluginProvider from '../extend/PluginProvider'; import PluginProvider from '../extend/PluginProvider';
import AppProviders from '../containers/AppProviders'; import AppProviders from '../containers/AppProviders';
import AuthenticationSender from '../containers/AuthenticationSender';
import AccessTokenSender from '../containers/AccessTokenSender';
const WorkspaceArea = lazy(() => import('../containers/WorkspaceArea')); const WorkspaceArea = lazy(() => import('../containers/WorkspaceArea'));
...@@ -22,8 +20,6 @@ export class App extends Component { ...@@ -22,8 +20,6 @@ export class App extends Component {
return ( return (
<PluginProvider plugins={plugins}> <PluginProvider plugins={plugins}>
<AppProviders dndManager={dndManager}> <AppProviders dndManager={dndManager}>
<AuthenticationSender />
<AccessTokenSender />
<Suspense <Suspense
fallback={<div />} fallback={<div />}
> >
......
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Fab from '@material-ui/core/Fab';
/**
*
*/
export class AuthenticationLogout extends Component {
/** */
constructor(props) {
super(props);
this.handleLogout = this.handleLogout.bind(this);
}
/** */
handleLogout() {
const {
authServiceId, depWindow, logoutServiceId, resetAuthenticationState,
} = this.props;
(depWindow || window).open(logoutServiceId);
resetAuthenticationState({ authServiceId });
}
/** */
render() {
const {
label, status, t,
} = this.props;
if (status !== 'ok') return <></>;
return (
<Fab color="primary" variant="extended" onClick={this.handleLogout}>
{label || t('logout')}
</Fab>
);
}
}
AuthenticationLogout.propTypes = {
authServiceId: PropTypes.string,
depWindow: PropTypes.object, // eslint-disable-line react/forbid-prop-types
label: PropTypes.string,
logoutServiceId: PropTypes.string,
resetAuthenticationState: PropTypes.func.isRequired,
status: PropTypes.string,
t: PropTypes.func,
};
AuthenticationLogout.defaultProps = {
authServiceId: undefined,
depWindow: undefined,
label: undefined,
logoutServiceId: undefined,
status: undefined,
t: () => {},
};
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { NewWindow } from './NewWindow';
/**
* Opens a new window for click
*/
export class AuthenticationSender extends Component {
/** */
constructor(props) {
super(props);
this.onClose = this.onClose.bind(this);
}
/** @private */
onClose() {
const { handleInteraction, url } = this.props;
handleInteraction(url);
}
/** */
render() {
const { url } = this.props;
if (!url) return <></>;
return <NewWindow name="IiifAuthenticationSender" url={`${url}?origin=${window.origin}`} features="centerscreen" onClose={this.onClose} />;
}
}
AuthenticationSender.propTypes = {
handleInteraction: PropTypes.func.isRequired,
url: PropTypes.string,
};
AuthenticationSender.defaultProps = {
url: undefined,
};
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { AccessTokenSender } from './AccessTokenSender';
import { NewWindow } from './NewWindow';
import WindowAuthenticationBar from '../containers/WindowAuthenticationBar';
/**
* Opens a new window for click
*/
export class IIIFAuthentication extends Component {
/** */
constructor(props) {
super(props);
this.performLogout = this.performLogout.bind(this);
this.onReceiveAccessTokenMessage = this.onReceiveAccessTokenMessage.bind(this);
}
/** */
onReceiveAccessTokenMessage(payload) {
const {
authServiceId, accessTokenServiceId, resolveAccessTokenRequest,
} = this.props;
resolveAccessTokenRequest(authServiceId, accessTokenServiceId, payload);
}
/** */
defaultAuthBarProps() {
const {
authServiceId, windowId, status, logoutServiceId,
} = this.props;
return {
authServiceId,
hasLogoutService: !!logoutServiceId,
status,
windowId,
};
}
/** handle the IIIF logout workflow */
performLogout() {
const {
accessTokenServiceId, authServiceId, features,
logoutServiceId, resetAuthenticationState, openWindow,
} = this.props;
openWindow(logoutServiceId, undefined, features);
resetAuthenticationState({ authServiceId, tokenServiceId: accessTokenServiceId });
}
/** Render the auth bar for logged in users */
renderLoggedIn() {
const {
isInteractive, t,
} = this.props;
if (!isInteractive) return null;
return (
<WindowAuthenticationBar
confirmButton={t('logout')}
onConfirm={this.performLogout}
{...this.defaultAuthBarProps()}
/>
);
}
/** Render whatever shows up after the interactive login attempt fails */
renderFailure() {
const {
handleAuthInteraction, failureHeader: header, failureDescription: description, t,
authServiceId, windowId,
} = this.props;
return (
<WindowAuthenticationBar
header={header}
description={description}
confirmButton={t('retry')}
onConfirm={() => handleAuthInteraction(windowId, authServiceId)}
{...this.defaultAuthBarProps()}
/>
);
}
/** Render the login bar after we're logging in */
renderLoggingInCookie() {
const {
accessTokenServiceId, authServiceId, resolveAuthenticationRequest, features,
} = this.props;
return (
<>
{this.renderLogin()}
<NewWindow name="IiifLoginSender" url={`${authServiceId}?origin=${window.origin}`} features={features} onClose={() => resolveAuthenticationRequest(authServiceId, accessTokenServiceId)} />
</>
);
}
/** Render the login bar after we're logging in */
renderLoggingInToken() {
const {
accessTokenServiceId,
} = this.props;
return (
<>
{this.renderLogin()}
<AccessTokenSender
handleAccessTokenMessage={this.onReceiveAccessTokenMessage}
url={accessTokenServiceId}
/>
</>
);
}
/** Render a login bar */
renderLogin() {
const {
confirm, description, handleAuthInteraction, header, isInteractive, label,
authServiceId, windowId,
} = this.props;
if (!isInteractive) return null;
return (
<WindowAuthenticationBar
header={header}
description={description}
label={label}
confirmButton={confirm}
onConfirm={() => handleAuthInteraction(windowId, authServiceId)}
{...this.defaultAuthBarProps()}
/>
);
}
/** */
render() {
const { authServiceId, status } = this.props;
if (!authServiceId) return null;
if (status === null) return this.renderLogin();
if (status === 'cookie') return this.renderLoggingInCookie();
if (status === 'token') return this.renderLoggingInToken();
if (status === 'failed') return this.renderFailure();
if (status === 'ok') return this.renderLoggedIn();
return null;
}
}
IIIFAuthentication.propTypes = {
accessTokenServiceId: PropTypes.string.isRequired,
authServiceId: PropTypes.string.isRequired,
confirm: PropTypes.string,
description: PropTypes.string,
failureDescription: PropTypes.string,
failureHeader: PropTypes.string,
features: PropTypes.string,
handleAuthInteraction: PropTypes.func.isRequired,
header: PropTypes.string,
isInteractive: PropTypes.bool,
label: PropTypes.string,
logoutServiceId: PropTypes.string,
openWindow: PropTypes.func,
resetAuthenticationState: PropTypes.func.isRequired,
resolveAccessTokenRequest: PropTypes.func.isRequired,
resolveAuthenticationRequest: PropTypes.func.isRequired,
status: PropTypes.oneOf(['logout', 'ok', 'token', 'cookie', 'failed', null]),
t: PropTypes.func,
windowId: PropTypes.string.isRequired,
};
IIIFAuthentication.defaultProps = {
confirm: undefined,
description: undefined,
failureDescription: undefined,
failureHeader: undefined,
features: 'centerscreen',
header: undefined,
isInteractive: true,
label: undefined,
logoutServiceId: undefined,
openWindow: window.open,
status: null,
t: k => k,
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment