Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Provide CWE Selector #523

Merged
merged 5 commits into from
Feb 6, 2025
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Support PURLs in affected components (`OSIDB-3412`)
* Increase step value for affects and trackers per page (`OSIDB-3508`)
* Support showing all trackers/affects in single page (`OSIDB-3506`)
* Add suggestions on CWE Field (`OSIDB-3743`)


### Fixed
Expand Down
12 changes: 12 additions & 0 deletions src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,15 @@ table {
pointer-events: none;
opacity: 0.5;
}

.flex-1 {
flex: 1;
}

.flex-2 {
flex: 2;
}

.flex-3 {
flex: 3;
}
191 changes: 191 additions & 0 deletions src/components/CweSelector/CweSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';

import EditableTextWithSuggestions from '@/widgets/EditableTextWithSuggestions/EditableTextWithSuggestions.vue';
import LabelDiv from '@/widgets/LabelDiv/LabelDiv.vue';
import type { CWEMemberType } from '@/types/mitreCwe';
import { DATA_KEY } from '@/services/CweService';

withDefaults(defineProps<{
error?: string;
label?: string;
}>(), {
label: '',
error: undefined,
Copy link
Collaborator

Choose a reason for hiding this comment

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

We're sort of split between usage of null and undefined in the codebase, but maybe undefined is better as a props default since the null values should come from the errors in useFlawModel

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I like what you have here better than the default nulls we have elsewhere

});

const modelValue = defineModel<null | string>('modelValue', { default: null });

const queryRef = ref('');

const cweData = ref<CWEMemberType[]>([]);
const suggestions = ref<CWEMemberType[]>([]);
const selectedIndex = ref(-1);

const loadCweData = () => {
const data = localStorage.getItem(DATA_KEY);
if (data) {
try {
cweData.value = JSON.parse(data);
} catch (e) {
console.error('CWESelector:loadCweData() Failed to parse CWE data:', e);
}
}
};

const filterSuggestions = (query: string) => {
queryRef.value = query;
const queryParts = query.toLowerCase().split(/(->|\(|\)|\|)/);
const lastQueryPart = queryParts[queryParts.length - 1];

suggestions.value = cweData.value.filter(
(cwe: CWEMemberType) =>
cwe.id.toLowerCase().includes(lastQueryPart)
|| `CWE-${cwe.id}`.toLowerCase().includes(lastQueryPart)
|| cwe.name.toLowerCase().includes(lastQueryPart)
|| cwe.status.toLowerCase().includes(lastQueryPart)
|| cwe.usage.toLowerCase().includes(lastQueryPart),
);

if (suggestions.value.length === 0) {
suggestions.value = cweData.value;
}
};

const handleSuggestionClick = (fn: (args?: any) => void, suggestion: string) => {
const queryParts = queryRef.value.split(/(->|\(|\)|\|)/);
const lastIndex = queryParts.length - 1;
queryParts[lastIndex] = suggestion;
superbuggy marked this conversation as resolved.
Show resolved Hide resolved
modelValue.value = queryParts.join('');
queryRef.value = modelValue.value;
suggestions.value = [];
// Ensure the modelValue is updated before calling the function
nextTick(fn);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this to defer/delay the click event until the query has been parsed into a suggestion?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not exactly. That is to ensure the suggestions and query have been updated in the DOM before the optional callback function (fn) is processed.
For example in the scenario of triggering the abort action (pressing ESC) just after clicking a suggestion.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That makes sense, would you mind adding a comment to that effect (waiting for 'abort'/'commit' events)?

};

const handleKeyDown = (event: KeyboardEvent) => {
if (suggestions.value.length === 0) return;

switch (event.key) {
case 'ArrowDown':
selectedIndex.value = (selectedIndex.value + 1) % suggestions.value.length;
break;
case 'ArrowUp':
selectedIndex.value = (selectedIndex.value - 1 + suggestions.value.length) % suggestions.value.length;
break;
case 'Enter':
if (selectedIndex.value >= 0 && selectedIndex.value < suggestions.value.length) {
handleSuggestionClick(() => {}, `CWE-${suggestions.value[selectedIndex.value].id}`);
}
break;
}
};

onMounted(loadCweData);

const usageClassMap: { [key: string]: string } = {
'prohibited': 'text-bg-primary',
'allowed': 'text-bg-success',
'allowed-with-review': 'text-bg-danger',
'discouraged': 'text-bg-warning',
};
const getUsageClass = (usage: string) => {
return usageClassMap[usage.toLowerCase()] ?? 'text-bg-secondary';
};
</script>

<template>
<LabelDiv :label class="mb-2">
<EditableTextWithSuggestions
v-model="modelValue"
:error
class="col-12"
@update:query="filterSuggestions"
@keydown="handleKeyDown($event)"
>
<template v-if="suggestions.length > 0" #suggestions="{ abort }">
<div class="dropdown-header">
<span class="flex-1">ID</span>
<span class="flex-3">Name</span>
<span class="flex-1">Usage</span>
<span class="flex-1"></span>
</div>
<div
v-for="(cwe, index) in suggestions"
:key="index"
class="item gap-1 d-flex justify-content-between"
:class="{'selected': index === selectedIndex }"
@click.prevent.stop="handleSuggestionClick(abort, `CWE-${cwe.id}`)"
@mouseenter="selectedIndex = index"
>
<span class="flex-1">{{ `CWE-${cwe.id} ` }}</span>
<span class="flex-3">{{ `${cwe.name}. ` }}</span>
<span class="badge flex-1" :class="getUsageClass(cwe.usage)">{{ `${cwe.usage}` }}</span>
<div class="flex-1">
<i v-show="cwe.summary" class="icon bi-info-circle" :title="cwe.summary" />
<a
:href="`https://cwe.mitre.org/data/definitions/${cwe.id}.html`"
class="icon"
target="_blank"
@click.stop
><i class="bi-box-arrow-up-right" title="View on Mitre" /></a>
</div>
</div>
</template>
</EditableTextWithSuggestions>
</LabelDiv>
</template>

<style scoped lang="scss">
.item.selected {
background-color: #dee2e6;
}

.dropdown-header {
display: flex;
justify-content: space-between;
position: sticky;
top: -0.5rem !important;
z-index: 1;
padding: 0.5rem 1rem;
background-color: #f8f9fa;
font-weight: bold;

&:hover {
background-color: #f8f9fa;
cursor: default;
}
}

.badge {
word-break: break-all;
white-space: normal;
}

.badge-red {
background-color: #dc3545;
color: white;
}

.badge-green {
background-color: #28a745;
color: white;
}

.badge-orange {
background-color: #fd7e14;
color: black;
}

.badge-yellow {
background-color: #ffc107;
color: black;
}

.icon {
color: gray;
font-size: 1.25rem;
float: right;
margin-left: 0.5rem;
}
</style>
4 changes: 2 additions & 2 deletions src/components/FlawForm/FlawForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import FlawHistory from '@/components/FlawHistory/FlawHistory.vue';
import FlawContributors from '@/components/FlawContributors/FlawContributors.vue';
import CvssExplainForm from '@/components/CvssExplainForm/CvssExplainForm.vue';
import FlawAffects from '@/components/FlawAffects/FlawAffects.vue';
import CweSelector from '@/components/CweSelector/CweSelector.vue';

import { useFlawModel } from '@/composables/useFlawModel';

Expand Down Expand Up @@ -244,10 +245,9 @@ const createdDate = computed(() => {
:allCvss="flaw.cvss_scores"
:nistCvss="nvdCvss3String"
/>
<LabelEditable
<CweSelector
v-model="flaw.cwe_id"
label="CWE ID"
type="text"
:error="errors.cwe_id"
/>
<LabelSelect
Expand Down
47 changes: 47 additions & 0 deletions src/components/__tests__/CweSelector.spec.ts
Copy link
Member

Choose a reason for hiding this comment

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

All the tests make use of the vm attribute to access internal state, but none of them tests actual component behavior, the functions may work but how do we know they are triggered when a user clicks something or enter text in the searchbox?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed, I have been trying to pay closer attention to the tests I write testing the UI and not the business logic of a component. That said, composables make sense to test for business logic though.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, beforeEach } from 'vitest';
import { flushPromises, type VueWrapper } from '@vue/test-utils';

import CweSelector from '@/components/CweSelector/CweSelector.vue';

import { mountWithConfig } from '@/__tests__/helpers';
import { DATA_KEY } from '@/services/CweService';

describe('cweSelector.vue', () => {
let wrapper: VueWrapper<any>;

beforeEach(() => {
wrapper = mountWithConfig(CweSelector);
vi.useFakeTimers();
});

it('renders correctly', () => {
expect(wrapper.exists()).toBe(true);
expect(wrapper.html()).toMatchSnapshot();
});

it('loads CWE data on component mount', async () => {
const data = JSON.stringify([{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' }]);
localStorage.setItem(DATA_KEY, data);
wrapper = mountWithConfig(CweSelector);
expect(wrapper.vm.cweData).toEqual([{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' }]);
});

it('filters suggestions correctly and updates model on suggestion click', async () => {
wrapper.vm.cweData = [
{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' },
{ id: '456', name: 'Another CWE', status: 'Draft', summary: '', usage: '' },
];
const input = wrapper.find('input');
await input.setValue('123');

vi.runAllTimers();
await flushPromises();
expect(wrapper.text()).toContain('CWE-123');

const suggestionRow = wrapper.findAll('.dropdown-menu .item');
await suggestionRow[0].trigger('click');
expect(wrapper.vm.modelValue).toBe('CWE-123');
expect(wrapper.vm.queryRef).toBe('CWE-123');
expect(wrapper.vm.suggestions).toEqual([]);
});
});
5 changes: 3 additions & 2 deletions src/components/__tests__/FlawForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import FlawForm from '@/components/FlawForm/FlawForm.vue';
import CvssCalculator from '@/components/CvssCalculator/CvssCalculator.vue';
import FlawFormOwner from '@/components/FlawFormOwner/FlawFormOwner.vue';
import IssueFieldEmbargo from '@/components/IssueFieldEmbargo/IssueFieldEmbargo.vue';
import CweSelector from '@/components/CweSelector/CweSelector.vue';

import { blankFlaw } from '@/composables/useFlaw';

Expand Down Expand Up @@ -106,7 +107,7 @@ describe('flawForm', () => {
expect(nvdCvssField?.exists()).toBe(true);

const cweIdField = subject
.findAllComponents(LabelEditable)
.findAllComponents(CweSelector)
.find(component => component.props().label === 'CWE ID');
expect(cweIdField?.exists()).toBe(true);

Expand Down Expand Up @@ -187,7 +188,7 @@ describe('flawForm', () => {
expect(nvdCvssField?.exists()).toBe(true);

const cweIdField = subject
.findAllComponents(LabelEditable)
.findAllComponents(CweSelector)
.find(component => component.props().label === 'CWE ID');
expect(cweIdField?.exists()).toBe(true);

Expand Down
23 changes: 23 additions & 0 deletions src/components/__tests__/__snapshots__/CweSelector.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`cweSelector.vue > renders correctly 1`] = `
"<div data-v-0e2ae48e="" data-v-478878c2="" class="osim-input ps-3 mb-2 mb-2">
<div data-v-0e2ae48e="" class="row"><span data-v-0e2ae48e="" class="form-label col-3 position-relative"><!--v-if--> <!--attrs: {{ $attrs }}--></span>
<div data-v-0e2ae48e="" class="col-9">
<!-- for invalid-tooltip positioning -->
<div data-v-478878c2="" class="position-relative col-9 osim-editable-field osim-text col-12">
<!--<Transition name="flash-bg" :duration="2000">-->
<transition-stub name="flash-bg" appear="false" persisted="true" css="true">
<div class="osim-editable-text" tabindex="0"><span class="osim-editable-text-value form-control"></span>
<!--if a button is inside a label, clicking the label clicks the button?--><button type="button" class="osim-editable-text-pen input-group-text" tabindex="-1"><i class="bi bi-pencil"></i></button>
</div>
</transition-stub>
<div class="input-group position-relative" tabindex="-1" style="display: none;">
<!--v-if--><input class="form-control" type="text"><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-check"></i></button><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-x"></i></button>
</div>
<!--v-if-->
</div>
</div>
</div>
</div>"
`;
30 changes: 17 additions & 13 deletions src/components/__tests__/__snapshots__/FlawForm.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,26 @@ exports[`flawForm > mounts and renders 1`] = `
</div>
</div>
<!--v-if-->
</div><label data-v-76509415="" data-v-76e7a15d="" class="osim-input ps-3 mb-2 input-group">
<div data-v-76509415="" class="row"><span data-v-76509415="" class="form-label col-3">CWE ID</span>
<!--v-if-->
<!-- for invalid-tooltip positioning -->
<div data-v-76509415="" class="position-relative col-9 osim-editable-field osim-text">
<!--<Transition name="flash-bg" :duration="2000">-->
<transition-stub name="flash-bg" appear="false" persisted="true" css="true">
<div class="osim-editable-text" tabindex="0"><span class="osim-editable-text-value form-control">CWE-1</span>
<!--if a button is inside a label, clicking the label clicks the button?--><button type="button" class="osim-editable-text-pen input-group-text" tabindex="-1"><i class="bi bi-pencil"></i></button>
</div>
<div data-v-0e2ae48e="" data-v-478878c2="" data-v-76e7a15d="" class="osim-input ps-3 mb-2 mb-2">
<div data-v-0e2ae48e="" class="row"><span data-v-0e2ae48e="" class="form-label col-3 position-relative"><!--v-if--> CWE ID <!--attrs: {{ $attrs }}--></span>
<div data-v-0e2ae48e="" class="col-9">
<!-- for invalid-tooltip positioning -->
<div data-v-478878c2="" class="position-relative col-9 osim-editable-field osim-text col-12">
<!--<Transition name="flash-bg" :duration="2000">-->
<transition-stub name="flash-bg" appear="false" persisted="true" css="true">
<div class="osim-editable-text" tabindex="0"><span class="osim-editable-text-value form-control">CWE-1</span>
<!--if a button is inside a label, clicking the label clicks the button?--><button type="button" class="osim-editable-text-pen input-group-text" tabindex="-1"><i class="bi bi-pencil"></i></button>
</div>
</transition-stub>
<div class="input-group position-relative" tabindex="-1" style="display: none;">
<!--v-if--><input class="form-control" type="text"><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-check"></i></button><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-x"></i></button>
</div>
</transition-stub>
<div class="input-group" style="display: none;"><input class="form-control" type="text"><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-check"></i></button><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-x"></i></button></div>
<!--v-if-->
<!--v-if-->
</div>
</div>
</div>
</label><label data-v-001a00e0="" data-v-76e7a15d="" class="osim-input mb-2 ps-3">
</div><label data-v-001a00e0="" data-v-76e7a15d="" class="osim-input mb-2 ps-3">
<div data-v-001a00e0="" class="row"><span data-v-001a00e0="" class="form-label col-3">CVE Source</span>
<div data-v-001a00e0="" class="col-9"><select data-v-001a00e0="" class="form-select" value="GIT">
<!--v-if-->
Expand Down
Loading