diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4ba43f170..480b5f31c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -151,6 +151,7 @@ import { IconViewComponent } from './components/icon-view/icon-view.component'; import { ObjectStatusComponent } from './components/object-status/object-status.component'; import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; import { NotesEditorComponent } from './components/stix/stix-page-tabs/notes-editor/notes-editor.component'; +import { ReleaseReviewDialogComponent } from './components/release-review-dialog/release-review-dialog.component'; import { IdentityPropertyComponent } from './components/stix/identity-property/identity-property.component'; import { CitationEditComponent } from './components/stix/citation-property/citation-edit/citation-edit.component'; @@ -350,6 +351,7 @@ export function initConfig(appConfigService: AppConfigService) { OrderedListViewComponent, OrderedListEditComponent, NotesEditorComponent, + ReleaseReviewDialogComponent, ObjectStatusComponent, RecentActivityComponent, IdentityPropertyComponent, diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts index af8470ae4..b68e5f265 100644 --- a/src/app/classes/release-tracks/api.ts +++ b/src/app/classes/release-tracks/api.ts @@ -140,7 +140,8 @@ export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase } export type ReleasePreviewSummary = - StandardReleasePreviewSummary | VirtualReleasePreviewSummary; + | StandardReleasePreviewSummary + | VirtualReleasePreviewSummary; export interface SnapshotBundleHashes { manifest_id: string; diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index bae5f0ad9..29852029b 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -2,7 +2,8 @@ import { WorkflowStatusType } from 'src/app/utils/types'; import { SnapshotTier } from './enums'; export type ReleaseTrackObjectTier = - SnapshotTier.Candidate | SnapshotTier.Staged; + | SnapshotTier.Candidate + | SnapshotTier.Staged; export type WorkflowRevisionSelector = Date | 'latest'; diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.html b/src/app/components/release-review-dialog/release-review-dialog.component.html new file mode 100644 index 000000000..c57cb9a3a --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.html @@ -0,0 +1,127 @@ + + + + Review {{ objectName }} + + Awaiting-review revision compared with the current released member. + 1">{{ progressLabel }} + + + + close + + + + + @switch (reviewItem.current.type) { + @case ('relationship') { + + } + @case ('intrusion-set') { + + } + @case ('campaign') { + + } + @case ('malware') { + + } + @case ('tool') { + + } + @case ('x-mitre-matrix') { + + } + @case ('course-of-action') { + + } + @case ('attack-pattern') { + + } + @case ('x-mitre-data-source') { + + } + @case ('x-mitre-data-component') { + + } + @case ('x-mitre-asset') { + + } + @case ('x-mitre-tactic') { + + } + @case ('x-mitre-collection') { + + } + @case ('x-mitre-detection-strategy') { + + } + @case ('x-mitre-analytic') { + + } + } + + + + + Updates requested + + + This note will be attached to the object. + + + + + + diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.scss b/src/app/components/release-review-dialog/release-review-dialog.component.scss new file mode 100644 index 000000000..36f1ca245 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.scss @@ -0,0 +1,86 @@ +@use '../../../style/colors'; + +.release-review-dialog-panel .mat-mdc-dialog-surface { + border: 1px solid; + border-radius: 10px; + overflow: hidden; + @include colors.theme-border-color; +} + +.release-review-dialog { + display: flex; + width: min(94vw, 1160px); + max-height: min(90vh, 900px); + flex-direction: column; + + .release-review-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 18px 24px; + border-bottom: 1px solid; + @include colors.theme-border-color; + @include colors.theme-property( + background, + rgba(colors.on-color(dark), 0.045), + rgba(colors.color(mitre-black), 0.025) + ); + + h2, + p { + margin: 0; + } + + p { + margin-top: 4px; + @include colors.theme-text-deemphasis; + + span { + margin-left: 12px; + font-weight: 700; + } + } + } + + .release-review-content { + min-height: 0; + flex: 1 1 auto; + padding: 20px 24px; + overflow: auto; + } + + .release-review-note { + padding: 16px 24px 0; + border-top: 1px solid; + @include colors.theme-border-color; + + mat-form-field { + width: 100%; + } + } + + .release-review-actions { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid; + @include colors.theme-border-color; + + .action-spacer { + flex: 1 1 auto; + } + } +} + +@media (max-width: 700px) { + .release-review-dialog .release-review-actions { + align-items: stretch; + flex-direction: column; + + .action-spacer { + display: none; + } + } +} diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts b/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts new file mode 100644 index 000000000..c60437cb8 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.spec.ts @@ -0,0 +1,99 @@ +import { ReleaseReviewDialogComponent } from './release-review-dialog.component'; + +describe('ReleaseReviewDialogComponent', () => { + const createComponent = (count = 2) => { + const dialogRef = { close: vi.fn() } as any; + const items = Array.from({ length: count }, (_, index) => ({ + item: { + object_ref: `attack-pattern--${index}`, + name: `Technique ${index}`, + }, + current: { + name: `Technique ${index}`, + type: 'attack-pattern', + }, + prior: null, + })) as any; + return { + component: new ReleaseReviewDialogComponent(dialogRef, { items }), + dialogRef, + }; + }; + + it('steps through items and returns approved objects', () => { + const { component, dialogRef } = createComponent(); + + component.approve(); + + expect(component.index).toBe(1); + expect(dialogRef.close).not.toHaveBeenCalled(); + + component.skip(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + approved: [expect.objectContaining({ object_ref: 'attack-pattern--0' })], + updateRequests: [], + }); + }); + + it('requires a note before requesting updates', () => { + const { component, dialogRef } = createComponent(1); + + component.requestUpdates(); + expect(dialogRef.close).not.toHaveBeenCalled(); + + component.note = 'Please update the description.'; + component.requestUpdates(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + approved: [], + updateRequests: [ + { + item: expect.objectContaining({ object_ref: 'attack-pattern--0' }), + note: 'Please update the description.', + }, + ], + }); + }); + + it('exposes the active object metadata and diff config', () => { + const { component } = createComponent(); + + expect(component.objectName).toBe('Technique 0'); + expect(component.progressLabel).toBe('1 of 2'); + expect(component.config).toEqual({ + mode: 'diff', + object: [component.reviewItem.current, component.reviewItem.prior], + editable: false, + sidebarControl: 'disable', + showRelationships: false, + }); + + delete (component.reviewItem.item as any).name; + (component.reviewItem.item as any).attack_id = 'T0001'; + expect(component.objectName).toBe('T0001'); + + delete (component.reviewItem.item as any).attack_id; + expect(component.objectName).toBe('Object'); + }); + + it('closes without a result when no actions have been completed', () => { + const { component, dialogRef } = createComponent(); + + component.cancel(); + + expect(dialogRef.close).toHaveBeenCalledWith(undefined); + }); + + it('preserves completed actions when closing before the final item', () => { + const { component, dialogRef } = createComponent(); + + component.approve(); + component.cancel(); + + expect(dialogRef.close).toHaveBeenLastCalledWith({ + approved: [expect.objectContaining({ object_ref: 'attack-pattern--0' })], + updateRequests: [], + }); + }); +}); diff --git a/src/app/components/release-review-dialog/release-review-dialog.component.ts b/src/app/components/release-review-dialog/release-review-dialog.component.ts new file mode 100644 index 000000000..242ba5df1 --- /dev/null +++ b/src/app/components/release-review-dialog/release-review-dialog.component.ts @@ -0,0 +1,109 @@ +import { Component, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { StixObject } from 'src/app/classes/stix'; +import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; +import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; + +export interface ReleaseReviewItem { + item: ReleaseTrackObjectItem; + current: StixObject; + prior: StixObject | null; +} + +export interface ReleaseReviewDialogData { + items: ReleaseReviewItem[]; +} + +export interface ReleaseReviewUpdateRequest { + item: ReleaseTrackObjectItem; + note: string; +} + +export interface ReleaseReviewDialogResult { + approved: ReleaseTrackObjectItem[]; + updateRequests: ReleaseReviewUpdateRequest[]; +} + +@Component({ + selector: 'app-release-review-dialog', + templateUrl: './release-review-dialog.component.html', + styleUrls: ['./release-review-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleaseReviewDialogComponent { + public index = 0; + public note = ''; + public showNote = false; + + private readonly result: ReleaseReviewDialogResult = { + approved: [], + updateRequests: [], + }; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ReleaseReviewDialogData + ) {} + + public get reviewItem(): ReleaseReviewItem { + return this.data.items[this.index]; + } + + public get objectName(): string { + return ( + this.reviewItem?.item?.name || + this.reviewItem?.item?.attack_id || + 'Object' + ); + } + + public get progressLabel(): string { + return `${this.index + 1} of ${this.data.items.length}`; + } + + public get config(): StixViewConfig { + return { + mode: 'diff', + object: [this.reviewItem.current, this.reviewItem.prior], + editable: false, + sidebarControl: 'disable', + showRelationships: false, + }; + } + + public approve(): void { + this.result.approved.push(this.reviewItem.item); + this.next(); + } + + public requestUpdates(): void { + const note = this.note.trim(); + if (!note) return; + + this.result.updateRequests.push({ item: this.reviewItem.item, note }); + this.next(); + } + + public skip(): void { + this.next(); + } + + public cancel(): void { + const hasCompletedActions = + this.result.approved.length > 0 || this.result.updateRequests.length > 0; + this.dialogRef.close(hasCompletedActions ? this.result : undefined); + } + + private next(): void { + if (this.index === this.data.items.length - 1) { + this.dialogRef.close(this.result); + return; + } + + this.index += 1; + this.note = ''; + this.showNote = false; + } +} diff --git a/src/app/components/stix/stix-list/stix-list.component.ts b/src/app/components/stix/stix-list/stix-list.component.ts index a0d6ce541..8efe40625 100644 --- a/src/app/components/stix/stix-list/stix-list.component.ts +++ b/src/app/components/stix/stix-list/stix-list.component.ts @@ -393,10 +393,12 @@ export class StixListComponent implements OnInit, AfterViewInit, OnDestroy { this.config.sourceRef ? sticky_allowed : false, ['relationship-name'] ); - if (!( - this.config.relationshipType && - this.config.relationshipType == 'subtechnique-of' - )) + if ( + !( + this.config.relationshipType && + this.config.relationshipType == 'subtechnique-of' + ) + ) this.addColumn( 'description', 'description', diff --git a/src/app/components/workbench-chip/workbench-chip.component.ts b/src/app/components/workbench-chip/workbench-chip.component.ts index 9a1d47a3d..7ba927cd7 100644 --- a/src/app/components/workbench-chip/workbench-chip.component.ts +++ b/src/app/components/workbench-chip/workbench-chip.component.ts @@ -6,7 +6,12 @@ import { } from 'src/app/utils/types'; export type WorkbenchChipVariant = - 'standard' | 'virtual' | 'tagged' | 'draft' | 'latest' | WorkflowStatusType; + | 'standard' + | 'virtual' + | 'tagged' + | 'draft' + | 'latest' + | WorkflowStatusType; const WORKBENCH_CHIP_LABELS: Record = { standard: 'Standard', diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 68a34ac0f..89aa535e6 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -101,7 +101,11 @@ export const WORKFLOW_STATUS_RANK: Record = { * Collection/release changelog categories */ export type ChangelogCategory = - 'additions' | 'changes' | 'minor_changes' | 'revocations' | 'deprecations'; + | 'additions' + | 'changes' + | 'minor_changes' + | 'revocations' + | 'deprecations'; export interface ReleaseTrackStatus { trackId: string; diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index a5c56555b..4d05f178a 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -334,7 +334,25 @@ Resolution Details }"> {{ lane.title }} ({{ lane.items.length }}) - + + + fact_check + Review all + + + done_all + Approve all + { }; mockRestApiConnector = { getAllObjects: vi.fn(() => of(createPaginatedResponse([]))), + postNote: vi.fn(() => of({})), triggerBrowserDownload: vi.fn(), }; const mockBreadcrumbService = { @@ -94,6 +96,7 @@ describe('ReleaseTrackPageComponent', () => { }; mockAuthenticationService = { canEdit: vi.fn(() => true), + isAuthorized: vi.fn(() => true), }; await TestBed.configureTestingModule({ @@ -2278,7 +2281,7 @@ describe('ReleaseTrackPageComponent', () => { expect(component.canManuallyPromote(lanes[0])).toBe(true); expect(component.canManuallyDemote(lanes[1])).toBe(true); expect(component.canReviewAndApprove(lanes[0].items[0], lanes[0])).toBe( - false + true ); }); @@ -2318,13 +2321,38 @@ describe('ReleaseTrackPageComponent', () => { const refreshSpy = vi .spyOn(component, 'getReleaseTrack') .mockImplementation(() => undefined); - mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); - component.id = 'release-track--123'; - - component.onReviewAndApprove({ + const candidate = { object_ref: 'attack-pattern--123', object_modified: new Date('2024-04-20T00:00:00.000Z'), + object_status: 'awaiting-review', + } as any; + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockReturnValue( + of({ current: { type: 'attack-pattern' }, prior: null }) + ); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ + approved: [candidate], + updateRequests: [], + }), }); + component.id = 'release-track--123'; + + component.onReviewAndApprove(candidate); + + expect(mockDialog.open).toHaveBeenCalledWith( + ReleaseReviewDialogComponent, + expect.objectContaining({ + data: { + items: [ + expect.objectContaining({ + item: candidate, + }), + ], + }, + }) + ); expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( 'release-track--123', @@ -2341,4 +2369,339 @@ describe('ReleaseTrackPageComponent', () => { ); expect(refreshSpy).toHaveBeenCalled(); }); + + it('should attach a note when updates are requested', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const candidate = { + object_ref: 'attack-pattern--123', + object_status: 'awaiting-review', + name: 'Example technique', + } as any; + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockReturnValue( + of({ current: { type: 'attack-pattern' }, prior: null }) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ + approved: [], + updateRequests: [{ item: candidate, note: 'Clarify the procedure.' }], + }), + }); + component.id = 'release-track--123'; + + component.onReviewAndApprove(candidate); + + expect(mockRestApiConnector.postNote).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Updates requested: Example technique', + content: 'Clarify the procedure.', + object_refs: ['attack-pattern--123'], + }) + ); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should restrict review actions to team leads and admins', () => { + mockAuthenticationService.isAuthorized.mockReturnValue(false); + const lane = { + type: 'candidate', + statusFallback: 'awaiting-review', + items: [{ object_ref: 'attack-pattern--123' }], + } as any; + + expect(component.canReviewAndApprove(lane.items[0], lane)).toBe(false); + expect(component.canReviewLane(lane)).toBe(false); + }); + + it('should step through all awaiting-review candidates in bulk review', () => { + const candidates = [ + { + object_ref: 'attack-pattern--one', + object_status: 'awaiting-review', + }, + { + object_ref: 'attack-pattern--wip', + object_status: 'work-in-progress', + }, + { + object_ref: 'attack-pattern--two', + object_status: 'awaiting-review', + }, + ] as any[]; + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockReturnValue( + of({ current: { type: 'attack-pattern' }, prior: null }) + ); + mockDialog.open.mockReturnValue({ afterClosed: () => of(undefined) }); + + component.onReviewAll(candidates); + + expect(mockDialog.open).toHaveBeenCalledWith( + ReleaseReviewDialogComponent, + expect.objectContaining({ + data: { + items: [ + expect.objectContaining({ item: candidates[0] }), + expect.objectContaining({ item: candidates[2] }), + ], + }, + }) + ); + }); + + it('should confirm before approving all awaiting-review candidates', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const candidates = [ + { + object_ref: 'attack-pattern--one', + object_status: 'awaiting-review', + }, + { + object_ref: 'attack-pattern--wip', + object_status: 'work-in-progress', + }, + { + object_ref: 'attack-pattern--two', + object_status: 'awaiting-review', + }, + ] as any[]; + component.id = 'release-track--123'; + mockDialog.open.mockReturnValue({ afterClosed: () => of(true) }); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + + component.onApproveAll(candidates); + + expect(mockDialog.open).toHaveBeenCalledWith( + ConfirmationDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Approve all awaiting-review objects?', + yes_label: 'Approve all (2)', + }), + }) + ); + expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( + 'release-track--123', + { + from: 'awaiting-review', + to: 'reviewed', + object_refs: ['attack-pattern--one', 'attack-pattern--two'], + } + ); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should not bulk approve when confirmation is cancelled', () => { + mockDialog.open.mockReturnValue({ afterClosed: () => of(false) }); + + component.onApproveAll([ + { + object_ref: 'attack-pattern--review', + object_status: 'awaiting-review', + }, + ] as any); + + expect( + mockReleaseTrackApiConnector.reviewCandidates + ).not.toHaveBeenCalled(); + }); + + it('should not open review without permission or awaiting-review objects', () => { + component.onReviewAll([ + { + object_ref: 'attack-pattern--wip', + object_status: 'work-in-progress', + }, + ] as any); + expect(mockDialog.open).not.toHaveBeenCalled(); + + mockAuthenticationService.isAuthorized.mockReturnValue(false); + component.onReviewAll([ + { + object_ref: 'attack-pattern--review', + object_status: 'awaiting-review', + }, + ] as any); + expect(mockDialog.open).not.toHaveBeenCalled(); + + component.onApproveAll([ + { + object_ref: 'attack-pattern--review', + object_status: 'awaiting-review', + }, + ] as any); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should report when review objects cannot be loaded', () => { + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockReturnValue( + of({ current: null, prior: null }) + ); + + component.onReviewAll([ + { + object_ref: 'attack-pattern--missing', + object_status: 'awaiting-review', + }, + ] as any); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to load the objects awaiting review.', + undefined, + { duration: 4000, panelClass: 'error' } + ); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('should omit review objects that fail to resolve', () => { + const candidates = [ + { + object_ref: 'attack-pattern--loaded', + object_status: 'awaiting-review', + }, + { + object_ref: 'attack-pattern--missing', + object_status: 'awaiting-review', + }, + ] as any[]; + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockImplementation( + (item: any) => + of({ + current: + item.object_ref === 'attack-pattern--loaded' + ? { type: 'attack-pattern' } + : null, + prior: null, + }) + ); + mockDialog.open.mockReturnValue({ afterClosed: () => of(undefined) }); + + component.onReviewAll(candidates); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Some objects could not be loaded and were omitted from review.', + undefined, + { duration: 4000 } + ); + expect(mockDialog.open).toHaveBeenCalledWith( + ReleaseReviewDialogComponent, + expect.objectContaining({ + data: { items: [expect.objectContaining({ item: candidates[0] })] }, + }) + ); + }); + + it('should report an error while loading review objects', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + vi.spyOn(component as any, 'resolveReviewDiffObjects').mockReturnValue( + throwError(() => new Error('load failed')) + ); + + component.onReviewAll([ + { + object_ref: 'attack-pattern--error', + object_status: 'awaiting-review', + }, + ] as any); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to load the objects awaiting review.', + undefined, + { duration: 4000, panelClass: 'error' } + ); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should resolve review diffs against the released member', () => { + const candidate = { + object_ref: 'attack-pattern--123', + object_modified: '2026-01-02T00:00:00.000Z', + } as any; + component.releaseTrack = { + members: [ + { + object_ref: 'attack-pattern--123', + object_modified: '2026-01-01T00:00:00.000Z', + }, + ], + } as any; + const fetchSpy = vi + .spyOn(component as any, 'fetchObjectVersion') + .mockImplementation((_id: string, modified: string) => + of({ modified } as any) + ); + let result: any; + + (component as any) + .resolveReviewDiffObjects(candidate) + .subscribe((value: any) => (result = value)); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.current.modified).toBe('2026-01-02T00:00:00.000Z'); + expect(result.prior.modified).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('should resolve a new review object without a member baseline', () => { + const candidate = { + object_ref: 'attack-pattern--new', + object_modified: '2026-01-02T00:00:00.000Z', + } as any; + component.releaseTrack = { members: [] } as any; + vi.spyOn(component as any, 'fetchObjectVersion').mockReturnValue( + of({ type: 'attack-pattern' } as any) + ); + let result: any; + + (component as any) + .resolveReviewDiffObjects(candidate) + .subscribe((value: any) => (result = value)); + + expect(result.current).toEqual({ type: 'attack-pattern' }); + expect(result.prior).toBeNull(); + }); + + it('should refresh and notify when saving review actions fails', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue( + throwError(() => new Error('save failed')) + ); + component.id = 'release-track--123'; + + (component as any).applyReviewResult({ + approved: [{ object_ref: 'attack-pattern--123' }], + updateRequests: [], + }); + + expect(mockSnackbar.open).toHaveBeenCalledWith( + 'Unable to save all review updates.', + undefined, + { duration: 5000, panelClass: 'error' } + ); + expect(refreshSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should ignore empty or cancelled review results', () => { + (component as any).applyReviewResult(undefined); + component.id = 'release-track--123'; + (component as any).applyReviewResult({ + approved: [], + updateRequests: [], + }); + + expect( + mockReleaseTrackApiConnector.reviewCandidates + ).not.toHaveBeenCalled(); + expect(mockRestApiConnector.postNote).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index d94af7eae..6b6642a5c 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -35,6 +35,8 @@ import { StixObjectRef, } from 'src/app/classes/release-tracks'; import { StixObject } from 'src/app/classes/stix'; +import { Note } from 'src/app/classes/stix/note'; +import { Role } from 'src/app/classes/authn/role'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; @@ -43,6 +45,11 @@ import { ReleasePreviewDialogComponent, ReleasePreviewSelection, } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; +import { + ReleaseReviewDialogComponent, + ReleaseReviewDialogResult, + ReleaseReviewItem, +} from 'src/app/components/release-review-dialog/release-review-dialog.component'; import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; @@ -984,11 +991,50 @@ export class ReleaseTrackPageComponent implements OnInit { } public onReviewAndApprove(item: any): void { - this.reviewCandidateStatus( - WorkflowStatus.AwaitingReview, - WorkflowStatus.Reviewed, - [item] + this.openReviewDialog([item]); + } + + public onReviewAll(items: ReleaseTrackObjectItem[]): void { + this.openReviewDialog(items); + } + + public onApproveAll(items: ReleaseTrackObjectItem[]): void { + const awaitingReview = items.filter( + item => this.getObjectStatus(item) === WorkflowStatus.AwaitingReview ); + if (!this.canReviewReleaseTrack || !awaitingReview.length) return; + + this.dialog + .open(ConfirmationDialogComponent, { + width: '32em', + autoFocus: false, + data: { + title: 'Approve all awaiting-review objects?', + message: + 'This will approve every awaiting-review object without stepping through its changes. Bulk approval is dangerous and cannot be undone from this review screen.', + no_label: 'Cancel', + yes_label: `Approve all (${awaitingReview.length})`, + confirm_color: 'warn', + confirm_appearance: 'raised', + layout: 'simple', + }, + }) + .afterClosed() + .pipe(take(1)) + .subscribe(confirmed => { + if (!confirmed) return; + this.applyReviewResult({ + approved: awaitingReview, + updateRequests: [], + }); + }); + } + + public get canReviewReleaseTrack(): boolean { + return this.authenticationService.isAuthorized([ + Role.ADMIN, + Role.TEAM_LEAD, + ]); } public canAddCandidates(lane: ReleaseTrackWorkspaceLane): boolean { @@ -1000,12 +1046,22 @@ export class ReleaseTrackPageComponent implements OnInit { lane: ReleaseTrackWorkspaceLane ): boolean { return ( - this.autoPromotionEnabled && + this.canReviewReleaseTrack && lane.type === 'candidate' && this.getLaneStatus(item, lane) === WorkflowStatus.AwaitingReview ); } + public canReviewLane(lane: ReleaseTrackWorkspaceLane): boolean { + return ( + this.canReviewReleaseTrack && + lane.type === 'candidate' && + lane.items.some( + item => this.getLaneStatus(item, lane) === WorkflowStatus.AwaitingReview + ) + ); + } + public canManuallyPromote(lane: ReleaseTrackWorkspaceLane): boolean { return !this.autoPromotionEnabled && lane.type === 'candidate'; } @@ -2228,30 +2284,130 @@ export class ReleaseTrackPageComponent implements OnInit { : undefined; } - private reviewCandidateStatus( - from: WorkflowStatusType, - to: WorkflowStatusType, - items: any[] - ): void { - if (!this.id || !items.length) return; + private openReviewDialog(items: ReleaseTrackObjectItem[]): void { + const awaitingReview = items.filter( + item => this.getObjectStatus(item) === WorkflowStatus.AwaitingReview + ); + if (!this.canReviewReleaseTrack || !awaitingReview.length) return; + + forkJoin( + awaitingReview.map(item => + this.resolveReviewDiffObjects(item).pipe( + map(({ current, prior }) => + current ? ({ item, current, prior } as ReleaseReviewItem) : null + ) + ) + ) + ) + .pipe(take(1)) + .subscribe({ + next: resolvedItems => { + const reviewItems = resolvedItems.filter( + (item): item is ReleaseReviewItem => !!item + ); + if (!reviewItems.length) { + this.snackbar.open( + 'Unable to load the objects awaiting review.', + undefined, + { duration: 4000, panelClass: 'error' } + ); + return; + } + + if (reviewItems.length !== awaitingReview.length) { + this.snackbar.open( + 'Some objects could not be loaded and were omitted from review.', + undefined, + { duration: 4000 } + ); + } + + this.openResolvedReviewDialog(reviewItems); + }, + error: err => { + console.error('Failed to load objects for review', err); + this.snackbar.open( + 'Unable to load the objects awaiting review.', + undefined, + { duration: 4000, panelClass: 'error' } + ); + }, + }); + } + + /** + * Review always compares the proposed candidate revision with the current + * released member. A staged entry is deliberately not used as the baseline. + */ + private resolveReviewDiffObjects(item: ReleaseTrackObjectItem): Observable<{ + current: StixObject | null; + prior: StixObject | null; + }> { + return this.resolveDiffObjects(item, this.findMemberEntry(item.object_ref)); + } + + private openResolvedReviewDialog(items: ReleaseReviewItem[]): void { + this.dialog + .open< + ReleaseReviewDialogComponent, + { items: ReleaseReviewItem[] }, + ReleaseReviewDialogResult + >(ReleaseReviewDialogComponent, { + data: { items }, + panelClass: 'release-review-dialog-panel', + maxWidth: 'none', + autoFocus: false, + restoreFocus: true, + }) + .afterClosed() + .pipe(take(1)) + .subscribe(result => this.applyReviewResult(result)); + } + + private applyReviewResult(result?: ReleaseReviewDialogResult): void { + if (!this.id || !result) return; - const objectRefs = items + const requests: Observable[] = []; + const approvedRefs = result.approved .map(item => this.getReviewObjectRef(item)) .filter((ref): ref is StixObjectRef => !!ref); - if (!objectRefs.length) return; + if (approvedRefs.length) { + requests.push( + this.connector.reviewCandidates(this.id, { + from: WorkflowStatus.AwaitingReview, + to: WorkflowStatus.Reviewed, + object_refs: approvedRefs, + }) + ); + } - this.connector - .reviewCandidates(this.id, { - from, - to, - object_refs: objectRefs, - }) + result.updateRequests.forEach(request => { + const note = new Note(); + note.title = `Updates requested: ${request.item.name || request.item.attack_id || 'Object'}`; + note.content = request.note; + note.object_refs = [request.item.object_ref]; + requests.push(this.restApiConnectorService.postNote(note)); + }); + + if (!requests.length) return; + + forkJoin(requests) .pipe(take(1)) .subscribe({ - next: () => this.refreshReleaseTrackState(), + next: () => { + this.snackbar.open('Review updates saved.', undefined, { + duration: 3000, + }); + this.refreshReleaseTrackState(); + }, error: err => { - console.error('Failed to update candidate review status', err); + console.error('Failed to save release track review', err); + this.snackbar.open('Unable to save all review updates.', undefined, { + duration: 5000, + panelClass: 'error', + }); + this.refreshReleaseTrackState(); }, }); } @@ -2312,18 +2468,7 @@ export class ReleaseTrackPageComponent implements OnInit { : this.findMemberEntry(item.object_ref); const baselineEntry = stagedEntry ?? memberEntry; - return forkJoin({ - current: this.fetchObjectVersion( - item.object_ref, - this.getDiffObjectModified(item) - ), - prior: baselineEntry - ? this.fetchObjectVersion( - baselineEntry.object_ref, - this.getDiffObjectModified(baselineEntry) - ) - : of(null), - }).pipe( + return this.resolveDiffObjects(item, baselineEntry).pipe( map(({ current, prior }) => ({ current, prior, @@ -2339,24 +2484,34 @@ export class ReleaseTrackPageComponent implements OnInit { }> { const memberEntry = this.findMemberEntry(item.object_ref); + return this.resolveDiffObjects(item, memberEntry).pipe( + map(({ current, prior }) => ({ + current, + prior, + expectedBaseline: !!memberEntry, + })) + ); + } + + private resolveDiffObjects( + item: ReleaseTrackObjectItem, + baselineEntry: ReleaseTrackObjectItem | null + ): Observable<{ + current: StixObject | null; + prior: StixObject | null; + }> { return forkJoin({ current: this.fetchObjectVersion( item.object_ref, this.getDiffObjectModified(item) ), - prior: memberEntry + prior: baselineEntry ? this.fetchObjectVersion( - memberEntry.object_ref, - this.getDiffObjectModified(memberEntry) + baselineEntry.object_ref, + this.getDiffObjectModified(baselineEntry) ) : of(null), - }).pipe( - map(({ current, prior }) => ({ - current, - prior, - expectedBaseline: !!memberEntry, - })) - ); + }); } private fetchObjectVersion( diff --git a/src/app/views/stix/relationship-view/relationship-view.component.ts b/src/app/views/stix/relationship-view/relationship-view.component.ts index fe588de8f..d94638960 100644 --- a/src/app/views/stix/relationship-view/relationship-view.component.ts +++ b/src/app/views/stix/relationship-view/relationship-view.component.ts @@ -240,11 +240,9 @@ export class RelationshipViewComponent extends StixViewPage implements OnInit { * @param {any} obj the raw STIX object */ public navigateTo(obj: any): void { - if (!( - obj?.stix?.type && - obj?.stix?.id && - StixTypeToAttackType[obj.stix.type] - )) { + if ( + !(obj?.stix?.type && obj?.stix?.id && StixTypeToAttackType[obj.stix.type]) + ) { console.warn('Invalid object passed to navigateTo:', obj); return; }
+ Awaiting-review revision compared with the current released member. + 1">{{ progressLabel }} +