diff --git a/README.md b/README.md
index 7d89875aee..71d6a29042 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Heimdall
+# Heimdall
[](https://github.com/mitre/heimdall2/actions/workflows/e2e-ui-tests.yml)
[](https://github.com/mitre/heimdall2/actions/workflows/frontend-tests.yml)
diff --git a/apps/frontend/src/App.vue b/apps/frontend/src/App.vue
index 498c4cef15..601276c219 100644
--- a/apps/frontend/src/App.vue
+++ b/apps/frontend/src/App.vue
@@ -21,6 +21,10 @@
import Footer from '@/components/global/Footer.vue';
import Snackbar from '@/components/global/Snackbar.vue';
import Spinner from '@/components/global/Spinner.vue';
+import {
+ InspecDataModule,
+ UNSAVED_CHANGES_MESSAGE
+} from '@/store/data_store';
import Vue from 'vue';
import Component from 'vue-class-component';
import {ServerModule} from './store/server';
@@ -33,6 +37,24 @@ import {ServerModule} from './store/server';
}
})
export default class App extends Vue {
+ mounted() {
+ window.addEventListener('beforeunload', this.confirmBeforeUnload);
+ }
+
+ beforeDestroy() {
+ window.removeEventListener('beforeunload', this.confirmBeforeUnload);
+ }
+
+ confirmBeforeUnload(event: BeforeUnloadEvent) {
+ if (!InspecDataModule.hasUnsavedFiles) {
+ return;
+ }
+
+ event.preventDefault();
+ event.returnValue = UNSAVED_CHANGES_MESSAGE;
+ return UNSAVED_CHANGES_MESSAGE;
+ }
+
get classificationStyle() {
return {
background: ServerModule.classificationBannerColor,
diff --git a/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue b/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue
index 68b3767438..4e613ab9a2 100644
--- a/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue
+++ b/apps/frontend/src/components/cards/controltable/ControlRowDetails.vue
@@ -15,7 +15,6 @@
caveat ||
justification ||
rationale ||
- comments ||
errorMessage
"
>
@@ -38,10 +37,20 @@
Rationale: {{ rationale }}
- Comments: {{ comments }}
+
Comments:
+
@@ -61,7 +70,17 @@
{{ detail.name }}:
-
+
+
@@ -91,6 +110,7 @@
diff --git a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue
index f5308bbec9..48b52774cd 100644
--- a/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue
+++ b/apps/frontend/src/components/global/sidebaritems/SidebarFileList.vue
@@ -13,12 +13,23 @@
- {{ file.filename }}
+
+ {{ file.filename }}
+
+ mdi-content-save-alert
+
+
- mdi-content-save
+ mdi-content-save
@@ -36,16 +47,26 @@
import RouteMixin from '@/mixins/RouteMixin';
import ServerMixin from '@/mixins/ServerMixin';
import {FilteredDataModule} from '@/store/data_filters';
-import {InspecDataModule} from '@/store/data_store';
+import {InspecDataModule, UNSAVED_CHANGES_MESSAGE} from '@/store/data_store';
import {EvaluationModule} from '@/store/evaluations';
import {EvaluationFile, ProfileFile} from '@/store/report_intake';
import {SnackbarModule} from '@/store/snackbar';
+import {
+ reviewedCopyTags,
+ reviewCopyFilename
+} from '@/utilities/review_copy_util';
import {ICreateEvaluation, IEvaluation} from '@heimdall/common/interfaces';
import axios from 'axios';
import * as _ from 'lodash';
import Component, {mixins} from 'vue-class-component';
import {Prop} from 'vue-property-decorator';
+type SaveToDatabaseOptions = {
+ filename?: string;
+ successMessage?: string;
+ tagValues?: string[];
+};
+
@Component
export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) {
@Prop({type: Object}) readonly file!: EvaluationFile | ProfileFile;
@@ -75,6 +96,13 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) {
//removes uploaded file from the currently observed files
remove_file() {
+ if (
+ this.file.hasUnsavedChanges &&
+ !globalThis.confirm(`${UNSAVED_CHANGES_MESSAGE}\n\nRemove it anyway?`)
+ ) {
+ return;
+ }
+
EvaluationModule.removeEvaluation(this.file.uniqueId);
InspecDataModule.removeFile(this.file.uniqueId);
// Remove any database files that may have been in the URL
@@ -85,8 +113,16 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) {
//saves file to database
save_file() {
+ if (this.saving) {
+ return;
+ }
+
if (this.file?.database_id) {
- SnackbarModule.failure('This file is already in the database.');
+ if (!this.file.hasUnsavedChanges) {
+ SnackbarModule.failure('This file is already in the database.');
+ return;
+ }
+ this.save_reviewed_copy(this.file);
} else if (this.file) {
this.save_to_database(this.file);
}
@@ -94,48 +130,103 @@ export default class SidebarFileList extends mixins(ServerMixin, RouteMixin) {
//determines if the use can save the file
get disable_saving() {
- return typeof this.file?.database_id !== 'undefined' || this.saving;
+ return (
+ this.saving ||
+ (this.file?.database_id !== undefined && !this.file.hasUnsavedChanges)
+ );
+ }
+
+ get save_button_title(): string {
+ if (this.file?.database_id !== undefined) {
+ return 'Save reviewed copy to the database';
+ }
+ return 'Save entry to the database';
+ }
+
+ file_data(file: EvaluationFile | ProfileFile) {
+ if (file.hasOwnProperty('evaluation')) {
+ return _.get(file, 'evaluation.data');
+ }
+ return _.get(file, 'profile.data');
+ }
+
+ reviewed_copy_tags(
+ sourceEvaluation: IEvaluation | undefined,
+ sourceDatabaseId: string
+ ): string[] {
+ const sourceTags =
+ sourceEvaluation?.evaluationTags?.map((tag) => tag.value) ?? [];
+ return reviewedCopyTags(sourceTags, sourceDatabaseId);
+ }
+
+ save_reviewed_copy(file: EvaluationFile | ProfileFile) {
+ const sourceDatabaseId = file.database_id?.toString();
+ if (!sourceDatabaseId) {
+ this.save_to_database(file);
+ return;
+ }
+
+ const sourceEvaluation = EvaluationModule.evaluationForFile(file) as
+ | IEvaluation
+ | undefined;
+ this.save_to_database(file, {
+ filename: reviewCopyFilename(file.filename),
+ successMessage: 'Reviewed copy saved successfully',
+ tagValues: this.reviewed_copy_tags(sourceEvaluation, sourceDatabaseId)
+ });
+ }
+
+ append_create_evaluation_form_data(
+ formData: FormData,
+ createEvaluationDto: ICreateEvaluation
+ ) {
+ formData.append('filename', createEvaluationDto.filename);
+ formData.append('public', String(createEvaluationDto.public));
+ formData.append(
+ 'evaluationTags',
+ createEvaluationDto.evaluationTags
+ ?.map((evaluationTag) => evaluationTag.value)
+ .join(',') ?? ''
+ );
+ if (createEvaluationDto.groups !== undefined) {
+ formData.append('groups', createEvaluationDto.groups.join(','));
+ }
}
- save_to_database(file: EvaluationFile | ProfileFile) {
+ save_to_database(
+ file: EvaluationFile | ProfileFile,
+ options: SaveToDatabaseOptions = {}
+ ) {
this.saving = true;
+ const filename = options.filename ?? file.filename;
const createEvaluationDto: ICreateEvaluation = {
- filename: file.filename,
+ filename,
public: false,
- evaluationTags: [],
+ evaluationTags:
+ options.tagValues?.map((tagValue) => ({value: tagValue})) ?? [],
groups: undefined
};
// Create a multipart form to upload our data
const formData = new FormData();
- // Add the DTO objects to form data
- for (const [key, value] of Object.entries(createEvaluationDto)) {
- if (typeof value !== 'undefined') {
- formData.append(key, value);
- }
- }
+ this.append_create_evaluation_form_data(formData, createEvaluationDto);
// Add evaluation data to the form
- if (file.hasOwnProperty('evaluation')) {
- formData.append(
- 'data',
- new Blob([JSON.stringify(_.get(file, 'evaluation.data'))], {
- type: 'text/plain'
- })
- );
- } else {
- formData.append(
- 'data',
- new Blob([JSON.stringify(_.get(file, 'profile.data'))], {
- type: 'text/plain'
- })
- );
- }
+ formData.append(
+ 'data',
+ new Blob([JSON.stringify(this.file_data(file))], {
+ type: 'text/plain'
+ })
+ );
axios
.post('/evaluations', formData)
.then((response) => {
- SnackbarModule.notify('File saved successfully');
- file.database_id = parseInt(response.data.id);
+ SnackbarModule.notify(
+ options.successMessage ?? 'File saved successfully'
+ );
+ file.filename = filename;
+ file.database_id = Number.parseInt(response.data.id, 10);
+ InspecDataModule.markFileSaved(file.uniqueId);
EvaluationModule.loadEvaluation(response.data.id);
const loadedDatabaseIds = InspecDataModule.loadedDatabaseIds.join(',');
this.navigateWithNoErrors(
diff --git a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue b/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue
index cedc607260..f71a4b3ff9 100644
--- a/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue
+++ b/apps/frontend/src/components/global/upload_tabs/LoadFileList.vue
@@ -268,7 +268,7 @@ import TagRow from '@/components/global/tags/TagRow.vue';
import EditEvaluationModal from '@/components/global/upload_tabs/EditEvaluationModal.vue';
import {EvaluationModule} from '@/store/evaluations';
import {SnackbarModule} from '@/store/snackbar';
-import {InspecDataModule} from '@/store/data_store';
+import {InspecDataModule, UNSAVED_CHANGES_MESSAGE} from '@/store/data_store';
import {
IEvalPaginationParams,
IEvaluation,
@@ -653,13 +653,22 @@ export default class LoadFileList extends mixins(ServerMixin, RouteMixin) {
}
async deleteItemConfirm(): Promise {
- EvaluationModule.deleteEvaluation(this.activeItem).then(async () => {
+ const fileId = await InspecDataModule.loadedFileIsForDatabaseIds(
+ Number(this.activeItem.id)
+ );
+
+ if (
+ fileId &&
+ InspecDataModule.fileHasUnsavedChanges(fileId) &&
+ !globalThis.confirm(`${UNSAVED_CHANGES_MESSAGE}\n\nDelete it anyway?`)
+ ) {
+ this.deleteItemDialog = false;
+ return;
+ }
+
+ EvaluationModule.deleteEvaluation(this.activeItem).then(() => {
SnackbarModule.notify('Deleted evaluation successfully.');
this.updateEvaluations();
- // Remove the file from the visualization panel if it is loaded.
- const fileId = await InspecDataModule.loadedFileIsForDatabaseIds(
- Number(this.activeItem.id)
- );
if (FilteredDataModule.selected_file_ids.includes(fileId)) {
//removes uploaded file from the currently observed files
EvaluationModule.removeEvaluation(fileId);
diff --git a/apps/frontend/src/store/data_store.ts b/apps/frontend/src/store/data_store.ts
index 67fa0941e2..49fa05a458 100644
--- a/apps/frontend/src/store/data_store.ts
+++ b/apps/frontend/src/store/data_store.ts
@@ -10,6 +10,8 @@ import {
SourcedContextualizedProfile
} from '@/store/report_intake';
import Store from '@/store/store';
+import {ContextualizedControl, ExecJSON} from 'inspecjs';
+import Vue from 'vue';
import {
Action,
getModule,
@@ -19,11 +21,160 @@ import {
} from 'vuex-module-decorators';
import {FilteredDataModule} from './data_filters';
+export const UNSAVED_CHANGES_MESSAGE =
+ 'This file has unsaved comments edits. Export the file or save a reviewed ' +
+ 'copy where available before removing it from the loaded results or leaving ' +
+ 'this page.';
+
+type UpdateControlCommentsPayload = {
+ control: ContextualizedControl;
+ comments: string;
+};
+
+type EditableControlData = {
+ descriptions?:
+ | ExecJSON.ControlDescription[]
+ | Record
+ | null;
+ id: string;
+ tags?: Record;
+};
+
+function nonEmptyString(value: unknown): string | undefined {
+ if (typeof value !== 'string' && typeof value !== 'number') {
+ return undefined;
+ }
+
+ const text = String(value).trim();
+ return text.length > 0 ? text : undefined;
+}
+
/** We make some new variant types of the Contextual types, to include their files*/
export function isFromProfileFile(p: SourcedContextualizedProfile) {
return p.sourcedFrom === null;
}
+function getFileForControl(
+ control: ContextualizedControl
+): EvaluationFile | ProfileFile | undefined {
+ const profile = control.sourcedFrom as SourcedContextualizedProfile;
+ const evaluation = profile.sourcedFrom as
+ | SourcedContextualizedEvaluation
+ | null;
+ return evaluation?.from_file ?? profile.from_file;
+}
+
+export function updateDescriptionArray(
+ control: ContextualizedControl,
+ comments: string
+) {
+ const controlData = control.data as EditableControlData;
+ if (control.hdf.isProfile) {
+ const descriptions: Record =
+ controlData.descriptions && !Array.isArray(controlData.descriptions)
+ ? controlData.descriptions
+ : {};
+ descriptions.comments = comments;
+ controlData.descriptions = descriptions;
+ return;
+ }
+
+ const descriptions = Array.isArray(controlData.descriptions)
+ ? controlData.descriptions
+ : [];
+ controlData.descriptions = descriptions;
+
+ const commentDescription = descriptions.find(
+ (description: ExecJSON.ControlDescription) =>
+ description.label === 'comments'
+ );
+
+ if (commentDescription) {
+ commentDescription.data = comments;
+ } else {
+ descriptions.push({data: comments, label: 'comments'});
+ }
+}
+
+export function updateStructuredChecklistComments(
+ currentComments: unknown,
+ comments: string
+): string {
+ const existingComments =
+ typeof currentComments === 'string' ? currentComments : '';
+
+ if (!existingComments.includes(' :: ')) {
+ return comments;
+ }
+
+ let commentsSectionUpdated = false;
+ const sections = existingComments
+ .split(/\n(?=[A-Z_]+ ::)/gv)
+ .map((section) => section.trimEnd())
+ .filter((section) => section.length > 0)
+ .flatMap((section) => {
+ if (!section.startsWith('COMMENTS :: ')) {
+ return [section];
+ }
+
+ commentsSectionUpdated = true;
+ return comments ? [`COMMENTS :: ${comments}`] : [];
+ });
+
+ if (!commentsSectionUpdated && comments) {
+ sections.push(`COMMENTS :: ${comments}`);
+ }
+
+ return sections.join('\n');
+}
+
+export function updateChecklistPassthroughComments(
+ file: EvaluationFile | ProfileFile,
+ control: ContextualizedControl,
+ comments: string
+) {
+ if (!('evaluation' in file)) {
+ return;
+ }
+
+ const evaluationData = file.evaluation
+ .data as unknown as Record;
+ const passthrough = evaluationData.passthrough as
+ | {checklist?: {stigs?: {vulns?: Record[]}[]}}
+ | undefined;
+ const checklist = passthrough?.checklist;
+ if (!checklist?.stigs) {
+ return;
+ }
+
+ const controlData = control.data as EditableControlData;
+ const controlTags = controlData.tags ?? {};
+ const targetIdentifiers = [
+ [controlData.id, 'vulnNum'],
+ [controlTags.rid, 'ruleId'],
+ [controlTags.stig_id, 'ruleVer'],
+ [controlTags.STIGRef, 'stigRef']
+ ] as const;
+ for (const stig of checklist.stigs) {
+ for (const vuln of stig.vulns ?? []) {
+ const matches = targetIdentifiers
+ .map(([targetValue, vulnKey]) => {
+ const target = nonEmptyString(targetValue);
+ const source = nonEmptyString(vuln[vulnKey]);
+ return target && source ? source === target : undefined;
+ })
+ .filter((match): match is boolean => match !== undefined);
+ if (matches.length >= 2 && matches.every(Boolean)) {
+ vuln.comments = updateStructuredChecklistComments(
+ vuln.comments,
+ comments
+ );
+ return;
+ }
+ }
+ }
+}
+
@Module({
namespaced: true,
dynamic: true,
@@ -55,6 +206,18 @@ export class InspecData extends VuexModule {
return this.profileFiles;
}
+ get hasUnsavedFiles(): boolean {
+ return this.allFiles.some((file) => file.hasUnsavedChanges);
+ }
+
+ get fileHasUnsavedChanges(): (fileId: FileID) => boolean {
+ return (fileId: FileID) =>
+ Boolean(
+ this.allFiles.find((file) => file.uniqueId === fileId)
+ ?.hasUnsavedChanges
+ );
+ }
+
/**
* Returns a readonly list of all executions currently held in the data store
* including associated context
@@ -113,6 +276,38 @@ export class InspecData extends VuexModule {
this.executionFiles.push(newExecution);
}
+ @Mutation
+ updateControlComments({control, comments}: UpdateControlCommentsPayload) {
+ updateDescriptionArray(control, comments);
+ control.hdf.descriptions.comments = comments;
+
+ const file = getFileForControl(control);
+ if (file) {
+ updateChecklistPassthroughComments(file, control, comments);
+ Vue.set(file, 'hasUnsavedChanges', true);
+ }
+ }
+
+ @Mutation
+ MARK_FILE_SAVED(fileId: FileID) {
+ const file = this.allFiles.find(
+ (storedFile) => storedFile.uniqueId === fileId
+ );
+ if (file) {
+ Vue.set(file, 'hasUnsavedChanges', false);
+ }
+ }
+
+ @Action
+ markFileSaved(fileId: FileID) {
+ this.context.commit('MARK_FILE_SAVED', fileId);
+ }
+
+ @Action
+ markFilesSaved(fileIds: FileID[]) {
+ fileIds.forEach((fileId) => this.context.commit('MARK_FILE_SAVED', fileId));
+ }
+
/**
* Unloads the file with the given id
*/
diff --git a/apps/frontend/src/store/report_intake.ts b/apps/frontend/src/store/report_intake.ts
index 7e7f271064..f34f5747ce 100644
--- a/apps/frontend/src/store/report_intake.ts
+++ b/apps/frontend/src/store/report_intake.ts
@@ -69,6 +69,9 @@ export type InspecFile = {
/** The filename that this file was uploaded under. */
filename: string;
+ /** True when local edits have not been exported or saved back to the server. */
+ hasUnsavedChanges?: boolean;
+
database_id?: number;
tags?: Tag[];
diff --git a/apps/frontend/src/utilities/review_copy_util.ts b/apps/frontend/src/utilities/review_copy_util.ts
new file mode 100644
index 0000000000..480be46c57
--- /dev/null
+++ b/apps/frontend/src/utilities/review_copy_util.ts
@@ -0,0 +1,50 @@
+export const REVIEW_TAG = 'heimdall:review';
+export const REVIEW_ROOT_TAG_PREFIX = 'heimdall:review-root:';
+export const REVIEW_PARENT_TAG_PREFIX = 'heimdall:review-parent:';
+
+const REVIEW_FILENAME_SUFFIX = /\s+- review \d{4}-\d{2}-\d{2} \d{2}-\d{2}$/u;
+
+export function reviewTimestamp(date = new Date()): string {
+ const pad = (value: number) => String(value).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
+ date.getDate()
+ )} ${pad(date.getHours())}-${pad(date.getMinutes())}`;
+}
+
+export function reviewCopyFilename(filename: string, date = new Date()): string {
+ return `${filename.replace(
+ REVIEW_FILENAME_SUFFIX,
+ ''
+ )} - review ${reviewTimestamp(date)}`;
+}
+
+export function reviewRootId(tagValues: string[]): string | undefined {
+ return tagValues
+ .find((tag) => tag.startsWith(REVIEW_ROOT_TAG_PREFIX))
+ ?.slice(REVIEW_ROOT_TAG_PREFIX.length);
+}
+
+export function reviewedCopyTags(
+ sourceTagValues: string[],
+ sourceDatabaseId: string
+): string[] {
+ const rootId = reviewRootId(sourceTagValues) ?? sourceDatabaseId;
+ const reviewTags = [
+ REVIEW_TAG,
+ `${REVIEW_ROOT_TAG_PREFIX}${rootId}`,
+ `${REVIEW_PARENT_TAG_PREFIX}${sourceDatabaseId}`
+ ];
+
+ return [
+ ...new Set(
+ sourceTagValues
+ .filter(
+ (tag) =>
+ tag !== REVIEW_TAG &&
+ !tag.startsWith(REVIEW_ROOT_TAG_PREFIX) &&
+ !tag.startsWith(REVIEW_PARENT_TAG_PREFIX)
+ )
+ .concat(reviewTags)
+ )
+ ];
+}
diff --git a/apps/frontend/tests/unit/ControlRowDetails.spec.ts b/apps/frontend/tests/unit/ControlRowDetails.spec.ts
new file mode 100644
index 0000000000..0ed6f740d0
--- /dev/null
+++ b/apps/frontend/tests/unit/ControlRowDetails.spec.ts
@@ -0,0 +1,83 @@
+import ControlRowDetails from '@/components/cards/controltable/ControlRowDetails.vue';
+import {mount, Wrapper} from '@vue/test-utils';
+import {ContextualizedControl} from 'inspecjs';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import Vue from 'vue';
+import Vuetify from 'vuetify';
+import {addElemWithDataAppToBody} from '../util/testingUtils';
+
+const mocks = vi.hoisted(() => ({
+ updateControlComments: vi.fn()
+}));
+
+vi.mock('@/store/data_store', () => ({
+ InspecDataModule: {
+ updateControlComments: mocks.updateControlComments
+ }
+}));
+
+addElemWithDataAppToBody();
+
+describe('ControlRowDetails', () => {
+ const vuetify = new Vuetify();
+ let control: ContextualizedControl;
+ let wrapper: Wrapper;
+
+ beforeEach(() => {
+ mocks.updateControlComments.mockReset();
+ control = {
+ data: {
+ id: 'V-1',
+ title: 'Control title',
+ desc: 'Control description',
+ impact: 0.5,
+ tags: {},
+ refs: []
+ },
+ full_code: '',
+ hdf: {
+ descriptions: {},
+ isProfile: false,
+ rawNistTags: [],
+ wraps: {
+ tags: {}
+ }
+ },
+ root: {
+ data: {
+ tags: {}
+ },
+ hdf: {
+ finding_details: 'Finding details',
+ segments: [{status: 'passed'}],
+ severity: 'medium'
+ }
+ }
+ } as unknown as ContextualizedControl;
+
+ wrapper = mount(ControlRowDetails, {
+ vuetify,
+ propsData: {
+ control
+ },
+ stubs: {
+ ControlRowCol: true,
+ prism: true
+ }
+ });
+ });
+
+ it('always shows an editable comments field on the Test tab', () => {
+ expect(wrapper.text()).toContain('Comments:');
+ expect(wrapper.find('textarea').exists()).toBe(true);
+ });
+
+ it('updates control comments from the Test tab comments field', async () => {
+ await wrapper.find('textarea').setValue('review note');
+
+ expect(mocks.updateControlComments).toHaveBeenCalledWith({
+ comments: 'review note',
+ control
+ });
+ });
+});
diff --git a/apps/frontend/tests/unit/data_store_comments.spec.ts b/apps/frontend/tests/unit/data_store_comments.spec.ts
new file mode 100644
index 0000000000..b3309798c2
--- /dev/null
+++ b/apps/frontend/tests/unit/data_store_comments.spec.ts
@@ -0,0 +1,119 @@
+import {
+ updateChecklistPassthroughComments,
+ updateDescriptionArray,
+ updateStructuredChecklistComments
+} from '@/store/data_store';
+import {EvaluationFile} from '@/store/report_intake';
+import {ContextualizedControl} from 'inspecjs';
+import {describe, expect, it} from 'vitest';
+
+function controlWithChecklistTags(): ContextualizedControl {
+ return {
+ data: {
+ id: 'V-1',
+ descriptions: [],
+ tags: {
+ rid: 'SV-1r1_rule',
+ stig_id: 'APP-1',
+ STIGRef: 'Application Security STIG'
+ }
+ },
+ hdf: {
+ descriptions: {},
+ isProfile: false
+ }
+ } as unknown as ContextualizedControl;
+}
+
+describe('data store comment mapping', () => {
+ it('updates OHDF control descriptions with comments', () => {
+ const control = controlWithChecklistTags();
+
+ updateDescriptionArray(control, 'review note');
+
+ expect(control.hdf.descriptions.comments).toBeUndefined();
+ expect(control.data.descriptions).toContainEqual({
+ data: 'review note',
+ label: 'comments'
+ });
+ });
+
+ it('updates the COMMENTS section while preserving other CKL structured comment sections', () => {
+ expect(
+ updateStructuredChecklistComments(
+ 'FINDING_DETAILS :: existing finding\nCOMMENTS :: old comment\nSEVERITY_OVERRIDE :: Low',
+ 'review note'
+ )
+ ).toBe(
+ 'FINDING_DETAILS :: existing finding\nCOMMENTS :: review note\nSEVERITY_OVERRIDE :: Low'
+ );
+ });
+
+ it('updates matching CKL passthrough comments without changing nonmatching vulns', () => {
+ const control = controlWithChecklistTags();
+ const matchingVuln = {
+ vulnNum: 'V-1',
+ ruleId: 'SV-1r1_rule',
+ ruleVer: 'APP-1',
+ stigRef: 'Application Security STIG',
+ comments:
+ 'FINDING_DETAILS :: existing finding\nCOMMENTS :: old comment'
+ };
+ const nonmatchingVuln = {
+ vulnNum: 'V-2',
+ ruleId: 'SV-2r1_rule',
+ comments: 'COMMENTS :: keep me'
+ };
+ const file = {
+ evaluation: {
+ data: {
+ passthrough: {
+ checklist: {
+ stigs: [
+ {
+ vulns: [matchingVuln, nonmatchingVuln]
+ }
+ ]
+ }
+ }
+ }
+ }
+ } as unknown as EvaluationFile;
+
+ updateChecklistPassthroughComments(file, control, 'review note');
+
+ expect(matchingVuln.comments).toBe(
+ 'FINDING_DETAILS :: existing finding\nCOMMENTS :: review note'
+ );
+ expect(nonmatchingVuln.comments).toBe('COMMENTS :: keep me');
+ });
+
+ it('does not update CKL passthrough comments when fewer than two identifiers match', () => {
+ const control = controlWithChecklistTags();
+ const vuln = {
+ vulnNum: 'V-1',
+ ruleId: 'different-rule',
+ ruleVer: 'different-stig',
+ comments: 'COMMENTS :: keep me'
+ };
+ const file = {
+ evaluation: {
+ data: {
+ passthrough: {
+ checklist: {
+ stigs: [
+ {
+ vulns: [vuln]
+ }
+ ]
+ }
+ }
+ }
+ }
+ } as unknown as EvaluationFile;
+
+ updateChecklistPassthroughComments(file, control, 'review note');
+
+ expect(vuln.comments).toBe('COMMENTS :: keep me');
+ });
+});
diff --git a/apps/frontend/tests/unit/review_copy_util.spec.ts b/apps/frontend/tests/unit/review_copy_util.spec.ts
new file mode 100644
index 0000000000..ab9389e877
--- /dev/null
+++ b/apps/frontend/tests/unit/review_copy_util.spec.ts
@@ -0,0 +1,71 @@
+import {
+ REVIEW_PARENT_TAG_PREFIX,
+ REVIEW_ROOT_TAG_PREFIX,
+ REVIEW_TAG,
+ reviewedCopyTags,
+ reviewCopyFilename,
+ reviewRootId,
+ reviewTimestamp
+} from '@/utilities/review_copy_util';
+import {describe, expect, it} from 'vitest';
+
+describe('review copy utilities', () => {
+ const reviewDate = new Date(2026, 5, 17, 9, 5);
+
+ it('formats reviewed copy timestamps for filenames', () => {
+ expect(reviewTimestamp(reviewDate)).toBe('2026-06-17 09-05');
+ });
+
+ it('appends a reviewed copy suffix to the source filename', () => {
+ expect(reviewCopyFilename('Acme Overlay Example', reviewDate)).toBe(
+ 'Acme Overlay Example - review 2026-06-17 09-05'
+ );
+ });
+
+ it('replaces an existing reviewed copy suffix instead of stacking suffixes', () => {
+ expect(
+ reviewCopyFilename(
+ 'Acme Overlay Example - review 2026-06-16 14-30',
+ reviewDate
+ )
+ ).toBe('Acme Overlay Example - review 2026-06-17 09-05');
+ });
+
+ it('finds the root evaluation id from existing review tags', () => {
+ expect(
+ reviewRootId([
+ REVIEW_TAG,
+ `${REVIEW_ROOT_TAG_PREFIX}123`,
+ `${REVIEW_PARENT_TAG_PREFIX}456`
+ ])
+ ).toBe('123');
+ });
+
+ it('creates review tags for a first-generation reviewed copy', () => {
+ expect(reviewedCopyTags(['team:red'], '123')).toEqual([
+ 'team:red',
+ REVIEW_TAG,
+ `${REVIEW_ROOT_TAG_PREFIX}123`,
+ `${REVIEW_PARENT_TAG_PREFIX}123`
+ ]);
+ });
+
+ it('preserves the original root and updates the parent for later generations', () => {
+ expect(
+ reviewedCopyTags(
+ [
+ 'team:red',
+ REVIEW_TAG,
+ `${REVIEW_ROOT_TAG_PREFIX}123`,
+ `${REVIEW_PARENT_TAG_PREFIX}123`
+ ],
+ '456'
+ )
+ ).toEqual([
+ 'team:red',
+ REVIEW_TAG,
+ `${REVIEW_ROOT_TAG_PREFIX}123`,
+ `${REVIEW_PARENT_TAG_PREFIX}456`
+ ]);
+ });
+});