diff --git a/src/components/ComplianceProgressPanel.vue b/src/components/ComplianceProgressPanel.vue new file mode 100644 index 00000000..5b59656d --- /dev/null +++ b/src/components/ComplianceProgressPanel.vue @@ -0,0 +1,259 @@ + + + diff --git a/src/composables/__tests__/useProfileCompliance.spec.ts b/src/composables/__tests__/useProfileCompliance.spec.ts new file mode 100644 index 00000000..4ef9a6aa --- /dev/null +++ b/src/composables/__tests__/useProfileCompliance.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref } from 'vue'; +import { useProfileCompliance } from '../useProfileCompliance'; +import type { ProfileComplianceProgress } from '@/types/compliance'; + +const mockExecute = vi.fn(); +const mockData: { value: ProfileComplianceProgress | undefined } = { + value: undefined, +}; + +vi.mock('@/composables/axios', () => ({ + useDataApi: vi.fn(() => ({ + data: mockData, + isLoading: ref(false), + error: ref(null), + execute: mockExecute, + })), +})); + +describe('useProfileCompliance', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockData.value = undefined; + }); + + describe('loadCompliance URL construction', () => { + it('builds a clean URL with no query params when no options given', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance(); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress', + ); + }); + + it('appends includeControls=true when explicitly set', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: true }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?includeControls=true', + ); + }); + + it('appends includeControls=false when explicitly set', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: false }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?includeControls=false', + ); + }); + + it('does not append includeControls when omitted from options', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({}); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('includeControls'); + }); + + it('appends sspId when provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ sspId: 'ssp-xyz' }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?sspId=ssp-xyz', + ); + }); + + it('appends both includeControls and sspId when both provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: false, sspId: 'ssp-xyz' }); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).toContain('includeControls=false'); + expect(calledUrl).toContain('sspId=ssp-xyz'); + }); + + it('does not append sspId when not provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: true }); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('sspId'); + }); + + it('resolves the profileId from a ref', async () => { + mockExecute.mockResolvedValue(undefined); + + const profileIdRef = ref('profile-from-ref'); + const { loadCompliance } = useProfileCompliance(profileIdRef); + await loadCompliance(); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-from-ref/compliance-progress', + ); + }); + }); + + describe('derived computed refs', () => { + const sampleProgress: ProfileComplianceProgress = { + scope: { type: 'profile', id: 'profile-abc', title: 'Test Profile' }, + summary: { + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + assessedPercent: 67, + }, + groups: [ + { + id: 'GRP-1', + title: 'Group One', + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + }, + ], + controls: [ + { + controlId: 'CTRL-1', + catalogId: 'catalog-1', + title: 'Control One', + statusCounts: [{ status: 'satisfied', count: 2 }], + computedStatus: 'satisfied', + }, + ], + }; + + it('summary is undefined when data has not loaded', () => { + mockData.value = undefined; + const { summary } = useProfileCompliance('profile-abc'); + expect(summary.value).toBeUndefined(); + }); + + it('summary returns the loaded summary', () => { + mockData.value = sampleProgress; + const { summary } = useProfileCompliance('profile-abc'); + expect(summary.value).toEqual(sampleProgress.summary); + }); + + it('controls defaults to empty array when data has not loaded', () => { + mockData.value = undefined; + const { controls } = useProfileCompliance('profile-abc'); + expect(controls.value).toEqual([]); + }); + + it('controls returns the loaded controls array', () => { + mockData.value = sampleProgress; + const { controls } = useProfileCompliance('profile-abc'); + expect(controls.value).toEqual(sampleProgress.controls); + }); + + it('groups defaults to empty array when data has not loaded', () => { + mockData.value = undefined; + const { groups } = useProfileCompliance('profile-abc'); + expect(groups.value).toEqual([]); + }); + + it('groups returns the loaded groups array', () => { + mockData.value = sampleProgress; + const { groups } = useProfileCompliance('profile-abc'); + expect(groups.value).toEqual(sampleProgress.groups); + }); + + it('progress returns the full data object', () => { + mockData.value = sampleProgress; + const { progress } = useProfileCompliance('profile-abc'); + expect(progress.value).toEqual(sampleProgress); + }); + }); +}); diff --git a/src/composables/useProfileCompliance.ts b/src/composables/useProfileCompliance.ts new file mode 100644 index 00000000..0ac8932e --- /dev/null +++ b/src/composables/useProfileCompliance.ts @@ -0,0 +1,53 @@ +import { computed, toValue, type MaybeRefOrGetter } from 'vue'; +import { useDataApi } from '@/composables/axios'; +import type { ProfileComplianceProgress } from '@/types/compliance'; + +interface LoadComplianceOptions { + includeControls?: boolean; + sspId?: string; +} + +export function useProfileCompliance(profileId: MaybeRefOrGetter) { + const { + data, + isLoading, + error, + execute: executeCompliance, + } = useDataApi(null, null, { + immediate: false, + }); + + const progress = computed(() => data.value); + const summary = computed(() => progress.value?.summary); + const controls = computed(() => progress.value?.controls ?? []); + const groups = computed(() => progress.value?.groups ?? []); + + async function loadCompliance(options: LoadComplianceOptions = {}) { + const resolvedProfileID = toValue(profileId); + + const params = new URLSearchParams(); + if (typeof options.includeControls === 'boolean') { + params.set('includeControls', String(options.includeControls)); + } + if (options.sspId) { + params.set('sspId', options.sspId); + } + + const query = params.toString(); + const url = query.length + ? `/api/oscal/profiles/${resolvedProfileID}/compliance-progress?${query}` + : `/api/oscal/profiles/${resolvedProfileID}/compliance-progress`; + + await executeCompliance(url); + } + + return { + progress, + summary, + controls, + groups, + isLoading, + error, + loadCompliance, + }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 2cb60a42..ec718bea 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -323,6 +323,14 @@ const authenticatedRoutes = [ requiresAuth: true, }, }, + { + path: 'compliance', + name: 'profile:view-compliance', + component: () => import('../views/profile/ProfileComplianceView.vue'), + meta: { + requiresAuth: true, + }, + }, { path: 'json', name: 'profile:view-json', @@ -622,6 +630,14 @@ const authenticatedRoutes = [ '../views/system-security-plans/SystemSecurityPlanControlImplementationView.vue' ), }, + { + path: 'compliance', + name: 'system-security-plan-compliance', + component: () => + import( + '../views/system-security-plans/SystemSecurityPlanComplianceView.vue' + ), + }, { path: 'json', name: 'system-security-plan-json', diff --git a/src/types/compliance.ts b/src/types/compliance.ts new file mode 100644 index 00000000..980bd4a2 --- /dev/null +++ b/src/types/compliance.ts @@ -0,0 +1,55 @@ +export interface ProfileComplianceStatusCount { + count: number; + status: string; +} + +export interface ProfileComplianceControl { + controlId: string; + catalogId: string; + title: string; + groupId?: string; + groupTitle?: string; + implemented?: boolean; + statusCounts: ProfileComplianceStatusCount[]; + computedStatus: string; +} + +export interface ProfileComplianceGroup { + id: string; + title: string; + totalControls: number; + satisfied: number; + notSatisfied: number; + unknown: number; + compliancePercent: number; +} + +export interface ProfileComplianceSummary { + totalControls: number; + satisfied: number; + notSatisfied: number; + unknown: number; + compliancePercent: number; + assessedPercent: number; + implementedControls?: number; +} + +export interface ProfileComplianceImplementation { + implementedControls: number; + implementationPercent: number; + unimplementedControls: number; +} + +export interface ProfileComplianceScope { + type: string; + id: string; + title: string; +} + +export interface ProfileComplianceProgress { + scope: ProfileComplianceScope; + summary: ProfileComplianceSummary; + implementation?: ProfileComplianceImplementation; + groups: ProfileComplianceGroup[]; + controls: ProfileComplianceControl[]; +} diff --git a/src/utils/compliance.spec.ts b/src/utils/compliance.spec.ts new file mode 100644 index 00000000..f0aaff79 --- /dev/null +++ b/src/utils/compliance.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + computeComplianceWidths, + controlKey, + percent, + statusClass, + statusCount, + statusLabel, +} from './compliance'; + +describe('compliance utils', () => { + it('computes widths from summary values', () => { + expect( + computeComplianceWidths({ + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + }), + ).toEqual({ + satisfied: 33, + notSatisfied: 33, + unknown: 34, + }); + }); + + it('returns zero widths when summary is missing', () => { + expect(computeComplianceWidths()).toEqual({ + satisfied: 0, + notSatisfied: 0, + unknown: 0, + }); + }); + + it('caps not-satisfied width to remaining percentage', () => { + expect( + computeComplianceWidths({ + totalControls: 1, + satisfied: 1, + notSatisfied: 1, + }), + ).toEqual({ + satisfied: 100, + notSatisfied: 0, + unknown: 0, + }); + }); + + it('computes percentages safely when total is zero', () => { + expect(percent(3, 0)).toBe(0); + }); + + it('builds a stable control key', () => { + expect( + controlKey({ + controlId: 'AC-1', + catalogId: 'NIST-800-53', + title: 'Access Control Policy and Procedures', + statusCounts: [], + computedStatus: 'unknown', + }), + ).toBe('NIST-800-53:AC-1'); + }); + + it('returns matching status count or zero fallback', () => { + const control = { + controlId: 'AC-2', + catalogId: 'NIST-800-53', + title: 'Account Management', + statusCounts: [ + { status: 'satisfied', count: 2 }, + { status: 'not-satisfied', count: 1 }, + ], + computedStatus: 'satisfied', + }; + + expect(statusCount(control, 'satisfied')).toBe(2); + expect(statusCount(control, 'unknown')).toBe(0); + }); + + it('maps status display classes and labels', () => { + expect(statusClass('satisfied')).toBe('bg-emerald-100 text-emerald-800'); + expect(statusClass('not-satisfied')).toBe('bg-red-100 text-red-800'); + expect(statusClass('anything-else')).toBe('bg-slate-100 text-slate-800'); + + expect(statusLabel('satisfied')).toBe('Satisfied'); + expect(statusLabel('not-satisfied')).toBe('Not Satisfied'); + expect(statusLabel('anything-else')).toBe('Unknown'); + }); +}); diff --git a/src/utils/compliance.ts b/src/utils/compliance.ts new file mode 100644 index 00000000..1f74c55f --- /dev/null +++ b/src/utils/compliance.ts @@ -0,0 +1,85 @@ +import type { + ProfileComplianceControl, + ProfileComplianceStatusCount, + ProfileComplianceSummary, +} from '@/types/compliance'; + +type ComplianceWidthSummary = Pick< + ProfileComplianceSummary, + 'totalControls' | 'satisfied' | 'notSatisfied' +>; + +export interface ComplianceWidths { + satisfied: number; + notSatisfied: number; + unknown: number; +} + +export function percent(part: number, total: number): number { + if (!total) return 0; + return Math.round((part / total) * 100); +} + +export function computeComplianceWidths( + summary?: ComplianceWidthSummary | null, +): ComplianceWidths { + if (!summary) { + return { + satisfied: 0, + notSatisfied: 0, + unknown: 0, + }; + } + + const satisfied = percent(summary.satisfied, summary.totalControls); + const notSatisfiedRaw = percent(summary.notSatisfied, summary.totalControls); + + const remaining = 100 - satisfied; + const notSatisfied = Math.min(notSatisfiedRaw, remaining); + + const used = Math.min(satisfied + notSatisfiedRaw, 100); + const unknown = 100 - used; + + return { + satisfied, + notSatisfied, + unknown, + }; +} + +export function controlKey(control: ProfileComplianceControl): string { + return `${control.catalogId}:${control.controlId}`; +} + +export function statusCount( + control: ProfileComplianceControl, + status: string, +): number { + return ( + control.statusCounts.find( + (item: ProfileComplianceStatusCount) => item.status === status, + )?.count || 0 + ); +} + +export function statusClass(status: string): string { + switch (status) { + case 'satisfied': + return 'bg-emerald-100 text-emerald-800'; + case 'not-satisfied': + return 'bg-red-100 text-red-800'; + default: + return 'bg-slate-100 text-slate-800'; + } +} + +export function statusLabel(status: string): string { + switch (status) { + case 'satisfied': + return 'Satisfied'; + case 'not-satisfied': + return 'Not Satisfied'; + default: + return 'Unknown'; + } +} diff --git a/src/views/__tests__/ProfileComplianceView.spec.ts b/src/views/__tests__/ProfileComplianceView.spec.ts new file mode 100644 index 00000000..b807c36e --- /dev/null +++ b/src/views/__tests__/ProfileComplianceView.spec.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { reactive, ref } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; +import ProfileComplianceView from '../profile/ProfileComplianceView.vue'; +import type { + ProfileComplianceControl, + ProfileComplianceGroup, + ProfileComplianceSummary, +} from '@/types/compliance'; + +const route = reactive({ params: { id: 'profile-1' } }); + +const mockSummary = ref(); +const mockControls = ref([]); +const mockGroups = ref([]); +const mockIsLoading = ref(false); +const mockError = ref(null); +const mockLoadCompliance = vi.fn(); +const mockToastAdd = vi.fn(); + +vi.mock('vue-router', () => ({ + useRoute: () => route, +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ + add: mockToastAdd, + }), +})); + +vi.mock('@/composables/useProfileCompliance', () => ({ + useProfileCompliance: () => ({ + summary: mockSummary, + controls: mockControls, + groups: mockGroups, + isLoading: mockIsLoading, + error: mockError, + loadCompliance: mockLoadCompliance, + }), +})); + +function mountComponent() { + return mount(ProfileComplianceView, { + global: { + stubs: { + PageHeader: { + template: '
', + }, + ResultStatusBadge: { + props: ['green', 'gray', 'red'], + template: + '
{{ green }} {{ gray }} {{ red }}
', + }, + }, + }, + }); +} + +describe('ProfileComplianceView', () => { + beforeEach(() => { + vi.clearAllMocks(); + route.params.id = 'profile-1'; + + mockSummary.value = { + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + assessedPercent: 67, + }; + + mockControls.value = [ + { + controlId: 'CTRL-SAT', + catalogId: 'catalog-1', + title: 'Satisfied Control', + statusCounts: [{ status: 'satisfied', count: 2 }], + computedStatus: 'satisfied', + }, + { + controlId: 'CTRL-NS', + catalogId: 'catalog-1', + title: 'Not Satisfied Control', + statusCounts: [{ status: 'not-satisfied', count: 1 }], + computedStatus: 'not-satisfied', + }, + { + controlId: 'CTRL-UNK', + catalogId: 'catalog-1', + title: 'Unknown Control', + statusCounts: [], + computedStatus: 'unknown', + }, + ]; + + mockGroups.value = [ + { + id: 'CC', + title: 'Common Criteria', + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + }, + ]; + + mockIsLoading.value = false; + mockError.value = null; + mockLoadCompliance.mockResolvedValue(undefined); + }); + + it('loads and renders compliance summary details', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + expect(mockLoadCompliance).toHaveBeenCalledWith({ includeControls: true }); + expect(wrapper.text()).toContain('Overall Profile Progress'); + expect(wrapper.text()).toContain('33% compliant'); + expect(wrapper.text()).toContain('CTRL-SAT'); + expect(wrapper.text()).toContain('CTRL-NS'); + expect(wrapper.text()).toContain('CTRL-UNK'); + }); + + it('shows loading state', async () => { + mockIsLoading.value = true; + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.text()).toContain('Loading compliance progress...'); + }); +}); diff --git a/src/views/dashboard/CompliancePostureWidget.vue b/src/views/dashboard/CompliancePostureWidget.vue new file mode 100644 index 00000000..3bbe07bc --- /dev/null +++ b/src/views/dashboard/CompliancePostureWidget.vue @@ -0,0 +1,245 @@ + + + diff --git a/src/views/dashboard/IndexView.vue b/src/views/dashboard/IndexView.vue index 999f6c41..6419ca10 100644 --- a/src/views/dashboard/IndexView.vue +++ b/src/views/dashboard/IndexView.vue @@ -1,4 +1,6 @@