From 579daa019e428d964d2d9372bd1f4abe81781c53 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Thu, 23 May 2024 09:45:36 +0200 Subject: [PATCH 01/13] Fix label tag issue --- src/components/header/ApplicationHeader.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/header/ApplicationHeader.vue b/src/components/header/ApplicationHeader.vue index d4daccdb..fb414a6d 100644 --- a/src/components/header/ApplicationHeader.vue +++ b/src/components/header/ApplicationHeader.vue @@ -5,12 +5,12 @@ >
Date: Mon, 27 May 2024 15:48:02 +0200 Subject: [PATCH 02/13] Create composable to manage detach action between user and access control --- src/composables/DetachDialog.js | 136 +++++++++++++ src/i18n/en-US/index.js | 4 + tests/unit/composables/DetachDialog.test.js | 206 ++++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 src/composables/DetachDialog.js create mode 100644 tests/unit/composables/DetachDialog.test.js diff --git a/src/composables/DetachDialog.js b/src/composables/DetachDialog.js new file mode 100644 index 00000000..e9fcd84d --- /dev/null +++ b/src/composables/DetachDialog.js @@ -0,0 +1,136 @@ +import { + ref, + computed, +} from 'vue'; +import { useDialog } from 'src/composables/Dialog'; +import ReloadUsersEvent from 'src/composables/events/ReloadUsersEvent'; +import ReloadGroupsEvent from 'src/composables/events/ReloadGroupsEvent'; +import ReloadRolesEvent from 'src/composables/events/ReloadRolesEvent'; +import DialogEvent from 'src/composables/events/DialogEvent'; +import * as GroupService from 'src/services/GroupService'; +import * as RoleService from 'src/services/RoleService'; +import * as UserService from 'src/services/UserService'; +import { Notify } from 'quasar'; +import { useI18n } from 'vue-i18n'; +import { useUserStore } from 'src/stores/UserStore'; + +/** + * Composable that extends useDialog to manage detach user and access control dialogs. + * @param {string} dialogName - Name of the dialog. + * @param {string} key - Dialog event key. + * @param {string} source - The entity that another entity is being detached from. + * @param {string} target - The detached entity. + * @returns {object} Object with refs and functions to manage detach access control dialog. + */ +export function useDetachDialog(dialogName, key, source, target) { + const userStore = useUserStore(); + const submitting = ref(false); + const group = ref(null); + const role = ref(null); + const user = ref(); + const isCurrentUser = computed(() => user.value?.login === userStore.login); + + const { show } = useDialog(key, (event) => { + submitting.value = false; + group.value = event.group; + user.value = event.user; + role.value = event.role; + }); + + const { t } = useI18n(); + + /** + * Dispatch a reload event for users, groups, or roles + * depending on the value of the `target` variable. + * @returns {void} + */ + function reloadEvent() { + if (target === 'user') { + return ReloadUsersEvent.next(); + } + + if (target === 'group') { + return ReloadGroupsEvent.next(); + } + + return ReloadRolesEvent.next(); + } + + /** + * Invoke the appropriate dissociate method from GroupService or RoleService. + * @returns {Promise} Promise with nothing on success. + */ + async function dissociate() { + if ([source, target].includes('group')) { + return GroupService.dissociateGroupAndUser(user.value.login, group.value.id); + } + + return RoleService.dissociateRoleAndUser(user.value.login, role.value.id); + } + + /** + * Manage the current user's permissions and trigger appropriate events. + * @returns {Promise} Promise with nothing on success. + */ + async function manageUserRolePermissions() { + if (isCurrentUser.value) { + userStore.permissions = await UserService.getMyPermissions(); + + if (!userStore.isAdmin) { + DialogEvent.next({ + key: 'redirect', + type: 'open', + }); + } + } + + if (!isCurrentUser.value || (isCurrentUser.value && userStore.isAdmin)) { + reloadEvent(); + } + } + + /** + * Detach user, send event to reload users and close dialog. + * @returns {Promise} Promise with nothing on success. + */ + async function detach() { + submitting.value = true; + + return dissociate().then(async () => { + Notify.create({ + type: 'positive', + message: t(`${dialogName}.text.notifySuccess`), + html: true, + }); + + if ([source, target].includes('role')) { + manageUserRolePermissions(); + } else { + reloadEvent(); + + if (isCurrentUser.value) { + userStore.permissions = await UserService.getMyPermissions(); + } + } + }).catch(() => { + Notify.create({ + type: 'negative', + message: t(`${dialogName}.text.notifyError`), + html: true, + }); + }).finally(() => { + submitting.value = false; + show.value = false; + }); + } + + return { + show, + submitting, + user, + group, + role, + isCurrentUser, + detach, + }; +} diff --git a/src/i18n/en-US/index.js b/src/i18n/en-US/index.js index ee765507..1b6fd5be 100644 --- a/src/i18n/en-US/index.js +++ b/src/i18n/en-US/index.js @@ -273,6 +273,7 @@ export default { cancel: 'Cancel', confirm: 'Confirm', notifySuccess: 'Group is detached from user.', + notifyError: 'Error during group detachment from user.', }, }, DetachUserFromGroupDialog: { @@ -282,6 +283,7 @@ export default { cancel: 'Cancel', confirm: 'Confirm', notifySuccess: 'User is detached from group.', + notifyError: 'Error during user detachment from group.', }, }, DetachRoleFromGroupDialog: { @@ -327,6 +329,7 @@ export default { cancel: 'Cancel', confirm: 'Confirm', notifySuccess: 'Role is detached from user.', + notifyError: 'Error during role detachment from user.', warning: 'Losing your role means losing permission, and if you no longer have admin access, you will be redirected to Leto-Modelizer.', }, icon: { @@ -340,6 +343,7 @@ export default { cancel: 'Cancel', confirm: 'Confirm', notifySuccess: 'User is detached from role.', + notifyError: 'Error during user detachment from role.', warning: 'Losing your role means losing permission, and if you no longer have admin access, you will be redirected to Leto-Modelizer.', }, icon: { diff --git a/tests/unit/composables/DetachDialog.test.js b/tests/unit/composables/DetachDialog.test.js new file mode 100644 index 00000000..d92a4361 --- /dev/null +++ b/tests/unit/composables/DetachDialog.test.js @@ -0,0 +1,206 @@ +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-vitest'; +import { mount } from '@vue/test-utils'; +import { Notify } from 'quasar'; +import { useI18n } from 'vue-i18n'; +import { useUserStore } from 'src/stores/UserStore'; +import { vi } from 'vitest'; +import { useDetachDialog } from 'src/composables/DetachDialog'; +import DialogEvent from 'src/composables/events/DialogEvent'; +import ReloadUsersEvent from 'src/composables/events/ReloadUsersEvent'; +import ReloadGroupsEvent from 'src/composables/events/ReloadGroupsEvent'; +import ReloadRolesEvent from 'src/composables/events/ReloadRolesEvent'; +import * as GroupService from 'src/services/GroupService'; +import * as RoleService from 'src/services/RoleService'; +import * as UserService from 'src/services/UserService'; + +installQuasarPlugin({ + plugins: [Notify], +}); + +vi.mock('src/composables/events/DialogEvent'); +vi.mock('src/composables/events/ReloadUsersEvent'); +vi.mock('src/composables/events/ReloadGroupsEvent'); +vi.mock('src/composables/events/ReloadRolesEvent'); +vi.mock('src/services/GroupService'); +vi.mock('src/services/RoleService'); +vi.mock('src/services/UserService'); +vi.mock('src/stores/UserStore'); +vi.mock('vue-i18n'); + +describe('Test: useDetachDialog', () => { + let wrapper; + let userStore; + + beforeEach(() => { + userStore = { + login: 'current_user', + isAdmin: false, + permissions: [], + }; + useUserStore.mockReturnValue(userStore); + + ReloadUsersEvent.next = vi.fn(); + ReloadGroupsEvent.next = vi.fn(); + ReloadRolesEvent.next = vi.fn(); + + useI18n.mockReturnValue({ + t: (key) => key, + }); + }); + + /** + * Mount component with composable. + * @param {string} dialogName - Name of the dialog. + * @param {string} key - Dialog event key. + * @param {string} source - The entity that another entity is being detached from. + * @param {string} target - The detached entity. + */ + function mountComponent(dialogName = 'testDialog', key = 'testKey', source = 'group', target = 'user') { + wrapper = mount({ + template: '
', + components: { useDetachDialog }, + setup() { + return useDetachDialog(dialogName, key, source, target); + }, + }); + + wrapper.vm.show = true; + } + + describe('Test function: reloadEvent', () => { + it('should call ReloadUserEvent when the target is "user"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'user'); + wrapper.vm.role = { id: 'role1' }; + wrapper.vm.user = { login: 'user1' }; + + await wrapper.vm.detach(); + + expect(ReloadUsersEvent.next).toHaveBeenCalled(); + }); + + it('should call ReloadGroupsEvent when the target is "group"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'group'); + wrapper.vm.group = { id: 'group1' }; + wrapper.vm.user = { login: 'user1' }; + + await wrapper.vm.detach(); + + expect(ReloadGroupsEvent.next).toHaveBeenCalled(); + }); + + it('should call ReloadRolesEvent when the target is "role"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'role'); + wrapper.vm.role = { id: 'role1' }; + wrapper.vm.user = { login: 'user1' }; + + await wrapper.vm.detach(); + + expect(ReloadRolesEvent.next).toHaveBeenCalled(); + }); + }); + + describe('Test function: dissociate', () => { + it('should call dissociateGroupAndUser if source or target is group', async () => { + mountComponent('testDialog', 'testKey', 'group', 'user'); + wrapper.vm.user = { login: 'user1' }; + wrapper.vm.group = { id: 'group1' }; + + await wrapper.vm.detach(); + + expect(GroupService.dissociateGroupAndUser).toHaveBeenCalledWith('user1', 'group1'); + }); + + it('should call dissociateRoleAndUser if source or target is role', async () => { + mountComponent('testDialog', 'testKey', 'role', 'user'); + wrapper.vm.user = { login: 'user1' }; + wrapper.vm.role = { id: 'role1' }; + + await wrapper.vm.detach(); + + expect(RoleService.dissociateRoleAndUser).toHaveBeenCalledWith('user1', 'role1'); + }); + }); + + describe('Test function: manageUserRolePermissions', () => { + beforeEach(() => { + mountComponent('testDialog', 'testKey', 'user', 'role'); + userStore.login = 'user1'; + userStore.permissions = 'user permissions'; + wrapper.vm.role = { id: 'role1' }; + wrapper.vm.user = { login: 'user1' }; + + UserService.getMyPermissions.mockImplementation(() => Promise.resolve('updated user permissions')); + DialogEvent.next.mockReset(); + }); + + it('should update user permissions and call DialogEvent when user is current and not admin', async () => { + userStore.isAdmin = false; + + await wrapper.vm.detach(); + + expect(userStore.permissions).toEqual('updated user permissions'); + expect(DialogEvent.next).toHaveBeenCalled(); + }); + + it('should update user permissions and call reload event when user is current and admin', async () => { + userStore.isAdmin = true; + + await wrapper.vm.detach(); + + expect(userStore.permissions).toEqual('updated user permissions'); + expect(ReloadRolesEvent.next).toHaveBeenCalled(); + expect(DialogEvent.next).not.toHaveBeenCalled(); + }); + + it('should only call reload event when user is not current', async () => { + wrapper.vm.user = { login: 'notCurrent' }; + + await wrapper.vm.detach(); + + expect(ReloadRolesEvent.next).toHaveBeenCalled(); + expect(DialogEvent.next).not.toHaveBeenCalled(); + expect(userStore.permissions).toEqual('user permissions'); + }); + }); + + describe('Test function: detach', () => { + beforeEach(() => { + mountComponent('testDialog', 'testKey', 'group', 'user'); + wrapper.vm.group = { id: 'group1' }; + wrapper.vm.user = { login: 'user1' }; + wrapper.vm.submitting = true; + + Notify.create = vi.fn(); + }); + + it('should call appropriate event reload and service with dissociate function, then show success notification', async () => { + userStore.login = 'user1'; + + await wrapper.vm.detach(); + + expect(ReloadUsersEvent.next).toHaveBeenCalled(); + expect(GroupService.dissociateGroupAndUser).toHaveBeenCalledWith('user1', 'group1'); + expect(Notify.create).toHaveBeenCalledWith({ + type: 'positive', + message: 'testDialog.text.notifySuccess', + html: true, + }); + expect(wrapper.vm.show).toBeFalsy(); + expect(wrapper.vm.submitting).toBeFalsy(); + }); + + it('should handle error and show error notification', async () => { + GroupService.dissociateGroupAndUser.mockRejectedValueOnce(new Error('Error')); + + await wrapper.vm.detach(); + + expect(Notify.create).toHaveBeenCalledWith({ + type: 'negative', + message: 'testDialog.text.notifyError', + html: true, + }); + expect(wrapper.vm.show).toBeFalsy(); + expect(wrapper.vm.submitting).toBeFalsy(); + }); + }); +}); From c9bcd45fe3c1c8fbfeee817fe1d2922853c78efc Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Mon, 27 May 2024 15:49:44 +0200 Subject: [PATCH 03/13] Use composable in DetachGroupFromUser dialog --- .../dialog/DetachGroupFromUserDialog.vue | 62 +++++-------------- .../dialog/DetachGroupFromUserDialog.test.js | 31 +--------- 2 files changed, 17 insertions(+), 76 deletions(-) diff --git a/src/components/dialog/DetachGroupFromUserDialog.vue b/src/components/dialog/DetachGroupFromUserDialog.vue index 843ce343..88f15679 100644 --- a/src/components/dialog/DetachGroupFromUserDialog.vue +++ b/src/components/dialog/DetachGroupFromUserDialog.vue @@ -7,7 +7,7 @@ { group: group.name, user: user.name }) }} - + {{ $t('DetachGroupFromUserDialog.text.content') }} @@ -35,50 +35,18 @@ diff --git a/tests/unit/components/dialog/DetachGroupFromUserDialog.test.js b/tests/unit/components/dialog/DetachGroupFromUserDialog.test.js index e728812a..ca3cb394 100644 --- a/tests/unit/components/dialog/DetachGroupFromUserDialog.test.js +++ b/tests/unit/components/dialog/DetachGroupFromUserDialog.test.js @@ -2,8 +2,6 @@ import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-v import { mount } from '@vue/test-utils'; import { vi } from 'vitest'; import DetachGroupFromUserDialog from 'src/components/dialog/DetachGroupFromUserDialog.vue'; -import * as GroupService from 'src/services/GroupService'; -import * as UserService from 'src/services/UserService'; import { Notify } from 'quasar'; import { createPinia, setActivePinia } from 'pinia'; import { useUserStore } from 'stores/UserStore'; @@ -32,32 +30,7 @@ describe('Test component: DetachGroupFromUserDialog', () => { }; }); - describe('Test function: onSubmit', () => { - it('should send positive notification after detaching group from user', async () => { - Notify.create = vi.fn(); - wrapper.vm.user = { - login: 'id', - }; - GroupService.dissociateGroupAndUser.mockImplementation(() => Promise.resolve()); - - await wrapper.vm.onSubmit(); - - expect(Notify.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'positive' })); - }); - - it('should update permissions for current user', async () => { - wrapper.vm.user = { - login: 'userLogin', - }; - store.login = 'userLogin'; - store.permissions = null; - - GroupService.dissociateGroupAndUser.mockImplementation(() => Promise.resolve()); - UserService.getMyPermissions.mockImplementation(() => Promise.resolve({ action: 'ACTION', entity: 'ENTITY' })); - - await wrapper.vm.onSubmit(); - - expect(store.permissions).toEqual({ action: 'ACTION', entity: 'ENTITY' }); - }); + it('should mount the component', () => { + expect(wrapper).not.toBeNull(); }); }); From c490ee6375e2ee7ef5b84b993ac626b72e71efc2 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Mon, 27 May 2024 15:50:03 +0200 Subject: [PATCH 04/13] Use composable in DetachUserFromGroup dialog --- .../dialog/DetachUserFromGroupDialog.vue | 63 +++++-------------- .../dialog/DetachUserFromGroupDialog.test.js | 31 +-------- 2 files changed, 17 insertions(+), 77 deletions(-) diff --git a/src/components/dialog/DetachUserFromGroupDialog.vue b/src/components/dialog/DetachUserFromGroupDialog.vue index 47c49e6f..e3278d54 100644 --- a/src/components/dialog/DetachUserFromGroupDialog.vue +++ b/src/components/dialog/DetachUserFromGroupDialog.vue @@ -7,7 +7,7 @@ { user: user.name, group: group.name }) }} - + {{ $t('DetachUserFromGroupDialog.text.content') }} @@ -35,51 +35,18 @@ diff --git a/tests/unit/components/dialog/DetachUserFromGroupDialog.test.js b/tests/unit/components/dialog/DetachUserFromGroupDialog.test.js index 3881b77c..9ae76e5f 100644 --- a/tests/unit/components/dialog/DetachUserFromGroupDialog.test.js +++ b/tests/unit/components/dialog/DetachUserFromGroupDialog.test.js @@ -2,8 +2,6 @@ import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-v import { mount } from '@vue/test-utils'; import { vi } from 'vitest'; import DetachUserFromGroupDialog from 'src/components/dialog/DetachUserFromGroupDialog.vue'; -import * as GroupService from 'src/services/GroupService'; -import * as UserService from 'src/services/UserService'; import { Notify } from 'quasar'; import { createPinia, setActivePinia } from 'pinia'; import { useUserStore } from 'stores/UserStore'; @@ -32,32 +30,7 @@ describe('Test component: DetachUserFromGroupDialog', () => { }; }); - describe('Test function: onSubmit', () => { - it('should send positive notification after detaching group from user', async () => { - Notify.create = vi.fn(); - wrapper.vm.user = { - login: 'id', - }; - GroupService.dissociateGroupAndUser.mockImplementation(() => Promise.resolve()); - - await wrapper.vm.onSubmit(); - - expect(Notify.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'positive' })); - }); - - it('should update permissions for current user', async () => { - wrapper.vm.user = { - login: 'userLogin', - }; - store.login = 'userLogin'; - store.permissions = null; - - GroupService.dissociateGroupAndUser.mockImplementation(() => Promise.resolve()); - UserService.getMyPermissions.mockImplementation(() => Promise.resolve({ action: 'ACTION', entity: 'ENTITY' })); - - await wrapper.vm.onSubmit(); - - expect(store.permissions).toEqual({ action: 'ACTION', entity: 'ENTITY' }); - }); + it('should mount the component', () => { + expect(wrapper).not.toBeNull(); }); }); From 3c6f31297db2bdb7885bf5ae239fe4162de3ef02 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Mon, 27 May 2024 15:50:16 +0200 Subject: [PATCH 05/13] Use composable in DetachRoleFromUser dialog --- .../dialog/DetachRoleFromUserDialog.vue | 73 ++++--------------- .../dialog/DetachRoleFromUserDialog.test.js | 67 +---------------- 2 files changed, 17 insertions(+), 123 deletions(-) diff --git a/src/components/dialog/DetachRoleFromUserDialog.vue b/src/components/dialog/DetachRoleFromUserDialog.vue index 7e0234a1..8899263b 100644 --- a/src/components/dialog/DetachRoleFromUserDialog.vue +++ b/src/components/dialog/DetachRoleFromUserDialog.vue @@ -6,7 +6,7 @@ {{ $t('DetachRoleFromUserDialog.text.title', { role: role.name, user: user.name }) }} - + diff --git a/tests/unit/components/dialog/DetachRoleFromUserDialog.test.js b/tests/unit/components/dialog/DetachRoleFromUserDialog.test.js index 8ec2222d..fc50ff08 100644 --- a/tests/unit/components/dialog/DetachRoleFromUserDialog.test.js +++ b/tests/unit/components/dialog/DetachRoleFromUserDialog.test.js @@ -2,12 +2,8 @@ import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-v import { mount } from '@vue/test-utils'; import { vi } from 'vitest'; import DetachRoleFromUserDialog from 'src/components/dialog/DetachRoleFromUserDialog.vue'; -import * as RoleService from 'src/services/RoleService'; -import * as UserService from 'src/services/UserService'; import { Notify } from 'quasar'; import { createPinia, setActivePinia } from 'pinia'; -import { useUserStore } from 'stores/UserStore'; -import DialogEvent from 'src/composables/events/DialogEvent'; installQuasarPlugin({ plugins: [Notify], @@ -21,73 +17,14 @@ vi.stubGlobal('$sanitize', true); describe('Test component: DetachRoleFromUserDialog', () => { let wrapper; - let store; beforeEach(() => { setActivePinia(createPinia()); - store = useUserStore(); - - RoleService.dissociateRoleAndUser.mockImplementation(() => Promise.resolve()); wrapper = mount(DetachRoleFromUserDialog); }); - describe('Test function: onSubmit', () => { - it('should send positive notification after detaching role from user', async () => { - Notify.create = vi.fn(); - - store.login = 'login'; - wrapper.vm.user = { - login: 'id', - }; - wrapper.vm.role = { - id: 'id', - }; - - await wrapper.vm.onSubmit(); - - expect(Notify.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'positive' })); - }); - - it('should not open redirect dialog if current user has admin access', async () => { - Notify.create = vi.fn(); - DialogEvent.next.mockImplementation(); - - store.login = 'id'; - wrapper.vm.user = { - login: 'id', - }; - wrapper.vm.role = { - id: 'id', - }; - - UserService.getMyPermissions.mockImplementation(() => Promise.resolve([{ action: 'ACCESS', entity: 'ADMIN' }])); - - await wrapper.vm.onSubmit(); - - expect(DialogEvent.next).not.toBeCalled(); - }); - - it('should open redirect dialog after losing admin access to current user', async () => { - Notify.create = vi.fn(); - DialogEvent.next.mockImplementation(); - - store.login = 'id'; - wrapper.vm.user = { - login: 'id', - }; - wrapper.vm.role = { - id: 'id', - }; - - UserService.getMyPermissions.mockImplementation(() => Promise.resolve([{ action: 'ACTION', entity: 'ENTITY' }])); - - await wrapper.vm.onSubmit(); - - expect(DialogEvent.next).toBeCalledWith({ - key: 'redirect', - type: 'open', - }); - }); + it('should mount the component', () => { + expect(wrapper).not.toBeNull(); }); }); From f9f668ee126b0279626f457f63fc966451a9b682 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Mon, 27 May 2024 15:50:26 +0200 Subject: [PATCH 06/13] Use composable in DetachUserFromRole dialog --- .../dialog/DetachUserFromRoleDialog.vue | 73 ++++--------------- .../dialog/DetachUserFromRoleDialog.test.js | 31 +------- 2 files changed, 17 insertions(+), 87 deletions(-) diff --git a/src/components/dialog/DetachUserFromRoleDialog.vue b/src/components/dialog/DetachUserFromRoleDialog.vue index c7bd995a..8e508922 100644 --- a/src/components/dialog/DetachUserFromRoleDialog.vue +++ b/src/components/dialog/DetachUserFromRoleDialog.vue @@ -6,7 +6,7 @@ {{ $t('DetachUserFromRoleDialog.text.title', { user: user.name, role: role.name }) }} - + diff --git a/tests/unit/components/dialog/DetachUserFromRoleDialog.test.js b/tests/unit/components/dialog/DetachUserFromRoleDialog.test.js index f969f154..cb5f4601 100644 --- a/tests/unit/components/dialog/DetachUserFromRoleDialog.test.js +++ b/tests/unit/components/dialog/DetachUserFromRoleDialog.test.js @@ -2,8 +2,6 @@ import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-v import { mount } from '@vue/test-utils'; import { vi } from 'vitest'; import DetachUserFromRoleDialog from 'src/components/dialog/DetachUserFromRoleDialog.vue'; -import * as RoleService from 'src/services/RoleService'; -import * as UserService from 'src/services/UserService'; import { Notify } from 'quasar'; import { createPinia, setActivePinia } from 'pinia'; import { useUserStore } from 'stores/UserStore'; @@ -32,32 +30,7 @@ describe('Test component: DetachUserFromRoleDialog', () => { }; }); - describe('Test function: onSubmit', () => { - it('should send positive notification after detaching user from role', async () => { - Notify.create = vi.fn(); - wrapper.vm.user = { - login: 'id', - }; - RoleService.dissociateRoleAndUser.mockImplementation(() => Promise.resolve()); - - await wrapper.vm.onSubmit(); - - expect(Notify.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'positive' })); - }); - - it('should update permissions for current user', async () => { - wrapper.vm.user = { - login: 'userLogin', - }; - store.login = 'userLogin'; - store.permissions = []; - - RoleService.dissociateRoleAndUser.mockImplementation(() => Promise.resolve()); - UserService.getMyPermissions.mockImplementation(() => Promise.resolve([{ action: 'ACTION', entity: 'ENTITY' }])); - - await wrapper.vm.onSubmit(); - - expect(store.permissions).toEqual([{ action: 'ACTION', entity: 'ENTITY' }]); - }); + it('should mount the component', () => { + expect(wrapper).not.toBeNull(); }); }); From c1d537f3fb38ffa158510e48bc00a3cb6609b9f6 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Tue, 28 May 2024 17:12:27 +0200 Subject: [PATCH 07/13] Create composable to manage attach action for dialog --- src/composables/AttachDialog.js | 176 ++++++++++++++++ tests/unit/composables/AttachDialog.test.js | 221 ++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/composables/AttachDialog.js create mode 100644 tests/unit/composables/AttachDialog.test.js diff --git a/src/composables/AttachDialog.js b/src/composables/AttachDialog.js new file mode 100644 index 00000000..e99aa7ba --- /dev/null +++ b/src/composables/AttachDialog.js @@ -0,0 +1,176 @@ +import { + ref, + computed, +} from 'vue'; +import { useDialog } from 'src/composables/Dialog'; +import ReloadUsersEvent from 'src/composables/events/ReloadUsersEvent'; +import ReloadGroupsEvent from 'src/composables/events/ReloadGroupsEvent'; +import ReloadRolesEvent from 'src/composables/events/ReloadRolesEvent'; +import ReloadPermissionsEvent from 'src/composables/events/ReloadPermissionsEvent'; +import SelectEvent from 'src/composables/events/SelectEvent'; +import * as GroupService from 'src/services/GroupService'; +import * as RoleService from 'src/services/RoleService'; +import * as UserService from 'src/services/UserService'; +import { Notify } from 'quasar'; +import { useI18n } from 'vue-i18n'; +import { useUserStore } from 'src/stores/UserStore'; + +/** + * Composable that extends useDialog to manage detach user and access control dialogs. + * @param {string} dialogName - Name of the dialog. + * @param {string} key - Dialog event key. + * @param {string} source - The entity that another entity is being detached from. + * @param {string} target - The detached entity. + * @param {Function} searchFunction - Function to call. + * @returns {object} Object with refs and functions to manage detach access control dialog. + */ +export function useAttachDialog(dialogName, key, source, target, searchFunction) { + const userStore = useUserStore(); + const submitting = ref(false); + const selectOnly = ref(false); + const selected = ref([]); + const groupId = ref(null); + const roleId = ref(null); + const userLogin = ref(); + const isCurrentUser = computed(() => userLogin.value === userStore.login); + + const { show } = useDialog(key, (event) => { + submitting.value = false; + groupId.value = event.groupId; + roleId.value = event.roleId; + userLogin.value = event.userLogin; + selected.value = []; + selectOnly.value = event.selectOnly || false; + if (searchFunction) searchFunction(); + }); + + const { t } = useI18n(); + + /** + * Dispatch a reload event for users, groups, roles or permissions + * depending on the value of the `target` variable. + * @returns {void} + */ + function reloadEvent() { + if (target === 'user') { + return ReloadUsersEvent.next(); + } + + if (target === 'group') { + return ReloadGroupsEvent.next(); + } + + if (target === 'role') { + return ReloadRolesEvent.next(); + } + + return ReloadPermissionsEvent.next(); + } + + /** + * Invoke the appropriate associate method from GroupService or RoleService. + * @param {string} selectedId - Selected id of the item to be attached. + * @returns {Promise} Promise with nothing on success. + */ + async function associate(selectedId) { + if (target === 'permission') { + return RoleService.associateRoleAndPermission(roleId.value, selectedId); + } + + if (target === 'group' && source === 'user') { + return GroupService.associateGroupAndUser(userLogin.value, selectedId); + } + + if (target === 'user' && source === 'group') { + return GroupService.associateGroupAndUser(selectedId, groupId.value); + } + + if (target === 'user' && source === 'role') { + return RoleService.associateRoleAndUser(selectedId, roleId.value); + } + + return RoleService.associateRoleAndUser(userLogin.value, selectedId); + } + + /** + * Handle failed requests by filtering out specific items based on the rejection reason message. + * @param {Array} results - results array containing status and reason of each request. + * @param {string} results[].status - status of the request ('fulfilled' or 'rejected'). + * @param {object} results[].reason - reason object if the request was rejected. + * @param {string} results[].reason.message - message describing why the request was rejected. + */ + function handleFailedRequest(results) { + const failedRequestObjects = []; + + results.forEach(({ status, reason }) => { + if (status === 'rejected' && reason.message) { + const filterKey = target === 'user' ? 'login' : 'id'; + + failedRequestObjects.push(...selected.value + .filter((item) => item[filterKey] === reason.message)); + } + }); + + selected.value = failedRequestObjects; + } + + /** + * Attach one or more groups to a user. + * @returns {Promise} Promise with nothing on success. + */ + async function attach() { + if (selectOnly.value) { + SelectEvent.SelectUsersEvent.next(selected.value); + show.value = false; + + return; + } + + submitting.value = true; + + const selectedIdList = selected.value.map((item) => (target === 'user' ? item.login : item.id)); + + await Promise.allSettled(selectedIdList + .map((selectedId) => associate(selectedId) + .catch(() => { + Notify.create({ + type: 'negative', + message: t(`${dialogName}.text.notifyError`), + html: true, + }); + + throw new Error(selectedId); + }))) + .then((results) => { + handleFailedRequest(results); + + if (results.every(({ status }) => status === 'fulfilled')) { + Notify.create({ + type: 'positive', + message: t(`${dialogName}.text.notifySuccess`), + html: true, + }); + + show.value = false; + } + }).finally(async () => { + reloadEvent(); + submitting.value = false; + + if (isCurrentUser.value || selectedIdList.includes(userStore.login) || target === 'permission') { + userStore.permissions = await UserService.getMyPermissions(); + } + }); + } + + return { + show, + submitting, + userLogin, + groupId, + roleId, + isCurrentUser, + selected, + attach, + }; +} diff --git a/tests/unit/composables/AttachDialog.test.js b/tests/unit/composables/AttachDialog.test.js new file mode 100644 index 00000000..5ba99564 --- /dev/null +++ b/tests/unit/composables/AttachDialog.test.js @@ -0,0 +1,221 @@ +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-vitest'; +import { mount } from '@vue/test-utils'; +import { Notify } from 'quasar'; +import { useI18n } from 'vue-i18n'; +import { useUserStore } from 'src/stores/UserStore'; +import { vi } from 'vitest'; +import { useAttachDialog } from 'src/composables/AttachDialog'; +import ReloadUsersEvent from 'src/composables/events/ReloadUsersEvent'; +import ReloadGroupsEvent from 'src/composables/events/ReloadGroupsEvent'; +import ReloadRolesEvent from 'src/composables/events/ReloadRolesEvent'; +import ReloadPermissionsEvent from 'src/composables/events/ReloadPermissionsEvent'; +import * as GroupService from 'src/services/GroupService'; +import * as RoleService from 'src/services/RoleService'; +import * as UserService from 'src/services/UserService'; + +installQuasarPlugin({ + plugins: [Notify], +}); + +vi.mock('src/composables/events/ReloadUsersEvent'); +vi.mock('src/composables/events/ReloadGroupsEvent'); +vi.mock('src/composables/events/ReloadRolesEvent'); +vi.mock('src/composables/events/ReloadPermissionsEvent'); +vi.mock('src/services/GroupService'); +vi.mock('src/services/RoleService'); +vi.mock('src/services/UserService'); +vi.mock('src/stores/UserStore'); +vi.mock('vue-i18n'); + +describe('Test: useAttachDialog', () => { + let wrapper; + let userStore; + + beforeEach(() => { + userStore = { + login: 'current_user', + isAdmin: false, + permissions: [], + }; + useUserStore.mockReturnValue(userStore); + + ReloadUsersEvent.next = vi.fn(); + ReloadGroupsEvent.next = vi.fn(); + ReloadRolesEvent.next = vi.fn(); + ReloadPermissionsEvent.next = vi.fn(); + + UserService.getMyPermissions.mockImplementation(() => Promise.resolve('updated user permissions')); + + useI18n.mockReturnValue({ + t: (key) => key, + }); + }); + + /** + * Mount component with composable. + * @param {string} dialogName - Name of the dialog. + * @param {string} key - Dialog event key. + * @param {string} source - The entity that another entity is being detached from. + * @param {string} target - The detached entity. + */ + function mountComponent(dialogName = 'testDialog', key = 'testKey', source = 'group', target = 'user') { + wrapper = mount({ + template: '
', + components: { useAttachDialog }, + setup() { + return useAttachDialog(dialogName, key, source, target); + }, + }); + + wrapper.vm.show = true; + } + + describe('Test function: reloadEvent', () => { + it('should call ReloadUserEvent when the target is "user"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'user'); + wrapper.vm.roleId = 'role1'; + wrapper.vm.userLogin = 'user1'; + + await wrapper.vm.attach(); + + expect(ReloadUsersEvent.next).toHaveBeenCalled(); + }); + + it('should call ReloadGroupsEvent when the target is "group"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'group'); + wrapper.vm.groupId = 'group1'; + wrapper.vm.userLogin = 'user1'; + + await wrapper.vm.attach(); + + expect(ReloadGroupsEvent.next).toHaveBeenCalled(); + }); + + it('should call ReloadRolesEvent when the target is "role"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'role'); + wrapper.vm.roleId = 'role1'; + wrapper.vm.userLogin = 'user1'; + + await wrapper.vm.attach(); + + expect(ReloadRolesEvent.next).toHaveBeenCalled(); + }); + + it('should call ReloadPermissionsEvent otherwise', async () => { + mountComponent('testDialog', 'testKey', 'source', 'permission'); + wrapper.vm.roleId = 'role1'; + wrapper.vm.userLogin = 'user1'; + + await wrapper.vm.attach(); + + expect(ReloadPermissionsEvent.next).toHaveBeenCalled(); + }); + }); + + describe('Test function: associate', () => { + it('should call associateGroupAndUser if source is "user" and target is "group"', async () => { + mountComponent('testDialog', 'testKey', 'user', 'group'); + wrapper.vm.userLogin = 'user1'; + wrapper.vm.selected = [{ id: 'group1' }]; + + await wrapper.vm.attach(); + + expect(GroupService.associateGroupAndUser).toHaveBeenCalledWith('user1', 'group1'); + }); + + it('should call associateGroupAndUser if source is "group" and target is "user"', async () => { + mountComponent('testDialog', 'testKey', 'group', 'user'); + wrapper.vm.groupId = 'group1'; + wrapper.vm.selected = [{ login: 'user1' }]; + + await wrapper.vm.attach(); + + expect(GroupService.associateGroupAndUser).toHaveBeenCalledWith('user1', 'group1'); + }); + + it('should call associateRoleAndUser if source is "role" and target is "user"', async () => { + mountComponent('testDialog', 'testKey', 'role', 'user'); + wrapper.vm.roleId = 'role1'; + wrapper.vm.selected = [{ login: 'user1' }]; + + await wrapper.vm.attach(); + + expect(RoleService.associateRoleAndUser).toHaveBeenCalledWith('user1', 'role1'); + }); + + it('should call associateRoleAndUser if source is "user" and target is "role"', async () => { + mountComponent('testDialog', 'testKey', 'user', 'role'); + wrapper.vm.userLogin = 'user1'; + wrapper.vm.selected = [{ id: 'role1' }]; + userStore.login = 'user1'; + + await wrapper.vm.attach(); + + expect(RoleService.associateRoleAndUser).toHaveBeenCalledWith('user1', 'role1'); + }); + + it('should call associateRoleAndPermission if target is "permission"', async () => { + mountComponent('testDialog', 'testKey', 'source', 'permission'); + wrapper.vm.roleId = 'role1'; + wrapper.vm.selected = [{ id: 'permission1' }]; + + await wrapper.vm.attach(); + + expect(RoleService.associateRoleAndPermission).toHaveBeenCalledWith('role1', 'permission1'); + }); + }); + + describe('Test function: onSubmit', () => { + beforeEach(() => { + mountComponent('testDialog', 'testKey', 'group', 'user'); + wrapper.vm.groupId = 'group1'; + wrapper.vm.userLogin = 'user1'; + wrapper.vm.selected = [{ login: 'user1' }]; + wrapper.vm.submitting = true; + wrapper.vm.selectOnly = true; + userStore.permissions = 'user permissions'; + + Notify.create = vi.fn(); + }); + + it('should call appropriate event reload and service with dissociate function, then show success notification', async () => { + await wrapper.vm.attach(); + + expect(ReloadUsersEvent.next).toHaveBeenCalled(); + expect(GroupService.associateGroupAndUser).toHaveBeenCalledWith('user1', 'group1'); + expect(Notify.create).toHaveBeenCalledWith({ + type: 'positive', + message: 'testDialog.text.notifySuccess', + html: true, + }); + expect(wrapper.vm.show).toBeFalsy(); + expect(wrapper.vm.submitting).toBeFalsy(); + }); + + it('should handle error and show error notification', async () => { + GroupService.associateGroupAndUser.mockImplementation(() => Promise.reject()); + + await wrapper.vm.attach(); + + expect(Notify.create).toHaveBeenCalledWith({ + type: 'negative', + message: 'testDialog.text.notifyError', + html: true, + }); + expect(wrapper.vm.show).toBeTruthy(); + expect(wrapper.vm.submitting).toBeFalsy(); + }); + + it('should update user permission', async () => { + wrapper.vm.userLogin = 'user1'; + wrapper.vm.selected = [{ login: 'user2' }]; + userStore.login = 'user2'; + + expect(userStore.permissions).toEqual('user permissions'); + + await wrapper.vm.attach(); + + expect(userStore.permissions).toEqual('updated user permissions'); + }); + }); +}); From df7331a5e647420450582661f40b8e8bc0bc1e56 Mon Sep 17 00:00:00 2001 From: Justine Hell Date: Tue, 28 May 2024 17:13:09 +0200 Subject: [PATCH 08/13] Use composable in AttachGroupToUser dialog --- .../dialog/AttachGroupToUserDialog.vue | 86 ++++--------------- .../dialog/AttachGroupToUserDialog.test.js | 44 ---------- 2 files changed, 15 insertions(+), 115 deletions(-) diff --git a/src/components/dialog/AttachGroupToUserDialog.vue b/src/components/dialog/AttachGroupToUserDialog.vue index 347bf5bf..12e46da6 100644 --- a/src/components/dialog/AttachGroupToUserDialog.vue +++ b/src/components/dialog/AttachGroupToUserDialog.vue @@ -6,7 +6,7 @@ {{ $t('AttachGroupToUserDialog.text.title') }} - +