diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts new file mode 100644 index 00000000..8c072918 --- /dev/null +++ b/src/composables/__tests__/axios.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { useAuthenticatedInstance } from '@/composables/axios'; + +vi.mock('@/stores/config.ts', () => ({ + useConfigStore: () => ({ + getConfig: vi.fn(async () => ({ API_URL: 'http://api.test' })), + }), +})); + +vi.mock('@/stores/auth', () => ({ + useUserStore: () => ({ + logout: vi.fn(), + }), +})); + +vi.mock('vue-router', () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ + add: vi.fn(), + }), +})); + +describe('axios response conversion', () => { + it('preserves dashboard suggestion label-map keys inside DataResponse arrays', async () => { + const instance = useAuthenticatedInstance(); + + const response = await instance.get('/dashboard-suggestions', { + // This endpoint returns camelCase field names; camelcase-keys matches + // stopPaths against the original keys, so the stop path is camelCase too. + camelcaseStopPaths: ['data.proposedFilterLabelSet'], + adapter: async (config) => ({ + data: { + data: [ + { + id: 'suggestion-1', + proposedFilterLabelSet: { + _policy: 'x', + service_name: 'api', + }, + }, + ], + }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }), + }); + + expect(response.data.data[0]).toEqual({ + id: 'suggestion-1', + proposedFilterLabelSet: { + _policy: 'x', + service_name: 'api', + }, + }); + }); +}); diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts index bd7b8cd2..a5f84d82 100644 --- a/src/composables/useDashboardSuggestions.ts +++ b/src/composables/useDashboardSuggestions.ts @@ -22,6 +22,7 @@ import { buildGeneralizeDashboardSuggestionsEndpoint, buildLatestDashboardSuggestionRunEndpoint, buildRejectDashboardSuggestionsEndpoint, + DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, type DashboardSuggestion, type DashboardSuggestionEvent, type DashboardSuggestionLabelKey, @@ -93,13 +94,9 @@ export function useDashboardSuggestions( // Preserve raw label keys (e.g. `_policy`) on the originating label set, // the proposed filter, and the AI baseline used for the edit diff; // otherwise camelcase conversion strips `_` and the diff shows phantom - // changes. + // changes. See DASHBOARD_SUGGESTION_LABEL_STOP_PATHS for why these are camelCase. { - camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', - ], + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, }, ); } @@ -194,11 +191,7 @@ export function useDashboardSuggestions( const response = await fetchHistoryRequest( buildDashboardSuggestionsEndpoint(sspId.value, status), { - camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', - ], + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, }, ); collected.push(...(response?.data.value?.data ?? [])); @@ -235,11 +228,7 @@ export function useDashboardSuggestions( data: payload, transformRequest: [decamelizeKeys], // Preserve raw label keys (e.g. `_policy`) the user kept in the filter. - camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', - ], + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, }, ); await refreshPendingSuggestions(); diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index ce998954..5e4da46c 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -103,11 +103,7 @@ ? 'View implementation' : 'No implementation yet' " - @click=" - openImplementationDrawer( - controlImplementations[slotProps.node.data.id], - ) - " + @click="openControlDrawer(slotProps.node.data.id)" > +
@@ -141,6 +142,15 @@ position="right" class="w-full! md:w-1/2! lg:w-3/5!" > + +

Components

uiStore.controlImplementationDrawerOpen, @@ -259,6 +280,10 @@ const controlImplementations = ref<{ [key: string]: ImplementedRequirement }>( {}, ); const selectedImplementedRequirement = ref(); +// The control whose drawer is open. Tracked separately from the implemented +// requirement so the drawer (and its AI suggestions panel) can open for a +// control that has pending suggestions but no implementation yet. +const selectedControlId = ref(); const RISK_FETCH_LIMIT = 100; const loadedSspRisksFor = ref(null); const preparingBulkSuggestions = ref(false); @@ -272,8 +297,15 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi( {}, { immediate: false }, ); +const pendingDashboardSuggestions = ref([]); +const dashboardSuggestionControlResults = ref([]); +const dashboardSuggestionStateLoading = ref(false); const nodes = ref>([]); +const loadedDashboardSuggestionStateFor = ref(null); +const loadingDashboardSuggestionStateFor = ref(null); +let dashboardSuggestionStateLoadPromise: Promise | null = null; +const dashboardSuggestionStateRequestId = ref(0); interface StatementSuggestionWorkItem { requirement: ImplementedRequirement; @@ -303,6 +335,53 @@ const bulkSuggestionsButtonLabel = computed(() => { return 'Apply All Suggestions'; }); +const pendingDashboardSuggestionsByControl = computed(() => { + const grouped: Record = {}; + for (const suggestion of pendingDashboardSuggestions.value ?? []) { + const key = normalizeId(suggestion.controlId); + if (!key) { + continue; + } + grouped[key] = grouped[key] ?? []; + grouped[key].push(suggestion); + } + return grouped; +}); + +const dashboardSuggestionResultsByControl = computed(() => { + const results: Record = {}; + for (const result of dashboardSuggestionControlResults.value ?? []) { + const key = normalizeId(result.controlId); + if (!key) { + continue; + } + results[key] = result; + } + return results; +}); + +const selectedDrawerControlId = computed( + () => + selectedControlId.value ?? selectedImplementedRequirement.value?.controlId, +); + +const selectedControlDashboardSuggestions = computed(() => { + const key = normalizeId(selectedDrawerControlId.value); + return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : []; +}); + +const selectedControlSuggestionResult = computed(() => { + const key = normalizeId(selectedDrawerControlId.value); + return key ? dashboardSuggestionResultsByControl.value[key] : undefined; +}); + +function controlSuggestionCount(controlId?: string): number { + const key = normalizeId(controlId); + return key + ? (pendingDashboardSuggestionsByControl.value[key]?.length ?? 0) + : 0; +} + function normalizeId(value?: string): string { return (value || '').trim().toLowerCase(); } @@ -377,6 +456,89 @@ function controlHighestSeverity( return stats.highestSeverity ?? 'high'; } +async function loadDashboardSuggestionState() { + const sspId = systemStore.system.securityPlan?.uuid; + if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) { + pendingDashboardSuggestions.value = []; + dashboardSuggestionControlResults.value = []; + loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; + return; + } + + if (loadedDashboardSuggestionStateFor.value === sspId) { + return; + } + + if ( + loadingDashboardSuggestionStateFor.value === sspId && + dashboardSuggestionStateLoadPromise + ) { + await dashboardSuggestionStateLoadPromise; + return; + } + + loadingDashboardSuggestionStateFor.value = sspId; + dashboardSuggestionStateLoading.value = true; + const requestId = (dashboardSuggestionStateRequestId.value += 1); + const loadPromise = (async () => { + const [pendingResult, controlResultsResult] = await Promise.allSettled([ + axios.get>( + buildDashboardSuggestionsEndpoint(sspId, 'pending'), + { + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, + }, + ), + axios.get>( + buildDashboardSuggestionControlResultsEndpoint(sspId), + ), + ]); + + if ( + dashboardSuggestionStateRequestId.value !== requestId || + systemStore.system.securityPlan?.uuid !== sspId + ) { + return; + } + + pendingDashboardSuggestions.value = + pendingResult.status === 'fulfilled' ? pendingResult.value.data.data : []; + dashboardSuggestionControlResults.value = + controlResultsResult.status === 'fulfilled' + ? controlResultsResult.value.data.data + : []; + loadedDashboardSuggestionStateFor.value = sspId; + })(); + dashboardSuggestionStateLoadPromise = loadPromise; + + try { + await loadPromise; + } finally { + if ( + loadingDashboardSuggestionStateFor.value === sspId && + dashboardSuggestionStateLoadPromise === loadPromise + ) { + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateLoading.value = false; + } + } +} + +async function initializeDashboardSuggestionState() { + try { + await aiConfigStore.fetchDashboardSuggestionsConfig(); + if (aiConfigStore.dashboardSuggestionsEnabled) { + await loadDashboardSuggestionState(); + } + } catch { + // Dashboard suggestions are optional; do not block the core controls view. + } +} + function openControlRisks(controlId?: string) { if (!controlId) { return; @@ -443,6 +605,7 @@ async function loadControlImplementations() { uiStore.controlImplementationDrawerOpen ) { selectedImplementedRequirement.value = impl; + selectedControlId.value = impl.controlId; selectedRequirementFound = true; } } @@ -823,13 +986,21 @@ function hasControlImplementation(controlId?: string): boolean { return !!(controlId && controlImplementations.value[controlId]); } -function openImplementationDrawer(req: ImplementedRequirement | undefined) { - if (!req) { +// Opens the implementation drawer for a control. The control need not have an +// implementation: when it only has pending AI suggestions, the drawer still +// opens (with an empty Components section) so the suggestions panel is reachable. +function openControlDrawer(controlId?: string) { + if (!controlId) { return; } + const requirement = controlImplementations.value[controlId]; uiStore.setControlImplementationDrawerOpen(true); - uiStore.setControlImplementationSelectedRequirementId(req.uuid); - selectedImplementedRequirement.value = req; + uiStore.setControlImplementationSelectedRequirementId( + requirement?.uuid ?? null, + ); + selectedControlId.value = controlId; + selectedImplementedRequirement.value = requirement; + void loadDashboardSuggestionState(); } watch( @@ -838,6 +1009,13 @@ watch( if (!sspId) { sspRisks.value = []; loadedSspRisksFor.value = null; + pendingDashboardSuggestions.value = []; + dashboardSuggestionControlResults.value = []; + loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; return; } @@ -858,17 +1036,43 @@ watch( { immediate: true }, ); +watch( + () => [ + systemStore.system.securityPlan?.uuid, + aiConfigStore.dashboardSuggestionsConfigFetched, + aiConfigStore.dashboardSuggestionsEnabled, + ], + ([sspId, configFetched, enabled]) => { + if (!sspId || !configFetched || !enabled) { + pendingDashboardSuggestions.value = []; + dashboardSuggestionControlResults.value = []; + loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; + return; + } + void loadDashboardSuggestionState(); + }, +); + watch( () => uiStore.controlImplementationDrawerOpen, (isOpen) => { - if (!isOpen && uiStore.controlImplementationSelectedRequirementId) { - uiStore.setControlImplementationSelectedRequirementId(null); + if (!isOpen) { + if (uiStore.controlImplementationSelectedRequirementId) { + uiStore.setControlImplementationSelectedRequirementId(null); + } selectedImplementedRequirement.value = undefined; + selectedControlId.value = undefined; } }, ); onMounted(async () => { + void initializeDashboardSuggestionState(); + try { await loadProfileBindings(); if (profileBindings.value.length > 0) { diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index fa1bcb82..a66b5531 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -1,12 +1,29 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { defineComponent, h, ref } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { defineComponent, h, reactive, ref } from 'vue'; import IndexView from '../IndexView.vue'; +enableAutoUnmount(afterEach); + const listProfiles = vi.fn(); const axiosGet = vi.fn(); const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } })); const fetchControlImplementations = vi.fn(); +const aiConfigState = reactive({ + dashboardSuggestionsEnabled: false, + dashboardSuggestionsConfigFetched: false, +}); +const systemStoreState = reactive({ + system: { securityPlan: { uuid: 'ssp-1' } as { uuid: string } | null }, +}); +const fetchDashboardSuggestionsConfig = vi.fn(async () => { + aiConfigState.dashboardSuggestionsConfigFetched = true; + return aiConfigState.dashboardSuggestionsEnabled; +}); +let pendingDashboardSuggestionsFixture: unknown[] = []; +let controlResultsFixture: unknown[] = []; +let pendingDashboardSuggestionsReject = false; +let controlResultsReject = false; const uiStore = { controlImplementationDrawerOpen: false, controlImplementationSelectedRequirementId: null as string | null, @@ -25,9 +42,7 @@ const uiStore = { }; vi.mock('@/stores/system.ts', () => ({ - useSystemStore: () => ({ - system: { securityPlan: { uuid: 'ssp-1' } }, - }), + useSystemStore: () => systemStoreState, })); vi.mock('@/stores/system-security-plans', () => ({ @@ -40,7 +55,24 @@ vi.mock('@/stores/ui.ts', () => ({ useUIStore: () => uiStore, })); +vi.mock('@/stores/ai-config', () => ({ + useAiConfigStore: () => ({ + get dashboardSuggestionsEnabled() { + return aiConfigState.dashboardSuggestionsEnabled; + }, + get dashboardSuggestionsConfigFetched() { + return aiConfigState.dashboardSuggestionsConfigFetched; + }, + fetchDashboardSuggestionsConfig, + }), +})); + vi.mock('vue-router', () => ({ + RouterLink: { + name: 'RouterLink', + props: ['to'], + template: '', + }, useRouter: () => ({ push: vi.fn() }), })); @@ -69,6 +101,14 @@ vi.mock('@/composables/axios', () => ({ execute: fetchControlImplementations, }; } + if (url === null) { + return { + data: ref([]), + isLoading: ref(false), + error: ref(null), + execute: loadRisks, + }; + } return { data: ref([]), isLoading: ref(false), @@ -94,6 +134,16 @@ function flushPromises() { return new Promise((resolve) => setTimeout(resolve, 0)); } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + async function waitForMountedControls() { for (let index = 0; index < 5; index += 1) { await flushPromises(); @@ -104,6 +154,7 @@ const stubs = { RouterLink: { template: '' }, Message: { template: '
' }, Badge: { template: '{{ value }}', props: ['value'] }, + Chip: { template: '{{ label }}', props: ['label'] }, Button: { props: ['disabled', 'ariaLabel', 'title', 'label'], emits: ['click'], @@ -145,13 +196,34 @@ const stubs = { describe('control implementations IndexView', () => { beforeEach(() => { vi.clearAllMocks(); + aiConfigState.dashboardSuggestionsEnabled = false; + aiConfigState.dashboardSuggestionsConfigFetched = false; + systemStoreState.system.securityPlan = { uuid: 'ssp-1' }; + pendingDashboardSuggestionsFixture = []; + controlResultsFixture = []; + pendingDashboardSuggestionsReject = false; + controlResultsReject = false; uiStore.controlImplementationDrawerOpen = false; uiStore.controlImplementationSelectedRequirementId = null; uiStore.controlImplementationExpandedKeys = {}; listProfiles.mockResolvedValue({ data: [{ uuid: 'profile-1', title: 'Profile One' }], }); - axiosGet.mockResolvedValue({ data: { data: { uuid: 'catalog-1' } } }); + axiosGet.mockImplementation(async (url: string) => { + if (url.includes('/dashboard-suggestions?status=pending')) { + if (pendingDashboardSuggestionsReject) { + throw new Error('pending failed'); + } + return { data: { data: pendingDashboardSuggestionsFixture } }; + } + if (url.includes('/dashboard-suggestions/control-results')) { + if (controlResultsReject) { + throw new Error('results failed'); + } + return { data: { data: controlResultsFixture } }; + } + return { data: { data: { uuid: 'catalog-1' } } }; + }); fetchControlImplementations.mockResolvedValue({ data: { value: { @@ -201,4 +273,310 @@ describe('control implementations IndexView', () => { uiStore.setControlImplementationSelectedRequirementId, ).toHaveBeenCalledWith('req-1'); }); + + it('does not render the AI dashboard suggestions panel when the feature flag is disabled', async () => { + const wrapper = mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + const implementationButton = wrapper + .findAll('button') + .find((button) => button.attributes('title') === 'View implementation'); + await implementationButton?.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).not.toContain('AI dashboard suggestions'); + expect(axiosGet).not.toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions?status=pending'), + expect.anything(), + ); + expect(axiosGet).not.toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions/control-results'), + ); + }); + + it('loads core controls data before dashboard suggestions config resolves', async () => { + const configDeferred = createDeferred(); + fetchDashboardSuggestionsConfig.mockImplementationOnce( + () => configDeferred.promise, + ); + + mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + expect(fetchDashboardSuggestionsConfig).toHaveBeenCalled(); + expect(listProfiles).toHaveBeenCalled(); + expect(fetchControlImplementations).toHaveBeenCalled(); + expect(axiosGet).not.toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions?status=pending'), + expect.anything(), + ); + }); + + it('matches pending suggestions to the selected control case-insensitively', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + pendingDashboardSuggestionsFixture = [ + { + id: 'suggestion-1', + status: 'pending', + controlId: 'AC-1', + labelSetHash: 'hash-1', + proposedFilterName: 'Production evidence', + proposedFilterLabelSet: { env: 'prod' }, + confidence: 0.8, + reasoning: 'Uppercase control id still matches.', + }, + ]; + + const wrapper = mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + const implementationButton = wrapper + .findAll('button') + .find((button) => button.attributes('title') === 'View implementation'); + await implementationButton?.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('AI dashboard suggestions'); + expect(wrapper.text()).toContain('Production evidence'); + expect(wrapper.text()).toContain('80% confidence'); + expect(axiosGet).toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions?status=pending'), + expect.objectContaining({ + camelcaseStopPaths: expect.arrayContaining([ + 'data.proposedFilterLabelSet', + ]), + }), + ); + expect(axiosGet).toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions/control-results'), + ); + }); + + it('loads dashboard suggestion state once when config resolution enables suggestions on mount', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + const configDeferred = createDeferred(); + const pendingDeferred = createDeferred<{ + data: { data: unknown[] }; + }>(); + const controlResultsDeferred = createDeferred<{ + data: { data: unknown[] }; + }>(); + + fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => { + await configDeferred.promise; + aiConfigState.dashboardSuggestionsConfigFetched = true; + return aiConfigState.dashboardSuggestionsEnabled; + }); + axiosGet.mockImplementation((url: string) => { + if (url.includes('/dashboard-suggestions?status=pending')) { + return pendingDeferred.promise; + } + if (url.includes('/dashboard-suggestions/control-results')) { + return controlResultsDeferred.promise; + } + return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } }); + }); + + mount(IndexView, { global: { stubs } }); + configDeferred.resolve(); + await flushPromises(); + + const pendingCalls = axiosGet.mock.calls.filter(([url]) => + String(url).includes('/dashboard-suggestions?status=pending'), + ); + const controlResultsCalls = axiosGet.mock.calls.filter(([url]) => + String(url).includes('/dashboard-suggestions/control-results'), + ); + expect(pendingCalls).toHaveLength(1); + expect(axiosGet).toHaveBeenCalledWith( + expect.stringContaining( + '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions?status=pending', + ), + expect.any(Object), + ); + expect(controlResultsCalls).toHaveLength(1); + expect(axiosGet).toHaveBeenCalledWith( + expect.stringContaining( + '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results', + ), + ); + + pendingDeferred.resolve({ data: { data: [] } }); + controlResultsDeferred.resolve({ data: { data: [] } }); + await waitForMountedControls(); + }); + + it('ignores stale dashboard suggestion responses after switching SSPs', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + aiConfigState.dashboardSuggestionsConfigFetched = true; + const ssp1Pending = createDeferred<{ data: { data: unknown[] } }>(); + const ssp1ControlResults = createDeferred<{ data: { data: unknown[] } }>(); + const ssp2Pending = createDeferred<{ data: { data: unknown[] } }>(); + const ssp2ControlResults = createDeferred<{ data: { data: unknown[] } }>(); + + axiosGet.mockImplementation((url: string) => { + if (url.includes('/ssp-1/dashboard-suggestions?status=pending')) { + return ssp1Pending.promise; + } + if (url.includes('/ssp-1/dashboard-suggestions/control-results')) { + return ssp1ControlResults.promise; + } + if (url.includes('/ssp-2/dashboard-suggestions?status=pending')) { + return ssp2Pending.promise; + } + if (url.includes('/ssp-2/dashboard-suggestions/control-results')) { + return ssp2ControlResults.promise; + } + return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } }); + }); + + const wrapper = mount(IndexView, { global: { stubs } }); + await flushPromises(); + + systemStoreState.system.securityPlan = { uuid: 'ssp-2' }; + await flushPromises(); + + ssp2Pending.resolve({ + data: { + data: [ + { + id: 'suggestion-2', + status: 'pending', + controlId: 'AC-1', + labelSetHash: 'hash-2', + proposedFilterName: 'Second SSP suggestion', + }, + ], + }, + }); + ssp2ControlResults.resolve({ data: { data: [] } }); + await flushPromises(); + + ssp1Pending.resolve({ + data: { + data: [ + { + id: 'suggestion-1', + status: 'pending', + controlId: 'AC-1', + labelSetHash: 'hash-1', + proposedFilterName: 'First SSP stale suggestion', + }, + ], + }, + }); + ssp1ControlResults.resolve({ data: { data: [] } }); + await waitForMountedControls(); + + const callsBeforeDrawerOpen = axiosGet.mock.calls.length; + const implementationButton = wrapper + .findAll('button') + .find((button) => button.attributes('title') === 'View implementation'); + await implementationButton?.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('Second SSP suggestion'); + expect(wrapper.text()).not.toContain('First SSP stale suggestion'); + expect(axiosGet.mock.calls).toHaveLength(callsBeforeDrawerOpen); + }); + + it('keeps pending suggestions visible when control-results cannot be fetched', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + controlResultsReject = true; + pendingDashboardSuggestionsFixture = [ + { + id: 'suggestion-1', + status: 'pending', + controlId: 'AC-1', + labelSetHash: 'hash-1', + proposedFilterName: 'Resilient suggestion', + }, + ]; + + const wrapper = mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + const implementationButton = wrapper + .findAll('button') + .find((button) => button.attributes('title') === 'View implementation'); + await implementationButton?.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('Resilient suggestion'); + }); + + it('keeps no-match state visible when pending suggestions cannot be fetched', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + pendingDashboardSuggestionsReject = true; + controlResultsFixture = [ + { + controlId: 'AC-1', + outcome: 'no_match', + evaluatedAt: '2026-06-18T10:30:00Z', + }, + ]; + + const wrapper = mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + const implementationButton = wrapper + .findAll('button') + .find((button) => button.attributes('title') === 'View implementation'); + await implementationButton?.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain( + 'AI reviewed this control and found no matching dashboard filter', + ); + }); + + it('surfaces a clickable suggestions badge that opens the drawer for a control with no implementation', async () => { + // ac-2 has no implemented requirement (only ac-1 does), so its eye button is + // disabled. A pending suggestion for ac-2 must still be reachable via a badge. + aiConfigState.dashboardSuggestionsEnabled = true; + pendingDashboardSuggestionsFixture = [ + { + id: 'suggestion-2', + status: 'pending', + controlId: 'AC-2', + labelSetHash: 'hash-2', + proposedFilterName: 'Unimplemented control suggestion', + }, + ]; + + const wrapper = mount(IndexView, { global: { stubs } }); + await waitForMountedControls(); + + // The ac-2 eye button is disabled (no implementation to view). + const eyeButtons = wrapper + .findAll('button') + .filter((button) => button.attributes('title') !== undefined); + expect( + eyeButtons.some((button) => button.attributes('disabled') !== undefined), + ).toBe(true); + + const badge = wrapper + .findAll('button') + .find((button) => + button + .attributes('aria-label') + ?.includes('pending AI dashboard suggestion'), + ); + expect(badge).toBeTruthy(); + expect(badge?.text()).toContain('1'); + + // Before opening, the drawer's suggestions panel is not scoped to ac-2. + expect(wrapper.text()).not.toContain('Unimplemented control suggestion'); + + await badge?.trigger('click'); + await flushPromises(); + + expect(uiStore.setControlImplementationDrawerOpen).toHaveBeenCalledWith( + true, + ); + expect( + uiStore.setControlImplementationSelectedRequirementId, + ).toHaveBeenCalledWith(null); + expect(wrapper.text()).toContain('Unimplemented control suggestion'); + }); }); diff --git a/src/views/control-implementations/partials/ControlImplementationSuggestions.vue b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue new file mode 100644 index 00000000..00ed6cb6 --- /dev/null +++ b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/views/control-implementations/partials/RiskIndicatorBadge.vue b/src/views/control-implementations/partials/RiskIndicatorBadge.vue index f4c98896..b5782df4 100644 --- a/src/views/control-implementations/partials/RiskIndicatorBadge.vue +++ b/src/views/control-implementations/partials/RiskIndicatorBadge.vue @@ -7,8 +7,10 @@ const props = defineProps<{ highestSeverity?: 'high' | 'medium' | 'low'; clickable?: boolean; }>(); +// Forward the native event so callers can use the `.stop` modifier without +// `.stopPropagation()` being called on `undefined`. const emit = defineEmits<{ - click: []; + click: [event: MouseEvent]; }>(); const displayCount = computed(() => @@ -36,11 +38,11 @@ const tooltipText = computed(() => { return `${displayCount.value} risks associated`; }); -function onClick() { +function onClick(event: MouseEvent) { if (!props.clickable) { return; } - emit('click'); + emit('click', event); } diff --git a/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue new file mode 100644 index 00000000..8f9cdc0c --- /dev/null +++ b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts new file mode 100644 index 00000000..1e029c66 --- /dev/null +++ b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent } from 'vue'; +import ControlImplementationSuggestions from '../ControlImplementationSuggestions.vue'; +import type { + ControlSuggestionResult, + DashboardSuggestion, +} from '@/views/dashboard/partials/dashboard-suggestions'; + +function suggestion( + overrides: Partial = {}, +): DashboardSuggestion { + return { + id: 'suggestion-1', + status: 'pending', + controlId: 'AC-1', + labelSetHash: 'hash-1', + proposedFilterName: 'Production evidence', + proposedFilterLabelSet: { + env: 'prod', + service: 'api', + _policy: 'internal', + }, + confidence: 0.91, + reasoning: 'The production API evidence maps cleanly to this control.', + ...overrides, + }; +} + +function mountComponent(props: { + suggestions?: DashboardSuggestion[]; + result?: ControlSuggestionResult; + loading?: boolean; +}) { + return mount(ControlImplementationSuggestions, { + props: { + controlId: 'ac-1', + sspId: 'ssp-1', + suggestions: [], + ...props, + }, + global: { + stubs: { + Chip: { + props: ['label'], + template: '{{ label }}', + }, + Message: { + template: '
', + }, + RouterLink: defineComponent({ + name: 'RouterLink', + props: ['to'], + template: '', + }), + }, + }, + }); +} + +describe('ControlImplementationSuggestions', () => { + it('renders a loading state while AI state is loading', () => { + expect(mountComponent({ suggestions: [], loading: true }).text()).toContain( + 'Loading AI dashboard suggestions...', + ); + }); + + it('lists pending suggestions with labels, confidence, reasoning, and review link', () => { + const wrapper = mountComponent({ suggestions: [suggestion()] }); + + expect(wrapper.text()).toContain('AI dashboard suggestions'); + expect(wrapper.text()).toContain('Production evidence'); + expect(wrapper.text()).toContain('env=prod'); + expect(wrapper.text()).toContain('service=api'); + expect(wrapper.text()).not.toContain('_policy=internal'); + expect(wrapper.text()).toContain('91% confidence'); + expect(wrapper.text()).toContain( + 'The production API evidence maps cleanly to this control.', + ); + expect(wrapper.find('[data-test="review-link"]').exists()).toBe(true); + expect(wrapper.findComponent({ name: 'RouterLink' }).props('to')).toEqual({ + name: 'dashboards.suggestions', + params: { sspId: 'ssp-1' }, + }); + }); + + it('renders evaluated no-match state with the run date', () => { + const evaluatedAt = '2026-06-18T10:30:00Z'; + const wrapper = mountComponent({ + result: { + controlId: 'AC-2', + outcome: 'no_match', + evaluatedAt, + }, + }); + + expect(wrapper.text()).toContain( + 'AI reviewed this control and found no matching dashboard filter', + ); + expect(wrapper.text()).toContain(new Date(evaluatedAt).toLocaleString()); + }); + + it('renders subtle empty states for not evaluated and matched-without-pending controls', () => { + expect(mountComponent({}).text()).toContain( + "AI hasn't evaluated this control yet.", + ); + expect( + mountComponent({ + result: { + controlId: 'AC-1', + outcome: 'matched', + }, + }).text(), + ).toContain('No pending AI dashboard suggestions for this control.'); + }); +}); diff --git a/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts new file mode 100644 index 00000000..0c5e5ff8 --- /dev/null +++ b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent } from 'vue'; +import RiskIndicatorBadge from '../RiskIndicatorBadge.vue'; + +describe('RiskIndicatorBadge', () => { + it('renders nothing when there are no risks', () => { + const wrapper = mount(RiskIndicatorBadge, { + props: { riskCount: 0 }, + }); + expect(wrapper.find('button').exists()).toBe(false); + expect(wrapper.find('span').exists()).toBe(false); + }); + + it('caps the displayed count at 99+ when capped', () => { + const wrapper = mount(RiskIndicatorBadge, { + props: { riskCount: 250, isCapped: true }, + }); + expect(wrapper.text()).toContain('99+'); + }); + + it('does not emit when not clickable', async () => { + const wrapper = mount(RiskIndicatorBadge, { + props: { riskCount: 3 }, + }); + await wrapper.find('span').trigger('click'); + expect(wrapper.emitted('click')).toBeUndefined(); + }); + + // Regression: emitting `click` without the native event made a parent + // `@click.stop` handler call `.stopPropagation()` on `undefined` and throw. + it('can be clicked through a parent @click.stop handler without throwing', async () => { + let handled = false; + const Parent = defineComponent({ + components: { RiskIndicatorBadge }, + setup() { + return { + onClick: () => { + handled = true; + }, + }; + }, + template: ``, + }); + + const wrapper = mount(Parent); + await wrapper.find('button').trigger('click'); + expect(handled).toBe(true); + }); +}); diff --git a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts index 0dab0e8a..6a2dbad1 100644 --- a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts +++ b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts @@ -1,10 +1,19 @@ import { describe, expect, it } from 'vitest'; import { + buildDashboardSuggestionControlResultsEndpoint, buildLabelFilter, computeGroupEditDiff, type DashboardSuggestion, } from '../dashboard-suggestions'; +describe('buildDashboardSuggestionControlResultsEndpoint', () => { + it('builds the latest control-result endpoint for an SSP', () => { + expect(buildDashboardSuggestionControlResultsEndpoint('ssp 1')).toBe( + '/api/oscal/system-security-plans/ssp%201/dashboard-suggestions/control-results', + ); + }); +}); + describe('buildLabelFilter', () => { it('returns undefined when there are no valid conditions', () => { expect(buildLabelFilter([])).toBeUndefined(); diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts index f193acc9..048c699f 100644 --- a/src/views/dashboard/partials/dashboard-suggestions.ts +++ b/src/views/dashboard/partials/dashboard-suggestions.ts @@ -157,6 +157,17 @@ export interface DashboardSuggestion { updatedAt?: string; } +export type ControlSuggestionOutcome = 'matched' | 'no_match'; + +export interface ControlSuggestionResult { + controlId: string; + controlCatalogId?: string; + outcome: ControlSuggestionOutcome; + suggestionCount?: number; + runId?: string; + evaluatedAt?: string; +} + export type LabelChipKind = 'unchanged' | 'added' | 'removed'; export interface LabelChip { @@ -333,6 +344,12 @@ export function buildDashboardSuggestionsEndpoint( return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions${query}`; } +export function buildDashboardSuggestionControlResultsEndpoint( + sspId: string, +): string { + return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/control-results`; +} + export function buildAcceptDashboardSuggestionsEndpoint(sspId: string): string { return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/accept`; } @@ -359,6 +376,18 @@ export function buildControlKey(catalogId: string, controlId: string): string { return `${catalogId}:${controlId}`; } +// Response paths whose nested objects are user-defined label maps (keys like +// `_policy` or `service_name`). The dashboard-suggestion endpoints return these +// field names already camelCased, and `camelcase-keys` matches stopPaths against +// the response's own keys — so these must stay camelCase, and the leading `_` +// and snake_case label keys inside the maps are preserved rather than mangled. +// Defined once so the request sites that read suggestion label maps can't drift. +export const DASHBOARD_SUGGESTION_LABEL_STOP_PATHS: readonly string[] = [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', +]; + export function formatLabelSet(labels: Record): string[] { return Object.entries(labels).map(([key, value]) => `${key}=${value}`); }