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) }}
+
+
+
+
+
+ Unattributed
+
+ |
{{ 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 @@
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+ {{ describeSubject(item) }}
+
+
+
+
+
+
+
+
+ Selected
+
+
+
+
+
+
+
+ + Add another
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ Unattributed
+
+
+
+ -
+
+
+
+ - Linked in SSPs:
+ -
+ {{ formatLinkedSsps(details[subject.subjectUuid]!) }}
+
+
+
+
+
+
+
+
+
+
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: '',
+ },
+ Chip: {
+ props: ['label'],
+ template: '{{ label }}',
+ },
+ TertiaryButton: {
+ emits: ['click'],
+ template:
+ '',
+ },
+ BIconX: { template: '' },
+ },
+ },
+ });
+}
+
+describe('SubjectPicker', () => {
+ beforeEach(() => {
+ searchMock.mockReset();
+ suggestions.value = [];
+ unsupported.value = false;
+ });
+
+ it('offers the SSPs and searches within the chosen one', async () => {
+ const wrapper = mountPicker();
+
+ const select = wrapper.findComponent(Select);
+ expect(select.props('options')).toEqual([
+ { label: 'Payments Platform', value: 'ssp-1' },
+ { label: 'ssp-2', value: 'ssp-2' },
+ ]);
+
+ await select.vm.$emit('update:modelValue', 'ssp-1');
+ wrapper.findComponent(AutoComplete).vm.$emit('complete', { query: 'fire' });
+
+ expect(searchMock).toHaveBeenCalledWith('fire', { ssp: 'ssp-1' });
+ });
+
+ it('adds a picked subject once and lists it as selected', async () => {
+ const wrapper = mountPicker([firewall]);
+ const autoComplete = wrapper.findComponent(AutoComplete);
+
+ autoComplete.vm.$emit('option-select', { value: networkTeam });
+ autoComplete.vm.$emit('option-select', { value: firewall });
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([
+ [[firewall, networkTeam]],
+ ]);
+ expect(
+ wrapper.findAll('[data-testid="chip"]').map((c) => c.text()),
+ ).toEqual(['Perimeter Firewall']);
+ expect(wrapper.text()).toContain('+ Add another');
+ });
+
+ it('does not offer subjects that are already picked', () => {
+ suggestions.value = [firewall, networkTeam];
+ const wrapper = mountPicker([firewall]);
+
+ expect(wrapper.findComponent(AutoComplete).props('suggestions')).toEqual([
+ networkTeam,
+ ]);
+ });
+
+ it('removes a selected subject', async () => {
+ const wrapper = mountPicker([firewall, networkTeam]);
+
+ await wrapper
+ .get('[aria-label="Remove Perimeter Firewall"]')
+ .trigger('click');
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([[[networkTeam]]]);
+ });
+
+ it('is hidden when the API does not support subjects', () => {
+ unsupported.value = true;
+ const wrapper = mountPicker();
+
+ expect(wrapper.find('[data-testid="subject-picker"]').exists()).toBe(false);
+ });
+});
diff --git a/src/components/evidence/__tests__/SubjectsSection.spec.ts b/src/components/evidence/__tests__/SubjectsSection.spec.ts
new file mode 100644
index 00000000..f87c066c
--- /dev/null
+++ b/src/components/evidence/__tests__/SubjectsSection.spec.ts
@@ -0,0 +1,221 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { DOMWrapper, flushPromises, mount } from '@vue/test-utils';
+import SubjectsSection from '../SubjectsSection.vue';
+import type { SubjectReference } from '@/oscal';
+import { CCF_OSCAL_NAMESPACE } from '@/types/subjects';
+
+const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() }));
+
+vi.mock('@/composables/subjects/useSubjectSearch', () => ({
+ useSubjectSearch: () => ({ lookup: lookupMock }),
+}));
+
+const ccf = (name: string, value: string) => ({
+ ns: CCF_OSCAL_NAMESPACE,
+ name,
+ value,
+});
+
+const templateSubject: SubjectReference = {
+ subjectUuid: 'dc-1',
+ type: 'component',
+ title: 'GitHub Organization: acme',
+ props: [
+ ccf('subject-source', 'template'),
+ ccf('subject-template', 'github-organization'),
+ ccf('display-priority', '0'),
+ ],
+ links: [
+ { href: 'https://example.com/other', rel: 'alternate' },
+ { href: 'https://github.com/acme', rel: 'canonical' },
+ ],
+};
+const declaredSubject: SubjectReference = {
+ subjectUuid: 'party-1',
+ type: 'party',
+ title: 'Network Team',
+ props: [ccf('subject-source', 'declared')],
+ links: [{ href: 'javascript:alert(1)' }],
+};
+const legacySubject: SubjectReference = {
+ subjectUuid: 'legacy-1',
+ type: 'Component',
+ props: [ccf('subject-source', 'legacy')],
+};
+
+// The value shown next to a label in a subject block.
+function definition(
+ block: Pick, 'findAll'>,
+ label: string,
+) {
+ const term = block.findAll('dt').find((dt) => dt.text() === label);
+ return term?.element.nextElementSibling?.textContent?.trim();
+}
+
+function mountCard(subjectReferences: SubjectReference[]) {
+ return mount(SubjectsSection, {
+ props: { subjectReferences },
+ global: {
+ directives: {
+ tooltip: {
+ mounted(el: HTMLElement, binding: { value: string }) {
+ el.setAttribute('data-tooltip', binding.value);
+ },
+ },
+ },
+ stubs: {
+ BIconExclamationTriangle: { template: '' },
+ RouterLink: {
+ props: ['to'],
+ template: '',
+ },
+ },
+ },
+ });
+}
+
+describe('SubjectsSection', () => {
+ beforeEach(() => {
+ lookupMock.mockReset();
+ lookupMock.mockResolvedValue([
+ {
+ subjectUuid: 'dc-1',
+ type: 'component',
+ kind: 'defined-component',
+ title: 'GitHub Organization: acme',
+ context: 'github-settings components',
+ identity: [{ key: 'organization', value: 'acme' }],
+ linkedSsps: [
+ {
+ sspId: 'ssp-1',
+ sspTitle: 'Payments Platform',
+ componentId: 'sc-1',
+ componentTitle: 'GitHub',
+ },
+ ],
+ },
+ {
+ subjectUuid: 'party-1',
+ type: 'party',
+ kind: 'party',
+ title: 'Network Team',
+ },
+ ]);
+ });
+
+ it('shows each subject in order, without legacy subjects', async () => {
+ const wrapper = mountCard([
+ templateSubject,
+ declaredSubject,
+ legacySubject,
+ ]);
+ await flushPromises();
+
+ expect(lookupMock).toHaveBeenCalledWith(['dc-1', 'party-1']);
+ const blocks = wrapper.findAll('[data-testid="subject-block"]');
+ expect(blocks).toHaveLength(2);
+
+ const org = blocks[0]!;
+ expect(org.text()).toContain('GitHub Organization: acme');
+ expect(org.text(), 'no kind line').not.toContain('Defined component');
+ expect(definition(org, 'Linked in SSPs:')).toBe(
+ 'Payments Platform → "GitHub"',
+ );
+ const title = org.get('[data-testid="subject-title"]');
+ expect(title.classes(), 'long titles are cut short').toContain('truncate');
+ expect(title.attributes('data-tooltip'), 'hover shows the full title').toBe(
+ 'GitHub Organization: acme',
+ );
+ const titleLink = org.get('a[target="_blank"]');
+ expect(titleLink.text()).toBe('GitHub Organization: acme');
+ expect(titleLink.attributes('href')).toBe('https://github.com/acme');
+ expect(titleLink.attributes('rel')).toBe('noopener noreferrer');
+ expect(org.text()).not.toContain('Open ↗');
+ const otherEvidence = org.get('[data-to]');
+ expect(otherEvidence.text()).toBe('Other evidence');
+ expect(otherEvidence.attributes('data-to')).toBe(
+ JSON.stringify({ name: 'evidence:index', query: { subject: 'dc-1' } }),
+ );
+ expect(
+ otherEvidence.element.parentElement,
+ 'the link sits on the title line',
+ ).toBe(title.element.parentElement);
+
+ const party = blocks[1]!;
+ expect(party.text()).toContain('Network Team');
+ expect(party.text()).not.toContain('Linked in SSPs');
+ expect(
+ party.find('a[target="_blank"]').exists(),
+ 'a title with only an unsafe link stays plain text',
+ ).toBe(false);
+ });
+
+ it('shows "Unattributed" when the evidence has only legacy subjects', async () => {
+ const wrapper = mountCard([legacySubject]);
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Unattributed');
+ expect(wrapper.find('[data-testid="subject-block"]').exists()).toBe(false);
+ });
+
+ it('still shows what the evidence knows when the lookup returns nothing', async () => {
+ lookupMock.mockResolvedValue([]);
+ const wrapper = mountCard([templateSubject]);
+ await flushPromises();
+
+ const block = wrapper.get('[data-testid="subject-block"]');
+ expect(block.get('a[target="_blank"]').text()).toBe(
+ 'GitHub Organization: acme',
+ );
+ expect(block.text()).not.toContain('Linked in SSPs');
+ });
+
+ it('shows two subjects, with the rest behind "Show N more"', async () => {
+ const extra = (n: number): SubjectReference => ({
+ subjectUuid: `extra-${n}`,
+ type: 'component',
+ title: `Extra ${n}`,
+ props: [ccf('subject-source', 'template')],
+ });
+ const wrapper = mountCard([
+ templateSubject,
+ declaredSubject,
+ legacySubject,
+ extra(1),
+ extra(2),
+ ]);
+ await flushPromises();
+
+ const titles = () =>
+ wrapper
+ .findAll('[data-testid="subject-title"]')
+ .map((title) => title.text());
+ expect(titles()).toEqual(['GitHub Organization: acme', 'Network Team']);
+ const toggle = wrapper.get('[data-testid="subjects-toggle"]');
+ expect(toggle.text(), 'legacy subjects are not counted').toBe(
+ 'Show 2 more subjects',
+ );
+
+ await toggle.trigger('click');
+ expect(titles()).toEqual([
+ 'GitHub Organization: acme',
+ 'Network Team',
+ 'Extra 1',
+ 'Extra 2',
+ ]);
+ expect(toggle.text()).toBe('Show fewer subjects');
+
+ await toggle.trigger('click');
+ expect(titles()).toHaveLength(2);
+ });
+
+ it('has no toggle with two subjects or fewer', async () => {
+ const wrapper = mountCard([templateSubject, declaredSubject]);
+ await flushPromises();
+
+ expect(wrapper.findAll('[data-testid="subject-block"]')).toHaveLength(2);
+ expect(wrapper.find('[data-testid="subjects-toggle"]').exists()).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/composables/evidence/__tests__/useEvidenceConfig.spec.ts b/src/composables/evidence/__tests__/useEvidenceConfig.spec.ts
new file mode 100644
index 00000000..946ac74c
--- /dev/null
+++ b/src/composables/evidence/__tests__/useEvidenceConfig.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+import { ref } from 'vue';
+import { useEvidenceConfig } from '../useEvidenceConfig';
+
+const { config } = vi.hoisted(() => ({
+ config: { value: undefined as unknown },
+}));
+
+vi.mock('@/composables/axios', () => ({
+ useDataApi: (url: string) => {
+ expect(url).toBe('/api/evidence/config');
+ return { data: ref(config.value) };
+ },
+}));
+
+describe('useEvidenceConfig', () => {
+ it('reports whether manual evidence needs a subject', () => {
+ config.value = { manualSubjectRequired: true };
+ expect(useEvidenceConfig().manualSubjectRequired.value).toBe(true);
+
+ config.value = { manualSubjectRequired: false };
+ expect(useEvidenceConfig().manualSubjectRequired.value).toBe(false);
+ });
+
+ it('does not require a subject when the API has no config', () => {
+ config.value = undefined;
+ expect(useEvidenceConfig().manualSubjectRequired.value).toBe(false);
+ });
+});
diff --git a/src/composables/evidence/useEvidenceConfig.ts b/src/composables/evidence/useEvidenceConfig.ts
new file mode 100644
index 00000000..8bf5d5fc
--- /dev/null
+++ b/src/composables/evidence/useEvidenceConfig.ts
@@ -0,0 +1,20 @@
+import { computed } from 'vue';
+import { useDataApi } from '@/composables/axios';
+
+interface EvidenceConfig {
+ manualSubjectRequired: boolean;
+}
+
+/**
+ * Evidence submission settings from GET /api/evidence/config. An API without the endpoint
+ * doesn't require a subject.
+ */
+export function useEvidenceConfig() {
+ const { data } = useDataApi('/api/evidence/config');
+
+ const manualSubjectRequired = computed(
+ () => data.value?.manualSubjectRequired === true,
+ );
+
+ return { manualSubjectRequired };
+}
diff --git a/src/composables/subjects/__tests__/useSubjectSearch.spec.ts b/src/composables/subjects/__tests__/useSubjectSearch.spec.ts
new file mode 100644
index 00000000..10eb03b8
--- /dev/null
+++ b/src/composables/subjects/__tests__/useSubjectSearch.spec.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { AxiosError, AxiosHeaders } from 'axios';
+import { useSubjectSearch } from '../useSubjectSearch';
+
+const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }));
+
+vi.mock('@/composables/axios', () => ({
+ useAuthenticatedInstance: () => ({ get: getMock }),
+}));
+
+function notFound() {
+ return new AxiosError('Not Found', '404', undefined, undefined, {
+ status: 404,
+ statusText: 'Not Found',
+ headers: {},
+ config: { headers: new AxiosHeaders() },
+ data: {},
+ });
+}
+
+describe('useSubjectSearch', () => {
+ beforeEach(() => {
+ getMock.mockReset();
+ });
+
+ it('searches subjects by title', async () => {
+ const subject = {
+ subjectUuid: 's-1',
+ type: 'component',
+ kind: 'defined-component',
+ title: 'GitHub Organization: acme',
+ };
+ getMock.mockResolvedValue({ data: { data: [subject] } });
+ const { suggestions, search } = useSubjectSearch();
+
+ await search(' acme ');
+
+ expect(getMock).toHaveBeenCalledWith('/api/subjects', {
+ params: { search: 'acme', ssp: undefined, limit: 20 },
+ });
+ expect(suggestions.value).toEqual([subject]);
+ });
+
+ it('lists the first subjects for an empty search', async () => {
+ getMock.mockResolvedValue({ data: { data: [] } });
+ const { search } = useSubjectSearch();
+
+ await search('');
+
+ expect(getMock).toHaveBeenCalledWith('/api/subjects', {
+ params: { search: undefined, ssp: undefined, limit: 20 },
+ });
+ });
+
+ it('reports an API without subjects as unsupported', async () => {
+ getMock.mockRejectedValue(notFound());
+ const { suggestions, unsupported, search } = useSubjectSearch();
+
+ await search('acme');
+
+ expect(unsupported.value).toBe(true);
+ expect(suggestions.value).toEqual([]);
+ });
+
+ it('narrows system components to an SSP', async () => {
+ getMock.mockResolvedValue({ data: { data: [] } });
+ const { search } = useSubjectSearch();
+
+ await search('firewall', { ssp: 'ssp-1' });
+
+ expect(getMock).toHaveBeenCalledWith('/api/subjects', {
+ params: { search: 'firewall', ssp: 'ssp-1', limit: 20 },
+ });
+ });
+
+ it('looks subjects up by ID', async () => {
+ getMock.mockResolvedValue({ data: { data: [{ subjectUuid: 's-1' }] } });
+ const { lookup } = useSubjectSearch();
+
+ const found = await lookup(['s-1', 's-2']);
+
+ expect(getMock).toHaveBeenCalledWith('/api/subjects', {
+ params: { ids: 's-1,s-2', limit: 2 },
+ });
+ expect(found).toEqual([{ subjectUuid: 's-1' }]);
+ });
+
+ it('returns no subjects when a lookup fails or there is nothing to look up', async () => {
+ getMock.mockRejectedValue(notFound());
+ const { lookup } = useSubjectSearch();
+
+ expect(await lookup(['s-1'])).toEqual([]);
+ expect(await lookup([])).toEqual([]);
+ expect(getMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/composables/subjects/useSubjectSearch.ts b/src/composables/subjects/useSubjectSearch.ts
new file mode 100644
index 00000000..4cac34ca
--- /dev/null
+++ b/src/composables/subjects/useSubjectSearch.ts
@@ -0,0 +1,58 @@
+import { ref } from 'vue';
+import { isAxiosError } from 'axios';
+import { useAuthenticatedInstance } from '@/composables/axios';
+import type { PaginatedListResponse } from '@/stores/types';
+import type { SubjectSummary } from '@/types/subjects';
+
+const SUBJECT_SEARCH_LIMIT = 20;
+
+/**
+ * Searches the subjects evidence can name (GET /api/subjects). `unsupported` turns true when
+ * the API doesn't have the endpoint, so callers can hide subject controls.
+ */
+export function useSubjectSearch() {
+ const suggestions = ref([]);
+ const unsupported = ref(false);
+ const authenticatedApi = useAuthenticatedInstance();
+
+ // ssp narrows system components to one SSP; other kinds are unaffected.
+ async function search(query: string, options: { ssp?: string } = {}) {
+ const trimmed = query.trim();
+ try {
+ const response = await authenticatedApi.get<
+ PaginatedListResponse
+ >('/api/subjects', {
+ params: {
+ search: trimmed || undefined,
+ ssp: options.ssp || undefined,
+ limit: SUBJECT_SEARCH_LIMIT,
+ },
+ });
+ suggestions.value = response.data.data ?? [];
+ } catch (error) {
+ suggestions.value = [];
+ if (isAxiosError(error) && error.response?.status === 404) {
+ unsupported.value = true;
+ }
+ }
+ }
+
+ // Looks subjects up by ID. Returns those found; a failed lookup returns none.
+ async function lookup(ids: string[]): Promise {
+ if (ids.length === 0) {
+ return [];
+ }
+ try {
+ const response = await authenticatedApi.get<
+ PaginatedListResponse
+ >('/api/subjects', {
+ params: { ids: ids.join(','), limit: ids.length },
+ });
+ return response.data.data ?? [];
+ } catch {
+ return [];
+ }
+ }
+
+ return { suggestions, unsupported, search, lookup };
+}
diff --git a/src/stores/evidence.ts b/src/stores/evidence.ts
index 016e1bb7..ebfe429a 100644
--- a/src/stores/evidence.ts
+++ b/src/stores/evidence.ts
@@ -3,7 +3,7 @@ import decamelizeKeys from 'decamelize-keys';
import { defineStore } from 'pinia';
import { useConfigStore } from '@/stores/config.ts';
import { type Filter } from '@/parsers/labelfilter.ts';
-import type { Activity, Hash, Link, Property } from '@/oscal';
+import type { Activity, Hash, Link, Property, SubjectReference } from '@/oscal';
import type { DataResponse } from '@/stores/types.ts';
import type { Control } from '@/oscal';
import type { BackMatter } from '@/oscal';
@@ -18,6 +18,11 @@ export interface EvidenceLabel {
value: string;
}
+// A subject named on submitted evidence (subjects[].subject-uuid).
+export interface DeclaredSubject {
+ subjectUuid: string;
+}
+
export interface Evidence {
id: string;
uuid: string;
@@ -32,6 +37,9 @@ export interface Evidence {
backMatter?: BackMatter;
status: EvidenceStatus;
activities: Activity[];
+ // What the evidence is about. Search rows carry a compact form (uuid, type, title) without
+ // legacy subjects; absent when the API doesn't provide subjects.
+ subjectReferences?: SubjectReference[];
}
export type EvidenceSortBy = 'lastSeenAt' | 'name' | 'status';
diff --git a/src/types/subject-templates.spec.ts b/src/types/subject-templates.spec.ts
index 6d7d96a9..62236fa6 100644
--- a/src/types/subject-templates.spec.ts
+++ b/src/types/subject-templates.spec.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
buildUpsertSubjectTemplatePayload,
createEmptySubjectTemplateForm,
+ createSubjectTemplateFormFromTemplate,
+ getDefinedComponentTypeLabel,
generateUniqueSubjectTemplateCopyName,
renderTemplateString,
} from './subject-templates';
@@ -63,6 +65,7 @@ describe('subject-templates helpers', () => {
name: 'GitHub Repo Subject',
type: 'component',
sourceMode: 'runtime-derived',
+ displayPriority: 0,
titleTemplate: '{{repository}}',
descriptionTemplate: 'Repo {{repository}} in {{organization}}',
purposeTemplate: 'Track repository risk',
@@ -93,6 +96,73 @@ describe('subject-templates helpers', () => {
});
});
+ function validComponentForm() {
+ const form = createEmptySubjectTemplateForm();
+ form.name = 'GitHub Organization';
+ form.type = 'component';
+ form.sourceMode = 'runtime-derived';
+ form.labelSchema = [{ key: 'organization', description: '' }];
+ form.selectorLabels = [{ key: '_plugin', value: 'github' }];
+ form.identityLabelKeys = ['organization'];
+ return form;
+ }
+
+ it('sends display priority as a number and the chosen component type', () => {
+ const form = validComponentForm();
+ form.displayPriority = '7';
+ form.componentType = 'software';
+
+ const payload = buildUpsertSubjectTemplatePayload(form);
+
+ expect(payload.displayPriority).toBe(7);
+ expect(payload.componentType).toBe('software');
+ });
+
+ it('defaults an empty display priority to 0 and leaves component type unset', () => {
+ const form = validComponentForm();
+ form.displayPriority = '';
+ form.componentType = null;
+
+ const payload = buildUpsertSubjectTemplatePayload(form);
+
+ expect(payload.displayPriority).toBe(0);
+ expect(payload.componentType).toBeUndefined();
+ });
+
+ it('rejects a fractional display priority', () => {
+ const form = validComponentForm();
+ form.displayPriority = 1.5;
+
+ expect(() => buildUpsertSubjectTemplatePayload(form)).toThrow(
+ 'Display priority must be a whole number.',
+ );
+ });
+
+ it('keeps display priority and component type when editing a template', () => {
+ const form = createSubjectTemplateFormFromTemplate({
+ name: 'GitHub Organization',
+ type: 'component',
+ sourceMode: 'runtime-derived',
+ displayPriority: 3,
+ componentType: 'software',
+ labelSchema: [{ key: 'organization' }],
+ selectorLabels: [{ key: '_plugin', value: 'github' }],
+ identityLabelKeys: ['organization'],
+ });
+
+ const payload = buildUpsertSubjectTemplatePayload(form);
+
+ expect(payload.displayPriority).toBe(3);
+ expect(payload.componentType).toBe('software');
+ });
+
+ it('labels component types, falling back to the default', () => {
+ expect(getDefinedComponentTypeLabel('process-procedure')).toBe(
+ 'Process Procedure',
+ );
+ expect(getDefinedComponentTypeLabel(null)).toBe('Default (service)');
+ });
+
it('rejects identity keys that are missing from label schema', () => {
const form = createEmptySubjectTemplateForm();
form.name = 'Template';
diff --git a/src/types/subject-templates.ts b/src/types/subject-templates.ts
index 00b32ac8..b33719da 100644
--- a/src/types/subject-templates.ts
+++ b/src/types/subject-templates.ts
@@ -37,6 +37,35 @@ export const SUBJECT_TEMPLATE_SOURCE_MODE_OPTIONS: Array<{
{ label: 'Manual', value: 'manual' },
];
+// OSCAL component types for a component definition's defined components. The API writes a
+// template's component type onto the defined components it derives from evidence.
+export const DEFINED_COMPONENT_TYPES = [
+ 'interconnection',
+ 'software',
+ 'hardware',
+ 'service',
+ 'policy',
+ 'physical',
+ 'process-procedure',
+ 'plan',
+ 'guidance',
+ 'standard',
+ 'validation',
+] as const;
+
+export const DEFAULT_DEFINED_COMPONENT_TYPE = 'service';
+
+export const DEFINED_COMPONENT_TYPE_OPTIONS: Array<{
+ label: string;
+ value: string;
+}> = DEFINED_COMPONENT_TYPES.map((value) => ({
+ label: value
+ .split('-')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' '),
+ value,
+}));
+
export interface SubjectTemplateSelectorLabel {
key: string;
value: string;
@@ -61,6 +90,8 @@ export interface SubjectTemplate {
props?: Property[];
links?: Link[];
sourceMode?: SubjectTemplateSourceMode;
+ displayPriority?: number;
+ componentType?: string | null;
selectorLabels?: SubjectTemplateSelectorLabel[];
labelSchema?: SubjectTemplateLabelSchemaField[];
}
@@ -76,6 +107,8 @@ export interface UpsertSubjectTemplateRequest {
props: Property[];
links: Link[];
sourceMode: SubjectTemplateSourceMode;
+ displayPriority: number;
+ componentType?: string;
selectorLabels: SubjectTemplateSelectorLabel[];
labelSchema: SubjectTemplateLabelSchemaField[];
}
@@ -109,6 +142,10 @@ export interface SubjectTemplateFormData {
name: string;
type: SubjectTemplateType;
sourceMode: SubjectTemplateSourceMode;
+ // A number input may hand back a string (or '' when cleared); parsed on submit.
+ displayPriority: number | string;
+ // null means the API default (service).
+ componentType: string | null;
titleTemplate: string;
descriptionTemplate: string;
purposeTemplate: string;
@@ -141,6 +178,8 @@ export function createEmptySubjectTemplateForm(): SubjectTemplateFormData {
name: '',
type: SUBJECT_TEMPLATE_TYPES[0],
sourceMode: SUBJECT_TEMPLATE_SOURCE_MODES[0],
+ displayPriority: 0,
+ componentType: null,
titleTemplate: '',
descriptionTemplate: '',
purposeTemplate: '',
@@ -159,6 +198,16 @@ export function isSubjectTemplateType(
return SUBJECT_TEMPLATE_TYPES.includes(value as SubjectTemplateType);
}
+export function getDefinedComponentTypeLabel(value?: string | null): string {
+ const option = DEFINED_COMPONENT_TYPE_OPTIONS.find(
+ (entry) => entry.value === value,
+ );
+ if (option) {
+ return option.label;
+ }
+ return value ? value : `Default (${DEFAULT_DEFINED_COMPONENT_TYPE})`;
+}
+
export function isSubjectTemplateSourceMode(
value: string,
): value is SubjectTemplateSourceMode {
@@ -290,6 +339,8 @@ export function createSubjectTemplateFormFromTemplate(
form.name = template.name ?? '';
form.type = template.type ?? SUBJECT_TEMPLATE_TYPES[0];
form.sourceMode = template.sourceMode ?? SUBJECT_TEMPLATE_SOURCE_MODES[0];
+ form.displayPriority = template.displayPriority ?? 0;
+ form.componentType = template.componentType ?? null;
form.titleTemplate = template.titleTemplate ?? '';
form.descriptionTemplate = template.descriptionTemplate ?? '';
form.purposeTemplate = template.purposeTemplate ?? '';
@@ -450,6 +501,20 @@ function normalizeLinks(links: SubjectTemplateLinkFormRow[]): Link[] {
return normalized;
}
+function normalizeDisplayPriority(value: number | string): number {
+ const raw = String(value ?? '').trim();
+ if (raw === '') {
+ return 0;
+ }
+
+ const priority = Number(raw);
+ if (!Number.isInteger(priority)) {
+ throw new Error('Display priority must be a whole number.');
+ }
+
+ return priority;
+}
+
export function buildUpsertSubjectTemplatePayload(
formData: SubjectTemplateFormData,
): UpsertSubjectTemplateRequest {
@@ -497,6 +562,8 @@ export function buildUpsertSubjectTemplatePayload(
name,
type: formData.type,
sourceMode: formData.sourceMode,
+ displayPriority: normalizeDisplayPriority(formData.displayPriority),
+ componentType: toOptionalTrimmed(formData.componentType),
titleTemplate: toOptionalTrimmed(formData.titleTemplate),
descriptionTemplate: toOptionalTrimmed(formData.descriptionTemplate),
purposeTemplate: toOptionalTrimmed(formData.purposeTemplate),
diff --git a/src/types/subjects.ts b/src/types/subjects.ts
new file mode 100644
index 00000000..fddacc2d
--- /dev/null
+++ b/src/types/subjects.ts
@@ -0,0 +1,65 @@
+import type { SubjectReference } from '@/oscal';
+
+// The kinds of entity evidence can name as its subject (GET /api/subjects).
+export type SubjectKind =
+ | 'defined-component'
+ | 'system-component'
+ | 'party'
+ | 'user';
+
+export interface SubjectSummary {
+ subjectUuid: string;
+ // The OSCAL subject type: component, party or user.
+ type: string;
+ kind: SubjectKind;
+ title: string;
+ // What the subject belongs to: a defined component's component definition, or a system
+ // component's SSP.
+ context?: string;
+ // A defined component's identity labels.
+ identity?: { key: string; value: string }[];
+ // The SSP system components a defined component is linked to.
+ linkedSsps?: {
+ sspId: string;
+ sspTitle: string;
+ componentId: string;
+ componentTitle: string;
+ }[];
+}
+
+// Namespace of the CCF props on evidence subject references.
+export const CCF_OSCAL_NAMESPACE =
+ 'https://compliance-framework.github.io/ns/oscal';
+
+function ccfPropValue(ref: SubjectReference, name: string) {
+ return ref.props?.find(
+ (prop) => prop.ns === CCF_OSCAL_NAMESPACE && prop.name === name,
+ )?.value;
+}
+
+// Where a subject reference came from: template, declared or legacy (ccf:subject-source).
+export function subjectSource(ref: SubjectReference) {
+ return ccfPropValue(ref, 'subject-source');
+}
+
+// The template a template-derived subject came from, and its display priority.
+export function subjectTemplate(ref: SubjectReference) {
+ const name = ccfPropValue(ref, 'subject-template');
+ if (!name) {
+ return undefined;
+ }
+ return { name, priority: ccfPropValue(ref, 'display-priority') ?? '0' };
+}
+
+const SUBJECT_KIND_LABELS: Record = {
+ 'defined-component': 'defined component',
+ 'system-component': 'system component',
+ party: 'party',
+ user: 'user',
+};
+
+// "kind · context", e.g. "system component · Payments Platform".
+export function describeSubject(subject: SubjectSummary): string {
+ const kind = SUBJECT_KIND_LABELS[subject.kind] ?? subject.kind;
+ return subject.context ? `${kind} · ${subject.context}` : kind;
+}
diff --git a/src/utils/links.spec.ts b/src/utils/links.spec.ts
new file mode 100644
index 00000000..16933c9e
--- /dev/null
+++ b/src/utils/links.spec.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { getSafeExternalHref, isInternalLink } from './links';
+
+describe('links', () => {
+ it('only allows http(s) and mailto links to be rendered as external links', () => {
+ expect(getSafeExternalHref(' https://github.com/acme ')).toBe(
+ 'https://github.com/acme',
+ );
+ expect(getSafeExternalHref('mailto:team@example.com')).toBe(
+ 'mailto:team@example.com',
+ );
+ expect(getSafeExternalHref('javascript:alert(1)')).toBe('');
+ expect(getSafeExternalHref(undefined)).toBe('');
+ });
+
+ it('recognises internal resource references', () => {
+ expect(isInternalLink('#resource-1')).toBe(true);
+ expect(isInternalLink('https://example.com')).toBe(false);
+ });
+});
diff --git a/src/utils/links.ts b/src/utils/links.ts
new file mode 100644
index 00000000..d16e3ec4
--- /dev/null
+++ b/src/utils/links.ts
@@ -0,0 +1,19 @@
+export function normalizeLinkHref(value?: string) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+export function isInternalLink(value?: string) {
+ return normalizeLinkHref(value).startsWith('#');
+}
+
+// The href, if it's safe to render as a clickable external link (http(s) or mailto);
+// otherwise ''.
+export function getSafeExternalHref(value?: string) {
+ const href = normalizeLinkHref(value);
+
+ if (/^(https?:|mailto:)/i.test(href)) {
+ return href;
+ }
+
+ return '';
+}
diff --git a/src/views/__tests__/SubjectTemplateDetailView.spec.ts b/src/views/__tests__/SubjectTemplateDetailView.spec.ts
index a5020e8e..16c1c470 100644
--- a/src/views/__tests__/SubjectTemplateDetailView.spec.ts
+++ b/src/views/__tests__/SubjectTemplateDetailView.spec.ts
@@ -8,6 +8,8 @@ const template = ref({
name: 'Template One',
type: 'component',
sourceMode: 'runtime-derived',
+ displayPriority: 4,
+ componentType: 'software',
titleTemplate: '{{repository}}',
descriptionTemplate: 'Desc',
purposeTemplate: 'Purpose',
@@ -109,6 +111,9 @@ describe('SubjectTemplateDetailView', () => {
expect(wrapper.text()).toContain('Template One');
expect(wrapper.text()).toContain('repository');
expect(wrapper.text()).toContain('plugin: github');
+ expect(wrapper.text()).toContain('Display Priority');
+ expect(wrapper.text()).toContain('4');
+ expect(wrapper.text()).toContain('Software');
const editButton = wrapper
.findAll('button')
diff --git a/src/views/__tests__/SubjectTemplatesView.spec.ts b/src/views/__tests__/SubjectTemplatesView.spec.ts
index 35d9202e..e0a5d9f8 100644
--- a/src/views/__tests__/SubjectTemplatesView.spec.ts
+++ b/src/views/__tests__/SubjectTemplatesView.spec.ts
@@ -384,4 +384,40 @@ describe('SubjectTemplatesView', () => {
const preview = wrapper.get('[data-testid="template-preview"]');
expect(preview.attributes('data-disabled')).toBe('true');
});
+
+ it('keeps display priority and component type when saving an edit', async () => {
+ templates.value = [
+ {
+ id: 'template-1',
+ name: 'GitHub Organization',
+ type: 'component',
+ sourceMode: 'runtime-derived',
+ displayPriority: 7,
+ componentType: 'software',
+ selectorLabels: [{ key: '_plugin', value: 'github' }],
+ labelSchema: [{ key: 'organization', description: '' }],
+ identityLabelKeys: ['organization'],
+ },
+ ];
+
+ const wrapper = mountView();
+
+ await findButtonByText(wrapper, 'Edit')!.trigger('click');
+
+ const priorityInput = wrapper.find('#subject-template-display-priority');
+ expect(priorityInput.exists()).toBe(true);
+ expect((priorityInput.element as HTMLInputElement).value).toBe('7');
+
+ await wrapper.find('form').trigger('submit');
+
+ expect(mockUpdateTemplate).toHaveBeenCalledWith(
+ '/api/admin/subject-templates/template-1',
+ {
+ data: expect.objectContaining({
+ displayPriority: 7,
+ componentType: 'software',
+ }),
+ },
+ );
+ });
});
diff --git a/src/views/admin/SubjectTemplateDetailView.vue b/src/views/admin/SubjectTemplateDetailView.vue
index faad8ae4..f5b4a18f 100644
--- a/src/views/admin/SubjectTemplateDetailView.vue
+++ b/src/views/admin/SubjectTemplateDetailView.vue
@@ -66,6 +66,26 @@
{{ getSourceModeLabel(template.sourceMode) }}
+
+
+ Display Priority
+
+
+ {{ template.displayPriority ?? 0 }}
+
+
+
+
+ Component Type
+
+
+ {{ getDefinedComponentTypeLabel(template.componentType) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 }}
-
-
-
+
+
+
+ Current State
+
+
+
+
- Go to latest evidence
-
-
-
- {{ 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 }}
+
+
+
+ Go to latest evidence
+
+
+
+ {{ 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.
+
+
+
+
+
+