From a0efab07772bffc6b55834e6b56ef8ce4efa502a Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:30:26 -0300 Subject: [PATCH 1/6] implement: control-suggestion-state-ui --- .../control-implementations/IndexView.vue | 133 ++++++++++++ .../__tests__/IndexView.spec.ts | 199 ++++++++++++++++++ .../ControlImplementationSuggestions.vue | 171 +++++++++++++++ .../ControlImplementationSuggestions.spec.ts | 116 ++++++++++ .../__tests__/dashboard-suggestions.spec.ts | 9 + .../partials/dashboard-suggestions.ts | 17 ++ 6 files changed, 645 insertions(+) create mode 100644 src/views/control-implementations/partials/ControlImplementationSuggestions.vue create mode 100644 src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index ce998954..43f252ac 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -141,6 +141,15 @@ position="right" class="w-full! md:w-1/2! lg:w-3/5!" > + +

Components

uiStore.controlImplementationDrawerOpen, @@ -272,8 +290,19 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi( {}, { immediate: false }, ); +const { + data: pendingDashboardSuggestions, + execute: fetchPendingDashboardSuggestions, + isLoading: pendingDashboardSuggestionsLoading, +} = useDataApi(null, {}, { immediate: false }); +const { + data: dashboardSuggestionControlResults, + execute: fetchDashboardSuggestionControlResults, + isLoading: dashboardSuggestionControlResultsLoading, +} = useDataApi(null, {}, { immediate: false }); const nodes = ref>([]); +const loadedDashboardSuggestionStateFor = ref(null); interface StatementSuggestionWorkItem { requirement: ImplementedRequirement; @@ -303,6 +332,47 @@ 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 selectedControlDashboardSuggestions = computed(() => { + const key = normalizeId(selectedImplementedRequirement.value?.controlId); + return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : []; +}); + +const selectedControlSuggestionResult = computed(() => { + const key = normalizeId(selectedImplementedRequirement.value?.controlId); + return key ? dashboardSuggestionResultsByControl.value[key] : undefined; +}); + +const dashboardSuggestionStateLoading = computed( + () => + pendingDashboardSuggestionsLoading.value || + dashboardSuggestionControlResultsLoading.value, +); + function normalizeId(value?: string): string { return (value || '').trim().toLowerCase(); } @@ -377,6 +447,45 @@ function controlHighestSeverity( return stats.highestSeverity ?? 'high'; } +async function loadDashboardSuggestionState(force = false) { + const sspId = systemStore.system.securityPlan?.uuid; + if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) { + pendingDashboardSuggestions.value = []; + dashboardSuggestionControlResults.value = []; + loadedDashboardSuggestionStateFor.value = null; + return; + } + + if (!force && loadedDashboardSuggestionStateFor.value === sspId) { + return; + } + + const [pendingResult, controlResultsResult] = await Promise.allSettled([ + fetchPendingDashboardSuggestions( + buildDashboardSuggestionsEndpoint(sspId, 'pending'), + { + camelcaseStopPaths: [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', + ], + }, + ), + fetchDashboardSuggestionControlResults( + buildDashboardSuggestionControlResultsEndpoint(sspId), + ), + ]); + + if (pendingResult.status === 'rejected') { + pendingDashboardSuggestions.value = []; + } + if (controlResultsResult.status === 'rejected') { + dashboardSuggestionControlResults.value = []; + } + + loadedDashboardSuggestionStateFor.value = sspId; +} + function openControlRisks(controlId?: string) { if (!controlId) { return; @@ -830,6 +939,7 @@ function openImplementationDrawer(req: ImplementedRequirement | undefined) { uiStore.setControlImplementationDrawerOpen(true); uiStore.setControlImplementationSelectedRequirementId(req.uuid); selectedImplementedRequirement.value = req; + void loadDashboardSuggestionState(); } watch( @@ -838,6 +948,9 @@ watch( if (!sspId) { sspRisks.value = []; loadedSspRisksFor.value = null; + pendingDashboardSuggestions.value = []; + dashboardSuggestionControlResults.value = []; + loadedDashboardSuggestionStateFor.value = null; return; } @@ -858,6 +971,23 @@ 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; + return; + } + void loadDashboardSuggestionState(); + }, +); + watch( () => uiStore.controlImplementationDrawerOpen, (isOpen) => { @@ -869,6 +999,9 @@ watch( ); onMounted(async () => { + await aiConfigStore.fetchDashboardSuggestionsConfig(); + await loadDashboardSuggestionState(); + 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..e20c3085 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -7,6 +7,19 @@ const listProfiles = vi.fn(); const axiosGet = vi.fn(); const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } })); const fetchControlImplementations = vi.fn(); +const fetchPendingDashboardSuggestions = vi.fn(); +const fetchDashboardSuggestionControlResults = vi.fn(); +let dashboardSuggestionsEnabled = false; +let dashboardSuggestionsConfigFetched = false; +const fetchDashboardSuggestionsConfig = vi.fn(async () => { + dashboardSuggestionsConfigFetched = true; + return dashboardSuggestionsEnabled; +}); +let pendingDashboardSuggestionsFixture: unknown[] = []; +let controlResultsFixture: unknown[] = []; +let pendingDashboardSuggestionsReject = false; +let controlResultsReject = false; +let useDataApiNullCallIndex = 0; const uiStore = { controlImplementationDrawerOpen: false, controlImplementationSelectedRequirementId: null as string | null, @@ -40,7 +53,24 @@ vi.mock('@/stores/ui.ts', () => ({ useUIStore: () => uiStore, })); +vi.mock('@/stores/ai-config', () => ({ + useAiConfigStore: () => ({ + get dashboardSuggestionsEnabled() { + return dashboardSuggestionsEnabled; + }, + get dashboardSuggestionsConfigFetched() { + return dashboardSuggestionsConfigFetched; + }, + fetchDashboardSuggestionsConfig, + }), +})); + vi.mock('vue-router', () => ({ + RouterLink: { + name: 'RouterLink', + props: ['to'], + template: '', + }, useRouter: () => ({ push: vi.fn() }), })); @@ -69,6 +99,38 @@ vi.mock('@/composables/axios', () => ({ execute: fetchControlImplementations, }; } + if (url === null) { + const callIndex = useDataApiNullCallIndex; + useDataApiNullCallIndex += 1; + if (callIndex % 3 === 1) { + const data = ref([]); + return { + data, + isLoading: ref(false), + error: ref(null), + execute: async (...args: unknown[]) => { + const response = await fetchPendingDashboardSuggestions(...args); + data.value = response.data.value.data; + return response; + }, + }; + } + if (callIndex % 3 === 2) { + const data = ref([]); + return { + data, + isLoading: ref(false), + error: ref(null), + execute: async (...args: unknown[]) => { + const response = await fetchDashboardSuggestionControlResults( + ...args, + ); + data.value = response.data.value.data; + return response; + }, + }; + } + } return { data: ref([]), isLoading: ref(false), @@ -104,6 +166,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,6 +208,13 @@ const stubs = { describe('control implementations IndexView', () => { beforeEach(() => { vi.clearAllMocks(); + useDataApiNullCallIndex = 0; + dashboardSuggestionsEnabled = false; + dashboardSuggestionsConfigFetched = false; + pendingDashboardSuggestionsFixture = []; + controlResultsFixture = []; + pendingDashboardSuggestionsReject = false; + controlResultsReject = false; uiStore.controlImplementationDrawerOpen = false; uiStore.controlImplementationSelectedRequirementId = null; uiStore.controlImplementationExpandedKeys = {}; @@ -167,6 +237,30 @@ describe('control implementations IndexView', () => { }, }, }); + fetchPendingDashboardSuggestions.mockImplementation(async () => { + if (pendingDashboardSuggestionsReject) { + throw new Error('pending failed'); + } + return { + data: { + value: { + data: pendingDashboardSuggestionsFixture, + }, + }, + }; + }); + fetchDashboardSuggestionControlResults.mockImplementation(async () => { + if (controlResultsReject) { + throw new Error('results failed'); + } + return { + data: { + value: { + data: controlResultsFixture, + }, + }, + }; + }); }); it('disables the implementation eye button when a control has no implementation', async () => { @@ -201,4 +295,109 @@ 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(fetchPendingDashboardSuggestions).not.toHaveBeenCalled(); + expect(fetchDashboardSuggestionControlResults).not.toHaveBeenCalled(); + }); + + it('matches pending suggestions to the selected control case-insensitively', async () => { + 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(fetchPendingDashboardSuggestions).toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions?status=pending'), + expect.objectContaining({ + camelcaseStopPaths: expect.arrayContaining([ + 'data.proposedFilterLabelSet', + ]), + }), + ); + expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions/control-results'), + ); + }); + + it('keeps pending suggestions visible when control-results cannot be fetched', async () => { + 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 () => { + 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', + ); + }); }); 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/__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/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..6f028b57 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`; } From c819d631cfb844bb3d8e05ce8ec0705c574bac9b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:36:29 -0300 Subject: [PATCH 2/6] self-review: address pass 1 findings --- .../control-implementations/IndexView.vue | 73 +++++++++++----- .../__tests__/IndexView.spec.ts | 87 ++++++++++++++++--- 2 files changed, 124 insertions(+), 36 deletions(-) diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index 43f252ac..8f51b7a3 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -303,6 +303,8 @@ const { const nodes = ref>([]); const loadedDashboardSuggestionStateFor = ref(null); +const loadingDashboardSuggestionStateFor = ref(null); +let dashboardSuggestionStateLoadPromise: Promise | null = null; interface StatementSuggestionWorkItem { requirement: ImplementedRequirement; @@ -453,6 +455,8 @@ async function loadDashboardSuggestionState(force = false) { pendingDashboardSuggestions.value = []; dashboardSuggestionControlResults.value = []; loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; return; } @@ -460,30 +464,55 @@ async function loadDashboardSuggestionState(force = false) { return; } - const [pendingResult, controlResultsResult] = await Promise.allSettled([ - fetchPendingDashboardSuggestions( - buildDashboardSuggestionsEndpoint(sspId, 'pending'), - { - camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', - ], - }, - ), - fetchDashboardSuggestionControlResults( - buildDashboardSuggestionControlResultsEndpoint(sspId), - ), - ]); - - if (pendingResult.status === 'rejected') { - pendingDashboardSuggestions.value = []; - } - if (controlResultsResult.status === 'rejected') { - dashboardSuggestionControlResults.value = []; + if ( + !force && + loadingDashboardSuggestionStateFor.value === sspId && + dashboardSuggestionStateLoadPromise + ) { + await dashboardSuggestionStateLoadPromise; + return; } - loadedDashboardSuggestionStateFor.value = sspId; + loadingDashboardSuggestionStateFor.value = sspId; + const loadPromise = (async () => { + const [pendingResult, controlResultsResult] = await Promise.allSettled([ + fetchPendingDashboardSuggestions( + buildDashboardSuggestionsEndpoint(sspId, 'pending'), + { + camelcaseStopPaths: [ + 'data.labelSet', + 'data.proposedFilterLabelSet', + 'data.originalProposedFilterLabelSet', + ], + }, + ), + fetchDashboardSuggestionControlResults( + buildDashboardSuggestionControlResultsEndpoint(sspId), + ), + ]); + + if (pendingResult.status === 'rejected') { + pendingDashboardSuggestions.value = []; + } + if (controlResultsResult.status === 'rejected') { + dashboardSuggestionControlResults.value = []; + } + + loadedDashboardSuggestionStateFor.value = sspId; + })(); + dashboardSuggestionStateLoadPromise = loadPromise; + + try { + await loadPromise; + } finally { + if ( + loadingDashboardSuggestionStateFor.value === sspId && + dashboardSuggestionStateLoadPromise === loadPromise + ) { + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + } + } } function openControlRisks(controlId?: string) { diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index e20c3085..1a723fcf 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -1,19 +1,23 @@ -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 fetchPendingDashboardSuggestions = vi.fn(); const fetchDashboardSuggestionControlResults = vi.fn(); -let dashboardSuggestionsEnabled = false; -let dashboardSuggestionsConfigFetched = false; +const aiConfigState = reactive({ + dashboardSuggestionsEnabled: false, + dashboardSuggestionsConfigFetched: false, +}); const fetchDashboardSuggestionsConfig = vi.fn(async () => { - dashboardSuggestionsConfigFetched = true; - return dashboardSuggestionsEnabled; + aiConfigState.dashboardSuggestionsConfigFetched = true; + return aiConfigState.dashboardSuggestionsEnabled; }); let pendingDashboardSuggestionsFixture: unknown[] = []; let controlResultsFixture: unknown[] = []; @@ -56,10 +60,10 @@ vi.mock('@/stores/ui.ts', () => ({ vi.mock('@/stores/ai-config', () => ({ useAiConfigStore: () => ({ get dashboardSuggestionsEnabled() { - return dashboardSuggestionsEnabled; + return aiConfigState.dashboardSuggestionsEnabled; }, get dashboardSuggestionsConfigFetched() { - return dashboardSuggestionsConfigFetched; + return aiConfigState.dashboardSuggestionsConfigFetched; }, fetchDashboardSuggestionsConfig, }), @@ -156,6 +160,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(); @@ -209,8 +223,8 @@ describe('control implementations IndexView', () => { beforeEach(() => { vi.clearAllMocks(); useDataApiNullCallIndex = 0; - dashboardSuggestionsEnabled = false; - dashboardSuggestionsConfigFetched = false; + aiConfigState.dashboardSuggestionsEnabled = false; + aiConfigState.dashboardSuggestionsConfigFetched = false; pendingDashboardSuggestionsFixture = []; controlResultsFixture = []; pendingDashboardSuggestionsReject = false; @@ -312,7 +326,7 @@ describe('control implementations IndexView', () => { }); it('matches pending suggestions to the selected control case-insensitively', async () => { - dashboardSuggestionsEnabled = true; + aiConfigState.dashboardSuggestionsEnabled = true; pendingDashboardSuggestionsFixture = [ { id: 'suggestion-1', @@ -351,8 +365,53 @@ describe('control implementations IndexView', () => { ); }); + it('loads dashboard suggestion state once when config resolution enables suggestions on mount', async () => { + aiConfigState.dashboardSuggestionsEnabled = true; + const configDeferred = createDeferred(); + const pendingDeferred = createDeferred<{ + data: { value: { data: unknown[] } }; + }>(); + const controlResultsDeferred = createDeferred<{ + data: { value: { data: unknown[] } }; + }>(); + + fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => { + await configDeferred.promise; + aiConfigState.dashboardSuggestionsConfigFetched = true; + return aiConfigState.dashboardSuggestionsEnabled; + }); + fetchPendingDashboardSuggestions.mockReturnValueOnce( + pendingDeferred.promise, + ); + fetchDashboardSuggestionControlResults.mockReturnValueOnce( + controlResultsDeferred.promise, + ); + + mount(IndexView, { global: { stubs } }); + configDeferred.resolve(); + await flushPromises(); + + expect(fetchPendingDashboardSuggestions).toHaveBeenCalledTimes(1); + expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith( + expect.stringContaining( + '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions?status=pending', + ), + expect.any(Object), + ); + expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledTimes(1); + expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith( + expect.stringContaining( + '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results', + ), + ); + + pendingDeferred.resolve({ data: { value: { data: [] } } }); + controlResultsDeferred.resolve({ data: { value: { data: [] } } }); + await waitForMountedControls(); + }); + it('keeps pending suggestions visible when control-results cannot be fetched', async () => { - dashboardSuggestionsEnabled = true; + aiConfigState.dashboardSuggestionsEnabled = true; controlResultsReject = true; pendingDashboardSuggestionsFixture = [ { @@ -377,7 +436,7 @@ describe('control implementations IndexView', () => { }); it('keeps no-match state visible when pending suggestions cannot be fetched', async () => { - dashboardSuggestionsEnabled = true; + aiConfigState.dashboardSuggestionsEnabled = true; pendingDashboardSuggestionsReject = true; controlResultsFixture = [ { From a58cdc26970ac8961277cd55c6859496d665aed1 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:46:34 -0300 Subject: [PATCH 3/6] self-review: address pass 2 findings --- src/composables/__tests__/axios.spec.ts | 61 +++++++ src/composables/useDashboardSuggestions.ts | 18 +-- .../control-implementations/IndexView.vue | 59 ++++--- .../__tests__/IndexView.spec.ts | 150 +++++++++++++++--- 4 files changed, 230 insertions(+), 58 deletions(-) create mode 100644 src/composables/__tests__/axios.spec.ts diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts new file mode 100644 index 00000000..2c69b2a8 --- /dev/null +++ b/src/composables/__tests__/axios.spec.ts @@ -0,0 +1,61 @@ +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', { + camelcaseStopPaths: ['data.proposed_filter_label_set'], + adapter: async (config) => ({ + data: { + data: [ + { + id: 'suggestion-1', + proposed_filter_label_set: { + _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..da1dc990 100644 --- a/src/composables/useDashboardSuggestions.ts +++ b/src/composables/useDashboardSuggestions.ts @@ -96,9 +96,9 @@ export function useDashboardSuggestions( // changes. { camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', + 'data.label_set', + 'data.proposed_filter_label_set', + 'data.original_proposed_filter_label_set', ], }, ); @@ -195,9 +195,9 @@ export function useDashboardSuggestions( buildDashboardSuggestionsEndpoint(sspId.value, status), { camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', + 'data.label_set', + 'data.proposed_filter_label_set', + 'data.original_proposed_filter_label_set', ], }, ); @@ -236,9 +236,9 @@ export function useDashboardSuggestions( transformRequest: [decamelizeKeys], // Preserve raw label keys (e.g. `_policy`) the user kept in the filter. camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', + 'data.label_set', + 'data.proposed_filter_label_set', + 'data.original_proposed_filter_label_set', ], }, ); diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index 8f51b7a3..59f26546 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -290,21 +290,15 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi( {}, { immediate: false }, ); -const { - data: pendingDashboardSuggestions, - execute: fetchPendingDashboardSuggestions, - isLoading: pendingDashboardSuggestionsLoading, -} = useDataApi(null, {}, { immediate: false }); -const { - data: dashboardSuggestionControlResults, - execute: fetchDashboardSuggestionControlResults, - isLoading: dashboardSuggestionControlResultsLoading, -} = useDataApi(null, {}, { 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; @@ -369,12 +363,6 @@ const selectedControlSuggestionResult = computed(() => { return key ? dashboardSuggestionResultsByControl.value[key] : undefined; }); -const dashboardSuggestionStateLoading = computed( - () => - pendingDashboardSuggestionsLoading.value || - dashboardSuggestionControlResultsLoading.value, -); - function normalizeId(value?: string): string { return (value || '').trim().toLowerCase(); } @@ -457,6 +445,8 @@ async function loadDashboardSuggestionState(force = false) { loadedDashboardSuggestionStateFor.value = null; loadingDashboardSuggestionStateFor.value = null; dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; return; } @@ -474,30 +464,38 @@ async function loadDashboardSuggestionState(force = false) { } loadingDashboardSuggestionStateFor.value = sspId; + dashboardSuggestionStateLoading.value = true; + const requestId = (dashboardSuggestionStateRequestId.value += 1); const loadPromise = (async () => { const [pendingResult, controlResultsResult] = await Promise.allSettled([ - fetchPendingDashboardSuggestions( + axios.get>( buildDashboardSuggestionsEndpoint(sspId, 'pending'), { camelcaseStopPaths: [ - 'data.labelSet', - 'data.proposedFilterLabelSet', - 'data.originalProposedFilterLabelSet', + 'data.label_set', + 'data.proposed_filter_label_set', + 'data.original_proposed_filter_label_set', ], }, ), - fetchDashboardSuggestionControlResults( + axios.get>( buildDashboardSuggestionControlResultsEndpoint(sspId), ), ]); - if (pendingResult.status === 'rejected') { - pendingDashboardSuggestions.value = []; - } - if (controlResultsResult.status === 'rejected') { - dashboardSuggestionControlResults.value = []; + 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; @@ -511,6 +509,7 @@ async function loadDashboardSuggestionState(force = false) { ) { loadingDashboardSuggestionStateFor.value = null; dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateLoading.value = false; } } } @@ -980,6 +979,10 @@ watch( pendingDashboardSuggestions.value = []; dashboardSuggestionControlResults.value = []; loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; return; } @@ -1011,6 +1014,10 @@ watch( pendingDashboardSuggestions.value = []; dashboardSuggestionControlResults.value = []; loadedDashboardSuggestionStateFor.value = null; + loadingDashboardSuggestionStateFor.value = null; + dashboardSuggestionStateLoadPromise = null; + dashboardSuggestionStateRequestId.value += 1; + dashboardSuggestionStateLoading.value = false; return; } void loadDashboardSuggestionState(); diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index 1a723fcf..3adc830e 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -15,6 +15,9 @@ 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; @@ -42,9 +45,7 @@ const uiStore = { }; vi.mock('@/stores/system.ts', () => ({ - useSystemStore: () => ({ - system: { securityPlan: { uuid: 'ssp-1' } }, - }), + useSystemStore: () => systemStoreState, })); vi.mock('@/stores/system-security-plans', () => ({ @@ -225,6 +226,7 @@ describe('control implementations IndexView', () => { useDataApiNullCallIndex = 0; aiConfigState.dashboardSuggestionsEnabled = false; aiConfigState.dashboardSuggestionsConfigFetched = false; + systemStoreState.system.securityPlan = { uuid: 'ssp-1' }; pendingDashboardSuggestionsFixture = []; controlResultsFixture = []; pendingDashboardSuggestionsReject = false; @@ -235,7 +237,21 @@ describe('control implementations IndexView', () => { 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: { @@ -321,8 +337,13 @@ describe('control implementations IndexView', () => { await flushPromises(); expect(wrapper.text()).not.toContain('AI dashboard suggestions'); - expect(fetchPendingDashboardSuggestions).not.toHaveBeenCalled(); - expect(fetchDashboardSuggestionControlResults).not.toHaveBeenCalled(); + expect(axiosGet).not.toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions?status=pending'), + expect.anything(), + ); + expect(axiosGet).not.toHaveBeenCalledWith( + expect.stringContaining('/dashboard-suggestions/control-results'), + ); }); it('matches pending suggestions to the selected control case-insensitively', async () => { @@ -352,15 +373,15 @@ describe('control implementations IndexView', () => { expect(wrapper.text()).toContain('AI dashboard suggestions'); expect(wrapper.text()).toContain('Production evidence'); expect(wrapper.text()).toContain('80% confidence'); - expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith( + expect(axiosGet).toHaveBeenCalledWith( expect.stringContaining('/dashboard-suggestions?status=pending'), expect.objectContaining({ camelcaseStopPaths: expect.arrayContaining([ - 'data.proposedFilterLabelSet', + 'data.proposed_filter_label_set', ]), }), ); - expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith( + expect(axiosGet).toHaveBeenCalledWith( expect.stringContaining('/dashboard-suggestions/control-results'), ); }); @@ -369,10 +390,10 @@ describe('control implementations IndexView', () => { aiConfigState.dashboardSuggestionsEnabled = true; const configDeferred = createDeferred(); const pendingDeferred = createDeferred<{ - data: { value: { data: unknown[] } }; + data: { data: unknown[] }; }>(); const controlResultsDeferred = createDeferred<{ - data: { value: { data: unknown[] } }; + data: { data: unknown[] }; }>(); fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => { @@ -380,34 +401,117 @@ describe('control implementations IndexView', () => { aiConfigState.dashboardSuggestionsConfigFetched = true; return aiConfigState.dashboardSuggestionsEnabled; }); - fetchPendingDashboardSuggestions.mockReturnValueOnce( - pendingDeferred.promise, - ); - fetchDashboardSuggestionControlResults.mockReturnValueOnce( - controlResultsDeferred.promise, - ); + 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(); - expect(fetchPendingDashboardSuggestions).toHaveBeenCalledTimes(1); - expect(fetchPendingDashboardSuggestions).toHaveBeenCalledWith( + 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(fetchDashboardSuggestionControlResults).toHaveBeenCalledTimes(1); - expect(fetchDashboardSuggestionControlResults).toHaveBeenCalledWith( + expect(controlResultsCalls).toHaveLength(1); + expect(axiosGet).toHaveBeenCalledWith( expect.stringContaining( '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results', ), ); - pendingDeferred.resolve({ data: { value: { data: [] } } }); - controlResultsDeferred.resolve({ data: { value: { data: [] } } }); + 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 () => { From db357acafaa335d33caa7454d7d28e3547594c69 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:51:09 -0300 Subject: [PATCH 4/6] self-review: address pass 3 findings --- .../control-implementations/IndexView.vue | 14 ++++++++++++-- .../__tests__/IndexView.spec.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index 59f26546..7c979c8a 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -514,6 +514,17 @@ async function loadDashboardSuggestionState(force = 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; @@ -1035,8 +1046,7 @@ watch( ); onMounted(async () => { - await aiConfigStore.fetchDashboardSuggestionsConfig(); - await loadDashboardSuggestionState(); + void initializeDashboardSuggestionState(); try { await loadProfileBindings(); diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index 3adc830e..7a31f94b 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -346,6 +346,24 @@ describe('control implementations IndexView', () => { ); }); + 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 = [ From 57b2547632e4c99ed925fee11e5661fcf2d3d8ef Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:15:31 -0300 Subject: [PATCH 5/6] fix: address review feedback --- .../control-implementations/IndexView.vue | 5 +- .../__tests__/IndexView.spec.ts | 64 ++----------------- 2 files changed, 8 insertions(+), 61 deletions(-) diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue index 7c979c8a..7e14319a 100644 --- a/src/views/control-implementations/IndexView.vue +++ b/src/views/control-implementations/IndexView.vue @@ -437,7 +437,7 @@ function controlHighestSeverity( return stats.highestSeverity ?? 'high'; } -async function loadDashboardSuggestionState(force = false) { +async function loadDashboardSuggestionState() { const sspId = systemStore.system.securityPlan?.uuid; if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) { pendingDashboardSuggestions.value = []; @@ -450,12 +450,11 @@ async function loadDashboardSuggestionState(force = false) { return; } - if (!force && loadedDashboardSuggestionStateFor.value === sspId) { + if (loadedDashboardSuggestionStateFor.value === sspId) { return; } if ( - !force && loadingDashboardSuggestionStateFor.value === sspId && dashboardSuggestionStateLoadPromise ) { diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index 7a31f94b..1c72341e 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -9,8 +9,6 @@ const listProfiles = vi.fn(); const axiosGet = vi.fn(); const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } })); const fetchControlImplementations = vi.fn(); -const fetchPendingDashboardSuggestions = vi.fn(); -const fetchDashboardSuggestionControlResults = vi.fn(); const aiConfigState = reactive({ dashboardSuggestionsEnabled: false, dashboardSuggestionsConfigFetched: false, @@ -26,7 +24,6 @@ let pendingDashboardSuggestionsFixture: unknown[] = []; let controlResultsFixture: unknown[] = []; let pendingDashboardSuggestionsReject = false; let controlResultsReject = false; -let useDataApiNullCallIndex = 0; const uiStore = { controlImplementationDrawerOpen: false, controlImplementationSelectedRequirementId: null as string | null, @@ -105,36 +102,12 @@ vi.mock('@/composables/axios', () => ({ }; } if (url === null) { - const callIndex = useDataApiNullCallIndex; - useDataApiNullCallIndex += 1; - if (callIndex % 3 === 1) { - const data = ref([]); - return { - data, - isLoading: ref(false), - error: ref(null), - execute: async (...args: unknown[]) => { - const response = await fetchPendingDashboardSuggestions(...args); - data.value = response.data.value.data; - return response; - }, - }; - } - if (callIndex % 3 === 2) { - const data = ref([]); - return { - data, - isLoading: ref(false), - error: ref(null), - execute: async (...args: unknown[]) => { - const response = await fetchDashboardSuggestionControlResults( - ...args, - ); - data.value = response.data.value.data; - return response; - }, - }; - } + return { + data: ref([]), + isLoading: ref(false), + error: ref(null), + execute: loadRisks, + }; } return { data: ref([]), @@ -223,7 +196,6 @@ const stubs = { describe('control implementations IndexView', () => { beforeEach(() => { vi.clearAllMocks(); - useDataApiNullCallIndex = 0; aiConfigState.dashboardSuggestionsEnabled = false; aiConfigState.dashboardSuggestionsConfigFetched = false; systemStoreState.system.securityPlan = { uuid: 'ssp-1' }; @@ -267,30 +239,6 @@ describe('control implementations IndexView', () => { }, }, }); - fetchPendingDashboardSuggestions.mockImplementation(async () => { - if (pendingDashboardSuggestionsReject) { - throw new Error('pending failed'); - } - return { - data: { - value: { - data: pendingDashboardSuggestionsFixture, - }, - }, - }; - }); - fetchDashboardSuggestionControlResults.mockImplementation(async () => { - if (controlResultsReject) { - throw new Error('results failed'); - } - return { - data: { - value: { - data: controlResultsFixture, - }, - }, - }; - }); }); it('disables the implementation eye button when a control has no implementation', async () => { From c8aed5a6e0f025d5f06c4616f57bbe7691df2c43 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 18 Jun 2026 11:46:27 -0300 Subject: [PATCH 6/6] fix: dashboard fixes Signed-off-by: Gustavo Carvalho --- src/composables/__tests__/axios.spec.ts | 6 +- src/composables/useDashboardSuggestions.ts | 21 ++---- .../control-implementations/IndexView.vue | 64 +++++++++++++------ .../__tests__/IndexView.spec.ts | 52 ++++++++++++++- .../partials/RiskIndicatorBadge.vue | 8 ++- .../partials/SuggestionIndicatorBadge.vue | 39 +++++++++++ .../__tests__/RiskIndicatorBadge.spec.ts | 50 +++++++++++++++ .../partials/dashboard-suggestions.ts | 12 ++++ 8 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 src/views/control-implementations/partials/SuggestionIndicatorBadge.vue create mode 100644 src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts index 2c69b2a8..8c072918 100644 --- a/src/composables/__tests__/axios.spec.ts +++ b/src/composables/__tests__/axios.spec.ts @@ -30,13 +30,15 @@ describe('axios response conversion', () => { const instance = useAuthenticatedInstance(); const response = await instance.get('/dashboard-suggestions', { - camelcaseStopPaths: ['data.proposed_filter_label_set'], + // 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', - proposed_filter_label_set: { + proposedFilterLabelSet: { _policy: 'x', service_name: 'api', }, diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts index da1dc990..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.label_set', - 'data.proposed_filter_label_set', - 'data.original_proposed_filter_label_set', - ], + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, }, ); } @@ -194,11 +191,7 @@ export function useDashboardSuggestions( const response = await fetchHistoryRequest( buildDashboardSuggestionsEndpoint(sspId.value, status), { - camelcaseStopPaths: [ - 'data.label_set', - 'data.proposed_filter_label_set', - 'data.original_proposed_filter_label_set', - ], + 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.label_set', - 'data.proposed_filter_label_set', - 'data.original_proposed_filter_label_set', - ], + 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 7e14319a..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)" > +
@@ -143,7 +144,7 @@ > ( {}, ); 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); @@ -353,16 +360,28 @@ const dashboardSuggestionResultsByControl = computed(() => { return results; }); +const selectedDrawerControlId = computed( + () => + selectedControlId.value ?? selectedImplementedRequirement.value?.controlId, +); + const selectedControlDashboardSuggestions = computed(() => { - const key = normalizeId(selectedImplementedRequirement.value?.controlId); + const key = normalizeId(selectedDrawerControlId.value); return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : []; }); const selectedControlSuggestionResult = computed(() => { - const key = normalizeId(selectedImplementedRequirement.value?.controlId); + 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(); } @@ -470,11 +489,7 @@ async function loadDashboardSuggestionState() { axios.get>( buildDashboardSuggestionsEndpoint(sspId, 'pending'), { - camelcaseStopPaths: [ - 'data.label_set', - 'data.proposed_filter_label_set', - 'data.original_proposed_filter_label_set', - ], + camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS, }, ), axios.get>( @@ -590,6 +605,7 @@ async function loadControlImplementations() { uiStore.controlImplementationDrawerOpen ) { selectedImplementedRequirement.value = impl; + selectedControlId.value = impl.controlId; selectedRequirementFound = true; } } @@ -970,13 +986,20 @@ 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(); } @@ -1037,9 +1060,12 @@ watch( 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; } }, ); diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts index 1c72341e..a66b5531 100644 --- a/src/views/control-implementations/__tests__/IndexView.spec.ts +++ b/src/views/control-implementations/__tests__/IndexView.spec.ts @@ -343,7 +343,7 @@ describe('control implementations IndexView', () => { expect.stringContaining('/dashboard-suggestions?status=pending'), expect.objectContaining({ camelcaseStopPaths: expect.arrayContaining([ - 'data.proposed_filter_label_set', + 'data.proposedFilterLabelSet', ]), }), ); @@ -529,4 +529,54 @@ describe('control implementations IndexView', () => { '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/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__/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/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts index 6f028b57..048c699f 100644 --- a/src/views/dashboard/partials/dashboard-suggestions.ts +++ b/src/views/dashboard/partials/dashboard-suggestions.ts @@ -376,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}`); }