From 2f75d430cc24a758073dd8dbe3f71d4d518de16b Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 9 Jan 2025 16:59:06 -0500 Subject: [PATCH 1/4] STCOR-933 fetch /saml/check when starting a new session (#1582) This is a follow-up to the botched implementation of [STCOR-816](https://folio-org.atlassian.net/browse/STCOR-816) in PR #1432. The description there is "When restoring an existing session..." but in fact the implementation is "When starting a new session or restoring an existing one...". When starting a new session, however, discovery has not happened yet, so there is no information about what interfaces are present, and the conditional would always return false. The implementation here corrects the conditional: 1. if we are starting a new session 2. if we are resuming an existing session and the `login-saml` interface is present. Refs STCOR-933 --- CHANGELOG.md | 1 + src/loginServices.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6032c8b1..984f777b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Don't override initial discovery and okapi data in test mocks. Refs STCOR-913. * `` must consume `QueryClient` in order to supply it to `loginServices::logout()`. Refs STCOR-907. * On resuming session, spread session and `_self` together to preserve session values. Refs STCOR-912. +* Fetch `/saml/check` when starting a new session, i.e. before discovery. Refs STCOR-933, STCOR-816. ## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0) diff --git a/src/loginServices.js b/src/loginServices.js index 5a38dc46..71670c70 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -851,8 +851,11 @@ export function checkOkapiSession(okapiUrl, store, tenant) { .then((sess) => { return sess?.user?.id ? validateUser(okapiUrl, store, tenant, sess) : null; }) - .then(() => { - if (store.getState().discovery?.interfaces?.['login-saml']) { + .then((res) => { + // check whether SSO is enabled if either + // 1. res is null (when we are starting a new session) + // 2. login-saml interface is present (when we are resuming an existing session) + if (!res || store.getState().discovery?.interfaces?.['login-saml']) { return getSSOEnabled(okapiUrl, store, tenant); } return Promise.resolve(); From 983ce869e2c6853ca683493b54763d8006df38e8 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 9 Jan 2025 17:08:47 -0500 Subject: [PATCH 2/4] STCOR-926 validate that cookies and storage are available (#1581) Detect the presence of localStorage, sessionStorage, and cookies early early early in the stripes-init process and show an error message if any are unavailable. This prevents a white screen of death if, say, session storage is unavailable but we call it anyway, resulting in an untrapped exception (Here's looking at you, OIDCRedirect). Refs STCOR-926 --- src/App.js | 63 ++++++++++++++++--- src/App.test.js | 36 +++++++++++ .../AppConfigError/AppConfigError.css | 32 ++++++++++ .../AppConfigError/AppConfigError.js | 47 ++++++++++++++ .../AppConfigError/AppConfigError.test.js | 12 ++++ src/components/AppConfigError/index.js | 1 + src/components/OIDCRedirect.js | 10 ++- 7 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 src/App.test.js create mode 100644 src/components/AppConfigError/AppConfigError.css create mode 100644 src/components/AppConfigError/AppConfigError.js create mode 100644 src/components/AppConfigError/AppConfigError.test.js create mode 100644 src/components/AppConfigError/index.js diff --git a/src/App.js b/src/App.js index 7f72ce32..4cd8ca07 100644 --- a/src/App.js +++ b/src/App.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { okapi as okapiConfig, config } from 'stripes-config'; import merge from 'lodash/merge'; +import AppConfigError from './components/AppConfigError'; import connectErrorEpic from './connectErrorEpic'; import configureEpics from './configureEpics'; import configureLogger from './configureLogger'; @@ -23,6 +24,39 @@ const StrictWrapper = ({ children }) => { return {children}; }; +/** + * isStorageEnabled + * Return true if local-storage, session-storage, and cookies are all enabled. + * Return false otherwise. + * @returns boolean true if storages are enabled; false otherwise. + */ +export const isStorageEnabled = () => { + let isEnabled = true; + // local storage + try { + localStorage.getItem('test-key'); + } catch (e) { + console.warn('local storage is disabled'); // eslint-disable-line no-console + isEnabled = false; + } + + // session storage + try { + sessionStorage.getItem('test-key'); + } catch (e) { + console.warn('session storage is disabled'); // eslint-disable-line no-console + isEnabled = false; + } + + // cookies + if (!navigator.cookieEnabled) { + console.warn('cookies are disabled'); // eslint-disable-line no-console + isEnabled = false; + } + + return isEnabled; +}; + StrictWrapper.propTypes = { children: PropTypes.node.isRequired, }; @@ -36,17 +70,23 @@ export default class StripesCore extends Component { constructor(props) { super(props); - const parsedTenant = getStoredTenant(); + if (isStorageEnabled()) { + const parsedTenant = getStoredTenant(); - const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) - ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true }; + const okapi = (typeof okapiConfig === 'object' && Object.keys(okapiConfig).length > 0) + ? { ...okapiConfig, tenant: parsedTenant?.tenantName || okapiConfig.tenant, clientId: parsedTenant?.clientId || okapiConfig.clientId } : { withoutOkapi: true }; - const initialState = merge({}, { okapi }, props.initialState); + const initialState = merge({}, { okapi }, props.initialState); - this.logger = configureLogger(config); - this.epics = configureEpics(connectErrorEpic); - this.store = configureStore(initialState, this.logger, this.epics); - this.actionNames = gatherActions(); + this.logger = configureLogger(config); + this.epics = configureEpics(connectErrorEpic); + this.store = configureStore(initialState, this.logger, this.epics); + this.actionNames = gatherActions(); + + this.state = { isStorageEnabled: true }; + } else { + this.state = { isStorageEnabled: false }; + } } componentWillUnmount() { @@ -54,6 +94,13 @@ export default class StripesCore extends Component { } render() { + // Stripes requires cookies (for login) and session and local storage + // (for session state and all manner of things). If these are not enabled, + // stop and show an error message. + if (!this.state.isStorageEnabled) { + return ; + } + // no need to pass along `initialState` // eslint-disable-next-line no-unused-vars const { initialState, ...props } = this.props; diff --git a/src/App.test.js b/src/App.test.js new file mode 100644 index 00000000..eb805f6b --- /dev/null +++ b/src/App.test.js @@ -0,0 +1,36 @@ +import { isStorageEnabled } from './App'; + +const storageMock = () => ({ + getItem: () => { + throw new Error(); + }, +}); + +describe('isStorageEnabled', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns true when all storage options are enabled', () => { + expect(isStorageEnabled()).toBeTrue; + }); + + describe('returns false when any storage option is disabled', () => { + it('handles local storage', () => { + Object.defineProperty(window, 'localStorage', { value: storageMock }); + const isEnabled = isStorageEnabled(); + expect(isEnabled).toBeFalse; + }); + it('handles session storage', () => { + Object.defineProperty(window, 'sessionStorage', { value: storageMock }); + const isEnabled = isStorageEnabled(); + expect(isEnabled).toBeFalse; + }); + + it('handles cookies', () => { + jest.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false); + const isEnabled = isStorageEnabled(); + expect(isEnabled).toBeFalse; + }); + }); +}); diff --git a/src/components/AppConfigError/AppConfigError.css b/src/components/AppConfigError/AppConfigError.css new file mode 100644 index 00000000..e8cc7eff --- /dev/null +++ b/src/components/AppConfigError/AppConfigError.css @@ -0,0 +1,32 @@ +@import "@folio/stripes-components/lib/variables.css"; + +.wrapper { + display: flex; + justify-content: center; + min-height: 100vh; +} + +.container { + width: 100%; + max-width: 940px; + min-height: 330px; + margin: 12vh 2rem 0; +} + +@media (--medium-up) { + .container { + min-height: initial; + } +} + +@media (--large-up) { + .header { + font-size: var(--font-size-xx-large); + } +} + +@media (height <= 440px) { + .container { + min-height: 330px; + } +} diff --git a/src/components/AppConfigError/AppConfigError.js b/src/components/AppConfigError/AppConfigError.js new file mode 100644 index 00000000..bf3623bc --- /dev/null +++ b/src/components/AppConfigError/AppConfigError.js @@ -0,0 +1,47 @@ +import { branding } from 'stripes-config'; + +import { + Row, + Col, + Headline, +} from '@folio/stripes-components'; + +import OrganizationLogo from '../OrganizationLogo'; +import styles from './AppConfigError.css'; + +/** + * AppConfigError + * Show an error message. This component is rendered by App, before anything + * else, when it detects that local storage, session storage, or cookies are + * unavailable. This happens _before_ Root has been initialized, i.e. before + * an IntlProvider is available, hence the hard-coded, English-only message. + * + * @returns English-only error message + */ +const AppConfigError = () => { + return ( +
+
+
+ + + + + + + + + FOLIO requires cookies, sessionStorage, and localStorage. Please enable these features and try again. + + + +
+
+
+ ); +}; + +export default AppConfigError; diff --git a/src/components/AppConfigError/AppConfigError.test.js b/src/components/AppConfigError/AppConfigError.test.js new file mode 100644 index 00000000..95f88aa3 --- /dev/null +++ b/src/components/AppConfigError/AppConfigError.test.js @@ -0,0 +1,12 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import AppConfigError from './AppConfigError'; + +jest.mock('../OrganizationLogo', () => () => 'OrganizationLogo'); +describe('AppConfigError', () => { + it('displays a warning message', async () => { + render(); + + expect(screen.getByText(/cookies/i)).toBeInTheDocument(); + expect(screen.getByText(/storage/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AppConfigError/index.js b/src/components/AppConfigError/index.js new file mode 100644 index 00000000..f6a6ec98 --- /dev/null +++ b/src/components/AppConfigError/index.js @@ -0,0 +1 @@ +export { default } from './AppConfigError'; diff --git a/src/components/OIDCRedirect.js b/src/components/OIDCRedirect.js index 9d463fe9..8898ca6e 100644 --- a/src/components/OIDCRedirect.js +++ b/src/components/OIDCRedirect.js @@ -5,7 +5,15 @@ import { getUnauthorizedPathFromSession, removeUnauthorizedPathFromSession } fro // Setting at top of component since value should be retained during re-renders // but will be correctly re-fetched when redirected from Keycloak login page. -const unauthorizedPath = getUnauthorizedPathFromSession(); +// The empty try/catch is necessary because, by setting this at the top of +// the component, it is automatically executed even before renders. +// IOW, even though we check for session-storage in App, we still have to +// protect the call here. +let unauthorizedPath = null; +try { + unauthorizedPath = getUnauthorizedPathFromSession(); +} catch (e) { // eslint-disable-line no-empty +} /** * OIDCRedirect authenticated route handler for /oidc-landing. From 792d3b2bc8021194b2f3ed1cbd89d8b2a6775589 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 13 Jan 2025 14:51:02 -0500 Subject: [PATCH 3/4] STCOR-934 CVE-2024-47764 update react cookie (#1559) Update `react-cookie` to `^7.2.2` to receive updates in transitive deps and avoid CVE-2024-47764. Refs STCOR-934 --- package.json | 2 +- yarn.lock | 47 ++++++++++++++++++++++++++--------------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 05dc18b3..62ea54f2 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "ms": "^2.1.3", "prop-types": "^15.5.10", "query-string": "^7.1.2", - "react-cookie": "^4.0.3", + "react-cookie": "^7.2.2", "react-final-form": "^6.3.0", "react-query": "^3.6.0", "react-titled": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 781fb316..a270f02e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,16 +2841,16 @@ "@types/node" "*" "@types/responselike" "^1.0.0" -"@types/cookie@^0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" - integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== - "@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== + "@types/cors@^2.8.12": version "2.8.17" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" @@ -2893,7 +2893,7 @@ dependencies: "@types/node" "*" -"@types/hoist-non-react-statics@^3.0.1", "@types/hoist-non-react-statics@^3.3.1": +"@types/hoist-non-react-statics@^3.3.1", "@types/hoist-non-react-statics@^3.3.5": version "3.3.5" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== @@ -4942,7 +4942,12 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== -cookie@^0.4.0, cookie@~0.4.1: +cookie@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cookie@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -7817,7 +7822,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -11377,14 +11382,14 @@ rc@1.2.8, rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-cookie@^4.0.3: - version "4.1.1" - resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-4.1.1.tgz#832e134ad720e0de3e03deaceaab179c4061a19d" - integrity sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A== +react-cookie@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-7.2.2.tgz#a7559e552ea9cca39a4b3686723a5acf504b8f84" + integrity sha512-e+hi6axHcw9VODoeVu8WyMWyoosa1pzpyjfvrLdF7CexfU+WSGZdDuRfHa4RJgTpfv3ZjdIpHE14HpYBieHFhg== dependencies: - "@types/hoist-non-react-statics" "^3.0.1" - hoist-non-react-statics "^3.0.0" - universal-cookie "^4.0.0" + "@types/hoist-non-react-statics" "^3.3.5" + hoist-non-react-statics "^3.3.2" + universal-cookie "^7.0.0" react-dom@^18.2.0: version "18.2.0" @@ -13361,13 +13366,13 @@ unique-string@^3.0.0: dependencies: crypto-random-string "^4.0.0" -universal-cookie@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" - integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== +universal-cookie@^7.0.0: + version "7.2.2" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-7.2.2.tgz#93ae9ec55baab89b24300473543170bb8112773c" + integrity sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ== dependencies: - "@types/cookie" "^0.3.3" - cookie "^0.4.0" + "@types/cookie" "^0.6.0" + cookie "^0.7.2" universal-user-agent@^6.0.0: version "6.0.1" From 2049911b2d2af10cf95781dcf37ae7abcc98cb56 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Wed, 22 Jan 2025 13:23:49 -0500 Subject: [PATCH 4/4] STCOR-932 implement useModuleInfo(path) to map path -> module (#1578) Query discovery to retrieve information about the module implementing a given endpoint. Simple usage: ``` const { module } = useModuleInfo('/path'); // { name: 'mod-foo', ... } ``` Refs STCOR-932 --- CHANGELOG.md | 1 + index.js | 1 + src/hooks/index.js | 4 +- src/hooks/useModuleInfo.js | 110 ++++++++++++++++++ src/hooks/useModuleInfo.test.js | 190 ++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useModuleInfo.js create mode 100644 src/hooks/useModuleInfo.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 984f777b..043f06d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * `` must consume `QueryClient` in order to supply it to `loginServices::logout()`. Refs STCOR-907. * On resuming session, spread session and `_self` together to preserve session values. Refs STCOR-912. * Fetch `/saml/check` when starting a new session, i.e. before discovery. Refs STCOR-933, STCOR-816. +* Provide `useModuleInfo(path)` hook that maps paths to their implementing modules. Refs STCOR-932. ## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0) diff --git a/index.js b/index.js index 3a9510d4..ba6c88b3 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,7 @@ export { getUserTenantsPermissions } from './src/queries'; /* Hooks */ export { useUserTenantPermissions } from './src/hooks'; +export { useModuleInfo } from './src/hooks'; /* misc */ export { supportedLocales } from './src/loginServices'; diff --git a/src/hooks/index.js b/src/hooks/index.js index 8a889a1b..27142732 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1 +1,3 @@ -export { default as useUserTenantPermissions } from './useUserTenantPermissions'; // eslint-disable-line import/prefer-default-export +export { default as useUserTenantPermissions } from './useUserTenantPermissions'; +export { default as useModuleInfo } from './useModuleInfo'; + diff --git a/src/hooks/useModuleInfo.js b/src/hooks/useModuleInfo.js new file mode 100644 index 00000000..beb6e435 --- /dev/null +++ b/src/hooks/useModuleInfo.js @@ -0,0 +1,110 @@ +import { useQuery } from 'react-query'; + +import { useStripes } from '../StripesContext'; +import { useNamespace } from '../components'; +import useOkapiKy from '../useOkapiKy'; + +/** + * map a module implementation string to a module-name, hopefully. + * given a string like "mod-users-16.2.0-SNAPSHOT.127" return "mod-users" + */ +const implToModule = (impl) => { + const moduleName = impl.match(/^(.*)-[0-9]+\.[0-9]+\.[0-9]+.*/); + return moduleName[1] ? moduleName[1] : ''; +}; + +/** + * mapPathToImpl + * Remap the input datastructure from an array of modules (containing + * details about the interfaces they implement, and the paths handled by + * each interface) to a map from path to module. + * + * i.e. map from sth like + * [{ + * provides: { + * handlers: [ + * { pathPattern: "/foo", ... } + * { pathPattern: "/bar", ... } + * ] + * } + * }, ... ] + * to + * { + * foo: { ...impl }, + * bar: { ...impl }, + * } + * @param {object} impl + * @returns object + */ +const mapPathToImpl = (impl) => { + const moduleName = implToModule(impl.id); + const paths = {}; + if (impl.provides) { + // not all interfaces actually implement routes, e.g. edge-connexion + // so those must be filtered out + impl.provides.filter(i => i.handlers).forEach(i => { + i.handlers.forEach(handler => { + if (!paths[handler.pathPattern]) { + paths[handler.pathPattern] = { name: moduleName, impl }; + } + }); + }); + } + return paths; +}; + +/** + * canonicalPath + * Prepend a leading / if none is present. + * Strip everything after ? + * @param {string} str a string that represents a portion of a URL + * @returns {string} + */ +const canonicalPath = (str) => { + return `${str.startsWith('/') ? '' : '/'}${str.split('?')[0]}`; +}; + +/** + * useModuleInfo + * Given a path, retrieve information about the module that implements it + * by querying the discovery endpoint /_/proxy/tenants/${tenant}/modules. + * + * @param {string} path + * @returns object shaped like { isFetching, isFetched, isLoading, module } + */ +const useModuleInfo = (path) => { + const stripes = useStripes(); + const ky = useOkapiKy(); + const [namespace] = useNamespace({ key: `/_/proxy/tenants/${stripes.okapi.tenant}/modules` }); + let paths = {}; + + const { + isFetching, + isFetched, + isLoading, + data, + } = useQuery( + [namespace], + ({ signal }) => { + return ky.get( + `/_/proxy/tenants/${stripes.okapi.tenant}/modules?full=true`, + { signal }, + ).json(); + } + ); + + if (data) { + data.forEach(impl => { + paths = { ...paths, ...mapPathToImpl(impl) }; + }); + } + + return ({ + isFetching, + isFetched, + isLoading, + module: paths?.[canonicalPath(path)], + }); +}; + +export default useModuleInfo; diff --git a/src/hooks/useModuleInfo.test.js b/src/hooks/useModuleInfo.test.js new file mode 100644 index 00000000..8413a9ef --- /dev/null +++ b/src/hooks/useModuleInfo.test.js @@ -0,0 +1,190 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import useModuleInfo from './useModuleInfo'; +import useOkapiKy from '../useOkapiKy'; + +const response = [ + { + 'id': 'mod-users-19.4.5-SNAPSHOT.330', + 'name': 'users', + 'provides': [ + { + 'id': 'users', + 'version': '16.3', + 'handlers': [ + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/users', + 'permissionsRequired': [ + 'users.collection.get' + ], + 'permissionsDesired': [ + 'users.basic-read.execute', + 'users.restricted-read.execute' + ] + }, + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/users/{id}', + 'permissionsRequired': [ + 'users.item.get' + ], + 'permissionsDesired': [ + 'users.basic-read.execute', + 'users.restricted-read.execute' + ] + }, + { + 'methods': [ + 'POST' + ], + 'pathPattern': '/users', + 'permissionsRequired': [ + 'users.item.post' + ] + }, + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/users/profile-picture/{id}', + 'permissionsRequired': [ + 'users.profile-picture.item.get' + ] + }, + ] + } + ] + }, + { + 'id': 'mod-circulation-24.4.0', + 'name': 'Circulation Module', + 'provides': [ + { + 'id': 'requests-reports', + 'version': '0.8', + 'handlers': [ + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/circulation/requests-reports/hold-shelf-clearance/{id}', + 'permissionsRequired': [ + 'circulation.requests.hold-shelf-clearance-report.get' + ], + 'modulePermissions': [ + 'modperms.circulation.requests.hold-shelf-clearance-report.get' + ] + } + ] + }, + { + 'id': 'inventory-reports', + 'version': '0.4', + 'handlers': [ + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/inventory-reports/items-in-transit', + 'permissionsRequired': [ + 'circulation.inventory.items-in-transit-report.get' + ], + 'modulePermissions': [ + 'modperms.inventory.items-in-transit-report.get' + ] + } + ] + }, + { + 'id': 'pick-slips', + 'version': '0.4', + 'handlers': [ + { + 'methods': [ + 'GET' + ], + 'pathPattern': '/circulation/pick-slips/{servicePointId}', + 'permissionsRequired': [ + 'circulation.pick-slips.get' + ], + 'modulePermissions': [ + 'modperms.circulation.pick-slips.get' + ] + } + ] + } + ], + } +]; + +jest.mock('../useOkapiKy', () => ({ + __esModule: true, // this property makes it work + default: () => ({ + get: () => ({ + json: () => response, + }) + }) +})); +jest.mock('../components', () => ({ + useNamespace: () => ([]), +})); +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + okapi: { + tenant: 't', + } + }), +})); + +const queryClient = new QueryClient(); + +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }) => ( + + {children} + +); + + +describe('useModuleInfo', () => { + beforeEach(() => { + useOkapiKy.get = () => ({ + json: () => console.log({ response }) + }); + }); + + describe('returns the module-name that provides the interface containing a given path', () => { + it('handles paths with leading /', async () => { + const { result } = renderHook(() => useModuleInfo('/users'), { wrapper }); + await waitFor(() => result.current.module.name); + expect(result.current.module.name).toEqual('mod-users'); + }); + + it('handles paths without leading /', async () => { + const { result } = renderHook(() => useModuleInfo('inventory-reports/items-in-transit'), { wrapper }); + await waitFor(() => result.current.module.name); + expect(result.current.module.name).toEqual('mod-circulation'); + }); + + it('ignores query string', async () => { + const { result } = renderHook(() => useModuleInfo('/users?query=foo==bar'), { wrapper }); + await waitFor(() => result.current.module.name); + expect(result.current.module.name).toEqual('mod-users'); + }); + }); + + it('returns undefined given an unmatched path', async () => { + const { result } = renderHook(() => useModuleInfo('/monkey-bagel'), { wrapper }); + await waitFor(() => result.current.module); + expect(result.current.module).toBeUndefined(); + }); +});