Skip to content

Commit

Permalink
STCOR-830 user-tenant-permissions hooks/functions (#1453)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
zburke authored Apr 11, 2024
1 parent 5e8419b commit e49e351
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 12 deletions.
32 changes: 22 additions & 10 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -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" ],
Expand All @@ -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"]
]
}
}
}
}

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useUserTenantPermissions } from './useUserTenantPermissions'; // eslint-disable-line import/prefer-default-export
59 changes: 59 additions & 0 deletions src/hooks/useUserTenantPermissions.js
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 71 additions & 0 deletions src/hooks/useUserTenantPermissions.test.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);

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({}));
});
});
39 changes: 39 additions & 0 deletions src/queries/getUserTenantsPermissions.js
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 71 additions & 0 deletions src/queries/getUserTenantsPermissions.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions src/queries/index.js
Original file line number Diff line number Diff line change
@@ -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';
30 changes: 30 additions & 0 deletions test/jest/fixtures/permissions.json
Original file line number Diff line number Diff line change
@@ -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
}
]

0 comments on commit e49e351

Please sign in to comment.