diff --git a/README.md b/README.md index 7d89875aee..71d6a29042 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Heimdall +# Heimdall [![Run E2E Backend + Frontend Tests](https://github.com/mitre/heimdall2/workflows/Run%20E2E%20Backend%20+%20Frontend%20Tests/badge.svg)](https://github.com/mitre/heimdall2/actions/workflows/e2e-ui-tests.yml) [![Run Frontend Tests](https://github.com/mitre/heimdall2/workflows/Run%20Frontend%20Tests/badge.svg)](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`
+    ]);
+  });
+});