From 3f9a16d574cdc059c87d0a079e6acb2809c84932 Mon Sep 17 00:00:00 2001 From: Reece Bedding Date: Fri, 2 Oct 2026 14:26:49 +0100 Subject: [PATCH 1/2] feat: add displaying of evidence subjects, and support for selecting subject on evidence --- src/components/EvidenceList.vue | 93 +++++++- src/components/__tests__/EvidenceList.spec.ts | 146 ++++++++++++ src/components/evidence/SubjectFilter.vue | 64 +++++ src/components/evidence/SubjectPicker.vue | 134 +++++++++++ src/components/evidence/SubjectsSection.vue | 143 ++++++++++++ .../evidence/__tests__/SubjectFilter.spec.ts | 80 +++++++ .../evidence/__tests__/SubjectPicker.spec.ts | 139 +++++++++++ .../__tests__/SubjectsSection.spec.ts | 221 ++++++++++++++++++ .../__tests__/useEvidenceConfig.spec.ts | 29 +++ src/composables/evidence/useEvidenceConfig.ts | 20 ++ .../__tests__/useSubjectSearch.spec.ts | 96 ++++++++ src/composables/subjects/useSubjectSearch.ts | 58 +++++ src/stores/evidence.ts | 10 +- src/types/subject-templates.spec.ts | 70 ++++++ src/types/subject-templates.ts | 67 ++++++ src/types/subjects.ts | 65 ++++++ src/utils/links.spec.ts | 20 ++ src/utils/links.ts | 19 ++ .../SubjectTemplateDetailView.spec.ts | 5 + .../__tests__/SubjectTemplatesView.spec.ts | 36 +++ src/views/admin/SubjectTemplateDetailView.vue | 21 ++ src/views/admin/SubjectTemplatesView.vue | 36 +++ src/views/evidence/CreateView.vue | 4 + src/views/evidence/IndexView.vue | 76 +++++- src/views/evidence/UpdateView.vue | 4 + src/views/evidence/ViewView.vue | 197 ++++++++-------- .../evidence/__tests__/IndexView.spec.ts | 107 +++++++++ src/views/evidence/__tests__/ViewView.spec.ts | 48 ++++ src/views/evidence/partial/EvidenceForm.vue | 40 +++- .../partial/__tests__/EvidenceForm.spec.ts | 65 ++++++ 30 files changed, 2013 insertions(+), 100 deletions(-) create mode 100644 src/components/evidence/SubjectFilter.vue create mode 100644 src/components/evidence/SubjectPicker.vue create mode 100644 src/components/evidence/SubjectsSection.vue create mode 100644 src/components/evidence/__tests__/SubjectFilter.spec.ts create mode 100644 src/components/evidence/__tests__/SubjectPicker.spec.ts create mode 100644 src/components/evidence/__tests__/SubjectsSection.spec.ts create mode 100644 src/composables/evidence/__tests__/useEvidenceConfig.spec.ts create mode 100644 src/composables/evidence/useEvidenceConfig.ts create mode 100644 src/composables/subjects/__tests__/useSubjectSearch.spec.ts create mode 100644 src/composables/subjects/useSubjectSearch.ts create mode 100644 src/types/subjects.ts create mode 100644 src/utils/links.spec.ts create mode 100644 src/utils/links.ts diff --git a/src/components/EvidenceList.vue b/src/components/EvidenceList.vue index aaba82bf..3a5f785e 100644 --- a/src/components/EvidenceList.vue +++ b/src/components/EvidenceList.vue @@ -3,6 +3,7 @@ + @@ -42,6 +43,9 @@ {{ getSortLabel('name') }} + + Subjects + + +
+ + + {{ subjectTitle(subject) }} + + +
+ + + {{ formatDateTime(item.end) }} @@ -156,6 +206,19 @@ /> + +
+ +
+
diff --git a/src/components/evidence/SubjectPicker.vue b/src/components/evidence/SubjectPicker.vue new file mode 100644 index 00000000..071232f6 --- /dev/null +++ b/src/components/evidence/SubjectPicker.vue @@ -0,0 +1,134 @@ + + + diff --git a/src/components/evidence/SubjectsSection.vue b/src/components/evidence/SubjectsSection.vue new file mode 100644 index 00000000..fc5b1f37 --- /dev/null +++ b/src/components/evidence/SubjectsSection.vue @@ -0,0 +1,143 @@ + + + diff --git a/src/components/evidence/__tests__/SubjectFilter.spec.ts b/src/components/evidence/__tests__/SubjectFilter.spec.ts new file mode 100644 index 00000000..b4a11133 --- /dev/null +++ b/src/components/evidence/__tests__/SubjectFilter.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import AutoComplete from '@/volt/AutoComplete.vue'; +import SubjectFilter from '../SubjectFilter.vue'; + +const { unsupported } = vi.hoisted(() => ({ unsupported: { value: false } })); + +vi.mock('@/composables/subjects/useSubjectSearch', () => ({ + useSubjectSearch: () => ({ + suggestions: ref([]), + unsupported: ref(unsupported.value), + search: vi.fn(), + }), +})); + +const subject = { + subjectUuid: 's-1', + type: 'component', + kind: 'system-component' as const, + title: 'Perimeter Firewall', + context: 'Payments Platform', +}; + +function mountFilter(modelValue: typeof subject | null = null) { + return mount(SubjectFilter, { + props: { modelValue }, + global: { + stubs: { + AutoComplete: { + props: ['modelValue', 'placeholder'], + emits: ['update:modelValue', 'complete'], + template: '', + }, + TertiaryButton: { + emits: ['click'], + template: + '', + }, + BIconX: { template: '' }, + }, + }, + }); +} + +describe('SubjectFilter', () => { + it('shows "Subject: any" until a subject is picked', () => { + const wrapper = mountFilter(); + + expect(wrapper.get('input').attributes('placeholder')).toBe('Subject: any'); + expect(wrapper.find('button').exists()).toBe(false); + }); + + it('emits the picked subject, ignoring text typed in between', async () => { + const wrapper = mountFilter(); + const autoComplete = wrapper.findComponent(AutoComplete); + + autoComplete.vm.$emit('update:modelValue', 'Perim'); + autoComplete.vm.$emit('update:modelValue', subject); + autoComplete.vm.$emit('update:modelValue', ''); + + expect(wrapper.emitted('update:modelValue')).toEqual([[subject], [null]]); + }); + + it('clears the filter', async () => { + const wrapper = mountFilter(subject); + + await wrapper.get('button').trigger('click'); + + expect(wrapper.emitted('update:modelValue')).toEqual([[null]]); + }); + + it('is hidden when the API does not support subjects', () => { + unsupported.value = true; + const wrapper = mountFilter(); + + expect(wrapper.find('input').exists()).toBe(false); + unsupported.value = false; + }); +}); diff --git a/src/components/evidence/__tests__/SubjectPicker.spec.ts b/src/components/evidence/__tests__/SubjectPicker.spec.ts new file mode 100644 index 00000000..5bec038c --- /dev/null +++ b/src/components/evidence/__tests__/SubjectPicker.spec.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import AutoComplete from '@/volt/AutoComplete.vue'; +import Select from '@/volt/Select.vue'; +import SubjectPicker from '../SubjectPicker.vue'; +import type { SubjectSummary } from '@/types/subjects'; + +const { searchMock, suggestions, unsupported } = vi.hoisted(() => ({ + searchMock: vi.fn(), + suggestions: { value: [] as unknown[] }, + unsupported: { value: false }, +})); + +vi.mock('@/composables/subjects/useSubjectSearch', () => ({ + useSubjectSearch: () => ({ + suggestions: ref(suggestions.value), + unsupported: ref(unsupported.value), + search: searchMock, + }), +})); + +vi.mock('@/composables/axios', () => ({ + useDataApi: () => ({ + data: ref([ + { id: 'ssp-1', metadata: { title: 'Payments Platform' } }, + { id: 'ssp-2' }, + ]), + }), +})); + +const firewall: SubjectSummary = { + subjectUuid: 's-1', + type: 'component', + kind: 'system-component', + title: 'Perimeter Firewall', + context: 'Payments Platform', +}; +const networkTeam: SubjectSummary = { + subjectUuid: 's-2', + type: 'party', + kind: 'party', + title: 'Network Team', +}; + +function mountPicker(modelValue: SubjectSummary[] = []) { + return mount(SubjectPicker, { + props: { modelValue }, + global: { + stubs: { + AutoComplete: { + props: ['modelValue', 'suggestions', 'invalid'], + emits: ['update:modelValue', 'complete', 'option-select'], + template: '', + }, + Select: { + props: ['modelValue', 'options'], + emits: ['update:modelValue'], + template: ' + + +
+ Share @@ -153,6 +158,9 @@ import type { SortDirection, } from '@/stores/evidence.ts'; import EvidenceList from '@/components/EvidenceList.vue'; +import SubjectFilter from '@/components/evidence/SubjectFilter.vue'; +import { useSubjectSearch } from '@/composables/subjects/useSubjectSearch'; +import type { SubjectSummary } from '@/types/subjects'; import BurgerMenu from '@/components/BurgerMenu.vue'; import { useAuthenticatedInstance, useDataApi } from '@/composables/axios'; import type { PaginatedListResponse } from '@/stores/types.ts'; @@ -178,6 +186,12 @@ const exportingCsv = ref(false); const lastExecutedFilter = ref(''); const lastExecutedSortBy = ref('lastSeenAt'); const lastExecutedSortDirection = ref('desc'); +// The subject filter lives in the route (?subject=) like the other list filters; +// selectedSubject is what the filter shows. +const selectedSubject = ref(null); +// Set once the API returns subjects on evidence rows; older APIs get no subject filter. +const subjectsSupported = ref(false); +const { lookup: lookupSubjects } = useSubjectSearch(); const EVIDENCE_PAGE_SIZE = 50; const EVIDENCE_EXPORT_PAGE_SIZE = 100; @@ -229,14 +243,16 @@ watch( () => route.query.page, () => route.query.sortBy, () => route.query.sortDirection, + () => route.query.subject, ], ( - [newFilter, newPage, newSortBy, newSortDirection], - [oldFilter, oldPage, oldSortBy, oldSortDirection], + [newFilter, newPage, newSortBy, newSortDirection, newSubject], + [oldFilter, oldPage, oldSortBy, oldSortDirection, oldSubject], ) => { const nextFilter = getRouteFilterValue(newFilter); uiStore.setEvidenceFilter(nextFilter); + syncSubjectFromRoute(newSubject); currentPage.value = parsePageQuery(newPage); applySort( @@ -248,7 +264,8 @@ watch( newFilter === oldFilter && newPage === oldPage && newSortBy === oldSortBy && - newSortDirection === oldSortDirection + newSortDirection === oldSortDirection && + newSubject === oldSubject ) { return; } @@ -462,6 +479,11 @@ function buildEvidenceSearchRequest( params.set('name', searchText); } + const subjectUuid = getRouteSubjectUuid(route.query.subject); + if (subjectUuid) { + params.set('subjectUuid', subjectUuid); + } + const complianceParams: Record = { interval: EVIDENCE_STATUS_INTERVAL, }; @@ -493,6 +515,7 @@ const navigationQuery = computed>(() => { sortDirection: sortDirection.value === 'desc' ? undefined : sortDirection.value, page: currentPage.value > 1 ? String(currentPage.value) : undefined, + subject: getRouteSubjectUuid(route.query.subject), }; }); @@ -520,6 +543,9 @@ async function search(page = currentPage.value) { const paginatedEvidence = evidenceResponse.data; evidence.value = paginatedEvidence.data ?? []; + if (evidence.value.some((item) => item.subjectReferences !== undefined)) { + subjectsSupported.value = true; + } totalEvidence.value = paginatedEvidence.total ?? 0; currentPage.value = paginatedEvidence.page ?? page; totalPages.value = Math.max(paginatedEvidence.totalPages ?? 1, 1); @@ -781,6 +807,49 @@ async function changeSort(nextSortBy: EvidenceSortBy) { }); } +function getRouteSubjectUuid(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +// Keeps the filter in step with the route. A subject that arrives by URL (e.g. from an +// evidence's Subjects card) shows its UUID until its title is looked up. +function syncSubjectFromRoute(value: unknown) { + const subjectUuid = getRouteSubjectUuid(value); + if (!subjectUuid) { + selectedSubject.value = null; + return; + } + if (selectedSubject.value?.subjectUuid === subjectUuid) { + return; + } + + subjectsSupported.value = true; + selectedSubject.value = { + subjectUuid, + type: '', + kind: 'defined-component', + title: subjectUuid, + }; + void lookupSubjects([subjectUuid]).then(([found]) => { + if (found && selectedSubject.value?.subjectUuid === subjectUuid) { + selectedSubject.value = found; + } + }); +} + +// Changing the subject goes through the route (back to the first page); the route watcher +// runs the search. +async function changeSubject(subject: SubjectSummary | null) { + selectedSubject.value = subject; + await router.replace({ + query: { + ...route.query, + subject: subject?.subjectUuid, + page: undefined, + }, + }); +} + async function changePage(page: number) { const nextPage = Math.min(Math.max(page, 1), totalPages.value); @@ -810,6 +879,7 @@ onMounted(() => { const nextFilter = getRouteFilterValue(route.query.filter); uiStore.setEvidenceFilter(nextFilter); + syncSubjectFromRoute(route.query.subject); currentPage.value = parsePageQuery(route.query.page); applySort( parseSortByQuery(route.query.sortBy, uiStore.evidenceSortBy), diff --git a/src/views/evidence/UpdateView.vue b/src/views/evidence/UpdateView.vue index e2109283..e00c9989 100644 --- a/src/views/evidence/UpdateView.vue +++ b/src/views/evidence/UpdateView.vue @@ -24,6 +24,7 @@ import EvidenceForm from './partial/EvidenceForm.vue'; import PageHeader from '@/components/PageHeader.vue'; import PageSubHeader from '@/components/PageSubHeader.vue'; import type { + DeclaredSubject, Evidence, EvidenceLabel, EvidenceStatus, @@ -63,6 +64,7 @@ async function submit( updatedEvidence: Partial, labels: EvidenceLabel[], status: EvidenceStatus, + subjects: DeclaredSubject[], ) { const flatLabels = {} as Record; labels.forEach((label) => { @@ -73,6 +75,8 @@ async function submit( ...updatedEvidence, status: status, labels: flatLabels, + // The picked subjects replace any subjects on the evidence being re-submitted. + subjects, backMatter: { resources: backmatterResources.value, }, diff --git a/src/views/evidence/ViewView.vue b/src/views/evidence/ViewView.vue index 43c559b6..6c69dcb1 100644 --- a/src/views/evidence/ViewView.vue +++ b/src/views/evidence/ViewView.vue @@ -167,83 +167,101 @@
- -

- Current State -

-
-
-

- Status Reason -

-

- {{ evidence.status?.reason || 'No reason provided.' }} -

-
-
-

- Labels -

-

- {{ evidence.labels.length }} -

-
-
-

- Tasks -

-

- {{ evidence.activities?.length || 0 }} -

-
-
-

- Metadata Links -

-

- {{ metadataLinks.length }} -

-
-
-

- Back Matter Resources -

-

- {{ backMatterResources.length }} -

-
-
-

- {{ overviewLifecycleLabel }} -

- -

- {{ expirationLabel }} -

+ Status Reason +

+

+ {{ evidence.status?.reason || 'No reason provided.' }} +

+
+
+

+ Labels +

+

+ {{ evidence.labels.length }} +

+
+
+

+ Tasks +

+

+ {{ evidence.activities?.length || 0 }} +

+
+
+

+ Metadata Links +

+

+ {{ metadataLinks.length }} +

+
+
+

+ Back Matter Resources +

+

+ {{ backMatterResources.length }} +

+
+
+

+ {{ overviewLifecycleLabel }} +

+ +

+ {{ expirationLabel }} +

+
- -
+ + + +

+ Subjects +

+ +
+ metadataProps.value.find((prop) => prop.name === POLICY_SOURCE_PROP)?.value, ); +// Older APIs leave subjectReferences out of the evidence response, and get no Subjects +// card. +const showSubjects = computed( + () => evidence.value?.subjectReferences !== undefined, +); const canPlayBack = computed(() => metadataProps.value.some((prop) => prop.name === POLICY_BUNDLE_DIGEST_PROP), ); @@ -1399,24 +1428,6 @@ function formatDateTime(value?: string) { return date.toLocaleString(); } -function normalizeLinkHref(value?: string) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isInternalLink(value?: string) { - return normalizeLinkHref(value).startsWith('#'); -} - -function getSafeExternalHref(value?: string) { - const href = normalizeLinkHref(value); - - if (/^(https?:|mailto:)/i.test(href)) { - return href; - } - - return ''; -} - function getMediaType(resource: BackMatterResource): string { return resource.base64?.mediaType?.split(';')[0]?.trim().toLowerCase() ?? ''; } diff --git a/src/views/evidence/__tests__/IndexView.spec.ts b/src/views/evidence/__tests__/IndexView.spec.ts index 50b30cc7..1e27952a 100644 --- a/src/views/evidence/__tests__/IndexView.spec.ts +++ b/src/views/evidence/__tests__/IndexView.spec.ts @@ -4,6 +4,21 @@ import { reactive, ref } from 'vue'; import IndexView from '../IndexView.vue'; import type { Evidence } from '@/stores/evidence.ts'; +const { subjectLookupGet } = vi.hoisted(() => ({ + subjectLookupGet: vi.fn(async () => ({ + data: { + data: [ + { + subjectUuid: 'subject-1', + type: 'component', + kind: 'defined-component', + title: 'GitHub Organization: acme', + }, + ], + }, + })), +})); + const { routeState, replaceMock, @@ -170,6 +185,12 @@ const { if (filterText.includes('exportable') || name.includes('exportable')) { data = exportableEvidence; } + if (name.includes('with-subjects')) { + data = baseEvidence.slice(0, 3).map((item) => ({ + ...item, + subjectReferences: [], + })); + } if (filterText.includes('huge-export') || name.includes('huge-export')) { data = hugeExportableEvidence; } @@ -284,6 +305,7 @@ vi.mock('@/composables/axios', async () => { return { useAuthenticatedInstance: () => ({ post: evidenceSearchPost, + get: subjectLookupGet, }), useDataApi: ( url?: string | null, @@ -384,6 +406,29 @@ describe('Evidence IndexView', () => { props: ['items'], template: '
Menu
', }, + SubjectFilter: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: ` +
+ {{ modelValue?.title }} + + +
+ `, + }, EvidenceList: { props: ['evidence', 'sortBy', 'sortDirection', 'navigationQuery'], emits: ['sort'], @@ -1201,4 +1246,66 @@ describe('Evidence IndexView', () => { }), ); }); + + it('hides the subject filter when the API returns no subjects on evidence', async () => { + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="subject-filter"]').exists()).toBe(false); + }); + + it('filters the evidence search by the chosen subject', async () => { + routeMock.query = { filter: 'with-subjects' }; + const wrapper = mountView(); + await flushPromises(); + + await wrapper.get('[data-testid="pick-subject"]').trigger('click'); + await flushPromises(); + + expect(evidenceSearchPost).toHaveBeenLastCalledWith( + '/api/evidence/search?page=1&limit=50&sortBy=lastSeenAt&sortDirection=desc&name=with-subjects&subjectUuid=subject-1', + expect.any(Object), + ); + + await wrapper.get('[data-testid="clear-subject"]').trigger('click'); + await flushPromises(); + + expect(evidenceSearchPost).toHaveBeenLastCalledWith( + '/api/evidence/search?page=1&limit=50&sortBy=lastSeenAt&sortDirection=desc&name=with-subjects', + expect.any(Object), + ); + }); + + it('filters by a subject given in the URL and shows its title', async () => { + routeMock.query = { filter: 'with-subjects', subject: 'subject-1' }; + const wrapper = mountView(); + await flushPromises(); + + expect(evidenceSearchPost).toHaveBeenLastCalledWith( + '/api/evidence/search?page=1&limit=50&sortBy=lastSeenAt&sortDirection=desc&name=with-subjects&subjectUuid=subject-1', + expect.any(Object), + ); + expect(subjectLookupGet).toHaveBeenCalledWith('/api/subjects', { + params: { ids: 'subject-1', limit: 1 }, + }); + expect(wrapper.get('[data-testid="selected-subject"]').text()).toBe( + 'GitHub Organization: acme', + ); + expect(wrapper.get('[data-testid="navigation-query"]').text()).toContain( + '"subject":"subject-1"', + ); + }); + + it('keeps a chosen subject in the URL, back on the first page', async () => { + routeMock.query = { filter: 'with-subjects' }; + const wrapper = mountView(); + await flushPromises(); + + await wrapper.get('[data-testid="pick-subject"]').trigger('click'); + await flushPromises(); + + expect(replaceMock).toHaveBeenLastCalledWith({ + query: { filter: 'with-subjects', subject: 'subject-1', page: undefined }, + }); + }); }); diff --git a/src/views/evidence/__tests__/ViewView.spec.ts b/src/views/evidence/__tests__/ViewView.spec.ts index 30187180..5e15d420 100644 --- a/src/views/evidence/__tests__/ViewView.spec.ts +++ b/src/views/evidence/__tests__/ViewView.spec.ts @@ -513,6 +513,11 @@ function mountView() { PageCard: { template: '
', }, + SubjectsSection: { + props: ['subjectReferences'], + template: + '
{{ subjectReferences.length }} subjects
', + }, SecondaryButton: { emits: ['click'], template: @@ -702,6 +707,49 @@ describe('Evidence ViewView', () => { expect(wrapper.text()).not.toContain('EvidencePlaybackSections'); }); + it('leaves out the Subjects card when the API returns no subjects', async () => { + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="subjects-card"]').exists()).toBe(false); + const currentState = wrapper + .findAll('h3') + .find((h3) => h3.text() === 'Current State')!; + expect( + currentState.element + .closest('.grid') + ?.classList.contains('lg:grid-cols-2'), + 'Current State takes the full width', + ).toBe(false); + }); + + it('shows the Subjects card to the right of Current State', async () => { + refs.evidenceResponse = { + ...structuredClone(baseEvidence), + subjectReferences: [ + { subjectUuid: 's-1', type: 'component', title: 'acme' }, + ], + }; + refs.evidence.value = structuredClone(refs.evidenceResponse); + + const wrapper = mountView(); + await flushPromises(); + + const card = wrapper.get('[data-testid="subjects-card"]'); + expect(card.find('h3').text()).toBe('Subjects'); + expect(card.get('[data-testid="subjects-section"]').text()).toBe( + '1 subjects', + ); + + const row = card.element.parentElement!; + expect(row.classList.contains('lg:grid-cols-2')).toBe(true); + expect( + row.firstElementChild?.textContent, + 'Current State on the left', + ).toContain('Current State'); + expect(row.lastElementChild, 'Subjects on the right').toBe(card.element); + }); + it('shows labels, props, and links in the metadata tab', async () => { const wrapper = mountView(); await flushPromises(); diff --git a/src/views/evidence/partial/EvidenceForm.vue b/src/views/evidence/partial/EvidenceForm.vue index 406aad80..165acb5b 100644 --- a/src/views/evidence/partial/EvidenceForm.vue +++ b/src/views/evidence/partial/EvidenceForm.vue @@ -68,6 +68,22 @@ +
+

+ Subject + * +

+

+ What this evidence is about. +

+
+ +
+
+

@@ -265,6 +281,7 @@