diff --git a/__tests__/src/sagas/auth.test.js b/__tests__/src/sagas/auth.test.js new file mode 100644 index 0000000000000000000000000000000000000000..f4979f7bfce8b1cb54bab63ea5e9a0ef40202c73 --- /dev/null +++ b/__tests__/src/sagas/auth.test.js @@ -0,0 +1,153 @@ +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 ActionTypes from '../../../src/state/actions/action-types'; +import { + refetchInfoResponses, +} from '../../../src/state/sagas/auth'; +import { + fetchInfoResponse, +} from '../../../src/state/sagas/iiif'; +import { + getAccessTokens, + getWindows, + selectInfoResponses, + getVisibleCanvases, +} from '../../../src/state/selectors'; + +describe('IIIF Authentication sagas', () => { + 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(); + }); + }); +}); diff --git a/src/state/sagas/auth.js b/src/state/sagas/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..23fafe71772889f7297d48f64e0385c509c620ae --- /dev/null +++ b/src/state/sagas/auth.js @@ -0,0 +1,59 @@ +import { + all, call, put, select, takeEvery, +} from 'redux-saga/effects'; +import { Utils } from 'manifesto.js/dist-esmodule/Utils'; +import flatten from 'lodash/flatten'; +import ActionTypes from '../actions/action-types'; +import MiradorCanvas from '../../lib/MiradorCanvas'; +import { + selectInfoResponses, + getVisibleCanvases, + getWindows, +} from '../selectors'; +import { fetchInfoResponse } from './iiif'; + +/** + * Figure out what info responses could have used the access token service and: + * - refetch, if they are currently visible + * - throw them out (and lazy re-fetch) otherwise + */ +export function* refetchInfoResponses({ serviceId }) { + const windows = yield select(getWindows); + + const canvases = yield all( + Object.keys(windows).map(windowId => select(getVisibleCanvases, { windowId })), + ); + + const visibleImageApiIds = flatten(flatten(canvases).map((canvas) => { + const miradorCanvas = new MiradorCanvas(canvas); + return miradorCanvas.imageServiceIds; + })); + + const infoResponses = yield select(selectInfoResponses); + /** */ + const haveThisTokenService = infoResponse => { + const services = Utils.getServices(infoResponse); + return services.some(e => { + const infoTokenService = Utils.getService(e, 'http://iiif.io/api/auth/1/token'); + return infoTokenService && infoTokenService.id === serviceId; + }); + }; + + const obsoleteInfoResponses = Object.values(infoResponses).filter( + i => i.json && haveThisTokenService(i.json), + ); + + yield all(obsoleteInfoResponses.map(({ id: infoId }) => { + if (visibleImageApiIds.includes(infoId)) { + return call(fetchInfoResponse, { infoId }); + } + return put({ infoId, type: ActionTypes.REMOVE_INFO_RESPONSE }); + })); +} + +/** */ +export default function* authSaga() { + yield all([ + takeEvery(ActionTypes.RECEIVE_ACCESS_TOKEN, refetchInfoResponses), + ]); +} diff --git a/src/state/sagas/iiif.js b/src/state/sagas/iiif.js index e8d44578ded2d5451a7e0f4b9460b3b8bd871024..862783c10fcbe1b81246bbf8cca7464de67e1f59 100644 --- a/src/state/sagas/iiif.js +++ b/src/state/sagas/iiif.js @@ -194,22 +194,6 @@ export function* fetchResourceManifest({ manifestId, manifestJson }) { if (!manifests[manifestId]) yield* fetchManifest({ manifestId }); } -/** @private */ -export function* refetchInfoResponses({ serviceId }) { - const accessTokens = yield select(getAccessTokens); - const tokenService = accessTokens && accessTokens[serviceId]; - - if (!tokenService || tokenService.infoIds === []) return; - - yield all( - tokenService.infoIds.map(infoId => call(fetchInfoResponse, { infoId, tokenService })), - ); - - // TODO: Other resources could be refetched too - - yield put({ serviceId, type: ActionTypes.CLEAR_ACCESS_TOKEN_QUEUE }); -} - /** */ export function* fetchManifests(...manifestIds) { const manifests = yield select(getManifests); @@ -227,7 +211,6 @@ export default function* iiifSaga() { takeEvery(ActionTypes.REQUEST_INFO_RESPONSE, fetchInfoResponse), takeEvery(ActionTypes.REQUEST_SEARCH, fetchSearchResponse), takeEvery(ActionTypes.REQUEST_ANNOTATION, fetchAnnotation), - takeEvery(ActionTypes.RECEIVE_ACCESS_TOKEN, refetchInfoResponses), takeEvery(ActionTypes.ADD_RESOURCE, fetchResourceManifest), ]); } diff --git a/src/state/sagas/index.js b/src/state/sagas/index.js index 3ebb80e450e713a8833833568d12182bc1854425..d3e23cca68b1591746be40fbf5c981e6c5dbd588 100644 --- a/src/state/sagas/index.js +++ b/src/state/sagas/index.js @@ -5,7 +5,8 @@ import { import appSaga from './app'; import iiifSaga from './iiif'; import windowSaga from './windows'; -import annotations from './annotations'; +import annotationsSaga from './annotations'; +import authSaga from './auth'; /** */ function* launchSaga(saga) { @@ -23,10 +24,11 @@ function* launchSaga(saga) { function getRootSaga(pluginSagas = []) { return function* rootSaga() { const sagas = [ - annotations, + annotationsSaga, appSaga, iiifSaga, windowSaga, + authSaga, ...pluginSagas, ];