test(editor): Increase test coverage for users settings page and modal (#10623)

This commit is contained in:
Raúl Gómez Morales 2024-08-30 16:45:18 +02:00 committed by GitHub
parent 81f4322d45
commit a20c915e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 398 additions and 130 deletions

View file

@ -4,6 +4,9 @@ import type { ISettingsState } from '@/Interface';
import { UserManagementAuthenticationMethod } from '@/Interface'; import { UserManagementAuthenticationMethod } from '@/Interface';
import { defaultSettings } from './defaults'; import { defaultSettings } from './defaults';
import { APP_MODALS_ELEMENT_ID } from '@/constants'; import { APP_MODALS_ELEMENT_ID } from '@/constants';
import type { Mock } from 'vitest';
import type { Store, StoreDefinition } from 'pinia';
import type { ComputedRef } from 'vue';
/** /**
* Retries the given assertion until it passes or the timeout is reached * Retries the given assertion until it passes or the timeout is reached
@ -108,3 +111,28 @@ export const createAppModals = () => {
export const cleanupAppModals = () => { export const cleanupAppModals = () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
}; };
/**
* Typescript helper for mocking pinia store actions return value
*
* @see https://pinia.vuejs.org/cookbook/testing.html#Mocking-the-returned-value-of-an-action
*/
export const mockedStore = <TStoreDef extends () => unknown>(
useStore: TStoreDef,
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<
Id,
State,
Record<string, never>,
{
[K in keyof Actions]: Actions[K] extends (...args: infer Args) => infer ReturnT
? Mock<Args, ReturnT>
: Actions[K];
}
> & {
[K in keyof Getters]: Getters[K] extends ComputedRef<infer T> ? T : never;
}
: ReturnType<TStoreDef> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useStore() as any;
};

View file

@ -0,0 +1,145 @@
import { createComponentRenderer } from '@/__tests__/render';
import DeleteUserModal from './DeleteUserModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { getDropdownItems } from '@/__tests__/utils';
import { createProjectListItem } from '@/__tests__/data/projects';
import { createUser } from '@/__tests__/data/users';
import { DELETE_USER_MODAL_KEY } from '@/constants';
import { ProjectTypes } from '@/types/projects.types';
import userEvent from '@testing-library/user-event';
import { useUsersStore } from '@/stores/users.store';
import { STORES } from '@/constants';
const ModalStub = {
template: `
<div>
<slot name="header" />
<slot name="title" />
<slot name="content" />
<slot name="footer" />
</div>
`,
};
const loggedInUser = createUser();
const invitedUser = createUser({ firstName: undefined });
const user = createUser();
const initialState = {
[STORES.UI]: {
modalsById: {
[DELETE_USER_MODAL_KEY]: {
open: true,
},
},
modalStack: [DELETE_USER_MODAL_KEY],
},
[STORES.PROJECTS]: {
projects: [
ProjectTypes.Personal,
ProjectTypes.Personal,
ProjectTypes.Team,
ProjectTypes.Team,
].map(createProjectListItem),
},
[STORES.USERS]: {
usersById: {
[loggedInUser.id]: loggedInUser,
[user.id]: user,
[invitedUser.id]: invitedUser,
},
},
};
const global = {
stubs: {
Modal: ModalStub,
},
};
const renderModal = createComponentRenderer(DeleteUserModal);
let pinia: ReturnType<typeof createTestingPinia>;
describe('DeleteUserModal', () => {
beforeEach(() => {
pinia = createTestingPinia({ initialState });
});
it('should delete invited users', async () => {
const { getByTestId } = renderModal({
props: {
activeId: invitedUser.id,
},
global,
pinia,
});
const userStore = useUsersStore();
await userEvent.click(getByTestId('confirm-delete-user-button'));
expect(userStore.deleteUser).toHaveBeenCalledWith({ id: invitedUser.id });
});
it('should delete user and transfer workflows and credentials', async () => {
const { getByTestId, getAllByRole } = renderModal({
props: {
activeId: user.id,
},
global,
pinia,
});
const confirmButton = getByTestId('confirm-delete-user-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(getAllByRole('radio')[0]);
const projectSelect = getByTestId('project-sharing-select');
expect(projectSelect).toBeVisible();
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
const userStore = useUsersStore();
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(userStore.deleteUser).toHaveBeenCalledWith({
id: user.id,
transferId: expect.any(String),
});
});
it('should delete user without transfer', async () => {
const { getByTestId, getAllByRole, getByRole } = renderModal({
props: {
activeId: user.id,
},
global,
pinia,
});
const userStore = useUsersStore();
const confirmButton = getByTestId('confirm-delete-user-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(getAllByRole('radio')[1]);
const input = getByRole('textbox');
await userEvent.type(input, 'delete all ');
expect(confirmButton).toBeDisabled();
await userEvent.type(input, 'data');
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(userStore.deleteUser).toHaveBeenCalledWith({
id: user.id,
});
});
});

View file

@ -639,6 +639,7 @@ export const enum STORES {
PUSH = 'push', PUSH = 'push',
ASSISTANT = 'assistant', ASSISTANT = 'assistant',
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator', BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
PROJECTS = 'projects',
} }
export const enum SignInType { export const enum SignInType {

View file

@ -18,8 +18,9 @@ import { hasPermission } from '@/utils/rbac/permissions';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { STORES } from '@/constants';
export const useProjectsStore = defineStore('projects', () => { export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const route = useRoute(); const route = useRoute();
const rootStore = useRootStore(); const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();

View file

@ -1,25 +1,70 @@
import { within } from '@testing-library/vue'; import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { cleanupAppModals, createAppModals, getDropdownItems } from '@/__tests__/utils'; import { getDropdownItems, mockedStore } from '@/__tests__/utils';
import ModalRoot from '@/components/ModalRoot.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import SettingsUsersView from '@/views/SettingsUsersView.vue'; import SettingsUsersView from '@/views/SettingsUsersView.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { createUser } from '@/__tests__/data/users'; import { createUser } from '@/__tests__/data/users';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useRBACStore } from '@/stores/rbac.store'; import { useRBACStore } from '@/stores/rbac.store';
import { DELETE_USER_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import * as usersApi from '@/api/users';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { defaultSettings } from '@/__tests__/defaults'; import { createTestingPinia, type TestingOptions } from '@pinia/testing';
import { ProjectTypes } from '@/types/projects.types'; import { merge } from 'lodash-es';
import { useUIStore } from '@/stores/ui.store';
import { useSSOStore } from '@/stores/sso.store';
import { STORES } from '@/constants';
const loggedInUser = createUser();
const invitedUser = createUser({
firstName: undefined,
inviteAcceptUrl: 'dummy',
role: 'global:admin',
});
const user = createUser();
const userWithDisabledSSO = createUser({
settings: { allowSSOManualLogin: true },
});
const initialState = {
[STORES.USERS]: {
currentUserId: loggedInUser.id,
usersById: {
[loggedInUser.id]: loggedInUser,
[invitedUser.id]: invitedUser,
[user.id]: user,
[userWithDisabledSSO.id]: userWithDisabledSSO,
},
},
[STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: true } } },
};
const getInitialState = (state: TestingOptions['initialState'] = {}) =>
merge({}, initialState, state);
const copy = vi.fn();
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copy,
}),
}));
const renderView = createComponentRenderer(SettingsUsersView);
const triggerUserAction = async (userListItem: HTMLElement, action: string) => {
expect(userListItem).toBeInTheDocument();
const actionToggle = within(userListItem).getByTestId('action-toggle');
const actionToggleButton = within(actionToggle).getByRole('button');
expect(actionToggleButton).toBeVisible();
await userEvent.click(actionToggle);
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
await userEvent.click(within(actionDropdown).getByTestId(`action-${action}`));
};
const showToast = vi.fn(); const showToast = vi.fn();
const showError = vi.fn(); const showError = vi.fn();
vi.mock('@/composables/useToast', () => ({ vi.mock('@/composables/useToast', () => ({
useToast: () => ({ useToast: () => ({
showToast, showToast,
@ -27,157 +72,205 @@ vi.mock('@/composables/useToast', () => ({
}), }),
})); }));
const wrapperComponentWithModal = {
components: { SettingsUsersView, ModalRoot, DeleteUserModal },
template: `
<div>
<SettingsUsersView />
<ModalRoot name="${DELETE_USER_MODAL_KEY}">
<template #default="{ modalName, activeId }">
<DeleteUserModal :modal-name="modalName" :active-id="activeId" />
</template>
</ModalRoot>
</div>
`,
};
const renderComponent = createComponentRenderer(wrapperComponentWithModal);
const loggedInUser = createUser();
const users = Array.from({ length: 3 }, createUser);
const projects = [
ProjectTypes.Personal,
ProjectTypes.Personal,
ProjectTypes.Team,
ProjectTypes.Team,
].map(createProjectListItem);
let pinia: ReturnType<typeof createPinia>;
let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let rbacStore: ReturnType<typeof useRBACStore>;
describe('SettingsUsersView', () => { describe('SettingsUsersView', () => {
beforeEach(() => { afterEach(() => {
pinia = createPinia(); copy.mockReset();
setActivePinia(pinia);
projectsStore = useProjectsStore();
usersStore = useUsersStore();
rbacStore = useRBACStore();
createAppModals();
useSettingsStore().settings.enterprise = {
...defaultSettings.enterprise,
[EnterpriseEditionFeature.AdvancedExecutionFilters]: true,
};
vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true);
vi.spyOn(usersApi, 'getUsers').mockResolvedValue(users);
vi.spyOn(usersStore, 'allUsers', 'get').mockReturnValue(users);
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
async () => await Promise.resolve(),
);
vi.spyOn(projectsStore, 'projects', 'get').mockReturnValue(projects);
usersStore.currentUserId = loggedInUser.id;
showToast.mockReset(); showToast.mockReset();
showError.mockReset(); showError.mockReset();
}); });
afterEach(() => { it('hides invite button visibility based on user permissions', async () => {
cleanupAppModals(); const pinia = createTestingPinia({ initialState: getInitialState() });
const userStore = useUsersStore(pinia);
// @ts-expect-error: mocked getter
userStore.currentUser = createUser({ isDefaultUser: true });
const { queryByTestId } = renderView({ pinia });
expect(queryByTestId('settings-users-invite-button')).not.toBeInTheDocument();
}); });
it('should show confirmation modal before deleting user and delete with transfer', async () => { describe('Below quota', () => {
const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {}); const pinia = createTestingPinia({ initialState: getInitialState() });
const { getByTestId } = renderComponent({ pinia }); const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isBelowUserQuota = false;
const userListItem = getByTestId(`user-list-item-${users[0].email}`); it('disables the invite button', async () => {
expect(userListItem).toBeInTheDocument(); const { getByTestId } = renderView({ pinia });
const actionToggle = within(userListItem).getByTestId('action-toggle'); expect(getByTestId('settings-users-invite-button')).toBeDisabled();
const actionToggleButton = within(actionToggle).getByRole('button'); });
expect(actionToggleButton).toBeVisible();
await userEvent.click(actionToggle); it('allows the user to upgrade', async () => {
const actionToggleId = actionToggleButton.getAttribute('aria-controls'); const { getByTestId } = renderView({ pinia });
const uiStore = useUIStore();
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; const actionBox = getByTestId('action-box');
const actionDelete = within(actionDropdown).getByTestId('action-delete'); expect(actionBox).toBeInTheDocument();
await userEvent.click(actionDelete);
const modal = getByTestId('deleteUser-modal'); await userEvent.click(await within(actionBox).findByText('View plans'));
expect(modal).toBeVisible();
const confirmButton = within(modal).getByTestId('confirm-delete-user-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[0]); expect(uiStore.goToUpgrade).toHaveBeenCalledWith('settings-users', 'upgrade-users');
const projectSelect = getByTestId('project-sharing-select');
expect(projectSelect).toBeVisible();
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteUserSpy).toHaveBeenCalledWith({
id: users[0].id,
transferId: expect.any(String),
}); });
}); });
it('should show confirmation modal before deleting user and delete without transfer', async () => { it('disables the invite button on SAML login', async () => {
const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {}); const pinia = createTestingPinia({ initialState: getInitialState() });
const ssoStore = useSSOStore(pinia);
ssoStore.isSamlLoginEnabled = true;
const { getByTestId } = renderComponent({ pinia }); const { getByTestId } = renderView({ pinia });
const userListItem = getByTestId(`user-list-item-${users[0].email}`); expect(getByTestId('settings-users-invite-button')).toBeDisabled();
expect(userListItem).toBeInTheDocument(); });
const actionToggle = within(userListItem).getByTestId('action-toggle'); it('shows the invite modal', async () => {
const actionToggleButton = within(actionToggle).getByRole('button'); const pinia = createTestingPinia({ initialState: getInitialState() });
expect(actionToggleButton).toBeVisible(); const { getByTestId } = renderView({ pinia });
await userEvent.click(actionToggle); const uiStore = useUIStore();
const actionToggleId = actionToggleButton.getAttribute('aria-controls'); await userEvent.click(getByTestId('settings-users-invite-button'));
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement; expect(uiStore.openModal).toHaveBeenCalledWith('inviteUser');
const actionDelete = within(actionDropdown).getByTestId('action-delete'); });
await userEvent.click(actionDelete);
const modal = getByTestId('deleteUser-modal'); it('shows warning when advanced permissions are not enabled', async () => {
expect(modal).toBeVisible(); const pinia = createTestingPinia({
const confirmButton = within(modal).getByTestId('confirm-delete-user-button'); initialState: getInitialState({
expect(confirmButton).toBeDisabled(); [STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: false } } },
}),
});
await userEvent.click(within(modal).getAllByRole('radio')[1]); const { getByText } = renderView({ pinia });
const input = within(modal).getByRole('textbox'); expect(getByText('to unlock the ability to create additional admin users'));
});
await userEvent.type(input, 'delete all '); describe('per user actions', () => {
expect(confirmButton).toBeDisabled(); it('should copy invite link to clipboard', async () => {
const action = 'copyInviteLink';
await userEvent.type(input, 'data'); const pinia = createTestingPinia({ initialState: getInitialState() });
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton); const { getByTestId } = renderView({ pinia });
expect(deleteUserSpy).toHaveBeenCalledWith({
id: users[0].id, await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
expect(copy).toHaveBeenCalledWith(invitedUser.inviteAcceptUrl);
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should re invite users', async () => {
const action = 'reinvite';
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isSmtpSetup = true;
const userStore = useUsersStore();
const { getByTestId } = renderView({ pinia });
await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
expect(userStore.reinviteUser).toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should show delete users modal with the right permissions', async () => {
const action = 'delete';
const pinia = createTestingPinia({ initialState: getInitialState() });
const rbacStore = mockedStore(useRBACStore);
rbacStore.hasScope.mockReturnValue(true);
const { getByTestId } = renderView({ pinia });
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
const uiStore = useUIStore();
expect(uiStore.openDeleteUserModal).toHaveBeenCalledWith(user.id);
});
it('should allow coping reset password link', async () => {
const action = 'copyPasswordResetLink';
const pinia = createTestingPinia({ initialState: getInitialState() });
const rbacStore = mockedStore(useRBACStore);
rbacStore.hasScope.mockReturnValue(true);
const userStore = mockedStore(useUsersStore);
userStore.getUserPasswordResetLink.mockResolvedValue({ link: 'dummy-reset-password' });
const { getByTestId } = renderView({ pinia });
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
expect(userStore.getUserPasswordResetLink).toHaveBeenCalledWith(user);
expect(copy).toHaveBeenCalledWith('dummy-reset-password');
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should enable SSO manual login', async () => {
const action = 'allowSSOManualLogin';
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isSamlLoginEnabled = true;
const userStore = useUsersStore();
const { getByTestId } = renderView({ pinia });
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
expect(userStore.updateOtherUserSettings).toHaveBeenCalledWith(user.id, {
allowSSOManualLogin: true,
});
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should disable SSO manual login', async () => {
const action = 'disallowSSOManualLogin';
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isSamlLoginEnabled = true;
const userStore = useUsersStore();
const { getByTestId } = renderView({ pinia });
await triggerUserAction(getByTestId(`user-list-item-${userWithDisabledSSO.email}`), action);
expect(userStore.updateOtherUserSettings).toHaveBeenCalledWith(userWithDisabledSSO.id, {
allowSSOManualLogin: false,
});
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
}); });
}); });
it("should show success toast when changing a user's role", async () => { it("should show success toast when changing a user's role", async () => {
const updateGlobalRoleSpy = vi.spyOn(usersStore, 'updateGlobalRole').mockResolvedValue(); const pinia = createTestingPinia({ initialState: getInitialState() });
const { getByTestId } = createComponentRenderer(SettingsUsersView)({ const rbacStore = mockedStore(useRBACStore);
pinia, rbacStore.hasScope.mockReturnValue(true);
});
const userListItem = getByTestId(`user-list-item-${users.at(-1)?.email}`); const userStore = useUsersStore();
const { getByTestId } = renderView({ pinia });
const userListItem = getByTestId(`user-list-item-${invitedUser.email}`);
expect(userListItem).toBeInTheDocument(); expect(userListItem).toBeInTheDocument();
const roleSelect = within(userListItem).getByTestId('user-role-select'); const roleSelect = within(userListItem).getByTestId('user-role-select');
@ -185,7 +278,7 @@ describe('SettingsUsersView', () => {
const roleDropdownItems = await getDropdownItems(roleSelect); const roleDropdownItems = await getDropdownItems(roleSelect);
await userEvent.click(roleDropdownItems[0]); await userEvent.click(roleDropdownItems[0]);
expect(updateGlobalRoleSpy).toHaveBeenCalledWith( expect(userStore.updateGlobalRole).toHaveBeenCalledWith(
expect.objectContaining({ newRoleName: 'global:member' }), expect.objectContaining({ newRoleName: 'global:member' }),
); );