Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"chartjs-adapter-date-fns": "^3.0.0",
"crypto-browserify": "^3.12.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.2",
"firebase": "^9.23.0",
Comment on lines 43 to 47
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR’s title/description focuses on adding unit tests, but this change also introduces DOMPurify as a new runtime dependency (and several UI sanitization changes). Please update the PR description/title to reflect the broader scope, or split the sanitization work into a separate PR to keep review/rollback risk isolated.

Copilot uses AI. Check for mistakes.
Comment on lines 45 to 47
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOMPurify v3.3.2 declares an engines constraint of Node >=20 (see package-lock.json). Since package.json doesn’t specify an engines field, developers/CI using older Node versions may get hard-to-diagnose install failures. Consider declaring a minimum supported Node version in package.json (or using a DOMPurify version compatible with your supported Node range).

Copilot uses AI. Check for mistakes.
"firebase-admin": "^13.6.1",
"firebase-functions": "^6.6.0",
Expand Down
6 changes: 4 additions & 2 deletions src/ux/UserTest/components/steps/ConsentStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<ShowInfo :title="testTitle + ' - ' + 'Consent'">
<template #content>
<div class="test-content pa-md-4 rounded-xl">
<div class="rich-text mb-6 pa-md-4" v-html="consentText" />
<div class="rich-text mb-6 pa-md-4" v-html="sanitizedConsentText" />
<v-row justify="center">
<v-col cols="12" md="6">
<v-text-field
Expand Down Expand Up @@ -63,8 +63,9 @@ color="primary" variant="flat" block :disabled="localConsentCompleted === null |
</v-dialog>
</template>
<script setup>
import DOMPurify from 'dompurify'
import ShowInfo from '@/shared/components/ShowInfo.vue';
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
const props = defineProps({
testTitle: String,
consentText: String,
Expand All @@ -76,6 +77,7 @@ const emit = defineEmits(['update:fullNameModel', 'update:consentCompletedModel'
const localFullName = ref(props.fullNameModel);
const localConsentCompleted = ref(null); // Always start with no selection
const showDeclineModal = ref(false);
const sanitizedConsentText = computed(() => DOMPurify.sanitize(props.consentText || ''));

watch(() => props.fullNameModel, val => { localFullName.value = val; });
// Removed watcher for consentCompletedModel to prevent pre-selection
Expand Down
5 changes: 4 additions & 1 deletion src/ux/UserTest/components/steps/FinishStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
{{ finalMessage }}!
</h3>

<div class="text-body-1 mt-2 text-grey-darken-1" v-html="congratulations"></div>
<div class="text-body-1 mt-2 text-grey-darken-1" v-html="sanitizedCongratulations"></div>

<p class="text-body-1 mt-6 text-grey-darken-1">
{{ submitMessage }}
Expand All @@ -34,6 +34,8 @@
</ShowInfo>
</template>
<script setup>
import DOMPurify from 'dompurify'
import { computed } from 'vue'
import ShowInfo from '@/shared/components/ShowInfo.vue';
const props = defineProps({
finalMessage: String,
Expand All @@ -42,4 +44,5 @@ const props = defineProps({
submitBtn: String
});
const emit = defineEmits(['submit']);
const sanitizedCongratulations = computed(() => DOMPurify.sanitize(props.congratulations || ''));
</script>
8 changes: 6 additions & 2 deletions src/ux/UserTest/components/steps/TaskStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<template v-if="stage === 1">
<div
class="rich-text mb-4 task-description"
v-html="task?.taskDescription || taskDescription"
v-html="sanitizedTaskDescription"
/>

<!-- Task Preview Information -->
Expand Down Expand Up @@ -177,7 +177,7 @@
</div>
<div
class="rich-text text-body-1 task-description"
v-html="task?.taskDescription || taskDescription"
v-html="sanitizedTaskDescription"
/>
</v-col>

Expand Down Expand Up @@ -469,6 +469,7 @@

<script setup>
import { ref, watch, nextTick, computed, onBeforeUnmount } from 'vue'
import DOMPurify from 'dompurify'
import ShowInfo from '@/shared/components/ShowInfo.vue'
import TipButton from '@/ux/UserTest/components/TipButton.vue'
import AudioRecorder from '@/ux/UserTest/components/AudioRecorder.vue'
Expand Down Expand Up @@ -506,6 +507,9 @@ const props = defineProps({
remoteStream: MediaStream, // props that receive the remote video stream in case of moderated test
shouldRecordModerator: Boolean, // props that indicate whether to record the moderator's video
})

const sanitizedTaskDescription = computed(() => DOMPurify.sanitize(props.task?.taskDescription || props.taskDescription || ''))

const emit = defineEmits([
'done',
'couldNotFinish',
Expand Down
5 changes: 4 additions & 1 deletion src/ux/UserTest/components/steps/WelcomeStep.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<div
v-if="welcomeMessage"
class="text-body-1 mb-4 text-grey-darken-3"
v-html="welcomeMessage"
v-html="sanitizedWelcomeMessage"
></div>
<p v-else class="text-body-1 mb-4 text-grey-darken-3">
{{ $t('UserTestView.WelcomeStep.description') }}
Expand Down Expand Up @@ -92,6 +92,8 @@
</template>

<script setup>
import DOMPurify from 'dompurify'
import { computed } from 'vue'
import ShowInfo from '@/shared/components/ShowInfo.vue'
import { VStepperVertical } from 'vuetify/labs/VStepperVertical'
import { useDisplay } from 'vuetify'
Expand All @@ -102,6 +104,7 @@ const props = defineProps({
})
const emit = defineEmits(['start'])
const { smAndDown } = useDisplay()
const sanitizedWelcomeMessage = computed(() => DOMPurify.sanitize(props.welcomeMessage || ''))
</script>

<style scoped>
Expand Down
11 changes: 7 additions & 4 deletions src/ux/UserTest/components/task-steps/TaskPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
</div>
<div
class="description-content"
v-html="
(task && task.taskDescription) ||
$t('CreateTask.preview.noDescription')
"
v-html="sanitizedTaskDescription"
></div>
</div>

Expand Down Expand Up @@ -164,6 +161,7 @@
</template>

<script setup>
import DOMPurify from 'dompurify'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'

Expand All @@ -179,6 +177,11 @@ const props = defineProps({

const emit = defineEmits(['validate'])

const sanitizedTaskDescription = computed(() => {
const raw = (props.task && props.task.taskDescription) || t('CreateTask.preview.noDescription')
return DOMPurify.sanitize(raw)
})

const recordingFeatures = computed(() => {
if (!props.task) return []

Expand Down
4 changes: 3 additions & 1 deletion src/ux/UserTest/components/transcription/ExportPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@
</div>

<!-- eslint-disable-next-line vue/no-v-html -->
<div class="mt-4" v-html="pdfSummaryHtml"></div>
<div class="mt-4" v-html="sanitizedPdfSummaryHtml"></div>

<div v-for="(run, i) in previewRuns" :key="run.id" class="mt-6">
<h3 class="text-subtitle-2 m-0">
Expand Down Expand Up @@ -259,6 +259,7 @@

<script setup>
import { ref, watch, computed } from 'vue'
import DOMPurify from 'dompurify'
import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable'
import { QuillEditor } from '@vueup/vue-quill'
Expand Down Expand Up @@ -291,6 +292,7 @@ const pdfMeta = ref({
const pdfSummaryHtml = ref('<p>Add an executive summary here…</p>')

const controller = new TranscriptionController()
const sanitizedPdfSummaryHtml = computed(() => DOMPurify.sanitize(pdfSummaryHtml.value || ''))

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preview now uses DOMPurify, but the PDF generation path still calls stripHtml(pdfSummaryHtml.value) which assigns the (unsanitized) HTML into innerHTML. To keep a single trust boundary, consider reusing the sanitized HTML for all downstream processing (preview + strip-to-text), or sanitize inside stripHtml before assigning to innerHTML.

Suggested change
// Ensure pdfSummaryHtml is always sanitized before any downstream usage (e.g. stripHtml / PDF export)
watch(
pdfSummaryHtml,
newVal => {
const safeHtml = DOMPurify.sanitize(newVal || '')
if (safeHtml !== (newVal || '')) {
pdfSummaryHtml.value = safeHtml
}
},
{ immediate: true },
)

Copilot uses AI. Check for mistakes.
const formatIcon = computed(
() =>
Expand Down
143 changes: 143 additions & 0 deletions tests/unit/HeuristicQuestionAnswer.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import HeuristicQuestionAnswer from '@/ux/Heuristic/models/HeuristicQuestionAnswer'

describe('HeuristicQuestionAnswer', () => {
describe('constructor', () => {
it('sets all fields from provided data', () => {
const data = {
heuristicId: 1,
heuristicAnswer: { text: 'Good', value: 4 },
heuristicComment: 'Looks fine',
answerImageUrl: 'https://example.com/img.png',
}

const answer = new HeuristicQuestionAnswer(data)

expect(answer.heuristicId).toBe(1)
expect(answer.heuristicAnswer).toEqual({ text: 'Good', value: 4 })
expect(answer.heuristicComment).toBe('Looks fine')
expect(answer.answerImageUrl).toBe('https://example.com/img.png')
})

it('defaults heuristicAnswer to empty object when undefined', () => {
const answer = new HeuristicQuestionAnswer({})

expect(answer.heuristicAnswer).toEqual({})
})

it('handles no arguments (empty constructor)', () => {
const answer = new HeuristicQuestionAnswer()

expect(answer.heuristicId).toBeUndefined()
expect(answer.heuristicAnswer).toEqual({})
expect(answer.heuristicComment).toBeUndefined()
expect(answer.answerImageUrl).toBeUndefined()
})
})

describe('toFirestore', () => {
it('returns correct Firestore shape', () => {
const answer = new HeuristicQuestionAnswer({
heuristicId: 5,
heuristicAnswer: { text: 'Bad', value: 1 },
heuristicComment: 'Poor contrast',
answerImageUrl: 'https://example.com/screenshot.png',
})

expect(answer.toFirestore()).toEqual({
heuristicId: 5,
heuristicAnswer: { text: 'Bad', value: 1 },
heuristicComment: 'Poor contrast',
answerImageUrl: 'https://example.com/screenshot.png',
})
})

it('defaults answerImageUrl to empty string when falsy', () => {
const answer = new HeuristicQuestionAnswer({ heuristicId: 1 })
const result = answer.toFirestore()

expect(result.answerImageUrl).toBe('')
})

it('preserves answerImageUrl when provided', () => {
const answer = new HeuristicQuestionAnswer({
answerImageUrl: 'https://img.example.com/a.png',
})

expect(answer.toFirestore().answerImageUrl).toBe('https://img.example.com/a.png')
})
})

describe('toHeuristicQuestionAnswer (static factory)', () => {
const testOptions = [
{ text: 'Very Bad', value: 0 },
{ text: 'Bad', value: 1 },
{ text: 'Neutral', value: 2 },
{ text: 'Good', value: 3 },
{ text: 'Very Good', value: 4 },
]

it('keeps heuristicAnswer as-is when it already has a text property', () => {
const data = {
heuristicId: 10,
heuristicAnswer: { text: 'Custom', value: 99 },
heuristicComment: 'Already formatted',
}

const result = HeuristicQuestionAnswer.toHeuristicQuestionAnswer(data, testOptions)

expect(result).toBeInstanceOf(HeuristicQuestionAnswer)
expect(result.heuristicAnswer).toEqual({ text: 'Custom', value: 99 })
})

it('converts a numeric heuristicAnswer to object using testOptions', () => {
const data = {
heuristicId: 10,
heuristicAnswer: 3,
heuristicComment: 'Nice',
}

const result = HeuristicQuestionAnswer.toHeuristicQuestionAnswer(data, testOptions)

expect(result).toBeInstanceOf(HeuristicQuestionAnswer)
expect(result.heuristicAnswer).toEqual({ text: 'Good', value: 3 })
})

it('sets text to empty string when numeric value is not found in testOptions', () => {
const data = {
heuristicId: 10,
heuristicAnswer: 999,
}

const result = HeuristicQuestionAnswer.toHeuristicQuestionAnswer(data, testOptions)

expect(result.heuristicAnswer).toEqual({ text: '', value: 999 })
})

it('spreads remaining data fields onto the instance', () => {
const data = {
heuristicId: 7,
heuristicAnswer: 0,
heuristicComment: 'Terrible',
answerImageUrl: 'https://img.test/x.png',
}

const result = HeuristicQuestionAnswer.toHeuristicQuestionAnswer(data, testOptions)

expect(result.heuristicId).toBe(7)
expect(result.heuristicComment).toBe('Terrible')
expect(result.answerImageUrl).toBe('https://img.test/x.png')
expect(result.heuristicAnswer).toEqual({ text: 'Very Bad', value: 0 })
})

it('handles null heuristicAnswer gracefully', () => {
const data = {
heuristicId: 1,
heuristicAnswer: null,
}

const result = HeuristicQuestionAnswer.toHeuristicQuestionAnswer(data, testOptions)

expect(result.heuristicAnswer).toEqual({ text: '', value: null })
})
})
})
37 changes: 37 additions & 0 deletions tests/unit/StudyAnswer.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import StudyAnswer from '@/shared/models/StudyAnswer'

describe('StudyAnswer', () => {
describe('constructor', () => {
it('sets type from provided data', () => {
const answer = new StudyAnswer({ type: 'HEURISTIC' })
expect(answer.type).toBe('HEURISTIC')
})

it('handles missing type', () => {
const answer = new StudyAnswer({})
expect(answer.type).toBeUndefined()
})

it('handles no arguments', () => {
const answer = new StudyAnswer()
expect(answer.type).toBeUndefined()
})
})

describe('toFirestore', () => {
it('returns correct shape with type', () => {
const answer = new StudyAnswer({ type: 'USER' })
expect(answer.toFirestore()).toEqual({ type: 'USER' })
})

it('defaults type to empty string when null', () => {
const answer = new StudyAnswer({ type: null })
expect(answer.toFirestore()).toEqual({ type: '' })
})

it('defaults type to empty string when undefined', () => {
const answer = new StudyAnswer({})
expect(answer.toFirestore()).toEqual({ type: '' })
})
})
})
Loading
Loading