diff --git a/src/web/hooks/use-query/__tests__/Groups.test.tsx b/src/web/hooks/use-query/__tests__/Groups.test.tsx new file mode 100644 index 0000000000..7b4f839651 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Groups.test.tsx @@ -0,0 +1,106 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import {createSession} from 'gmp/testing'; +import {useGetAllGroups, useGetGroups} from 'web/hooks/use-query/groups'; + +const filter = QueryFilter.fromString('name~group'); +const groups = [{id: 'group-1', name: 'Group 1'}]; + +const createGmp = () => { + const response = { + data: groups, + meta: { + filter, + counts: new CollectionCounts({all: 1, filtered: 1, length: 1}), + }, + }; + + return { + session: createSession({token: 'test-token'}), + settings: {}, + groups: { + get: testing.fn().mockResolvedValue(response), + getAll: testing.fn().mockResolvedValue(response), + }, + }; +}; + +describe('group query hooks', () => { + test('should fetch groups with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetGroups({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('group')).toHaveTextContent('Group 1'); + }); + + expect(gmp.groups.get).toHaveBeenCalledWith({filter}); + expect(gmp.groups.getAll).not.toHaveBeenCalled(); + }); + + test('should fetch all groups with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetAllGroups({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('group')).toHaveTextContent('Group 1'); + }); + + expect(gmp.groups.getAll).toHaveBeenCalledWith({filter}); + expect(gmp.groups.get).not.toHaveBeenCalled(); + }); + + test('should not fetch groups when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetGroups({enabled: false}); + useGetAllGroups({enabled: false}); + return
; + }; + + render(); + + expect(gmp.groups.get).not.toHaveBeenCalled(); + expect(gmp.groups.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch groups without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetGroups({filter}); + useGetAllGroups({filter}); + return
; + }; + + render(); + + expect(gmp.groups.get).not.toHaveBeenCalled(); + expect(gmp.groups.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/use-query/__tests__/Roles.test.tsx b/src/web/hooks/use-query/__tests__/Roles.test.tsx new file mode 100644 index 0000000000..bcad03a074 --- /dev/null +++ b/src/web/hooks/use-query/__tests__/Roles.test.tsx @@ -0,0 +1,106 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import {createSession} from 'gmp/testing'; +import {useGetAllRoles, useGetRoles} from 'web/hooks/use-query/roles'; + +const filter = QueryFilter.fromString('name~role'); +const roles = [{id: 'role-1', name: 'Role 1'}]; + +const createGmp = () => { + const response = { + data: roles, + meta: { + filter, + counts: new CollectionCounts({all: 1, filtered: 1, length: 1}), + }, + }; + + return { + session: createSession({token: 'test-token'}), + settings: {}, + roles: { + get: testing.fn().mockResolvedValue(response), + getAll: testing.fn().mockResolvedValue(response), + }, + }; +}; + +describe('role query hooks', () => { + test('should fetch roles with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetRoles({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('role')).toHaveTextContent('Role 1'); + }); + + expect(gmp.roles.get).toHaveBeenCalledWith({filter}); + expect(gmp.roles.getAll).not.toHaveBeenCalled(); + }); + + test('should fetch all roles with a filter', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + const {data} = useGetAllRoles({filter}); + return
{data?.entities[0]?.name}
; + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('role')).toHaveTextContent('Role 1'); + }); + + expect(gmp.roles.getAll).toHaveBeenCalledWith({filter}); + expect(gmp.roles.get).not.toHaveBeenCalled(); + }); + + test('should not fetch roles when disabled', () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetRoles({enabled: false}); + useGetAllRoles({enabled: false}); + return
; + }; + + render(); + + expect(gmp.roles.get).not.toHaveBeenCalled(); + expect(gmp.roles.getAll).not.toHaveBeenCalled(); + }); + + test('should not fetch roles without a session token', () => { + const gmp = createGmp(); + gmp.session.token = undefined; + const {render} = rendererWith({gmp, router: true}); + + const TestComponent = () => { + useGetRoles({filter}); + useGetAllRoles({filter}); + return
; + }; + + render(); + + expect(gmp.roles.get).not.toHaveBeenCalled(); + expect(gmp.roles.getAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/hooks/use-query/groups.ts b/src/web/hooks/use-query/groups.ts new file mode 100644 index 0000000000..136960abab --- /dev/null +++ b/src/web/hooks/use-query/groups.ts @@ -0,0 +1,36 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type FilterType} from 'gmp/models/filter'; +import type Group from 'gmp/models/group'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetGroupsParams { + enabled?: boolean; + filter?: FilterType; +} + +export const useGetGroups = ({filter, enabled}: UseGetGroupsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.groups.get.bind(gmp.groups), + enabled, + queryId: 'get_groups', + filter, + keepPreviousData: true, + }); +}; + +export const useGetAllGroups = ({filter, enabled}: UseGetGroupsParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.groups.getAll.bind(gmp.groups), + enabled, + queryId: 'get_groups', + filter, + keepPreviousData: true, + }); +}; diff --git a/src/web/hooks/use-query/roles.ts b/src/web/hooks/use-query/roles.ts new file mode 100644 index 0000000000..85952613e8 --- /dev/null +++ b/src/web/hooks/use-query/roles.ts @@ -0,0 +1,36 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type FilterType} from 'gmp/models/filter'; +import type Role from 'gmp/models/role'; +import useGmp from 'web/hooks/useGmp'; +import useGetEntities from 'web/queries/useGetEntities'; + +interface UseGetRolesParams { + enabled?: boolean; + filter?: FilterType; +} + +export const useGetRoles = ({filter, enabled}: UseGetRolesParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.roles.get.bind(gmp.roles), + enabled, + queryId: 'get_roles', + filter, + keepPreviousData: true, + }); +}; + +export const useGetAllRoles = ({filter, enabled}: UseGetRolesParams = {}) => { + const gmp = useGmp(); + return useGetEntities({ + gmpMethod: gmp.roles.getAll.bind(gmp.roles), + enabled, + queryId: 'get_roles', + filter, + keepPreviousData: true, + }); +}; diff --git a/src/web/pages/users/UserComponent.tsx b/src/web/pages/users/UserComponent.tsx index 179942c837..45d83b09ea 100644 --- a/src/web/pages/users/UserComponent.tsx +++ b/src/web/pages/users/UserComponent.tsx @@ -5,7 +5,6 @@ import {type ReactNode, useState} from 'react'; import {type EntityActionData} from 'gmp/commands/entity'; -import type Model from 'gmp/models/model'; import type Settings from 'gmp/models/settings'; import type User from 'gmp/models/user'; import {isDefined} from 'gmp/utils/identity'; @@ -67,12 +66,10 @@ const UserComponent = ({ const [accessHosts, setAccessHosts] = useState([]); const [comment, setComment] = useState(); const [groupIds, setGroupIds] = useState(); - const [groups, setGroups] = useState(); const [hostsAllow, setHostsAllow] = useState(); const [name, setName] = useState(); const [oldName, setOldName] = useState(); const [roleIds, setRoleIds] = useState(); - const [roles, setRoles] = useState(); const [settings, setSettings] = useState(); const [title, setTitle] = useState(); const [user, setUser] = useState(); @@ -111,17 +108,9 @@ const UserComponent = ({ const openUserDialog = async (user?: User) => { try { - const [groupsResponse, rolesResponse, authSettingsResponse] = - await Promise.all([ - gmp.groups.getAll({ - filter: 'permission=modify_group', // list only groups current user may modify - }), - gmp.roles.getAll(), - gmp.user.currentAuthSettings(), - ]); - - setGroups(groupsResponse.data); - setRoles(rolesResponse.data); + const [authSettingsResponse] = await Promise.all([ + gmp.user.currentAuthSettings(), + ]); const settings = authSettingsResponse.data; setSettings(settings); @@ -200,12 +189,10 @@ const UserComponent = ({ accessHosts={accessHosts} comment={comment} groupIds={groupIds} - groups={groups} hostsAllow={hostsAllow} name={name} oldName={oldName} roleIds={roleIds} - roles={roles} settings={settings} title={title} user={user} diff --git a/src/web/pages/users/UsersDialog.tsx b/src/web/pages/users/UsersDialog.tsx index 0c9a47cb85..fc3f79e647 100644 --- a/src/web/pages/users/UsersDialog.tsx +++ b/src/web/pages/users/UsersDialog.tsx @@ -4,7 +4,7 @@ */ import {useState} from 'react'; -import type Model from 'gmp/models/model'; +import QueryFilter from 'gmp/models/filter/query-filter'; import type Settings from 'gmp/models/settings'; import { ACCESS_ALLOW_ALL, @@ -27,6 +27,8 @@ import PasswordField from 'web/components/form/PasswordField'; import Radio from 'web/components/form/Radio'; import TextField from 'web/components/form/TextField'; import Row from 'web/components/layout/Row'; +import {useGetAllGroups} from 'web/hooks/use-query/groups'; +import {useGetAllRoles} from 'web/hooks/use-query/roles'; import useCapabilities from 'web/hooks/useCapabilities'; import useTranslation from 'web/hooks/useTranslation'; import useUserName from 'web/hooks/useUserName'; @@ -58,13 +60,11 @@ interface UserDialogProps { roleIds?: string[]; accessHosts?: string[]; comment?: string; - groups?: Model[]; groupIds?: string[]; hostsAllow?: string; name?: string; oldName?: string; password?: string; - roles?: Model[]; settings: Settings; title?: string; user?: User; @@ -76,13 +76,11 @@ const UsersDialog = ({ roleIds: initialRoleIds = [], accessHosts = [], comment = '', - groups, groupIds = [], hostsAllow = ACCESS_ALLOW_ALL, name, oldName, password = '', - roles, settings, title, user, @@ -104,6 +102,12 @@ const UsersDialog = ({ >(undefined); const [roleIds, setRoleIds] = useState(initialRoleIds); + const groupsQuery = useGetAllGroups({ + filter: QueryFilter.fromString('permission=modify_group'), // list only groups current user may modify + }); + + const rolesQuery = useGetAllRoles(); + const dialogName = name || _('Unnamed'); const dialogTitle = title || _('New User'); @@ -184,12 +188,12 @@ const UsersDialog = ({ roleIds, }; - const rolesOptions = map(roles, role => ({ + const rolesOptions = map(rolesQuery.data?.entities ?? [], role => ({ label: role.name ?? '', value: role.id ?? '', })); - const groupsOptions = map(groups, group => ({ + const groupsOptions = map(groupsQuery.data?.entities ?? [], group => ({ label: group.name ?? '', value: group.id ?? '', })); @@ -314,6 +318,7 @@ const UsersDialog = ({ {capabilities.mayAccess('role') && ( ({ + settings: { + reloadInterval: 0, + reloadIntervalActive: 0, + reloadIntervalInactive: 0, + }, user: { create: testing.fn().mockResolvedValue({data: {id: 'created'}}), save: testing.fn().mockResolvedValue({data: {id: 'saved'}}), @@ -68,12 +74,24 @@ const createGmp = () => ({ .mockResolvedValue(currentSettingsDefaultResponse), }, groups: { - getAll: testing.fn().mockResolvedValue({data: groups}), + getAll: testing.fn().mockResolvedValue({ + data: groups, + meta: { + filter: undefined, + counts: new CollectionCounts({all: groups.length}), + }, + }), }, roles: { - getAll: testing.fn().mockResolvedValue({data: roles}), + getAll: testing.fn().mockResolvedValue({ + data: roles, + meta: { + filter: undefined, + counts: new CollectionCounts({all: roles.length}), + }, + }), }, - session: createSession({username: 'admin'}), + session: createSession({username: 'admin', token: 'test-token'}), }); describe('UserComponent', () => { @@ -88,8 +106,9 @@ describe('UserComponent', () => { }); test('should open and close user dialog', async () => { + const gmp = createGmp(); const {render} = rendererWith({ - gmp: createGmp(), + gmp, capabilities: true, store: true, }); @@ -101,6 +120,10 @@ describe('UserComponent', () => { fireEvent.click(screen.getByTestId('open')); await screen.findByText('New User'); + await waitFor(() => { + expect(gmp.groups.getAll).toHaveBeenCalled(); + expect(gmp.roles.getAll).toHaveBeenCalled(); + }); fireEvent.click(screen.getDialogCloseButton()); await waitFor(() => { @@ -121,6 +144,10 @@ describe('UserComponent', () => { fireEvent.click(screen.getByTestId('open')); await screen.findByText('Edit User user 1'); + await waitFor(() => { + expect(gmp.groups.getAll).toHaveBeenCalled(); + expect(gmp.roles.getAll).toHaveBeenCalled(); + }); fireEvent.click(screen.getDialogSaveButton()); await waitFor(() => { @@ -158,6 +185,10 @@ describe('UserComponent', () => { fireEvent.click(screen.getByTestId('open')); await screen.findByText('Edit User user 2'); + await waitFor(() => { + expect(gmp.groups.getAll).toHaveBeenCalled(); + expect(gmp.roles.getAll).toHaveBeenCalled(); + }); fireEvent.click(screen.getDialogSaveButton()); await waitFor(() => { @@ -181,6 +212,7 @@ describe('UserComponent', () => { const gmp = createGmp(); const error = new Error('Unable to load authentication settings'); gmp.user.currentAuthSettings.mockRejectedValue(error); + testing.spyOn(console, 'error').mockImplementation(() => {}); const onDialogError = testing.fn(); const {render} = rendererWith({gmp, capabilities: true, store: true}); diff --git a/src/web/pages/users/__tests__/UsersDialog.test.tsx b/src/web/pages/users/__tests__/UsersDialog.test.tsx index cad0129374..5c07eab7cd 100644 --- a/src/web/pages/users/__tests__/UsersDialog.test.tsx +++ b/src/web/pages/users/__tests__/UsersDialog.test.tsx @@ -9,8 +9,11 @@ import { fireEvent, rendererWith, screen, + waitFor, within, + type RendererOptions, } from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; import Group from 'gmp/models/group'; import Role from 'gmp/models/role'; import Settings from 'gmp/models/settings'; @@ -34,15 +37,42 @@ const createSettings = ({ldap = false, radius = false} = {}) => { return settings; }; -const renderDialog = (props = {}, options = {}) => { +const createGmp = () => ({ + session: createSession({username: 'admin', token: 'test-token'}), + settings: { + reloadInterval: 0, + reloadIntervalActive: 0, + reloadIntervalInactive: 0, + }, + groups: { + getAll: testing.fn().mockResolvedValue({ + data: groups, + meta: { + filter: undefined, + counts: new CollectionCounts({all: groups.length}), + }, + }), + }, + roles: { + getAll: testing.fn().mockResolvedValue({ + data: roles, + meta: { + filter: undefined, + counts: new CollectionCounts({all: roles.length}), + }, + }), + }, +}); + +const renderDialog = (props = {}, options: RendererOptions = {}) => { const onClose = testing.fn(); const onSave = testing.fn(); + const gmp = createGmp(); + const {gmp: optionGmp, ...rendererOptions} = options; const {render} = rendererWith({ - capabilities: true, - gmp: { - session: createSession({username: 'admin'}), - }, - ...options, + ...rendererOptions, + capabilities: options.capabilities ?? true, + gmp: {...gmp, ...optionGmp}, }); render( @@ -54,7 +84,7 @@ const renderDialog = (props = {}, options = {}) => { />, ); - return {onClose, onSave}; + return {gmp, onClose, onSave}; }; describe('UsersDialog tests', () => { @@ -91,8 +121,6 @@ describe('UsersDialog tests', () => { oldName: undefined, password: '', roleIds: [], - roles: undefined, - groups: undefined, }); }); @@ -125,15 +153,34 @@ describe('UsersDialog tests', () => { ).toBeInTheDocument(); }); - test('saves a user after selecting a role and group', () => { - const {onSave} = renderDialog({roles, groups}); + test('saves a user after selecting a role and group', async () => { + const {gmp, onSave} = renderDialog(); const dialog = within(screen.getDialog()); + await waitFor(() => { + expect(gmp.roles.getAll).toHaveBeenCalled(); + expect(gmp.groups.getAll).toHaveBeenCalled(); + }); + await waitFor(() => { + const selects = dialog.getAllByTestId('multi-select'); + expect(selects[0]).not.toBeDisabled(); + expect(selects[1]).not.toBeDisabled(); + }); const selects = dialog.getAllByTestId('multi-select'); fireEvent.click(selects[0]); - fireEvent.click(screen.getByRole('option', {name: 'Administrator'})); + const roleOptions = screen.getSelectItemElementsForMultiSelect(); + fireEvent.click( + roleOptions.find( + option => option.textContent === 'Administrator', + ) as HTMLElement, + ); fireEvent.click(selects[1]); - fireEvent.click(screen.getByRole('option', {name: 'Security'})); + const groupOptions = screen.getSelectItemElementsForMultiSelect(); + fireEvent.click( + groupOptions.find( + option => option.textContent === 'Security', + ) as HTMLElement, + ); fireEvent.click(screen.getDialogSaveButton()); @@ -204,10 +251,7 @@ describe('UsersDialog tests', () => { test('confirms saving changes to the current Super Admin user', () => { const user = new User({id: 'admin-id', name: 'admin'}); - const {onSave} = renderDialog( - {user, name: 'admin', roleIds: ['role-1']}, - {gmp: {session: createSession({username: 'admin'})}}, - ); + const {onSave} = renderDialog({user, name: 'admin', roleIds: ['role-1']}); fireEvent.click(screen.getDialogSaveButton()); expect(screen.getByText('Save Super Admin User')).toBeInTheDocument(); @@ -225,7 +269,7 @@ describe('UsersDialog tests', () => { }); test('hides role and group fields without capabilities', () => { - renderDialog({roles, groups}, {capabilities: false}); + renderDialog({}, {capabilities: false}); expect(screen.queryByText('Roles')).toBeNull(); expect(screen.queryByText('Groups')).toBeNull();