From e49e351e0a2e09629d484e8e48fa37246899e198 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Thu, 11 Apr 2024 08:56:47 -0400 Subject: [PATCH] STCOR-830 user-tenant-permissions hooks/functions (#1453) Provide user-tenant-permissions functionality, both centralizing this functionality and insulating other applications from needing to depend on the permissions interface. * `useUserTenantPermissions` provides permissions for the currently authenticated user in a single tenant * `getUserTenantsPermissions` provides permissions for the currently authenticated user across an array of tenants Refs STCOR-830 --- .eslintrc | 32 ++++++--- CHANGELOG.md | 1 + index.js | 4 ++ src/hooks/index.js | 1 + src/hooks/useUserTenantPermissions.js | 59 +++++++++++++++ src/hooks/useUserTenantPermissions.test.js | 71 +++++++++++++++++++ src/queries/getUserTenantsPermissions.js | 39 ++++++++++ src/queries/getUserTenantsPermissions.test.js | 71 +++++++++++++++++++ src/queries/index.js | 4 +- test/jest/fixtures/permissions.json | 30 ++++++++ 10 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 src/hooks/index.js create mode 100644 src/hooks/useUserTenantPermissions.js create mode 100644 src/hooks/useUserTenantPermissions.test.js create mode 100644 src/queries/getUserTenantsPermissions.js create mode 100644 src/queries/getUserTenantsPermissions.test.js create mode 100644 test/jest/fixtures/permissions.json diff --git a/.eslintrc b/.eslintrc index c57b8a0b7..fe280f5cf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,13 +1,8 @@ { - "extends": "@folio/eslint-config-stripes", - "parser": "@babel/eslint-parser", - "rules": { - "global-require": "off", - "import/no-cycle": [ 2, { "maxDepth": 1 } ], - "import/no-dynamic-require": "off", - "import/no-extraneous-dependencies": "off", - "prefer-object-spread": "off" + "env": { + "jest": true }, + "extends": "@folio/eslint-config-stripes", "overrides": [ { "files": [ "src/**/tests/*", "test/**/*", "*test.js" ], @@ -21,7 +16,24 @@ } } ], - "env": { - "jest": true + "parser": "@babel/eslint-parser", + "rules": { + "global-require": "off", + "import/no-cycle": [ 2, { "maxDepth": 1 } ], + "import/no-dynamic-require": "off", + "import/no-extraneous-dependencies": "off", + "prefer-object-spread": "off" + }, + "settings": { + "import/resolver": { + "alias": { + "map": [ + ["__mock__", "./test/jest/__mock__"], + ["fixtures", "./test/jest/fixtures"], + ["helpers", "./test/jest/helpers"] + ] + } + } } } + diff --git a/CHANGELOG.md b/CHANGELOG.md index 499beb5a5..222f001e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Utilize the `tenant` procured through the SSO login process. Refs STCOR-769. * Remove tag-based selectors from Login, ResetPassword, Forgot UserName/Password form CSS. Refs STCOR-712. +* Provide `useUserTenantPermissions` hook. Refs STCOR-830. ## 10.1.0 IN PROGRESS diff --git a/index.js b/index.js index 5109591d8..67fd4d1d0 100644 --- a/index.js +++ b/index.js @@ -37,6 +37,10 @@ export { /* Queries */ export { useChunkedCQLFetch } from './src/queries'; +export { getUserTenantsPermissions } from './src/queries'; + +/* Hooks */ +export { useUserTenantPermissions } from './src/hooks'; /* misc */ export { supportedLocales } from './src/loginServices'; diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 000000000..8a889a1b9 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1 @@ +export { default as useUserTenantPermissions } from './useUserTenantPermissions'; // eslint-disable-line import/prefer-default-export diff --git a/src/hooks/useUserTenantPermissions.js b/src/hooks/useUserTenantPermissions.js new file mode 100644 index 000000000..80030eea1 --- /dev/null +++ b/src/hooks/useUserTenantPermissions.js @@ -0,0 +1,59 @@ +import { useQuery } from 'react-query'; + +import { useStripes } from '../StripesContext'; +import { useNamespace } from '../components'; +import useOkapiKy from '../useOkapiKy'; + +const INITIAL_DATA = []; + +const useUserTenantPermissions = ( + { tenantId }, + options = {}, +) => { + const stripes = useStripes(); + const ky = useOkapiKy(); + const api = ky.extend({ + hooks: { + beforeRequest: [(req) => req.headers.set('X-Okapi-Tenant', tenantId)] + } + }); + const [namespace] = useNamespace({ key: 'user-affiliation-permissions' }); + + const user = stripes.user.user; + + const searchParams = { + full: 'true', + indexField: 'userId', + }; + + const { + isFetching, + isLoading, + data = {}, + } = useQuery( + [namespace, user?.id, tenantId], + ({ signal }) => { + return api.get( + `perms/users/${user.id}/permissions`, + { + searchParams, + signal, + }, + ).json(); + }, + { + enabled: Boolean(user?.id && tenantId), + keepPreviousData: true, + ...options, + }, + ); + + return ({ + isFetching, + isLoading, + userPermissions: data.permissionNames || INITIAL_DATA, + totalRecords: data.totalRecords, + }); +}; + +export default useUserTenantPermissions; diff --git a/src/hooks/useUserTenantPermissions.test.js b/src/hooks/useUserTenantPermissions.test.js new file mode 100644 index 000000000..e64b9c440 --- /dev/null +++ b/src/hooks/useUserTenantPermissions.test.js @@ -0,0 +1,71 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import permissions from 'fixtures/permissions'; +import useUserTenantPermissions from './useUserTenantPermissions'; +import useOkapiKy from '../useOkapiKy'; + +jest.mock('../useOkapiKy'); +jest.mock('../components', () => ({ + useNamespace: () => ([]), +})); +jest.mock('../StripesContext', () => ({ + useStripes: () => ({ + user: { + user: { + id: 'userId' + } + } + }), +})); + +const queryClient = new QueryClient(); + +// eslint-disable-next-line react/prop-types +const wrapper = ({ children }) => ( + + {children} + +); + +const response = { + permissionNames: permissions, + totalRecords: permissions.length, +}; + +describe('useUserTenantPermissions', () => { + const getMock = jest.fn(() => ({ + json: () => Promise.resolve(response), + })); + const setHeaderMock = jest.fn(); + const kyMock = { + extend: jest.fn(({ hooks: { beforeRequest } }) => { + beforeRequest.forEach(handler => handler({ headers: { set: setHeaderMock } })); + + return { + get: getMock, + }; + }), + }; + + beforeEach(() => { + getMock.mockClear(); + useOkapiKy.mockClear().mockReturnValue(kyMock); + }); + + it('should fetch user permissions for specified tenant', async () => { + const options = { + userId: 'userId', + tenantId: 'tenantId', + }; + const { result } = renderHook(() => useUserTenantPermissions(options), { wrapper }); + + await waitFor(() => !result.current.isLoading); + + expect(setHeaderMock).toHaveBeenCalledWith('X-Okapi-Tenant', options.tenantId); + expect(getMock).toHaveBeenCalledWith(`perms/users/${options.userId}/permissions`, expect.objectContaining({})); + }); +}); diff --git a/src/queries/getUserTenantsPermissions.js b/src/queries/getUserTenantsPermissions.js new file mode 100644 index 000000000..6afe466a9 --- /dev/null +++ b/src/queries/getUserTenantsPermissions.js @@ -0,0 +1,39 @@ +/** + * getUserTenantsPermissions + * Retrieve the currently-authenticated user's permissions in each of the + * given tenants + * @param {object} stripes + * @param {array} tenants array of tenantIds + * @returns [] + */ +const getUserTenantsPermissions = async (stripes, tenants = []) => { + const { + user: { user: { id } }, + okapi: { + url, + token, + } + } = stripes; + const userTenantIds = tenants.map(tenant => tenant.id || tenant); + + const promises = userTenantIds.map(async (tenantId) => { + const result = await fetch(`${url}/perms/users/${id}/permissions?full=true&indexField=userId`, { + headers: { + 'X-Okapi-Tenant': tenantId, + 'Content-Type': 'application/json', + ...(token && { 'X-Okapi-Token': token }), + }, + credentials: 'include', + }); + + const json = await result.json(); + + return { tenantId, ...json }; + }); + + const userTenantsPermissions = await Promise.allSettled(promises); + + return userTenantsPermissions.map(userTenantsPermission => userTenantsPermission.value); +}; + +export default getUserTenantsPermissions; diff --git a/src/queries/getUserTenantsPermissions.test.js b/src/queries/getUserTenantsPermissions.test.js new file mode 100644 index 000000000..d9ae848be --- /dev/null +++ b/src/queries/getUserTenantsPermissions.test.js @@ -0,0 +1,71 @@ +import getUserTenantsPermissions from './getUserTenantsPermissions'; + +const mockFetch = jest.fn(); + +describe('getUserTenantsPermissions', () => { + beforeEach(() => { + global.fetch = mockFetch; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('X-Okapi-Token header is present if present in stripes', async () => { + const stripes = { + user: { user: { id: 'userId' } }, + okapi: { + url: 'http://okapiUrl', + token: 'elevensies', + }, + }; + mockFetch.mockResolvedValueOnce('non-okapi-success'); + + await getUserTenantsPermissions(stripes, ['tenantId']); + await expect(mockFetch.mock.calls).toHaveLength(1); + + expect(mockFetch.mock.lastCall[0]).toMatch(/[stripes.okapi.url]/); + expect(mockFetch.mock.lastCall[1]).toMatchObject({ + headers: { + 'X-Okapi-Tenant': 'tenantId' + } + }); + }); + + it('X-Okapi-Token header is absent if absent from stripes', async () => { + const stripes = { + user: { user: { id: 'userId' } }, + okapi: { + url: 'http://okapiUrl', + }, + }; + mockFetch.mockResolvedValueOnce('non-okapi-success'); + + await getUserTenantsPermissions(stripes, ['tenantId']); + await expect(mockFetch.mock.calls).toHaveLength(1); + + expect(mockFetch.mock.lastCall[0]).toMatch(/[stripes.okapi.url]/); + expect(mockFetch.mock.lastCall[1].headers.keys).toEqual(expect.not.arrayContaining(['X-Okapi-Token'])); + }); + + it('response aggregates permissions across tenants', async () => { + const stripes = { + user: { user: { id: 'userId' } }, + okapi: { + url: 'http://okapiUrl', + }, + }; + + const t1 = { p: ['t1-p1', 't1-p2'] }; + const t2 = { p: ['t2-p3', 't2-p4'] }; + + mockFetch + .mockResolvedValueOnce(Promise.resolve({ json: () => Promise.resolve(t1) })) + .mockResolvedValueOnce(Promise.resolve({ json: () => Promise.resolve(t2) })); + + const response = await getUserTenantsPermissions(stripes, ['t1', 't2']); + + expect(response[0]).toMatchObject(t1); + expect(response[1]).toMatchObject(t2); + }); +}); diff --git a/src/queries/index.js b/src/queries/index.js index bc4832f21..b15f4b55b 100644 --- a/src/queries/index.js +++ b/src/queries/index.js @@ -1,4 +1,4 @@ - +export { default as getUserTenantsPermissions } from './getUserTenantsPermissions'; +export { default as useChunkedCQLFetch } from './useChunkedCQLFetch'; export { default as useConfigurations } from './useConfigurations'; export { default as useOkapiEnv } from './useOkapiEnv'; -export { default as useChunkedCQLFetch } from './useChunkedCQLFetch'; diff --git a/test/jest/fixtures/permissions.json b/test/jest/fixtures/permissions.json new file mode 100644 index 000000000..74545fcaa --- /dev/null +++ b/test/jest/fixtures/permissions.json @@ -0,0 +1,30 @@ +[ + { + "deprecated": false, + "description": "Grants all permissions included in Agreements: Search & view agreements plus the ability to delete agreements. This does not include the ability to edit agreements, only to delete them", + "displayName": "Agreements: Delete agreements", + "grantedTo": ["8fafcfdb-419c-483e-a698-d7f8f46ea694"], + "id": "026bc082-add6-4cdd-a4fd-a588439a57b6", + "moduleName": "folio_agreements", + "moduleVersion": "8.1.1000825", + "mutable": false, + "permissionName": "ui-agreements.agreements.delete", + "subPermissions": ["ui-agreements.agreements.view", "erm.agreements.item.delete"], + "tags": [], + "visible": true + }, + { + "deprecated": false, + "description": "Grants all permissions included in Agreements: Search & view agreements plus the ability to delete agreements. This does not include the ability to edit agreements, only to delete them", + "displayName": "Agreements: Edit agreements", + "grantedTo": ["8fafcfdb-419c-483e-a698-d7f8f46ea694"], + "id": "b52da718-7770-407a-af6d-668d258b2309", + "moduleName": "folio_agreements", + "moduleVersion": "8.1.1000825", + "mutable": false, + "permissionName": "ui-agreements.agreements.edit", + "subPermissions": ["ui-agreements.agreements.view", "erm.agreements.item.delete"], + "tags": [], + "visible": true + } +]