From 4f09c68da3c54e94a5add395ec5a718947c0e36b Mon Sep 17 00:00:00 2001 From: Terry Pasquet Date: Thu, 17 Sep 2026 17:09:54 +0200 Subject: [PATCH 1/4] feat(report): add relationships tab and functionality to manage report relationships --- .../ReportStixCoreRelationships.test.ts | 39 ++++ .../reports/ReportStixCoreRelationships.tsx | 160 ++++++++++++++ .../components/analyses/reports/Root.tsx | 6 + .../StixDomainObjectMain.test.tsx | 1 + .../StixDomainObjectMain.tsx | 3 + .../StixDomainObjectTabsBox.test.tsx | 1 + .../StixDomainObjectTabsBox.tsx | 5 + .../tests_e2e/dataForTesting/report.data.ts | 2 + .../tests_e2e/model/SDOTabs.pageModel.ts | 4 + .../report/reportRelationships.spec.ts | 144 +++++++++++++ .../01-unit/database/filtering-utils-test.ts | 49 ++++- .../02-resolvers/container-test.ts | 200 ++++++++++++++++++ 12 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.test.ts create mode 100644 opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx create mode 100644 opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.test.ts b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.test.ts new file mode 100644 index 000000000000..eb936c3c3a69 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { buildReportRelationshipsContextFilters } from './ReportStixCoreRelationships'; +import type { FilterGroup } from '../../../../utils/filters/filtersHelpers-types'; + +describe('buildReportRelationshipsContextFilters', () => { + it('scopes the query to the report objects, with no user filters', () => { + const result = buildReportRelationshipsContextFilters('report-id', undefined); + + expect(result).toEqual({ + mode: 'and', + filters: [{ key: 'objects', values: ['report-id'], operator: 'eq', mode: 'or' }], + filterGroups: [], + }); + }); + + it('keeps the report membership filter mandatory, nesting user filters instead of merging them', () => { + const userFilters: FilterGroup = { + mode: 'or', + filters: [{ key: 'relationship_type', values: ['uses'], operator: 'eq', mode: 'or' }], + filterGroups: [], + }; + + const result = buildReportRelationshipsContextFilters('report-id', userFilters); + + expect(result.filters).toEqual([{ key: 'objects', values: ['report-id'], operator: 'eq', mode: 'or' }]); + expect(result.filterGroups).toEqual([userFilters]); + // The mandatory filter and the user filters are combined with AND: user filters, + // however permissive ('or' mode), can never widen the query outside the report. + expect(result.mode).toEqual('and'); + }); + + it('drops empty user filter groups instead of nesting them', () => { + const emptyUserFilters: FilterGroup = { mode: 'and', filters: [], filterGroups: [] }; + + const result = buildReportRelationshipsContextFilters('report-id', emptyUserFilters); + + expect(result.filterGroups).toEqual([]); + }); +}); diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx new file mode 100644 index 000000000000..d46e1c3c66d7 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx @@ -0,0 +1,160 @@ +import React, { FunctionComponent } from 'react'; +import { AutoFix } from 'mdi-material-ui'; +import { useTheme } from '@mui/styles'; +import { getDraftModeColor } from '@components/common/draft/DraftChip'; +import { + stixCoreRelationshipsFragment, + stixCoreRelationshipsLinesFragment, + stixCoreRelationshipsLinesQuery, +} from '@components/common/stix_core_relationships/StixCoreRelationships'; +import { + StixCoreRelationshipsLinesPaginationQuery, + StixCoreRelationshipsLinesPaginationQuery$variables, +} from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLinesPaginationQuery.graphql'; +import { StixCoreRelationshipsLines_data$data } from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLines_data.graphql'; +import DataTable from '../../../../components/dataGrid/DataTable'; +import { DataTableProps } from '../../../../components/dataGrid/dataTableTypes'; +import { usePaginationLocalStorage } from '../../../../utils/hooks/useLocalStorage'; +import useQueryLoading from '../../../../utils/hooks/useQueryLoading'; +import { UsePreloadedPaginationFragment } from '../../../../utils/hooks/usePreloadedPaginationFragment'; +import useAuth from '../../../../utils/hooks/useAuth'; +import ItemEntityType from '../../../../components/ItemEntityType'; +import ItemIcon from '../../../../components/ItemIcon'; +import { itemColor } from '../../../../utils/Colors'; +import { emptyFilterGroup, isFilterGroupNotEmpty, useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../../../utils/filters/filtersUtils'; +import type { Theme } from '../../../../components/Theme'; +import type { FilterGroup } from '../../../../utils/filters/filtersHelpers-types'; + +interface ReportStixCoreRelationshipsProps { + reportId: string; +} + +/** + * Builds the filters sent to `stixCoreRelationships`, scoped to the relationships referenced + * by the report (rel_object), never entities-to-entities relationships that merely happen to + * connect two objects of the report. The 'objects' filter is mandatory and stays outside + * `userFilters` so it can never be widened or removed by the user. + */ +export const buildReportRelationshipsContextFilters = ( + reportId: string, + userFilters: FilterGroup | undefined, +): FilterGroup => ({ + mode: 'and', + filters: [{ key: 'objects', values: [reportId], operator: 'eq', mode: 'or' }], + filterGroups: userFilters && isFilterGroupNotEmpty(userFilters) ? [userFilters] : [], +}); + +const ReportStixCoreRelationships: FunctionComponent = ({ reportId }) => { + const theme = useTheme(); + const { + platformModuleHelpers: { isRuntimeFieldEnable }, + } = useAuth(); + const isRuntimeSort = isRuntimeFieldEnable() ?? false; + const LOCAL_STORAGE_KEY = `report-${reportId}-relationships`; + + const dataColumns: DataTableProps['dataColumns'] = { + is_inferred: { + id: 'is_inferred', + label: ' ', + isSortable: false, + percentWidth: 3, + render: ({ is_inferred, entity_type, draftVersion }) => { + if (is_inferred) { + const inferredColor = draftVersion ? getDraftModeColor(theme) : itemColor(entity_type); + return (); + } + if (draftVersion) { + return (); + } + return (); + }, + }, + fromType: { + id: 'fromType', + label: 'From type', + percentWidth: 9, + isSortable: false, + render: (node) => ( + + ), + }, + fromName: { + percentWidth: 15, + }, + relationship_type: { + percentWidth: 8, + }, + toType: { + id: 'toType', + label: 'To type', + percentWidth: 9, + isSortable: false, + render: (node) => ( + + ), + }, + toName: { + percentWidth: 15, + }, + createdBy: { percentWidth: 8, isSortable: isRuntimeSort }, + creator: { percentWidth: 8, isSortable: isRuntimeSort }, + created_at: { percentWidth: 15 }, + objectMarking: { percentWidth: 10, isSortable: isRuntimeSort }, + }; + + const initialValues = { + searchTerm: '', + sortBy: 'created_at', + orderAsc: false, + openExports: false, + filters: emptyFilterGroup, + }; + + const { paginationOptions, viewStorage, helpers: storageHelpers } = usePaginationLocalStorage( + LOCAL_STORAGE_KEY, + initialValues, + true, + ); + const { filters } = viewStorage; + + // 'objects' (report membership) is mandatory and cannot be widened by user filters. + const userFilters = useRemoveIdAndIncorrectKeysFromFilterGroupObject(filters, ['stix-core-relationship']); + const contextFilters = buildReportRelationshipsContextFilters(reportId, userFilters); + + const queryPaginationOptions = { + ...paginationOptions, + filters: contextFilters, + } as unknown as StixCoreRelationshipsLinesPaginationQuery$variables; + + const queryRef = useQueryLoading( + stixCoreRelationshipsLinesQuery, + queryPaginationOptions, + ); + const preloadedPaginationProps = { + linesQuery: stixCoreRelationshipsLinesQuery, + linesFragment: stixCoreRelationshipsLinesFragment, + queryRef, + nodePath: ['stixCoreRelationships', 'pageInfo', 'globalCount'], + setNumberOfElements: storageHelpers.handleSetNumberOfElements, + } as UsePreloadedPaginationFragment; + + return ( +
+ {queryRef && ( + data.stixCoreRelationships?.edges?.map((n) => n.node)} + storageKey={LOCAL_STORAGE_KEY} + initialValues={initialValues} + contextFilters={contextFilters} + lineFragment={stixCoreRelationshipsFragment} + preloadedPaginationProps={preloadedPaginationProps} + exportContext={{ entity_id: reportId, entity_type: 'stix-core-relationship' }} + container={{ id: reportId }} + /> + )} +
+ ); +}; + +export default ReportStixCoreRelationships; diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/Root.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/Root.tsx index eef1fb71307b..d0887ed150c3 100644 --- a/opencti-platform/opencti-front/src/private/components/analyses/reports/Root.tsx +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/Root.tsx @@ -15,6 +15,7 @@ import ContainerHeader from '../../common/containers/ContainerHeader'; import Loader from '../../../../components/Loader'; import ContainerStixDomainObjects from '../../common/containers/ContainerStixDomainObjects'; import ContainerStixCyberObservables from '../../common/containers/ContainerStixCyberObservables'; +import ReportStixCoreRelationships from './ReportStixCoreRelationships'; import ErrorNotFound from '../../../../components/ErrorNotFound'; import StixCoreObjectFilesAndHistory from '../../common/stix_core_objects/StixCoreObjectFilesAndHistory'; import Breadcrumbs from '../../../../components/Breadcrumbs'; @@ -172,6 +173,11 @@ const RootReport = () => { enableReferences={enableReferences} /> ), + relationships: ( + + ), files: ( )} + {tabs.includes('relationships') && ( + + )} {tabs.includes('files') && ( )} diff --git a/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.test.tsx b/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.test.tsx index 1254326dcff5..df8761a4093c 100644 --- a/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.test.tsx +++ b/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.test.tsx @@ -20,6 +20,7 @@ const TABS_TEST_DATA = [ ['Sightings', 'sightings', '/sightings'], ['Entities', 'entities', '/entities'], ['Observables', 'observables', '/observables'], + ['Relationships', 'relationships', '/relationships'], ['Data', 'files', '/files'], ['History', 'history', '/history'], ] as const; diff --git a/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.tsx b/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.tsx index 951fbff5dcf0..8822ef3ecf67 100644 --- a/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.tsx +++ b/opencti-platform/opencti-front/src/private/components/common/stix_domain_objects/StixDomainObjectTabsBox.tsx @@ -16,6 +16,7 @@ export type StixDomainObjectTabsBoxTab | 'sightings' | 'entities' | 'observables' + | 'relationships' | 'files' | 'history'; @@ -69,6 +70,10 @@ const TABS_INFO: readonly TabInfo[] = [{ tab: 'observables', path: 'observables', label: 'Observables', +}, { + tab: 'relationships', + path: 'relationships', + label: 'Relationships', }, { tab: 'files', path: 'files', diff --git a/opencti-platform/opencti-front/tests_e2e/dataForTesting/report.data.ts b/opencti-platform/opencti-front/tests_e2e/dataForTesting/report.data.ts index 0856d2b093d2..d0a384a451c0 100644 --- a/opencti-platform/opencti-front/tests_e2e/dataForTesting/report.data.ts +++ b/opencti-platform/opencti-front/tests_e2e/dataForTesting/report.data.ts @@ -3,6 +3,7 @@ import { graphqlQuery } from './query-utils'; interface AddReportInput { name: string; + objects?: string[]; } const addReportMutation = (input: AddReportInput) => ` @@ -10,6 +11,7 @@ const addReportMutation = (input: AddReportInput) => ` reportAdd(input: { name: "${input.name}", published: "${new Date().toISOString()}" + ${input.objects ? `objects: [${input.objects.map((id) => `"${id}"`).join(', ')}]` : ''} }) { id } diff --git a/opencti-platform/opencti-front/tests_e2e/model/SDOTabs.pageModel.ts b/opencti-platform/opencti-front/tests_e2e/model/SDOTabs.pageModel.ts index f3eb3228aaa8..f2e6323959ab 100644 --- a/opencti-platform/opencti-front/tests_e2e/model/SDOTabs.pageModel.ts +++ b/opencti-platform/opencti-front/tests_e2e/model/SDOTabs.pageModel.ts @@ -38,6 +38,10 @@ export default class SDOTabs { return this.page.getByRole('tab', { name: 'Observables' }).click(); } + goToRelationshipsTab() { + return this.page.getByRole('tab', { name: 'Relationships' }).click(); + } + goToHistoryTab() { return this.page.getByRole('tab', { name: 'History' }).click(); } diff --git a/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts new file mode 100644 index 000000000000..f415300c5524 --- /dev/null +++ b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts @@ -0,0 +1,144 @@ +import { v4 as uuid } from 'uuid'; +import { expect, test } from '../fixtures/baseFixtures'; +import LeftBarPage from '../model/menu/leftBar.pageModel'; +import ReportPage from '../model/report.pageModel'; +import ReportDetailsPage from '../model/reportDetails.pageModel'; +import DataProcessingTasksPage from '../model/DataProcessingTasks.pageModel'; +import { addReport, deleteReport } from '../dataForTesting/report.data'; +import { addRelationship, deleteRelationship } from '../dataForTesting/relationship.data'; +import { graphqlQuery } from '../dataForTesting/query-utils'; +import { awaitUntilCondition, sleep } from '../utils'; + +/** + * Content of the test + * ------------------- + * Create a relationship and a report referencing it (via the API, for speed and determinism). + * Open the report's Relationships tab. + * Check that the referenced relationship is listed. + * Check that removing the report does not remove the relationship itself. + */ +test('Report relationships tab', { tag: ['@report', '@knowledge', '@mutation', '@ce', '@group1'] }, async ({ page, request }) => { + const leftNavigation = new LeftBarPage(page); + const reportPage = new ReportPage(page); + const reportDetailsPage = new ReportDetailsPage(page); + + const relationshipInput = { + relationship_type: 'targets', + fromId: 'malware--48534a79-a9d7-4c34-a292-f5f102d26dea', + toId: 'location--5acd8b26-51c2-4608-86ed-e9edd43ad971', + createdBy: 'identity--7b82b010-b1c0-4dae-981f-7756374a17df', + }; + const relationshipResponse = await addRelationship(request, relationshipInput); + const relationshipId = (await relationshipResponse.json()).data.stixCoreRelationshipAdd.id; + + const reportName = `Report with relationships - ${uuid()}`; + const reportResponse = await addReport(request, { name: reportName, objects: [relationshipId] }); + const reportId = (await reportResponse.json()).data.reportAdd.id; + + try { + await reportPage.goto(); + await reportPage.navigateFromMenu(); + await leftNavigation.open(); + + const waitForReportCreated = async () => { + await reportPage.navigateFromMenu(); + return reportPage.getItemFromList(reportName).isVisible(); + }; + await awaitUntilCondition(waitForReportCreated, 2000, 10); + + await reportPage.getItemFromList(reportName).click(); + await reportDetailsPage.tabs.goToRelationshipsTab(); + + await expect(page.getByText('E2E dashboard - Malware - month ago')).toBeVisible(); + await expect(page.getByText('targets', { exact: true })).toBeVisible(); + + await deleteReport(request, reportId); + // The relationship must still exist after the report is deleted: it is only a reference, + // deleting the report must not cascade-delete relationships it merely referenced. + const survivingRelationship = await graphqlQuery(request, ` + query { + stixCoreRelationship(id: "${relationshipId}") { + id + } + } + `); + expect((await survivingRelationship.json()).data.stixCoreRelationship?.id).toEqual(relationshipId); + } finally { + await deleteRelationship(request, relationshipInput); + } +}); + +/** + * Content of the test + * ------------------- + * Create a relationship and a report referencing it (via the API). + * Select it in the Relationships tab and launch the "Remove from the container" bulk action. + * Wait for the background task to complete. + * Check the relationship is no longer listed in the report, but still exists globally. + */ +test('Report relationships tab - bulk remove from container', { tag: ['@report', '@knowledge', '@mutation', '@ce', '@group1'] }, async ({ page, request }) => { + const leftNavigation = new LeftBarPage(page); + const reportPage = new ReportPage(page); + const reportDetailsPage = new ReportDetailsPage(page); + const tasksPage = new DataProcessingTasksPage(page); + + const relationshipInput = { + relationship_type: 'targets', + fromId: 'malware--48534a79-a9d7-4c34-a292-f5f102d26dea', + toId: 'location--5acd8b26-51c2-4608-86ed-e9edd43ad971', + createdBy: 'identity--7b82b010-b1c0-4dae-981f-7756374a17df', + }; + const relationshipResponse = await addRelationship(request, relationshipInput); + const relationshipId = (await relationshipResponse.json()).data.stixCoreRelationshipAdd.id; + + const reportName = `Report with relationships for bulk remove - ${uuid()}`; + const reportResponse = await addReport(request, { name: reportName, objects: [relationshipId] }); + const reportId = (await reportResponse.json()).data.reportAdd.id; + + try { + await reportPage.goto(); + await reportPage.navigateFromMenu(); + await leftNavigation.open(); + + const waitForReportCreated = async () => { + await reportPage.navigateFromMenu(); + return reportPage.getItemFromList(reportName).isVisible(); + }; + await awaitUntilCondition(waitForReportCreated, 2000, 10); + + await reportPage.getItemFromList(reportName).click(); + await reportDetailsPage.tabs.goToRelationshipsTab(); + await expect(page.getByText('targets', { exact: true })).toBeVisible(); + + await page.getByRole('checkbox', { name: 'Select line' }).first().click(); + const toolbar = page.getByTestId('opencti-toolbar'); + await toolbar.getByRole('button', { name: 'remove' }).click(); + await page.getByRole('button', { name: 'Launch' }).click(); + + // Background task: poll the processing tasks page until it completes. + const waitForTaskComplete = async () => { + await tasksPage.goto(); + return page.getByText('Complete').first().isVisible(); + }; + await sleep(3000); + await awaitUntilCondition(waitForTaskComplete, 3000, 20); + + await reportPage.navigateFromMenu(); + await reportPage.getItemFromList(reportName).click(); + await reportDetailsPage.tabs.goToRelationshipsTab(); + await expect(page.getByText('targets', { exact: true })).toBeHidden(); + + // The relationship must still exist globally: it was only removed from the report. + const survivingRelationship = await graphqlQuery(request, ` + query { + stixCoreRelationship(id: "${relationshipId}") { + id + } + } + `); + expect((await survivingRelationship.json()).data.stixCoreRelationship?.id).toEqual(relationshipId); + } finally { + await deleteReport(request, reportId); + await deleteRelationship(request, relationshipInput); + } +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/database/filtering-utils-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/database/filtering-utils-test.ts index 36f9791265e4..ed73fd9100e6 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/database/filtering-utils-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/database/filtering-utils-test.ts @@ -2,12 +2,59 @@ import { describe, expect, it } from 'vitest'; // Registers all entity/relation modules so schemaAttributesDefinition / schemaRelationsRefDefinition // are populated, without requiring a live DB/ES stack (pure in-memory schema registration). import '../../../src/modules/index'; -import { checkFiltersValidity } from '../../../src/utils/filtering/filtering-utils'; +import { addFilter, checkFiltersValidity, convertRelationRefsFilterKeys } from '../../../src/utils/filtering/filtering-utils'; import { buildRefRelationKey } from '../../../src/schema/general'; import { RELATION_OBJECT, RELATION_CREATED_BY } from '../../../src/schema/stixRefRelationship'; import type { FilterGroup } from '../../../src/generated/graphql'; describe('Filtering utils', () => { + describe('report relationship filters', () => { + it('should convert report membership to the indexed object reference', () => { + const filters = addFilter(undefined, 'objects', 'report-id'); + + expect(() => checkFiltersValidity(filters)).not.toThrow(); + expect(convertRelationRefsFilterKeys(filters)).toEqual({ + mode: 'and', + filters: [{ key: [buildRefRelationKey(RELATION_OBJECT, '*')], values: ['report-id'], operator: 'eq', mode: 'or' }], + filterGroups: [], + }); + expect(filters.filters[0].key).toEqual(['objects']); + }); + + it('should keep report membership mandatory when user filters use OR', () => { + const userFilters = { + mode: 'or', + filters: [ + { key: ['relationship_type'], values: ['uses'], operator: 'eq', mode: 'or' }, + { key: ['confidence'], values: ['80'], operator: 'gte', mode: 'or' }, + ], + filterGroups: [], + } as FilterGroup; + const originalFilters = structuredClone(userFilters); + const filters = addFilter(userFilters, 'objects', 'report-id'); + + expect(convertRelationRefsFilterKeys(filters)).toEqual({ + mode: 'and', + filters: [{ key: [buildRefRelationKey(RELATION_OBJECT, '*')], values: ['report-id'], operator: 'eq', mode: 'or' }], + filterGroups: [originalFilters], + }); + expect(userFilters).toEqual(originalFilters); + }); + + it('should preserve report membership inside nested filter groups', () => { + const filters = addFilter(addFilter(undefined, 'objects', 'report-id'), 'relationship_type', 'uses'); + const converted = convertRelationRefsFilterKeys(filters); + + expect(converted.filters[0].key).toEqual(['relationship_type']); + expect(converted.filterGroups[0].filters[0]).toEqual({ + key: [buildRefRelationKey(RELATION_OBJECT, '*')], + values: ['report-id'], + operator: 'eq', + mode: 'or', + }); + }); + }); + it('should reject a filter key containing an extra invalid segment after the first dot', () => { // "name" alone is a valid schema key, but the full composed key carries extra content // after the first dot that should not be accepted. diff --git a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/container-test.ts b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/container-test.ts index ebd58561fb74..62290883c9fa 100644 --- a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/container-test.ts +++ b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/container-test.ts @@ -5,6 +5,34 @@ import { queryAsAdmin } from '../../utils/testQueryHelper'; import { isStixCoreObject } from '../../../src/schema/stixCoreObject'; import { isStixCoreRelationship } from '../../../src/schema/stixCoreRelationship'; import { isStixRefRelationship } from '../../../src/schema/stixRefRelationship'; +import { addFilter } from '../../../src/utils/filtering/filtering-utils'; + +const REPORT_RELATIONSHIPS_QUERY = gql` + query paginatedReportRelationships($filters: FilterGroup!, $first: Int!, $after: ID) { + stixCoreRelationships(filters: $filters, first: $first, after: $after, orderBy: created_at, orderMode: asc) { + edges { + node { + id + relationship_type + confidence + from { ... on BasicObject { id } } + to { ... on BasicObject { id } } + } + } + pageInfo { + endCursor + hasNextPage + globalCount + } + } + } +`; + +type ReportRelationship = { + id: string; + relationship_type: string; + confidence: number; +}; describe('Container resolver standard behavior', () => { const REPORT_RAW_ID = 'report--a445d22a-db0c-4b5d-9ec8-e9ad0b6dbdd7'; @@ -195,6 +223,178 @@ describe('Container resolver standard behavior', () => { expect(relationships.length).toEqual(11); }); + it('should list and paginate only the core relationships referenced by a report', async () => { + const containerResult = await queryAsAdmin({ + query: gql` + query reportRelationships($id: String!) { + container(id: $id) { + id + objects(types: ["stix-core-relationship"], first: 100) { + edges { + node { + ... on StixCoreRelationship { + id + relationship_type + confidence + } + } + } + } + } + } + `, + variables: { id: REPORT_RAW_ID }, + }); + expect(containerResult.errors).toBeUndefined(); + const container = containerResult.data?.container; + const expectedRelationships: ReportRelationship[] = container.objects.edges.map((edge: { node: ReportRelationship }) => edge.node); + const expectedIds = expectedRelationships.map((relationship) => relationship.id); + expect(expectedIds).toHaveLength(11); + + const filters = addFilter(undefined, 'objects', container.id); + const firstPage = await queryAsAdmin({ query: REPORT_RELATIONSHIPS_QUERY, variables: { filters, first: 6 } }); + expect(firstPage.errors).toBeUndefined(); + const firstConnection = firstPage.data?.stixCoreRelationships; + expect(firstConnection.edges).toHaveLength(6); + expect(firstConnection.pageInfo.globalCount).toEqual(11); + expect(firstConnection.pageInfo.hasNextPage).toEqual(true); + + const secondPage = await queryAsAdmin({ + query: REPORT_RELATIONSHIPS_QUERY, + variables: { filters, first: 6, after: firstConnection.pageInfo.endCursor }, + }); + expect(secondPage.errors).toBeUndefined(); + const secondConnection = secondPage.data?.stixCoreRelationships; + expect(secondConnection.edges).toHaveLength(5); + expect(secondConnection.pageInfo.globalCount).toEqual(11); + expect(secondConnection.pageInfo.hasNextPage).toEqual(false); + const actualIds = [...firstConnection.edges, ...secondConnection.edges] + .map((edge: { node: { id: string } }) => edge.node.id); + expect(actualIds.sort()).toEqual(expectedIds.sort()); + + const relationshipType = expectedRelationships[0].relationship_type; + const filteredResult = await queryAsAdmin({ + query: REPORT_RELATIONSHIPS_QUERY, + variables: { filters: addFilter(filters, 'relationship_type', relationshipType), first: 100 }, + }); + expect(filteredResult.errors).toBeUndefined(); + const filteredIds = expectedRelationships + .filter((relationship) => relationship.relationship_type === relationshipType) + .map((relationship) => relationship.id); + expect(filteredResult.data?.stixCoreRelationships.pageInfo.globalCount).toEqual(filteredIds.length); + expect(filteredResult.data?.stixCoreRelationships.edges.map((edge: { node: { id: string } }) => edge.node.id).sort()) + .toEqual(filteredIds.sort()); + + const userFilters = { + ...addFilter(undefined, 'relationship_type', relationshipType), + mode: 'or', + filters: [ + ...addFilter(undefined, 'relationship_type', relationshipType).filters, + ...addFilter(undefined, 'confidence', '80', 'gte').filters, + ], + } as ReturnType; + const combinedResult = await queryAsAdmin({ + query: REPORT_RELATIONSHIPS_QUERY, + variables: { filters: addFilter(userFilters, 'objects', container.id), first: 100 }, + }); + expect(combinedResult.errors).toBeUndefined(); + const combinedIds = expectedRelationships + .filter((relationship) => relationship.relationship_type === relationshipType || relationship.confidence >= 80) + .map((relationship) => relationship.id); + expect(combinedResult.data?.stixCoreRelationships.pageInfo.globalCount).toEqual(combinedIds.length); + expect(combinedResult.data?.stixCoreRelationships.edges.map((edge: { node: { id: string } }) => edge.node.id).sort()) + .toEqual(combinedIds.sort()); + }); + + it('should require explicit report membership and preserve a shared relationship when removing it', async () => { + const relationshipResult = await queryAsAdmin({ + query: gql` + query sharedReportRelationship($id: String!) { + stixCoreRelationship(id: $id) { + id + from { ... on BasicObject { id } } + to { ... on BasicObject { id } } + } + } + `, + variables: { id: 'relationship--e35b3fc1-47f3-4ccb-a8fe-65a0864edd02' }, + }); + expect(relationshipResult.errors).toBeUndefined(); + const relationship = relationshipResult.data?.stixCoreRelationship; + expect(relationship).toBeTruthy(); + const reportIds: string[] = []; + const createReport = async (name: string, objects: string[]) => { + const result = await queryAsAdmin({ + query: gql` + mutation reportRelationshipListCreate($input: ReportAddInput!) { + reportAdd(input: $input) { id } + } + `, + variables: { input: { name, published: '2020-02-26T00:51:35.000Z', objects } }, + }); + if (result.data?.reportAdd?.id) reportIds.push(result.data.reportAdd.id); + expect(result.errors).toBeUndefined(); + return result.data?.reportAdd.id as string; + }; + const listRelationships = async (reportId: string) => { + const result = await queryAsAdmin({ + query: REPORT_RELATIONSHIPS_QUERY, + variables: { filters: addFilter(undefined, 'objects', reportId), first: 100 }, + }); + expect(result.errors).toBeUndefined(); + return result.data?.stixCoreRelationships; + }; + + try { + const reportId = await createReport('Relationship list membership test', [relationship.from.id, relationship.to.id]); + const sharedReportId = await createReport('Relationship list shared reference test', [relationship.id]); + const initiallyEmpty = await listRelationships(reportId); + expect(initiallyEmpty.edges).toEqual([]); + expect(initiallyEmpty.pageInfo.globalCount).toEqual(0); + + const addResult = await queryAsAdmin({ + query: gql` + mutation reportRelationshipListAdd($id: ID!, $input: StixRefRelationshipAddInput!) { + reportEdit(id: $id) { relationAdd(input: $input) { id } } + } + `, + variables: { id: reportId, input: { toId: relationship.id, relationship_type: 'object' } }, + }); + expect(addResult.errors).toBeUndefined(); + const afterAdd = await listRelationships(reportId); + expect(afterAdd.edges.map((edge: { node: { id: string } }) => edge.node.id)).toEqual([relationship.id]); + expect(afterAdd.pageInfo.globalCount).toEqual(1); + + const removeResult = await queryAsAdmin({ + query: gql` + mutation reportRelationshipListRemove($id: ID!, $toId: StixRef!) { + reportEdit(id: $id) { relationDelete(toId: $toId, relationship_type: "object") { id } } + } + `, + variables: { id: reportId, toId: relationship.id }, + }); + expect(removeResult.errors).toBeUndefined(); + const afterRemove = await listRelationships(reportId); + expect(afterRemove.edges).toEqual([]); + expect(afterRemove.pageInfo.globalCount).toEqual(0); + const sharedRelationships = await listRelationships(sharedReportId); + expect(sharedRelationships.edges.map((edge: { node: { id: string } }) => edge.node.id)).toEqual([relationship.id]); + expect(sharedRelationships.pageInfo.globalCount).toEqual(1); + } finally { + for (const reportId of reportIds) { + const deleted = await queryAsAdmin({ + query: gql` + mutation reportRelationshipListDelete($id: ID!) { + reportEdit(id: $id) { delete } + } + `, + variables: { id: reportId }, + }); + expect(deleted.errors).toBeUndefined(); + } + } + }); + it('should container containersObjectsOfObject from malware', async () => { const queryResult = await queryAsAdmin( { From b5fa4a4b217b4267e11920e966ef0403704ab81d Mon Sep 17 00:00:00 2001 From: Terry Pasquet Date: Fri, 18 Sep 2026 16:04:22 +0200 Subject: [PATCH 2/4] feat(report): display relationships in a report container as a list with enhanced functionality --- .../reports/ReportStixCoreRelationships.tsx | 151 ++++++++++-------- .../ReportStixCoreRelationshipsLine.test.tsx | 112 +++++++++++++ .../ReportStixCoreRelationshipsLine.tsx | 144 +++++++++++++++++ .../ReportStixCoreRelationshipsLines.tsx | 73 +++++++++ .../report/reportRelationships.spec.ts | 8 +- 5 files changed, 420 insertions(+), 68 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLines.tsx diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx index d46e1c3c66d7..c327fd6d646c 100644 --- a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationships.tsx @@ -1,9 +1,5 @@ import React, { FunctionComponent } from 'react'; -import { AutoFix } from 'mdi-material-ui'; -import { useTheme } from '@mui/styles'; -import { getDraftModeColor } from '@components/common/draft/DraftChip'; import { - stixCoreRelationshipsFragment, stixCoreRelationshipsLinesFragment, stixCoreRelationshipsLinesQuery, } from '@components/common/stix_core_relationships/StixCoreRelationships'; @@ -11,19 +7,19 @@ import { StixCoreRelationshipsLinesPaginationQuery, StixCoreRelationshipsLinesPaginationQuery$variables, } from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLinesPaginationQuery.graphql'; -import { StixCoreRelationshipsLines_data$data } from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLines_data.graphql'; -import DataTable from '../../../../components/dataGrid/DataTable'; -import { DataTableProps } from '../../../../components/dataGrid/dataTableTypes'; +import ListLines from '../../../../components/list_lines/ListLines'; +import { DataColumns } from '../../../../components/list_lines'; +import ToolBar from '../../data/ToolBar'; +import useEntityToggle from '../../../../utils/hooks/useEntityToggle'; import { usePaginationLocalStorage } from '../../../../utils/hooks/useLocalStorage'; import useQueryLoading from '../../../../utils/hooks/useQueryLoading'; -import { UsePreloadedPaginationFragment } from '../../../../utils/hooks/usePreloadedPaginationFragment'; import useAuth from '../../../../utils/hooks/useAuth'; -import ItemEntityType from '../../../../components/ItemEntityType'; -import ItemIcon from '../../../../components/ItemIcon'; -import { itemColor } from '../../../../utils/Colors'; +import { useFormatter } from '../../../../components/i18n'; import { emptyFilterGroup, isFilterGroupNotEmpty, useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../../../utils/filters/filtersUtils'; import type { Theme } from '../../../../components/Theme'; import type { FilterGroup } from '../../../../utils/filters/filtersHelpers-types'; +import ReportStixCoreRelationshipsLines from './ReportStixCoreRelationshipsLines'; +import type { ReportRelationshipNode } from './ReportStixCoreRelationshipsLine'; interface ReportStixCoreRelationshipsProps { reportId: string; @@ -45,61 +41,42 @@ export const buildReportRelationshipsContextFilters = ( }); const ReportStixCoreRelationships: FunctionComponent = ({ reportId }) => { - const theme = useTheme(); + const { t_i18n } = useFormatter(); const { platformModuleHelpers: { isRuntimeFieldEnable }, } = useAuth(); const isRuntimeSort = isRuntimeFieldEnable() ?? false; const LOCAL_STORAGE_KEY = `report-${reportId}-relationships`; - const dataColumns: DataTableProps['dataColumns'] = { - is_inferred: { - id: 'is_inferred', - label: ' ', - isSortable: false, - percentWidth: 3, - render: ({ is_inferred, entity_type, draftVersion }) => { - if (is_inferred) { - const inferredColor = draftVersion ? getDraftModeColor(theme) : itemColor(entity_type); - return (); - } - if (draftVersion) { - return (); - } - return (); - }, - }, + const dataColumns: DataColumns = { fromType: { - id: 'fromType', label: 'From type', - percentWidth: 9, + width: '11%', isSortable: false, - render: (node) => ( - - ), }, fromName: { - percentWidth: 15, + label: 'From name', + width: '16%', + isSortable: false, }, relationship_type: { - percentWidth: 8, + label: 'Relationship type', + width: '12%', + isSortable: true, }, toType: { - id: 'toType', label: 'To type', - percentWidth: 9, + width: '11%', isSortable: false, - render: (node) => ( - - ), }, toName: { - percentWidth: 15, + label: 'To name', + width: '16%', + isSortable: false, }, - createdBy: { percentWidth: 8, isSortable: isRuntimeSort }, - creator: { percentWidth: 8, isSortable: isRuntimeSort }, - created_at: { percentWidth: 15 }, - objectMarking: { percentWidth: 10, isSortable: isRuntimeSort }, + createdBy: { label: 'Author', width: '9%', isSortable: isRuntimeSort }, + created_at: { label: 'Created', width: '9%', isSortable: true }, + objectMarking: { label: 'Marking', width: '8%', isSortable: isRuntimeSort }, }; const initialValues = { @@ -130,31 +107,71 @@ const ReportStixCoreRelationships: FunctionComponent; + const { + selectedElements, + deSelectedElements, + selectAll, + numberOfSelectedElements, + handleClearSelectedElements, + handleToggleSelectAll, + onToggleEntity, + } = useEntityToggle(LOCAL_STORAGE_KEY); - return ( -
- {queryRef && ( - + + data.stixCoreRelationships?.edges?.map((n) => n.node)} - storageKey={LOCAL_STORAGE_KEY} - initialValues={initialValues} - contextFilters={contextFilters} - lineFragment={stixCoreRelationshipsFragment} - preloadedPaginationProps={preloadedPaginationProps} - exportContext={{ entity_id: reportId, entity_type: 'stix-core-relationship' }} + paginationOptions={queryPaginationOptions} + queryRef={queryRef} + setNumberOfElements={storageHelpers.handleSetNumberOfElements} + selectedElements={selectedElements} + deSelectedElements={deSelectedElements} + selectAll={selectAll} + onToggleEntity={onToggleEntity} + /> + - )} -
- ); + + + ) : null; }; export default ReportStixCoreRelationships; diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx new file mode 100644 index 000000000000..3e3f913987ae --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { BrowserRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ReportStixCoreRelationshipsLine, { ReportRelationshipNode } from './ReportStixCoreRelationshipsLine'; +import type { DataColumns } from '../../../../components/list_lines'; + +const useFragmentMock = vi.fn(); + +vi.mock('react-relay', () => ({ + useFragment: useFragmentMock, +})); + +vi.mock('@filigran/design-system', () => ({ + Checkbox: ({ checked, ...props }: { checked: boolean; 'aria-label': string }) => ( + + ), +})); + +vi.mock('../../../../components/ItemEntityType', () => ({ + default: ({ entityType }: { entityType: string }) => {entityType}, +})); + +vi.mock('../../../../components/ItemIcon', () => ({ + default: () => null, +})); + +vi.mock('../../../../utils/defaultRepresentatives', () => ({ + getMainRepresentative: (entity: { representative?: { main?: string | null } }) => entity.representative?.main, +})); + +vi.mock('../../../../utils/Entity', () => ({ + resolveLink: (entityType: string) => `/dashboard/${entityType}`, +})); + +const dataColumns: DataColumns = { + fromType: { label: 'From type', width: '11%', isSortable: false }, + fromName: { label: 'From name', width: '16%', isSortable: false }, + relationship_type: { label: 'Relationship type', width: '12%', isSortable: true }, + toType: { label: 'To type', width: '11%', isSortable: false }, + toName: { label: 'To name', width: '16%', isSortable: false }, + createdBy: { label: 'Author', width: '9%', isSortable: false }, + created_at: { label: 'Created', width: '9%', isSortable: true }, + objectMarking: { label: 'Marking', width: '8%', isSortable: false }, +}; + +const relationship: ReportRelationshipNode = { + id: 'relationship-id', + entity_type: 'stix-core-relationship', + relationship_type: 'targets', + created_at: '2026-09-18T10:00:00.000Z', + createdBy: { name: 'Analyst' }, + objectMarking: [{ definition: 'TLP:AMBER' }], + from: { + id: 'malware-id', + entity_type: 'Malware', + representative: { main: 'Example malware' }, + }, + to: { + id: 'location-id', + entity_type: 'Location', + representative: { main: 'Example location' }, + }, +}; + +const renderLine = (onToggleEntity = vi.fn()) => render( + + + + + , +); + +describe('ReportStixCoreRelationshipsLine', () => { + beforeEach(() => { + useFragmentMock.mockReturnValue(relationship); + }); + + it('renders the materialized relationship values and relation link', () => { + renderLine(); + + expect(screen.getByText('Malware')).toBeInTheDocument(); + expect(screen.getByText('Example malware')).toBeInTheDocument(); + expect(screen.getByText('targets')).toBeInTheDocument(); + expect(screen.getByText('Location')).toBeInTheDocument(); + expect(screen.getByText('Example location')).toBeInTheDocument(); + expect(screen.getByText('Analyst')).toBeInTheDocument(); + expect(screen.getByText('TLP:AMBER')).toBeInTheDocument(); + expect(screen.queryByText(/Entity_undefined|Unknown/)).not.toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/dashboard/Malware/malware-id/knowledge/relations/relationship-id', + ); + }); + + it('passes the materialized relationship to the selection handler', () => { + const onToggleEntity = vi.fn(); + renderLine(onToggleEntity); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select line' })); + + expect(onToggleEntity).toHaveBeenCalledWith(relationship, expect.anything()); + }); +}); diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.tsx new file mode 100644 index 000000000000..b16220352d0f --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { Checkbox } from '@filigran/design-system'; +import { KeyboardArrowRight } from '@mui/icons-material'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import makeStyles from '@mui/styles/makeStyles'; +import ItemEntityType from '../../../../components/ItemEntityType'; +import ItemIcon from '../../../../components/ItemIcon'; +import { bodyItemStyle } from '../../../../components/list_lines/listLineStyles'; +import { getMainRepresentative } from '../../../../utils/defaultRepresentatives'; +import { EMPTY_VALUE } from '../../../../utils/String'; +import { resolveLink } from '../../../../utils/Entity'; +import type { DataColumns } from '../../../../components/list_lines'; +import type { Theme } from '../../../../components/Theme'; +import { useFragment } from 'react-relay'; +import { stixCoreRelationshipsFragment } from '@components/common/stix_core_relationships/StixCoreRelationships'; + +export type ReportRelationshipNode = { + id: string; + entity_type: string; + relationship_type: string; + created_at: string; + createdBy?: { name: string } | null; + objectMarking?: ReadonlyArray<{ definition?: string | null }> | null; + from?: { id: string; entity_type?: string; representative?: { main?: string | null } } | null; + to?: { id: string; entity_type?: string; representative?: { main?: string | null } } | null; +}; + +const useStyles = makeStyles((theme) => ({ + item: { + paddingLeft: 10, + height: 50, + }, + itemIcon: { + color: theme.palette.primary.main, + }, + bodyItem: bodyItemStyle, + goIcon: { + position: 'absolute', + right: -10, + }, +})); + +interface ReportStixCoreRelationshipsLineProps { + dataColumns: DataColumns; + node: ReportRelationshipNode; + onToggleEntity: (node: ReportRelationshipNode, event: React.SyntheticEvent) => void; + selectedElements: Record; + deSelectedElements: Record; + selectAll: boolean; +} + +const ReportStixCoreRelationshipsLine = ({ + dataColumns, + node, + onToggleEntity, + selectedElements, + deSelectedElements, + selectAll, +}: ReportStixCoreRelationshipsLineProps) => { + const classes = useStyles(); + const relationship = useFragment(stixCoreRelationshipsFragment, node as never) as ReportRelationshipNode; + const from = relationship.from; + const to = relationship.to; + const isRestricted = !from || !to; + const relationshipLink = from + ? `${resolveLink(from.entity_type)}/${from.id}/knowledge/relations/${relationship.id}` + : to + ? `${resolveLink(to.entity_type)}/${to.id}/knowledge/relations/${relationship.id}` + : undefined; + + return ( + + onToggleEntity(relationship, event)} + > + + + + + + +
+ +
+
+ {from ? getMainRepresentative(from) : EMPTY_VALUE} +
+
+ +
+
+ +
+
+ {to ? getMainRepresentative(to) : EMPTY_VALUE} +
+
+ {relationship.createdBy?.name ?? EMPTY_VALUE} +
+
+ {relationship.created_at ?? EMPTY_VALUE} +
+
+ {isRestricted ? EMPTY_VALUE : relationship.objectMarking?.[0]?.definition ?? EMPTY_VALUE} +
+ + )} + /> + + + +
+ ); +}; + +export const ReportStixCoreRelationshipsLineDummy = ({ dataColumns }: { dataColumns: DataColumns }) => ( + + ( + + ))} + /> + +); + +export default ReportStixCoreRelationshipsLine; diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLines.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLines.tsx new file mode 100644 index 000000000000..330c5ceed14a --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLines.tsx @@ -0,0 +1,73 @@ +import React, { FunctionComponent } from 'react'; +import { PreloadedQuery } from 'react-relay'; +import ListLinesContent from '../../../../components/list_lines/ListLinesContent'; +import usePreloadedPaginationFragment from '../../../../utils/hooks/usePreloadedPaginationFragment'; +import { DataColumns } from '../../../../components/list_lines'; +import { UseLocalStorageHelpers } from '../../../../utils/hooks/useLocalStorage'; +import { + StixCoreRelationshipsLinesPaginationQuery, + StixCoreRelationshipsLinesPaginationQuery$variables, +} from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLinesPaginationQuery.graphql'; +import { + StixCoreRelationshipsLines_data$key, +} from '@components/common/stix_core_relationships/__generated__/StixCoreRelationshipsLines_data.graphql'; +import { + stixCoreRelationshipsLinesFragment, + stixCoreRelationshipsLinesQuery, +} from '@components/common/stix_core_relationships/StixCoreRelationships'; +import ReportStixCoreRelationshipsLine, { ReportStixCoreRelationshipsLineDummy, ReportRelationshipNode } from './ReportStixCoreRelationshipsLine'; + +interface ReportStixCoreRelationshipsLinesProps { + dataColumns: DataColumns; + paginationOptions: StixCoreRelationshipsLinesPaginationQuery$variables; + queryRef: PreloadedQuery; + setNumberOfElements: UseLocalStorageHelpers['handleSetNumberOfElements']; + selectedElements: Record; + deSelectedElements: Record; + selectAll: boolean; + onToggleEntity: (node: ReportRelationshipNode, event: React.SyntheticEvent) => void; +} + +const ReportStixCoreRelationshipsLines: FunctionComponent = ({ + dataColumns, + paginationOptions, + queryRef, + setNumberOfElements, + selectedElements, + deSelectedElements, + selectAll, + onToggleEntity, +}) => { + const { data, hasMore, loadMore, isLoadingMore } = usePreloadedPaginationFragment< + StixCoreRelationshipsLinesPaginationQuery, + StixCoreRelationshipsLines_data$key + >({ + linesQuery: stixCoreRelationshipsLinesQuery, + linesFragment: stixCoreRelationshipsLinesFragment, + queryRef, + nodePath: ['stixCoreRelationships', 'pageInfo', 'globalCount'], + setNumberOfElements, + }); + + return ( + + ); +}; + +export default ReportStixCoreRelationshipsLines; diff --git a/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts index f415300c5524..eece6e3bfc1f 100644 --- a/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts +++ b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts @@ -49,8 +49,12 @@ test('Report relationships tab', { tag: ['@report', '@knowledge', '@mutation', ' await reportPage.getItemFromList(reportName).click(); await reportDetailsPage.tabs.goToRelationshipsTab(); - await expect(page.getByText('E2E dashboard - Malware - month ago')).toBeVisible(); await expect(page.getByText('targets', { exact: true })).toBeVisible(); + await expect(page.getByText('Entity_undefined', { exact: true })).toBeHidden(); + await expect(page.getByText('Unknown', { exact: true })).toBeHidden(); + await expect( + page.locator('main a[href*="/knowledge/relations/"]').first(), + ).toHaveAttribute('href', /\/knowledge\/relations\//); await deleteReport(request, reportId); // The relationship must still exist after the report is deleted: it is only a reference, @@ -109,6 +113,8 @@ test('Report relationships tab - bulk remove from container', { tag: ['@report', await reportPage.getItemFromList(reportName).click(); await reportDetailsPage.tabs.goToRelationshipsTab(); await expect(page.getByText('targets', { exact: true })).toBeVisible(); + await expect(page.getByText('Entity_undefined', { exact: true })).toBeHidden(); + await expect(page.getByText('Unknown', { exact: true })).toBeHidden(); await page.getByRole('checkbox', { name: 'Select line' }).first().click(); const toolbar = page.getByTestId('opencti-toolbar'); From 30746e660640bfe918a51e4991602f1545b24914 Mon Sep 17 00:00:00 2001 From: Terry Pasquet Date: Fri, 18 Sep 2026 16:19:05 +0200 Subject: [PATCH 3/4] feat(translations): add warning message for deleting selected relationships in multiple languages --- opencti-platform/opencti-front/lang/front/de.json | 1 + opencti-platform/opencti-front/lang/front/en.json | 1 + opencti-platform/opencti-front/lang/front/es.json | 1 + opencti-platform/opencti-front/lang/front/fr.json | 1 + opencti-platform/opencti-front/lang/front/it.json | 1 + opencti-platform/opencti-front/lang/front/ja.json | 1 + opencti-platform/opencti-front/lang/front/ko.json | 1 + opencti-platform/opencti-front/lang/front/ru.json | 1 + opencti-platform/opencti-front/lang/front/zh.json | 1 + 9 files changed, 9 insertions(+) diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index fc2aab2710a1..3823b6d4e8b7 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Seien Sie vorsichtig, definieren Sie bitte einen Filter für Ihre Ausschlussregel, da sonst, da keine Filter gesetzt sind, jeder Indikator der Regel entspricht und eine Ausschlussregel haben wird", "Be careful, you are about to delete the selected entities": "Seien Sie vorsichtig, Sie sind dabei, die ausgewählten Einheiten zu löschen", "Be careful, you are about to delete the selected entities (not the relationships)": "Seien Sie vorsichtig, Sie sind dabei, die ausgewählten Entitäten zu löschen (nicht die Beziehungen)", + "Be careful, you are about to delete the selected relationships": "Seien Sie vorsichtig, Sie sind dabei, die ausgewählten Beziehungen zu löschen", "Be careful, you are about to delete the selected observables (not the relationships)": "Vorsicht, Sie sind im Begriff, die ausgewählten Observablen (nicht die Beziehungen) zu löschen", "Bearer token": "Bearer-Token", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "Denn standardmäßig enthält der Entwurf keine Änderungen, die nach der Erstellung der Workbench an der Entität vorgenommen wurden.", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 5f5206f73a9e..018cb8233473 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule", "Be careful, you are about to delete the selected entities": "Be careful, you are about to delete the selected entities", "Be careful, you are about to delete the selected entities (not the relationships)": "Be careful, you are about to delete the selected entities (not the relationships)", + "Be careful, you are about to delete the selected relationships": "Be careful, you are about to delete the selected relationships", "Be careful, you are about to delete the selected observables (not the relationships)": "Be careful, you are about to delete the selected observables (not the relationships)", "Bearer token": "Bearer token", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "Because by default the draft won't include the updates made on the entity after the creation of the workbench.", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index e2933b37ef7d..d46999488770 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Tenga cuidado, por favor defina algún filtro para su regla de exclusión, de lo contrario, ya que no se establecen filtros, cualquier indicador coincidirá con la regla y tendrá una regla de exclusión", "Be careful, you are about to delete the selected entities": "Tenga cuidado, está a punto de eliminar las entidades seleccionadas", "Be careful, you are about to delete the selected entities (not the relationships)": "Tenga cuidado, está a punto de eliminar las entidades seleccionadas (no las relaciones)", + "Be careful, you are about to delete the selected relationships": "Tenga cuidado, está a punto de eliminar las relaciones seleccionadas", "Be careful, you are about to delete the selected observables (not the relationships)": "Cuidado, está a punto de borrar los observables seleccionados (no las relaciones)", "Bearer token": "Token portador", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "Porque, por defecto, el borrador no incluirá las actualizaciones realizadas en la entidad tras la creación del entorno de trabajo.", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index e874c1655c7e..e7871a922c62 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Attention, définissez un filtre pour votre règle d'exclusion, sinon, comme aucun filtre n'est défini, n'importe quel indicateur correspondra à la règle et aura une règle d'exclusion", "Be careful, you are about to delete the selected entities": "Attention, vous êtes sur le point de supprimer les entités sélectionnées", "Be careful, you are about to delete the selected entities (not the relationships)": "Attention, vous êtes sur le point de supprimer les entités sélectionnées (pas les relations)", + "Be careful, you are about to delete the selected relationships": "Attention, vous êtes sur le point de supprimer les relations sélectionnées", "Be careful, you are about to delete the selected observables (not the relationships)": "Attention, vous êtes sur le point de supprimer les observables sélectionnés (pas les relations)", "Bearer token": "Token d'authentification", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "En effet, par défaut, le brouillon n'inclut pas les mises à jour apportées à l'entité après la création du tableau de bord.", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index eb8dd96a24df..2b801b54df4a 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Attenzione, definire un filtro per la regola di esclusione, altrimenti, poiché non sono impostati filtri, qualsiasi indicatore corrisponderà alla regola e avrà una regola di esclusione", "Be careful, you are about to delete the selected entities": "Attenzione, stai per eliminare le entità selezionate", "Be careful, you are about to delete the selected entities (not the relationships)": "Attenzione, stai per eliminare le entità selezionate (non le relazioni)", + "Be careful, you are about to delete the selected relationships": "Attenzione, stai per eliminare le relazioni selezionate", "Be careful, you are about to delete the selected observables (not the relationships)": "Attenzione, stai per eliminare gli osservabili selezionati (non le relazioni)", "Bearer token": "Token Bearer", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "Questo perché, per impostazione predefinita, la bozza non includerà gli aggiornamenti apportati all'entità dopo la creazione del workbench.", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 84ebab7a3096..12b70cc31eb5 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "さもないと、フィルターが設定されていないため、どのインジケーターもルールにマッチし、除外ルールを持つことになります。", "Be careful, you are about to delete the selected entities": "選択したエンティティを削除しようとしているので注意してください。", "Be careful, you are about to delete the selected entities (not the relationships)": "選択したエンティティを削除しようとしているので注意してください(リレーションシップではありません)。", + "Be careful, you are about to delete the selected relationships": "選択したリレーションシップを削除しようとしているので注意してください。", "Be careful, you are about to delete the selected observables (not the relationships)": "注意してください、選択されたオブザーバブルを削除しようとしています(リレーションシップではありません)", "Bearer token": "ベアラートークン", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "デフォルトでは、ワークベンチ作成後にエンティティに対して行われた更新内容は下書きに含まれないためです。", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index bf59960e94fd..35258907e163 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "제외 규칙에 대한 필터를 정의하지 않으면 필터가 설정되어 있지 않으므로 모든 지표가 규칙과 일치하고 제외 규칙을 갖게 되므로 주의하세요", "Be careful, you are about to delete the selected entities": "주의하십시오, 선택한 엔터티를 삭제하려고 합니다", "Be careful, you are about to delete the selected entities (not the relationships)": "주의하십시오, 선택한 엔터티를 삭제하려고 합니다 (관계는 제외)", + "Be careful, you are about to delete the selected relationships": "주의하십시오, 선택한 관계를 삭제하려고 합니다", "Be careful, you are about to delete the selected observables (not the relationships)": "주의하십시오, 선택한 관찰 가능 항목을 삭제하려고 합니다 (관계는 제외)", "Bearer token": "베어러 토큰", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "기본적으로 초안에는 워크벤치 생성 후 엔티티에 적용된 업데이트 내용이 포함되지 않기 때문입니다.", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index cde924be586a..b366a32d6f5a 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "Будьте внимательны, задайте какой-нибудь фильтр для правила исключения, иначе, поскольку фильтры не заданы, любой индикатор будет соответствовать правилу и будет иметь правило исключения", "Be careful, you are about to delete the selected entities": "Будьте осторожны, вы собираетесь удалить выбранные сущности.", "Be careful, you are about to delete the selected entities (not the relationships)": "Будьте осторожны, вы собираетесь удалить выбранные сущности (не связи).", + "Be careful, you are about to delete the selected relationships": "Будьте осторожны, вы собираетесь удалить выбранные связи.", "Be careful, you are about to delete the selected observables (not the relationships)": "Будьте осторожны, вы собираетесь удалить выбранные объекты наблюдения (не отношения).", "Bearer token": "Токен Bearer", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "Поскольку по умолчанию черновик не будет включать изменения, внесенные в объект после создания рабочей среды.", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index 674120adbeab..f58019a7a496 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -567,6 +567,7 @@ "Be careful, please define some filter for your exclusion rule, otherwise, since no filters are set, any indicator will match the rule and will have an exclusion rule": "请注意,请为您的排除规则定义一些筛选器,否则,由于没有设置筛选器,任何指标都将匹配该规则,并具有一个排除规则", "Be careful, you are about to delete the selected entities": "小心,您将删除选定的实体", "Be careful, you are about to delete the selected entities (not the relationships)": "小心,您将删除选定的实体(而不是关系)", + "Be careful, you are about to delete the selected relationships": "小心,您将删除选定的关系", "Be careful, you are about to delete the selected observables (not the relationships)": "小心,您将删除所选的可观察对象(而不是关系)", "Bearer token": "承载令牌", "Because by default the draft won't include the updates made on the entity after the creation of the workbench.": "因为默认情况下,草稿不会包含在工作台创建后对该实体所做的更新。", From 976fc6cc1495919975acfe9104950800bb5faf87 Mon Sep 17 00:00:00 2001 From: Terry Pasquet Date: Fri, 18 Sep 2026 17:42:46 +0200 Subject: [PATCH 4/4] feat(tests): enhance background task handling in report relationships tests --- .../ReportStixCoreRelationshipsLine.test.tsx | 2 +- .../report/reportRelationships.spec.ts | 78 ++++++++++++++++--- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx index 3e3f913987ae..d74346cfacd5 100644 --- a/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx +++ b/opencti-platform/opencti-front/src/private/components/analyses/reports/ReportStixCoreRelationshipsLine.test.tsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import ReportStixCoreRelationshipsLine, { ReportRelationshipNode } from './ReportStixCoreRelationshipsLine'; import type { DataColumns } from '../../../../components/list_lines'; -const useFragmentMock = vi.fn(); +const useFragmentMock = vi.hoisted(() => vi.fn()); vi.mock('react-relay', () => ({ useFragment: useFragmentMock, diff --git a/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts index eece6e3bfc1f..c5c2644f11ac 100644 --- a/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts +++ b/opencti-platform/opencti-front/tests_e2e/report/reportRelationships.spec.ts @@ -3,11 +3,52 @@ import { expect, test } from '../fixtures/baseFixtures'; import LeftBarPage from '../model/menu/leftBar.pageModel'; import ReportPage from '../model/report.pageModel'; import ReportDetailsPage from '../model/reportDetails.pageModel'; -import DataProcessingTasksPage from '../model/DataProcessingTasks.pageModel'; import { addReport, deleteReport } from '../dataForTesting/report.data'; import { addRelationship, deleteRelationship } from '../dataForTesting/relationship.data'; import { graphqlQuery } from '../dataForTesting/query-utils'; -import { awaitUntilCondition, sleep } from '../utils'; +import { awaitUntilCondition } from '../utils'; + +const waitForBackgroundTaskComplete = async (request: Parameters[0], taskId: string, timeoutMs = 180_000) => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await graphqlQuery(request, ` + query { + backgroundTasks( + first: 1 + filters: { + mode: and + filters: [{ key: "id", values: ["${taskId}"], operator: eq }] + filterGroups: [] + } + ) { + edges { + node { + id + completed + errors { + message + } + } + } + } + } + `); + const payload = await response.json(); + const task = payload.data?.backgroundTasks?.edges?.[0]?.node; + + if (task?.errors?.length) { + throw new Error(`Bulk removal task failed: ${task.errors[0].message ?? 'unknown error'}`); + } + if (task?.completed === true) { + return task; + } + + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + throw new Error(`Bulk removal task ${taskId} did not complete within ${timeoutMs}ms`); +}; /** * Content of the test @@ -81,10 +122,10 @@ test('Report relationships tab', { tag: ['@report', '@knowledge', '@mutation', ' * Check the relationship is no longer listed in the report, but still exists globally. */ test('Report relationships tab - bulk remove from container', { tag: ['@report', '@knowledge', '@mutation', '@ce', '@group1'] }, async ({ page, request }) => { + test.setTimeout(300_000); const leftNavigation = new LeftBarPage(page); const reportPage = new ReportPage(page); const reportDetailsPage = new ReportDetailsPage(page); - const tasksPage = new DataProcessingTasksPage(page); const relationshipInput = { relationship_type: 'targets', @@ -119,15 +160,20 @@ test('Report relationships tab - bulk remove from container', { tag: ['@report', await page.getByRole('checkbox', { name: 'Select line' }).first().click(); const toolbar = page.getByTestId('opencti-toolbar'); await toolbar.getByRole('button', { name: 'remove' }).click(); + const taskResponsePromise = page.waitForResponse((response) => ( + response.url().endsWith('/graphql') + && response.request().method() === 'POST' + && response.request().postData()?.includes('listTaskAdd') === true + )); await page.getByRole('button', { name: 'Launch' }).click(); + const taskResponse = await taskResponsePromise; + const taskPayload = await taskResponse.json(); + const taskId = taskPayload.data?.listTaskAdd?.id; + if (!taskId) { + throw new Error('Bulk removal task ID was not returned by listTaskAdd'); + } - // Background task: poll the processing tasks page until it completes. - const waitForTaskComplete = async () => { - await tasksPage.goto(); - return page.getByText('Complete').first().isVisible(); - }; - await sleep(3000); - await awaitUntilCondition(waitForTaskComplete, 3000, 20); + await waitForBackgroundTaskComplete(request, taskId); await reportPage.navigateFromMenu(); await reportPage.getItemFromList(reportName).click(); @@ -144,7 +190,15 @@ test('Report relationships tab - bulk remove from container', { tag: ['@report', `); expect((await survivingRelationship.json()).data.stixCoreRelationship?.id).toEqual(relationshipId); } finally { - await deleteReport(request, reportId); - await deleteRelationship(request, relationshipInput); + try { + await deleteReport(request, reportId); + } catch (error) { + console.warn(`Unable to delete report ${reportId}:`, error); + } + try { + await deleteRelationship(request, relationshipInput); + } catch (error) { + console.warn('Unable to delete test relationship:', error); + } } });