diff --git a/src/gmp/__tests__/gmp.test.ts b/src/gmp/__tests__/gmp.test.ts index d2f2d51a27..e454fe2622 100644 --- a/src/gmp/__tests__/gmp.test.ts +++ b/src/gmp/__tests__/gmp.test.ts @@ -217,6 +217,8 @@ describe('Gmp tests', () => { 'nvts', 'ociimagetarget', 'ociimagetargets', + 'override', + 'overrides', 'performance', 'webapplicationtarget', 'webapplicationtargets', @@ -270,8 +272,6 @@ describe('Gmp tests', () => { 'license', 'operatingsystem', 'operatingsystems', - 'override', - 'overrides', 'scanconfig', 'scanconfigs', 'schedule', diff --git a/src/gmp/commands/__tests__/override.test.ts b/src/gmp/commands/__tests__/override.test.ts new file mode 100644 index 0000000000..3e9952554a --- /dev/null +++ b/src/gmp/commands/__tests__/override.test.ts @@ -0,0 +1,159 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; +import OverrideCommand from 'gmp/commands/override'; +import { + createHttp, + createEntityResponse, + createActionResultResponse, +} from 'gmp/commands/testing'; +import { + ACTIVE_YES_FOR_NEXT_VALUE, + ACTIVE_YES_UNTIL_VALUE, + ANY, + MANUAL, +} from 'gmp/models/override'; + +describe('OverrideCommand tests', () => { + test('should request single override', async () => { + const response = createEntityResponse('override', {_id: 'foo'}); + const fakeHttp = createHttp(response); + + const cmd = new OverrideCommand(fakeHttp); + const resp = await cmd.get({id: 'foo'}); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_override', + override_id: 'foo', + }, + }); + const {data} = resp; + expect(data.id).toEqual('foo'); + }); + + test('should create a simple override', async () => { + const response = createActionResultResponse(); + const fakeHttp = createHttp(response); + + const cmd = new OverrideCommand(fakeHttp); + const resp = await cmd.create({ + text: 'override text', + oid: 'oid', + }); + expect(fakeHttp.request).toHaveBeenCalledWith('post', { + data: { + cmd: 'create_override', + new_severity: -1, + oid: 'oid', + text: 'override text', + }, + }); + const {data} = resp; + expect(data.id).toEqual('foo'); + }); + + test('should create an override with details', async () => { + const response = createActionResultResponse(); + const fakeHttp = createHttp(response); + + const cmd = new OverrideCommand(fakeHttp); + const resp = await cmd.create({ + active: ACTIVE_YES_FOR_NEXT_VALUE, + customSeverity: true, + days: 15, + hostsManual: 'host1,host2', + hosts: MANUAL, + newSeverity: 4.5, + oid: 'oid', + port: MANUAL, + portManual: '22/tcp', + resultId: MANUAL, + resultUuid: 'result-uuid', + severity: 'High', + taskId: MANUAL, + taskUuid: 'task-uuid', + text: 'override text', + }); + expect(fakeHttp.request).toHaveBeenCalledWith('post', { + data: { + active: '1', + cmd: 'create_override', + hosts: 'host1,host2', + new_severity: 4.5, + oid: 'oid', + port: '22/tcp', + result_id: 'result-uuid', + severity: 'High', + task_id: 'task-uuid', + text: 'override text', + }, + }); + const {data} = resp; + expect(data.id).toEqual('foo'); + }); + + test('should save a simple override', async () => { + const response = createActionResultResponse(); + const fakeHttp = createHttp(response); + + const cmd = new OverrideCommand(fakeHttp); + const resp = await cmd.save({ + id: 'foo', + text: 'updated override text', + oid: 'oid', + }); + expect(fakeHttp.request).toHaveBeenCalledWith('post', { + data: { + cmd: 'save_override', + new_severity: -1, + oid: 'oid', + override_id: 'foo', + text: 'updated override text', + }, + }); + const {data} = resp; + expect(data.id).toEqual('foo'); + }); + + test('should allow to save an override with details', async () => { + const response = createActionResultResponse(); + const fakeHttp = createHttp(response); + + const cmd = new OverrideCommand(fakeHttp); + const resp = await cmd.save({ + id: 'foo', + active: ACTIVE_YES_UNTIL_VALUE, + days: 15, + hostsManual: 'host1,host2', + hosts: MANUAL, + newSeverityFromList: 0, + oid: 'oid', + port: MANUAL, + portManual: '22/tcp', + resultId: ANY, + resultUuid: 'result-uuid', + severity: 'High', + taskId: ANY, + taskUuid: 'task-uuid', + text: 'updated override text', + }); + expect(fakeHttp.request).toHaveBeenCalledWith('post', { + data: { + active: 15, + cmd: 'save_override', + hosts: 'host1,host2', + new_severity: 0, + oid: 'oid', + override_id: 'foo', + port: '22/tcp', + severity: 'High', + text: 'updated override text', + }, + }); + const {data} = resp; + expect(data.id).toEqual('foo'); + }); +}); diff --git a/src/gmp/commands/__tests__/overrides.test.ts b/src/gmp/commands/__tests__/overrides.test.ts new file mode 100644 index 0000000000..b4cb979593 --- /dev/null +++ b/src/gmp/commands/__tests__/overrides.test.ts @@ -0,0 +1,155 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect} from '@gsa/testing'; +import OverridesCommand from 'gmp/commands/overrides'; +import { + createHttp, + createEntitiesResponse, + createAggregatesResponse, +} from 'gmp/commands/testing'; +import Override from 'gmp/models/override'; + +describe('OverridesCommand tests', () => { + test('should fetch overrides with default params', async () => { + const response = createEntitiesResponse('override', [ + {_id: '1', text: 'Override 1'}, + {_id: '2', text: 'Override 2'}, + ]); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.get(); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: {cmd: 'get_overrides', details: 1}, + }); + expect(result.data).toEqual([ + new Override({id: '1', text: 'Override 1'}), + new Override({id: '2', text: 'Override 2'}), + ]); + }); + + test('should fetch overrides with custom filter', async () => { + const response = createEntitiesResponse('override', [ + {_id: '3', text: 'Custom Override'}, + ]); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.get({filter: "text='Custom Override'"}); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_overrides', + filter: "text='Custom Override'", + details: 1, + }, + }); + expect(result.data).toEqual([ + new Override({id: '3', text: 'Custom Override'}), + ]); + }); + + test('should fetch all overrides', async () => { + const response = createEntitiesResponse('override', [ + {_id: '1', text: 'Override 1'}, + {_id: '2', text: 'Override 2'}, + ]); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.getAll(); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: {cmd: 'get_overrides', filter: 'first=1 rows=-1', details: 1}, + }); + expect(result.data).toEqual([ + new Override({id: '1', text: 'Override 1'}), + new Override({id: '2', text: 'Override 2'}), + ]); + }); + + test('should fetch active days aggregates', async () => { + const response = createAggregatesResponse({ + group: [ + {value: 1, count: 5}, + {value: 2, count: 3}, + ], + }); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.getActiveDaysAggregates(); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_aggregate', + aggregate_type: 'override', + details: 1, + group_column: 'active_days', + max_groups: '250', + }, + }); + expect(result.data).toEqual({ + groups: [ + {value: 1, count: 5}, + {value: 2, count: 3}, + ], + }); + }); + + test('should fetch created aggregates', async () => { + const response = createAggregatesResponse({ + group: [ + {value: '2024-01-01', count: 10}, + {value: '2024-01-02', count: 7}, + ], + }); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.getCreatedAggregates(); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_aggregate', + aggregate_type: 'override', + details: 1, + group_column: 'created', + aggregate_mode: 'count', + }, + }); + expect(result.data).toEqual({ + groups: [ + {value: '2024-01-01', count: 10}, + {value: '2024-01-02', count: 7}, + ], + }); + }); + + test('should fetch word counts aggregates', async () => { + const response = createAggregatesResponse({ + group: [ + {value: 'vulnerability', count: 15}, + {value: 'false positive', count: 8}, + ], + }); + const fakeHttp = createHttp(response); + + const cmd = new OverridesCommand(fakeHttp); + const result = await cmd.getWordCountsAggregates(); + expect(fakeHttp.request).toHaveBeenCalledWith('get', { + args: { + cmd: 'get_aggregate', + aggregate_type: 'override', + details: 1, + group_column: 'text', + aggregate_mode: 'word_counts', + }, + }); + expect(result.data).toEqual({ + groups: [ + {value: 'vulnerability', count: 15}, + {value: 'false positive', count: 8}, + ], + }); + }); +}); diff --git a/src/gmp/commands/override.ts b/src/gmp/commands/override.ts new file mode 100644 index 0000000000..74513d5cd1 --- /dev/null +++ b/src/gmp/commands/override.ts @@ -0,0 +1,103 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import EntityCommand from 'gmp/commands/entity'; +import type Http from 'gmp/http/http'; +import {type XmlResponseData} from 'gmp/http/transform/fast-xml'; +import Override, { + ACTIVE_YES_UNTIL_VALUE, + ANY, + DEFAULT_DAYS, + MANUAL, + SEVERITY_FALSE_POSITIVE, + type Active, + type AnyOrManual, + type OverrideElement, +} from 'gmp/models/override'; + +interface OverrideCommandCreateParams { + active?: Active; + customSeverity?: boolean; + days?: number; + hostsManual?: string; + hosts?: AnyOrManual; + oid: string; + newSeverity?: number; + newSeverityFromList?: number; + portManual?: string; + port?: AnyOrManual; + resultId?: AnyOrManual; + resultUuid?: string; + severity?: string; + taskId?: AnyOrManual; + taskUuid?: string; + text: string; +} + +interface OverrideCommandSaveParams extends OverrideCommandCreateParams { + id: string; +} + +class OverrideCommand extends EntityCommand { + constructor(http: Http) { + super(http, 'override', Override); + } + + getElementFromRoot(root: XmlResponseData): OverrideElement { + // @ts-expect-error + return root.get_override.get_overrides_response.override; + } + + create(args: OverrideCommandCreateParams) { + return this._save({...args, cmd: 'create_override'}); + } + + save(args: OverrideCommandSaveParams) { + return this._save({...args, cmd: 'save_override'}); + } + + _save( + args: OverrideCommandCreateParams & { + id?: string; + cmd: 'create_override' | 'save_override'; + }, + ) { + const { + cmd, + oid, + id, + active, + days = DEFAULT_DAYS, + hosts = ANY, + hostsManual, + resultId, + resultUuid, + port = ANY, + portManual, + severity, + taskId, + taskUuid, + text, + customSeverity = false, + newSeverity, + newSeverityFromList = SEVERITY_FALSE_POSITIVE, + } = args; + return this.action({ + cmd, + oid, + id, + active: active === ACTIVE_YES_UNTIL_VALUE ? days : active, + new_severity: customSeverity ? newSeverity : newSeverityFromList, + hosts: hosts === MANUAL ? hostsManual : undefined, + result_id: resultId === MANUAL ? resultUuid : undefined, + task_id: taskId === MANUAL ? taskUuid : undefined, + port: port === MANUAL ? portManual : undefined, + severity, + text, + }); + } +} + +export default OverrideCommand; diff --git a/src/gmp/commands/overrides.js b/src/gmp/commands/overrides.js deleted file mode 100644 index 1205e756c7..0000000000 --- a/src/gmp/commands/overrides.js +++ /dev/null @@ -1,122 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import registerCommand from 'gmp/command'; -import EntitiesCommand from 'gmp/commands/entities'; -import EntityCommand from 'gmp/commands/entity'; -import logger from 'gmp/log'; -import Override, { - ANY, - MANUAL, - ACTIVE_YES_ALWAYS_VALUE, - DEFAULT_DAYS, - SEVERITY_FALSE_POSITIVE, -} from 'gmp/models/override'; -import {NO_VALUE} from 'gmp/parser'; - -const log = logger.getLogger('gmp.commands.overrides'); - -class OverrideCommand extends EntityCommand { - constructor(http) { - super(http, 'override', Override); - } - - getElementFromRoot(root) { - return root.get_override.get_overrides_response.override; - } - - create(args) { - return this._save({...args, cmd: 'create_override'}); - } - - save(args) { - return this._save({...args, cmd: 'save_override'}); - } - - _save(args) { - const { - cmd, - oid, - id, - active = ACTIVE_YES_ALWAYS_VALUE, - days = DEFAULT_DAYS, - hosts = ANY, - hosts_manual = '', - result_id = '', - result_uuid = '', - port = ANY, - port_manual = '', - severity = '', - task_id = '', - task_uuid = '', - text, - custom_severity = NO_VALUE, - newSeverity = '', - new_severity_from_list = SEVERITY_FALSE_POSITIVE, - } = args; - log.debug('Saving override', args); - return this.action({ - cmd, - oid, - id, - active, - custom_severity, - new_severity: newSeverity, - new_severity_from_list, - days, - hosts: hosts === MANUAL ? '--' : '', - hosts_manual, - result_id, - result_uuid, - task_id, - task_uuid, - port: port === MANUAL ? '--' : '', - port_manual, - severity, - text, - }); - } -} - -class OverridesCommand extends EntitiesCommand { - constructor(http) { - super(http, 'override', Override); - this.setDefaultParam('details', 1); - } - - getEntitiesResponse(root) { - return root.get_overrides.get_overrides_response; - } - - getActiveDaysAggregates({filter} = {}) { - return this.getAggregates({ - aggregate_type: 'override', - group_column: 'active_days', - filter, - maxGroups: 250, - }); - } - - getCreatedAggregates({filter} = {}) { - return this.getAggregates({ - aggregate_type: 'override', - group_column: 'created', - aggregate_mode: 'count', - filter, - }); - } - - getWordCountsAggregates({filter} = {}) { - return this.getAggregates({ - aggregate_type: 'override', - group_column: 'text', - aggregate_mode: 'word_counts', - filter, - }); - } -} - -registerCommand('override', OverrideCommand); -registerCommand('overrides', OverridesCommand); diff --git a/src/gmp/commands/overrides.ts b/src/gmp/commands/overrides.ts new file mode 100644 index 0000000000..2da94cc70c --- /dev/null +++ b/src/gmp/commands/overrides.ts @@ -0,0 +1,55 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import EntitiesCommand from 'gmp/commands/entities'; +import type Http from 'gmp/http/http'; +import type Filter from 'gmp/models/filter'; +import {type Element} from 'gmp/models/model'; +import Override from 'gmp/models/override'; + +interface OverrideAggregateParams { + filter?: Filter | string; +} + +class OverridesCommand extends EntitiesCommand { + constructor(http: Http) { + super(http, 'override', Override); + this.setDefaultParam('details', 1); + } + + getEntitiesResponse(root: Element): Element { + // @ts-expect-error + return root.get_overrides.get_overrides_response; + } + + getActiveDaysAggregates({filter}: OverrideAggregateParams = {}) { + return this.getAggregates({ + aggregate_type: 'override', + group_column: 'active_days', + filter, + maxGroups: 250, + }); + } + + getCreatedAggregates({filter}: OverrideAggregateParams = {}) { + return this.getAggregates({ + aggregate_type: 'override', + group_column: 'created', + aggregate_mode: 'count', + filter, + }); + } + + getWordCountsAggregates({filter}: OverrideAggregateParams = {}) { + return this.getAggregates({ + aggregate_type: 'override', + group_column: 'text', + aggregate_mode: 'word_counts', + filter, + }); + } +} + +export default OverridesCommand; diff --git a/src/gmp/gmp.ts b/src/gmp/gmp.ts index 0984962660..0f8bcfe914 100644 --- a/src/gmp/gmp.ts +++ b/src/gmp/gmp.ts @@ -8,7 +8,6 @@ import 'gmp/commands/groups'; import 'gmp/commands/hosts'; import 'gmp/commands/license'; import 'gmp/commands/os'; -import 'gmp/commands/overrides'; import 'gmp/commands/scan-configs'; import 'gmp/commands/schedules'; import 'gmp/commands/tickets'; @@ -51,6 +50,8 @@ import NvtFamiliesCommand from 'gmp/commands/nvt-families'; import NvtsCommand from 'gmp/commands/nvts'; import OciImageTargetCommand from 'gmp/commands/oci-image-target'; import OciImageTargetsCommand from 'gmp/commands/oci-image-targets'; +import OverrideCommand from 'gmp/commands/override'; +import OverridesCommand from 'gmp/commands/overrides'; import PerformanceCommand from 'gmp/commands/performance'; import PermissionCommand from 'gmp/commands/permission'; import PermissionsCommand from 'gmp/commands/permissions'; @@ -149,6 +150,8 @@ class Gmp { public readonly nvts: NvtsCommand; public readonly ociimagetarget: OciImageTargetCommand; public readonly ociimagetargets: OciImageTargetsCommand; + public readonly override: OverrideCommand; + public readonly overrides: OverridesCommand; public readonly performance: PerformanceCommand; public readonly webapplicationtarget: WebApplicationTargetCommand; public readonly webapplicationtargets: WebApplicationTargetsCommand; @@ -254,6 +257,8 @@ class Gmp { this.nvts = new NvtsCommand(this.http); this.ociimagetarget = new OciImageTargetCommand(this.http); this.ociimagetargets = new OciImageTargetsCommand(this.http); + this.override = new OverrideCommand(this.http); + this.overrides = new OverridesCommand(this.http); this.performance = new PerformanceCommand(this.http); this.webapplicationtarget = new WebApplicationTargetCommand(this.http); this.webapplicationtargets = new WebApplicationTargetsCommand(this.http); diff --git a/src/web/pages/overrides/OverrideComponent.jsx b/src/web/pages/overrides/OverrideComponent.jsx deleted file mode 100644 index 55e158ef89..0000000000 --- a/src/web/pages/overrides/OverrideComponent.jsx +++ /dev/null @@ -1,263 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React, {useState} from 'react'; -import { - ANY, - MANUAL, - TASK_ANY, - TASK_SELECTED, - RESULT_ANY, - RESULT_UUID, - ACTIVE_NO_VALUE, - ACTIVE_YES_ALWAYS_VALUE, - ACTIVE_YES_UNTIL_VALUE, -} from 'gmp/models/override'; -import {NO_VALUE, YES_VALUE} from 'gmp/parser'; -import {hasId} from 'gmp/utils/id'; -import {isDefined} from 'gmp/utils/identity'; -import {shorten} from 'gmp/utils/string'; -import EntityComponent from 'web/entity/EntityComponent'; -import useGmp from 'web/hooks/useGmp'; -import useTranslation from 'web/hooks/useTranslation'; -import OverrideDialog from 'web/pages/overrides/Dialog'; -import PropTypes from 'web/utils/PropTypes'; -import { - FALSE_POSITIVE_VALUE, - LOG_VALUE, - MEDIUM_VALUE, - LOW_VALUE, - getSeverityLevelBoundaries, -} from 'web/utils/severity'; - -const OverrideComponent = ({ - children, - onCloned, - onCloneError, - onCreated, - onCreateError, - onDeleted, - onDeleteError, - onDownloaded, - onDownloadError, - onSaved, - onSaveError, -}) => { - const gmp = useGmp(); - const [_] = useTranslation(); - - const severityBoundaries = getSeverityLevelBoundaries( - gmp.settings.severityRating, - ); - - const SEVERITIES_LIST = new Set([ - ...(severityBoundaries.minCritical ? [severityBoundaries.minCritical] : []), - severityBoundaries.minHigh, - MEDIUM_VALUE, - LOW_VALUE, - LOG_VALUE, - FALSE_POSITIVE_VALUE, - ]); - - const [dialogVisible, setDialogVisible] = useState(false); - - const [override, setOverride] = useState(); - const [id, setId] = useState(); - const [active, setActive] = useState(); - - const [severity, setSeverity] = useState(); - const [newSeverity, setNewSeverity] = useState(); - const [newSeverityFromList, setNewSeverityFromList] = useState(); - const [customSeverity, setCustomSeverity] = useState(); - - const [hosts, setHosts] = useState(); - const [hostsManual, setHostsManual] = useState(); - - const [port, setPort] = useState(); - const [portManual, setPortManual] = useState(); - - const [nvtName, setNvtName] = useState(); - const [oid, setOid] = useState(); - - const [resultId, setResultId] = useState(); - const [resultName, setResultName] = useState(); - const [resultUuid, setResultUuid] = useState(); - - const [taskId, setTaskId] = useState(); - const [taskUuid, setTaskUuid] = useState(); - const [tasks, setTasks] = useState(); - - const [text, setText] = useState(); - const [title, setTitle] = useState(); - - const [initialProps, setInitialProps] = useState({}); - - const loadTasks = () => { - gmp.tasks.getAll().then(response => setTasks(response.data)); - }; - - const closeOverrideDialog = () => { - setDialogVisible(false); - }; - - const handleCloseOverrideDialog = () => { - closeOverrideDialog(); - }; - - const openOverrideDialog = (overrideEntity, initial = {}) => { - if (isDefined(overrideEntity)) { - let activeValue = ACTIVE_NO_VALUE; - if (overrideEntity.isActive()) { - if (isDefined(overrideEntity.endTime)) { - activeValue = ACTIVE_YES_UNTIL_VALUE; - } else { - activeValue = ACTIVE_YES_ALWAYS_VALUE; - } - } - - let customSeverityValue = NO_VALUE; - let newSeverityFromListValue; - let newSeverityValue; - - if (SEVERITIES_LIST.has(overrideEntity.newSeverity)) { - newSeverityFromListValue = overrideEntity.newSeverity; - } else { - customSeverityValue = YES_VALUE; - newSeverityValue = overrideEntity.newSeverity; - } - - const {result, task, nvt, hosts: overrideHosts} = overrideEntity; - - setDialogVisible(true); - setId(overrideEntity.id); - setActive(activeValue); - setCustomSeverity(customSeverityValue); - setHosts(overrideHosts.length > 0 ? MANUAL : ANY); - setHostsManual(overrideHosts.join(', ')); - setNewSeverity(newSeverityValue); - setNewSeverityFromList(newSeverityFromListValue); - setNvtName(isDefined(nvt) ? nvt.name : undefined); - setOid(isDefined(nvt) ? nvt.oid : undefined); - setOverride(overrideEntity); - setPort(isDefined(overrideEntity.port) ? MANUAL : ANY); - setPortManual(overrideEntity.port); - setResultId(hasId(result) ? RESULT_UUID : RESULT_ANY); - setResultName(hasId(result) ? result.name : undefined); - setResultUuid(hasId(result) ? result.id : undefined); - setSeverity(overrideEntity.severity); - setTaskId(hasId(task) ? TASK_SELECTED : TASK_ANY); - setTaskUuid(hasId(task) ? task.id : undefined); - setText(overrideEntity.text); - setTitle( - _('Edit Override {{- name}}', { - name: shorten(overrideEntity.text, 20), - }), - ); - setInitialProps({}); - } else { - setDialogVisible(true); - setActive(undefined); - setCustomSeverity(undefined); - setHosts(undefined); - setHostsManual(undefined); - setId(undefined); - setNewSeverity(undefined); - setNewSeverityFromList(undefined); - setNvtName(undefined); - setOid(undefined); - setOverride(undefined); - setPort(undefined); - setPortManual(undefined); - setResultId(undefined); - setResultName(undefined); - setResultUuid(undefined); - setSeverity(undefined); - setTaskId(undefined); - setTaskUuid(undefined); - setText(undefined); - setTitle(undefined); - setInitialProps(initial); - } - - loadTasks(); - }; - - const openCreateOverrideDialog = (initial = {}) => { - openOverrideDialog(undefined, initial); - }; - - return ( - - {({save, create, ...other}) => ( - <> - {children({ - ...other, - create: openCreateOverrideDialog, - edit: openOverrideDialog, - })} - {dialogVisible && ( - { - const promise = isDefined(d.id) ? save(d) : create(d); - return promise.then(() => closeOverrideDialog()); - }} - {...initialProps} - /> - )} - - )} - - ); -}; - -OverrideComponent.propTypes = { - children: PropTypes.func.isRequired, - onCloneError: PropTypes.func, - onCloned: PropTypes.func, - onCreateError: PropTypes.func, - onCreated: PropTypes.func, - onDeleteError: PropTypes.func, - onDeleted: PropTypes.func, - onDownloadError: PropTypes.func, - onDownloaded: PropTypes.func, - onSaveError: PropTypes.func, - onSaved: PropTypes.func, -}; - -export default OverrideComponent; diff --git a/src/web/pages/overrides/OverrideComponent.tsx b/src/web/pages/overrides/OverrideComponent.tsx new file mode 100644 index 0000000000..26113501c5 --- /dev/null +++ b/src/web/pages/overrides/OverrideComponent.tsx @@ -0,0 +1,304 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import { + type default as Override, + type Active, + type AnyOrManual, + ANY, + MANUAL, + ACTIVE_NO_VALUE, + ACTIVE_YES_ALWAYS_VALUE, + ACTIVE_YES_UNTIL_VALUE, +} from 'gmp/models/override'; +import type Task from 'gmp/models/task'; +import {hasId} from 'gmp/utils/id'; +import {isDefined} from 'gmp/utils/identity'; +import {shorten} from 'gmp/utils/string'; +import EntityComponent from 'web/entity/EntityComponent'; +import {type EntityCloneResponse} from 'web/entity/hooks/useEntityClone'; +import {type EntityCreateResponse} from 'web/entity/hooks/useEntityCreate'; +import {type OnDownloadedFunc} from 'web/entity/hooks/useEntityDownload'; +import useGmp from 'web/hooks/useGmp'; +import useTranslation from 'web/hooks/useTranslation'; +import OverrideDialog from 'web/pages/overrides/OverrideDialog'; +import { + FALSE_POSITIVE_VALUE, + LOG_VALUE, + MEDIUM_VALUE, + LOW_VALUE, + getSeverityLevelBoundaries, +} from 'web/utils/severity'; + +interface OverrideComponentInitialData { + active?: Active; + customSeverity?: boolean; + hostsManual?: string; + hosts?: AnyOrManual; + id?: string; + newSeverityFromList?: number; + newSeverity?: number; + nvtName?: string; + oid?: string; + override?: Override; + portManual?: string; + port?: AnyOrManual; + resultId?: AnyOrManual; + resultName?: string; + resultUuid?: string; + severity?: number; + taskId?: AnyOrManual; + taskUuid?: string; + text?: string; + title?: string; +} + +interface OverrideComponentRenderProps { + clone: (override: Override) => Promise; + create: (initial?: OverrideComponentInitialData) => void; + delete: (override: Override) => Promise; + download: (override: Override) => Promise; + edit: (override: Override, initial?: OverrideComponentInitialData) => void; +} + +interface OverrideComponentProps { + children: (props: OverrideComponentRenderProps) => React.ReactNode; + onCloneError?: (error: Error) => void; + onCloned?: (data: EntityCloneResponse) => void; + onCreateError?: (error: Error) => void; + onCreated?: (data: EntityCreateResponse) => void; + onDeleteError?: (error: Error) => void; + onDeleted?: () => void; + onDownloadError?: (error: Error) => void; + onDownloaded?: OnDownloadedFunc; + onSaveError?: (error: Error) => void; + onSaved?: () => void; +} +const OverrideComponent = ({ + children, + onCloned, + onCloneError, + onCreated, + onCreateError, + onDeleted, + onDeleteError, + onDownloaded, + onDownloadError, + onSaved, + onSaveError, +}: OverrideComponentProps) => { + const gmp = useGmp(); + const [_] = useTranslation(); + + const severityBoundaries = getSeverityLevelBoundaries( + gmp.settings.severityRating, + ); + + const SEVERITIES_LIST = new Set([ + ...(severityBoundaries.minCritical ? [severityBoundaries.minCritical] : []), + severityBoundaries.minHigh, + MEDIUM_VALUE, + LOW_VALUE, + LOG_VALUE, + FALSE_POSITIVE_VALUE, + ]); + + const [dialogVisible, setDialogVisible] = useState(false); + + const [override, setOverride] = useState(); + const [id, setId] = useState(); + const [active, setActive] = useState(); + + const [severity, setSeverity] = useState(); + const [newSeverity, setNewSeverity] = useState(); + const [newSeverityFromList, setNewSeverityFromList] = useState< + number | undefined + >(); + const [customSeverity, setCustomSeverity] = useState(false); + + const [hosts, setHosts] = useState(); + const [hostsManual, setHostsManual] = useState(); + + const [port, setPort] = useState(); + const [portManual, setPortManual] = useState(); + + const [nvtName, setNvtName] = useState(); + const [oid, setOid] = useState(); + + const [resultId, setResultId] = useState(); + const [resultName, setResultName] = useState(); + const [resultUuid, setResultUuid] = useState(); + + const [taskId, setTaskId] = useState(); + const [taskUuid, setTaskUuid] = useState(); + const [tasks, setTasks] = useState([]); + + const [text, setText] = useState(); + const [title, setTitle] = useState(); + + const [initialProps, setInitialProps] = + useState({}); + + const loadTasks = async () => { + const response = await gmp.tasks.getAll(); + setTasks(response.data); + }; + + const closeOverrideDialog = () => { + setDialogVisible(false); + }; + + const handleCloseOverrideDialog = () => { + closeOverrideDialog(); + }; + + const openOverrideDialog = ( + overrideEntity: Override | undefined, + initial: OverrideComponentInitialData = {}, + ) => { + if (isDefined(overrideEntity)) { + let activeValue: Active = ACTIVE_NO_VALUE; + if (overrideEntity.isActive()) { + if (isDefined(overrideEntity.endTime)) { + activeValue = ACTIVE_YES_UNTIL_VALUE; + } else { + activeValue = ACTIVE_YES_ALWAYS_VALUE; + } + } + + let customSeverityValue: boolean = false; + let newSeverityFromListValue: number | undefined; + let newSeverityValue: number | undefined; + + if (SEVERITIES_LIST.has(overrideEntity.newSeverity as number)) { + newSeverityFromListValue = overrideEntity.newSeverity as number; + } else { + customSeverityValue = true; + newSeverityValue = overrideEntity.newSeverity as number; + } + + const {result, task, nvt, hosts: overrideHosts} = overrideEntity; + + setDialogVisible(true); + setId(overrideEntity.id); + setActive(activeValue); + setCustomSeverity(customSeverityValue); + setHosts( + isDefined(overrideHosts) && overrideHosts.length > 0 ? MANUAL : ANY, + ); + setHostsManual(overrideHosts?.join(', ')); + setNewSeverity(newSeverityValue); + setNewSeverityFromList(newSeverityFromListValue); + setNvtName(isDefined(nvt) ? nvt.name : undefined); + setOid(isDefined(nvt) ? nvt.oid : undefined); + setOverride(overrideEntity); + setPort(isDefined(overrideEntity.port) ? MANUAL : ANY); + setPortManual(overrideEntity.port); + setResultId(hasId(result) ? MANUAL : ANY); + setResultName(hasId(result) ? result?.name : undefined); + setResultUuid(hasId(result) ? result?.id : undefined); + setSeverity(overrideEntity.severity); + setTaskId(hasId(task) ? MANUAL : ANY); + setTaskUuid(hasId(task) ? task?.id : undefined); + setText(overrideEntity.text); + setTitle( + _('Edit Override {{- name}}', { + name: shorten(overrideEntity.text, 20), + }), + ); + setInitialProps({}); + } else { + setDialogVisible(true); + setActive(undefined); + setCustomSeverity(false); + setHosts(undefined); + setHostsManual(undefined); + setId(undefined); + setNewSeverity(undefined); + setNewSeverityFromList(undefined); + setNvtName(undefined); + setOid(undefined); + setOverride(undefined); + setPort(undefined); + setPortManual(undefined); + setResultId(undefined); + setResultName(undefined); + setResultUuid(undefined); + setSeverity(undefined); + setTaskId(undefined); + setTaskUuid(undefined); + setText(undefined); + setTitle(undefined); + setInitialProps(initial); + } + + void loadTasks(); + }; + + const openCreateOverrideDialog = (initial = {}) => { + openOverrideDialog(undefined, initial); + }; + + return ( + + {({save, create, ...other}) => ( + <> + {children({ + ...other, + create: openCreateOverrideDialog, + edit: openOverrideDialog, + })} + {dialogVisible && ( + { + const promise = isDefined(d.id) ? save(d) : create(d); + await promise; + return closeOverrideDialog(); + }} + /> + )} + + )} + + ); +}; + +export default OverrideComponent; diff --git a/src/web/pages/overrides/Dialog.jsx b/src/web/pages/overrides/OverrideDialog.tsx similarity index 65% rename from src/web/pages/overrides/Dialog.jsx rename to src/web/pages/overrides/OverrideDialog.tsx index 0d721b658d..e0c033b65f 100644 --- a/src/web/pages/overrides/Dialog.jsx +++ b/src/web/pages/overrides/OverrideDialog.tsx @@ -3,38 +3,39 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; import { + type default as Override, + type Active, ANY, MANUAL, ACTIVE_YES_ALWAYS_VALUE, DEFAULT_DAYS, DEFAULT_OID_VALUE, - RESULT_ANY, - TASK_ANY, ACTIVE_YES_UNTIL_VALUE, ACTIVE_YES_FOR_NEXT_VALUE, ACTIVE_NO_VALUE, - TASK_SELECTED, - RESULT_UUID, + type AnyOrManual, } from 'gmp/models/override'; -import {parseFloat, parseYesNo, YES_VALUE, NO_VALUE} from 'gmp/parser'; +import type Task from 'gmp/models/task'; +import {parseBoolean, parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; +import {isEmpty} from 'gmp/utils/string'; import DateTime from 'web/components/date/DateTime'; import SaveDialog from 'web/components/dialog/SaveDialog'; import FormGroup from 'web/components/form/FormGroup'; import NumberField from 'web/components/form/NumberField'; import Radio from 'web/components/form/Radio'; -import Select from 'web/components/form/Select'; +import Select, {type SelectItem} from 'web/components/form/Select'; import Spinner from 'web/components/form/Spinner'; import TextArea from 'web/components/form/TextArea'; import TextField from 'web/components/form/TextField'; import Row from 'web/components/layout/Row'; import useGmp from 'web/hooks/useGmp'; import useTranslation from 'web/hooks/useTranslation'; -import PropTypes from 'web/utils/PropTypes'; import { + getNvtDisplayName, renderNvtName, + type RenderSelectItemProps, renderSelectItems, severityFormat, } from 'web/utils/Render'; @@ -53,34 +54,91 @@ import { getSeverityLevelBoundaries, } from 'web/utils/severity'; +interface OverrideDialogDefaultValues { + active?: Active; + customSeverity?: boolean; + days?: number; + hosts?: string; + hostsManual?: string; + newSeverity?: number; + newSeverityFromList?: number; + oid?: string; + override?: Override; + port?: string; + portManual?: string; + resultId?: string; + resultUuid?: string; + severity?: number; + taskId?: string; + taskName?: string; + tasks?: Task[]; + taskUuid?: string; + text: string; +} + +interface OverrideDialogValues { + id?: string; +} + +type OverrideDialogState = OverrideDialogDefaultValues & OverrideDialogValues; + +interface OverrideDialogProps { + active?: Active; + customSeverity?: boolean; + days?: number; + fixed?: boolean; + hosts?: AnyOrManual; + hostsManual?: string; + id?: string; + newSeverity?: number; + newSeverityFromList?: number; + nvtName?: string; + oid?: string; + override?: Override; + port?: AnyOrManual; + portManual?: string; + resultId?: AnyOrManual; + resultName?: string; + resultUuid?: string; + severity?: number; + taskId?: AnyOrManual; + taskName?: string; + tasks?: Task[]; + taskUuid?: string; + text?: string; + title?: string; + onClose: () => void; + onSave: (values: OverrideDialogState) => void; +} + const OverrideDialog = ({ active = ACTIVE_YES_ALWAYS_VALUE, - custom_severity = NO_VALUE, + customSeverity = false, days = DEFAULT_DAYS, fixed = false, hosts = ANY, - hosts_manual = '', + hostsManual = '', id, newSeverity, - new_severity_from_list = FALSE_POSITIVE_VALUE, - nvt_name, + newSeverityFromList = FALSE_POSITIVE_VALUE, + nvtName, oid, override, port = ANY, - port_manual = '', - result_id = RESULT_ANY, - result_name, - result_uuid = '', + portManual = '', + resultId = ANY, + resultName, + resultUuid, severity, - task_id = TASK_ANY, - task_name, + taskId = ANY, + taskName, tasks, - task_uuid, + taskUuid, text = '', title, onClose, onSave, -}) => { +}: OverrideDialogProps) => { const [_] = useTranslation(); const gmp = useGmp(); const isEdit = isDefined(override); @@ -92,27 +150,27 @@ const OverrideDialog = ({ const data = { active, - custom_severity, + customSeverity, days, hosts, - hosts_manual, + hostsManual, newSeverity, - new_severity_from_list, + newSeverityFromList, oid: isDefined(oid) ? oid : DEFAULT_OID_VALUE, override, port, - port_manual, - result_id, - result_uuid, - severity: isDefined(severity) ? severity : '', - task_id, - task_name, + portManual, + resultId, + resultUuid, + severity, + taskId, + taskName, tasks, - task_uuid, + taskUuid, text, }; - const severityFromListItems = []; + const severityFromListItems: SelectItem[] = []; if (isEdit) { severityFromListItems.push({ @@ -123,36 +181,36 @@ const OverrideDialog = ({ if (severityBoundaries.minCritical) { severityFromListItems.push({ - value: severityBoundaries.minCritical, + value: String(severityBoundaries.minCritical), label: `${_CRITICAL}`, }); } severityFromListItems.push( { - value: severityBoundaries.minHigh, + value: String(severityBoundaries.minHigh), label: `${_HIGH}`, }, { - value: MEDIUM_VALUE, + value: String(MEDIUM_VALUE), label: `${_MEDIUM}`, }, { - value: LOW_VALUE, + value: String(LOW_VALUE), label: `${_LOW}`, }, { - value: LOG_VALUE, + value: String(LOG_VALUE), label: `${_LOG}`, }, { - value: FALSE_POSITIVE_VALUE, + value: String(FALSE_POSITIVE_VALUE), label: `${_FALSE_POSITIVE}`, }, ); return ( - defaultValues={data} title={title} values={{id}} @@ -163,21 +221,21 @@ const OverrideDialog = ({ return ( <> {fixed && isDefined(oid) && ( - - {renderNvtName(oid, nvt_name)} + + {renderNvtName(oid, nvtName)} )} {fixed && !isDefined(oid) && ( - - {renderNvtName(state.oid, nvt_name)} + + {renderNvtName(state.oid as string, nvtName)} )} {isEdit && !fixed && ( - + @@ -208,7 +266,7 @@ const OverrideDialog = ({ )} - + - + - + - + @@ -339,7 +397,7 @@ const OverrideDialog = ({ convert={parseFloat} name="severity" title={_('> 0.0')} - value="0.1" + value={0.1} onChange={onValueChange} /> )} - + - + checked={!state.customSeverity} + convert={parseBoolean} + name="customSeverity" + value={false} onChange={onValueChange} /> - + - {(result_id === RESULT_ANY || result_id === RESULT_UUID) && - !fixed && ( - - )} + {!fixed && ( + + )} @@ -472,38 +532,4 @@ const OverrideDialog = ({ ); }; -OverrideDialog.propTypes = { - active: PropTypes.oneOf([ - ACTIVE_NO_VALUE, - ACTIVE_YES_FOR_NEXT_VALUE, - ACTIVE_YES_ALWAYS_VALUE, - ACTIVE_YES_UNTIL_VALUE, - ]), - custom_severity: PropTypes.yesno, - days: PropTypes.number, - fixed: PropTypes.bool, - hosts: PropTypes.string, - hosts_manual: PropTypes.string, - id: PropTypes.string, - newSeverity: PropTypes.number, - new_severity_from_list: PropTypes.number, - nvt_name: PropTypes.string, - oid: PropTypes.string, - override: PropTypes.model, - port: PropTypes.string, - port_manual: PropTypes.string, - result_id: PropTypes.id, - result_name: PropTypes.string, - result_uuid: PropTypes.id, - severity: PropTypes.number, - task_id: PropTypes.id, - task_name: PropTypes.string, - task_uuid: PropTypes.id, - tasks: PropTypes.array, - text: PropTypes.string, - title: PropTypes.string, - onClose: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, -}; - export default OverrideDialog; diff --git a/src/web/pages/overrides/__tests__/OverrideComponent.test.tsx b/src/web/pages/overrides/__tests__/OverrideComponent.test.tsx new file mode 100644 index 0000000000..0dfcecdb04 --- /dev/null +++ b/src/web/pages/overrides/__tests__/OverrideComponent.test.tsx @@ -0,0 +1,388 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import { + fireEvent, + getSelectItemElementsForSelect, + rendererWith, + screen, + wait, + within, +} from 'web/testing'; +import Override, { + ACTIVE_YES_ALWAYS_VALUE, + ACTIVE_YES_FOR_NEXT_VALUE, + ANY, + DEFAULT_DAYS, + DEFAULT_OID_VALUE, + MANUAL, + SEVERITY_FALSE_POSITIVE, +} from 'gmp/models/override'; +import {createSession} from 'gmp/testing'; +import { + SEVERITY_RATING_CVSS_2, + SEVERITY_RATING_CVSS_3, +} from 'gmp/utils/severity'; +import {currentSettingsDefaultResponse} from 'web/pages/__fixtures__/current-settings'; +import OverrideComponent from 'web/pages/overrides/OverrideComponent'; + +const createTask = (id = 'task-1', name = 'Task 1') => ({ + name, + id, +}); + +const createGmp = ({ + getTasks = testing.fn().mockResolvedValue({ + data: [createTask()], + }), + create = testing.fn().mockResolvedValue({}), + currentSettings = testing + .fn() + .mockResolvedValue(currentSettingsDefaultResponse), + severityRating = SEVERITY_RATING_CVSS_3, +} = {}) => ({ + user: {currentSettings}, + tasks: {getAll: getTasks}, + override: {create}, + session: createSession(), + settings: {severityRating}, +}); + +describe('OverrideComponent', () => { + test('should render create override dialog', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp}); + + render( + + {({create}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + const dialog = screen.getDialog(); + expect(dialog).toBeInTheDocument(); + + expect(screen.getByName('oid')).toHaveValue(DEFAULT_OID_VALUE); + + const activeFormGroup = within(screen.getByTestId('group-active')); + const activeRadioInputs = activeFormGroup.getRadioInputs(); + expect(activeRadioInputs).toHaveLength(3); + expect(activeRadioInputs[0]).toBeChecked(); + + const hostsFormGroup = within(screen.getByTestId('group-hosts')); + const hostsRadioInputs = hostsFormGroup.getRadioInputs(); + expect(hostsRadioInputs).toHaveLength(2); + expect(hostsRadioInputs[0]).toBeChecked(); + expect(hostsFormGroup.getByName('hostsManual')).toBeDisabled(); + + const locationFormGroup = within(screen.getByTestId('group-location')); + const locationRadioInputs = locationFormGroup.getRadioInputs(); + expect(locationRadioInputs).toHaveLength(2); + expect(locationRadioInputs[0]).toBeChecked(); + expect(screen.getByName('portManual')).toBeDisabled(); + + const severityFormGroup = within(screen.getByTestId('group-severity')); + const severityRadioInputs = severityFormGroup.getRadioInputs(); + expect(severityRadioInputs).toHaveLength(4); + + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const newSeverityRadioInputs = newSeverityFormGroup.getRadioInputs(); + expect(newSeverityRadioInputs).toHaveLength(2); + expect(newSeverityRadioInputs[0]).toBeChecked(); + expect(newSeverityFormGroup.getByName('newSeverity')).toBeDisabled(); + + const taskFormGroup = within(screen.getByTestId('group-task')); + const taskRadioInputs = taskFormGroup.getRadioInputs(); + expect(taskRadioInputs).toHaveLength(2); + expect(taskRadioInputs[0]).toBeChecked(); + expect(taskFormGroup.getSelectElement()).toBeDisabled(); + + const resultFormGroup = within(screen.getByTestId('group-result')); + const resultRadioInputs = resultFormGroup.getRadioInputs(); + expect(resultRadioInputs).toHaveLength(2); + expect(resultRadioInputs[0]).toBeChecked(); + expect(resultFormGroup.getByName('resultUuid')).toBeDisabled(); + + expect(screen.getByName('text')).toHaveValue(''); + + const saveButton = screen.getDialogSaveButton(); + fireEvent.click(saveButton); + + expect(gmp.override.create).toHaveBeenCalledWith({ + active: ACTIVE_YES_ALWAYS_VALUE, + customSeverity: false, + days: DEFAULT_DAYS, + hosts: ANY, + hostsManual: '', + id: undefined, + newSeverity: undefined, + newSeverityFromList: SEVERITY_FALSE_POSITIVE, + oid: DEFAULT_OID_VALUE, + override: undefined, + port: ANY, + portManual: '', + resultId: ANY, + resultUuid: undefined, + severity: undefined, + taskId: ANY, + taskName: undefined, + tasks: [], + taskUuid: undefined, + text: '', + }); + }); + + test('should render create override dialog with initial values', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp}); + + render( + + {({create}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + const dialog = screen.getDialog(); + expect(dialog).toBeInTheDocument(); + + await wait(); + + // Check text and OID + expect(screen.getByName('text')).toHaveValue('foo bar'); + expect(screen.getByName('oid')).toHaveValue('1.2.3'); + + // Check active: "yes, for the next" + const activeFormGroup = within(screen.getByTestId('group-active')); + const activeRadios = activeFormGroup.getRadioInputs(); + expect(activeRadios[1]).toBeChecked(); + expect(activeFormGroup.getByName('days')).toHaveValue('30'); + + // Check hosts: MANUAL with manual input + const hostsFormGroup = within(screen.getByTestId('group-hosts')); + const hostsRadios = hostsFormGroup.getRadioInputs(); + expect(hostsRadios[1]).toBeChecked(); + expect(hostsFormGroup.getByName('hostsManual')).toHaveValue('host1, host2'); + + // Check location/port: MANUAL with manual input + const locationFormGroup = within(screen.getByTestId('group-location')); + const locationRadios = locationFormGroup.getRadioInputs(); + expect(locationRadios[1]).toBeChecked(); + expect(screen.getByName('portManual')).toHaveValue('1234'); + + // Check severity + const severityFormGroup = within(screen.getByTestId('group-severity')); + const severityRadios = severityFormGroup.getRadioInputs(); + expect(severityRadios[1]).toBeChecked(); + + // Check new severity: predefined list (7 is in the list) + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const newSeverityRadios = newSeverityFormGroup.getRadioInputs(); + expect(newSeverityRadios[0]).toBeChecked(); + // Select shows "High" (label), not the numeric value + expect(newSeverityFormGroup.getSelectElement()).toHaveValue('High'); + + // Check task: MANUAL with selected task + const taskFormGroup = within(screen.getByTestId('group-task')); + const taskRadios = taskFormGroup.getRadioInputs(); + expect(taskRadios[1]).toBeChecked(); + expect(taskFormGroup.getSelectElement()).toHaveValue('Task 1'); + + // Check result: MANUAL with result name + const resultFormGroup = within(screen.getByTestId('group-result')); + const resultRadios = resultFormGroup.getRadioInputs(); + expect(resultRadios[1]).toBeChecked(); + expect( + resultFormGroup.getByText('Only selected result (Test Result)'), + ).toBeInTheDocument(); + + const saveButton = screen.getDialogSaveButton(); + fireEvent.click(saveButton); + + expect(gmp.override.create).toHaveBeenCalled(); + }); + + test('should detect custom severity when newSeverity is not in the list', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp}); + + const override = new Override({ + id: 'override-1', + text: 'existing', + newSeverity: 8.1, // Not in standard list, should be custom + }); + + render( + + {({edit}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + await wait(); + + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const newSeverityRadios = newSeverityFormGroup.getRadioInputs(); + + // Should have custom severity toggle selected + expect(newSeverityRadios[1]).toBeChecked(); + + const newSeverityInput = newSeverityFormGroup.getByName('newSeverity'); + expect(newSeverityInput).toHaveValue('8.1'); + }); + + test('should use predefined severity from list when available', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp}); + + // 9 is HIGH value in CVSS3 + const override = new Override({ + id: 'override-1', + text: 'existing', + newSeverity: 9, + }); + + render( + + {({edit}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + await wait(); + + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const newSeverityRadios = newSeverityFormGroup.getRadioInputs(); + + // Should have predefined list option selected + expect(newSeverityRadios[0]).toBeChecked(); + }); + + test('should include critical severity boundary for CVSSv3', async () => { + const gmp = createGmp({severityRating: SEVERITY_RATING_CVSS_3}); + const {render} = rendererWith({gmp}); + + render( + + {({create}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + await wait(); + + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const selectElement = newSeverityFormGroup.getSelectElement(); + + expect(selectElement).toBeInTheDocument(); + + // Get the select items + const items = await getSelectItemElementsForSelect(selectElement); + + // CVSSv3 should include Critical in the options + const criticalItem = items.find(item => + item.textContent?.includes('Critical'), + ); + expect(criticalItem).toBeDefined(); + }); + + test('should exclude critical severity boundary for CVSSv2', async () => { + const gmp = createGmp({severityRating: SEVERITY_RATING_CVSS_2}); + const {render} = rendererWith({gmp}); + + render( + + {({create}) => ( + + )} + , + ); + + const button = screen.getByTestId('button'); + fireEvent.click(button); + + await wait(); + + const newSeverityFormGroup = within( + screen.getByTestId('group-new-severity'), + ); + const selectElement = newSeverityFormGroup.getSelectElement(); + + expect(selectElement).toBeInTheDocument(); + + // Get the select items + const items = await getSelectItemElementsForSelect(selectElement); + + // CVSSv2 should NOT include Critical in the options + const criticalItem = items.find(item => + item.textContent?.includes('Critical'), + ); + expect(criticalItem).toBeUndefined(); + }); +}); diff --git a/src/web/pages/overrides/__tests__/OverrideDialog.test.tsx b/src/web/pages/overrides/__tests__/OverrideDialog.test.tsx new file mode 100644 index 0000000000..f44d210288 --- /dev/null +++ b/src/web/pages/overrides/__tests__/OverrideDialog.test.tsx @@ -0,0 +1,389 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, test, expect, testing} from '@gsa/testing'; +import { + fireEvent, + getSelectItemElementsForSelect, + rendererWith, + screen, + within, +} from 'web/testing'; +import date from 'gmp/models/date'; +import Override, { + ACTIVE_YES_ALWAYS_VALUE, + ACTIVE_YES_FOR_NEXT_VALUE, + ACTIVE_YES_UNTIL_VALUE, + ANY, + DEFAULT_DAYS, + DEFAULT_OID_VALUE, + MANUAL, + SEVERITY_FALSE_POSITIVE, +} from 'gmp/models/override'; +import Task from 'gmp/models/task'; +import {createSession} from 'gmp/testing'; +import { + SEVERITY_RATING_CVSS_2, + SEVERITY_RATING_CVSS_3, +} from 'gmp/utils/severity'; +import OverrideDialog from 'web/pages/overrides/OverrideDialog'; + +const createTask = (id: string = 'task-1', name: string = 'Task 1') => + new Task({ + id, + name, + }); + +const createTasks = () => [ + createTask('task-1', 'Task 1'), + createTask('task-2', 'Task 2'), +]; + +const tasks = createTasks(); + +const createGmp = ({severityRating = SEVERITY_RATING_CVSS_3} = {}) => ({ + session: createSession({timezone: 'UTC'}), + settings: { + severityRating, + }, +}); + +describe('OverrideDialog tests', () => { + test('should render with default values and handle close', () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({gmp: createGmp()}); + + render(); + + expect(screen.getDialog()).toBeInTheDocument(); + expect(screen.getDialogTitle()).toHaveTextContent('New Override'); + + expect(screen.getByName('oid')).toHaveValue(DEFAULT_OID_VALUE); + + const activeRadios = within( + screen.getByTestId('group-active'), + ).getRadioInputs(); + expect(activeRadios).toHaveLength(3); + expect(activeRadios[0]).toBeChecked(); + + const hostsGroup = within(screen.getByTestId('group-hosts')); + const hostsRadios = hostsGroup.getRadioInputs(); + expect(hostsRadios).toHaveLength(2); + expect(hostsRadios[0]).toBeChecked(); + expect(hostsGroup.getByName('hostsManual')).toBeDisabled(); + + const locationGroup = within(screen.getByTestId('group-location')); + const locationRadios = locationGroup.getRadioInputs(); + expect(locationRadios).toHaveLength(2); + expect(locationRadios[0]).toBeChecked(); + expect(locationGroup.getByName('portManual')).toBeDisabled(); + + const severityGroup = within(screen.getByTestId('group-severity')); + const severityRadios = severityGroup.getRadioInputs(); + expect(severityRadios).toHaveLength(4); + + const newSeverityGroup = within(screen.getByTestId('group-new-severity')); + const newSeverityRadios = newSeverityGroup.getRadioInputs(); + expect(newSeverityRadios).toHaveLength(2); + expect(newSeverityRadios[0]).toBeChecked(); + expect(newSeverityGroup.getByName('newSeverity')).toBeDisabled(); + + const taskGroup = within(screen.getByTestId('group-task')); + const taskRadios = taskGroup.getRadioInputs(); + expect(taskRadios).toHaveLength(2); + expect(taskRadios[0]).toBeChecked(); + expect(taskGroup.getByName('taskUuid')).toBeDisabled(); + + const resultGroup = within(screen.getByTestId('group-result')); + const resultRadios = resultGroup.getRadioInputs(); + expect(resultRadios).toHaveLength(2); + expect(resultRadios[0]).toBeChecked(); + expect(resultGroup.getByName('resultUuid')).toBeDisabled(); + + expect(screen.getByName('text')).toHaveValue(''); + + fireEvent.click(screen.getDialogCloseButton()); + expect(onClose).toHaveBeenCalled(); + }); + + test('should allow to save with default values', () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({gmp: createGmp()}); + + render(); + + fireEvent.click(screen.getDialogSaveButton()); + + expect(onSave).toHaveBeenCalledWith({ + active: ACTIVE_YES_ALWAYS_VALUE, + customSeverity: false, + days: DEFAULT_DAYS, + hosts: ANY, + hostsManual: '', + newSeverity: undefined, + newSeverityFromList: SEVERITY_FALSE_POSITIVE, + oid: DEFAULT_OID_VALUE, + override: undefined, + port: ANY, + portManual: '', + resultId: ANY, + resultUuid: undefined, + severity: undefined, + taskId: ANY, + taskName: undefined, + tasks, + taskUuid: undefined, + text: '', + id: undefined, + }); + }); + + test('should render fixed dialog with initial values and save', () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({gmp: createGmp()}); + + render( + , + ); + + expect(screen.queryByName('oid')).not.toBeInTheDocument(); + expect(screen.getByTestId('group-nvt')).toHaveTextContent('Test NVT'); + + const activeRadios = within( + screen.getByTestId('group-active'), + ).getRadioInputs(); + expect(activeRadios).toHaveLength(3); + expect(activeRadios[1]).toBeChecked(); + + const hostsGroup = within(screen.getByTestId('group-hosts')); + const hostsRadios = hostsGroup.getRadioInputs(); + expect(hostsRadios[1]).toBeChecked(); + expect(hostsGroup.getByName('hostsManual')).toHaveValue('host1, host2'); + + const locationGroup = within(screen.getByTestId('group-location')); + const locationRadios = locationGroup.getRadioInputs(); + expect(locationRadios[1]).toBeChecked(); + expect(locationGroup.getByName('portManual')).toHaveValue('1234'); + + const severityRadios = within( + screen.getByTestId('group-severity'), + ).getRadioInputs(); + expect(severityRadios).toHaveLength(2); + expect(severityRadios[1]).toBeChecked(); + + const taskGroup = within(screen.getByTestId('group-task')); + const taskRadios = taskGroup.getRadioInputs(); + expect(taskRadios[1]).toBeChecked(); + expect(taskGroup.getByName('taskUuid')).toHaveValue('task-1'); + + const resultGroup = within(screen.getByTestId('group-result')); + const resultRadios = resultGroup.getRadioInputs(); + expect(resultRadios[1]).toBeChecked(); + expect(resultGroup.queryByName('resultUuid')).not.toBeInTheDocument(); + expect( + resultGroup.getByText('Only selected result (Test Result)'), + ).toBeInTheDocument(); + + expect(screen.getByName('text')).toHaveValue('foo bar'); + + fireEvent.click(screen.getDialogSaveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + active: ACTIVE_YES_FOR_NEXT_VALUE, + hosts: MANUAL, + hostsManual: 'host1, host2', + oid: '1.2.3', + port: MANUAL, + portManual: '1234', + resultId: MANUAL, + taskId: MANUAL, + taskName: 'Task 1', + taskUuid: 'task-1', + text: 'foo bar', + }), + ); + }); + + test('should allow to change task selection', async () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({gmp: createGmp()}); + + render(); + + const taskGroup = within(screen.getByTestId('group-task')); + fireEvent.click(taskGroup.getRadioInputs()[1]); + + const taskOptions = await getSelectItemElementsForSelect( + taskGroup.getSelectElement(), + ); + fireEvent.click(taskOptions[1]); + + fireEvent.click(screen.getDialogSaveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: MANUAL, + taskUuid: 'task-2', + }), + ); + }); + + test('should allow to toggle custom severity and save numeric value', () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({gmp: createGmp()}); + + render(); + + const newSeverityGroup = within(screen.getByTestId('group-new-severity')); + fireEvent.click(newSeverityGroup.getRadioInputs()[1]); + + const newSeverityInput = newSeverityGroup.getByName('newSeverity'); + expect(newSeverityInput).not.toBeDisabled(); + fireEvent.change(newSeverityInput, {target: {value: '8.1'}}); + + fireEvent.click(screen.getDialogSaveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + customSeverity: true, + newSeverity: 8.1, + }), + ); + }); + + test('should render severity list with critical on CVSSv3', async () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({ + gmp: createGmp({severityRating: SEVERITY_RATING_CVSS_3}), + }); + + render(); + + const newSeverityGroup = within(screen.getByTestId('group-new-severity')); + const options = await getSelectItemElementsForSelect( + newSeverityGroup.getSelectElement(), + ); + expect(options.map(option => option.textContent)).toEqual([ + 'Critical', + 'High', + 'Medium', + 'Low', + 'Log', + 'False Positive', + ]); + }); + + test('should render severity list without critical on CVSSv2', async () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const {render} = rendererWith({ + gmp: createGmp({severityRating: SEVERITY_RATING_CVSS_2}), + }); + + render(); + + const newSeverityGroup = within(screen.getByTestId('group-new-severity')); + const options = await getSelectItemElementsForSelect( + newSeverityGroup.getSelectElement(), + ); + expect(options.map(option => option.textContent)).toEqual([ + 'High', + 'Medium', + 'Low', + 'Log', + 'False Positive', + ]); + }); + + test('should render edit dialog and include until-active and edit severity list option', async () => { + const onClose = testing.fn(); + const onSave = testing.fn(); + const override = new Override({ + id: 'override-1', + active: 1, + endTime: date('2026-01-01T12:00:00Z'), + text: 'existing override', + }); + const {render} = rendererWith({gmp: createGmp()}); + + render( + , + ); + + const nvtGroup = within(screen.getByTestId('group-nvt')); + const nvtRadios = nvtGroup.getRadioInputs(); + expect(nvtRadios).toHaveLength(2); + expect(nvtRadios[0]).toBeChecked(); + + const oidInput = nvtGroup.getByRole('textbox'); + expect(oidInput).toBeDisabled(); + expect(oidInput).toHaveValue(DEFAULT_OID_VALUE); + + const activeGroup = within(screen.getByTestId('group-active')); + const activeRadios = activeGroup.getRadioInputs(); + expect(activeRadios).toHaveLength(4); + expect(activeRadios[1]).toBeChecked(); + expect(activeGroup.getByText('yes, until')).toBeInTheDocument(); + + const newSeverityGroup = within(screen.getByTestId('group-new-severity')); + const options = await getSelectItemElementsForSelect( + newSeverityGroup.getSelectElement(), + ); + expect(options[0]).toHaveTextContent('--'); + + fireEvent.click(nvtRadios[1]); + expect(oidInput).not.toBeDisabled(); + + fireEvent.click(screen.getDialogSaveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'override-1', + oid: DEFAULT_OID_VALUE, + taskId: MANUAL, + taskUuid: 'task-1', + }), + ); + }); +}); diff --git a/src/web/pages/results/DetailsPage.jsx b/src/web/pages/results/DetailsPage.jsx index da71dbde47..1d640749cb 100644 --- a/src/web/pages/results/DetailsPage.jsx +++ b/src/web/pages/results/DetailsPage.jsx @@ -5,7 +5,7 @@ import React from 'react'; import {connect} from 'react-redux'; -import {MANUAL, TASK_SELECTED, RESULT_UUID} from 'gmp/models/override'; +import {MANUAL} from 'gmp/models/override'; import {isDefined} from 'gmp/utils/identity'; import SeverityBar from 'web/components/bar/SeverityBar'; import {OverrideIcon, ResultIcon} from 'web/components/icon'; @@ -287,18 +287,18 @@ class Page extends React.Component { createFunc({ fixed: true, oid: information.id, - nvt_name: information.name, - task_id: TASK_SELECTED, - task_name: task.name, - result_id: RESULT_UUID, - task_uuid: task.id, - result_uuid: result.id, - result_name: result.name, + nvtName: information.name, + taskId: MANUAL, + taskName: task.name, + resultId: MANUAL, + taskUuid: task.id, + resultUuid: result.id, + resultName: result.name, severity: result.original_severity > 0 ? 0.1 : result.original_severity, hosts: MANUAL, - hosts_manual: host.name, + hostsManual: host.name, port: MANUAL, - port_manual: result.port, + portManual: result.port, }); }