diff --git a/src/router/index.ts b/src/router/index.ts index b7492dce..fa4d7b2e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -769,6 +769,14 @@ const authenticatedRoutes = [ '../views/system-security-plans/SystemSecurityPlanLeverageView.vue' ), }, + { + path: 'inherited-capabilities', + name: 'system-security-plan-inherited-capabilities', + component: () => + import( + '../views/system-security-plans/SystemSecurityPlanInheritedCapabilitiesView.vue' + ), + }, { path: 'risks', name: 'system-security-plan-risks', diff --git a/src/types/ssp-leverage.ts b/src/types/ssp-leverage.ts index 0fd1485b..3eca3127 100644 --- a/src/types/ssp-leverage.ts +++ b/src/types/ssp-leverage.ts @@ -1,6 +1,8 @@ // Types for the downstream SSP leverage/subscribe endpoints (BCH-1338). Hand-written, // camelCase JSON — not OSCAL — so requests must not use decamelizeKeys. +import type { UpstreamResponsibility } from './ssp-export-offerings'; + export type SSPLeverageSatisfaction = 'full' | 'partial'; export type SSPLeverageStatus = 'active' | 'drifted' | 'revoked' | 'superseded'; @@ -36,3 +38,28 @@ export interface SubscribeRequest { satisfiedResponsibilityUuids?: string[]; }>; } + +// GET /oscal/system-security-plans/:id/leveraged-controls (BCH-1338/1339/1341/1346). +export type ResponsibilityPostureValue = + | 'satisfied' + | 'not-satisfied' + | 'unknown'; + +export interface LeveragedControlInheritedFrom { + upstreamSspId: string; + offeringId: string; + offeringTitle: string; + offeringVersion: number; +} + +export interface LeveragedControl { + id: string; + controlId: string; + statementId?: string; + inheritedFrom: LeveragedControlInheritedFrom; + satisfaction: SSPLeverageSatisfaction; + status: SSPLeverageStatus; + outstandingResponsibilities: UpstreamResponsibility[]; + responsibilityPosture: Record; + driftRiskId?: string; +} diff --git a/src/utils/get-poam-id-from-route.ts b/src/utils/get-id-from-route.ts similarity index 100% rename from src/utils/get-poam-id-from-route.ts rename to src/utils/get-id-from-route.ts diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesBackMatterView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesBackMatterView.vue index 46f4d1fc..87b15421 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesBackMatterView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesBackMatterView.vue @@ -187,7 +187,7 @@ import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; import type { AxiosError } from 'axios'; import type { ErrorResponse, ErrorBody } from '@/stores/types'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesFindingsView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesFindingsView.vue index 66e72e4c..3806e551 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesFindingsView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesFindingsView.vue @@ -168,7 +168,7 @@ import FindingCreateForm from '@/components/poam/FindingCreateForm.vue'; import FindingEditForm from '@/components/poam/FindingEditForm.vue'; import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesImportSspView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesImportSspView.vue index 33dc0472..71a08c13 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesImportSspView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesImportSspView.vue @@ -115,7 +115,7 @@ import type { ImportSSP } from '@/oscal'; import ImportSspForm from '@/components/poam/ImportSspForm.vue'; import Dialog from '@/volt/Dialog.vue'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesLocalDefinitionsView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesLocalDefinitionsView.vue index 6b75f5f8..87724977 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesLocalDefinitionsView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesLocalDefinitionsView.vue @@ -254,7 +254,7 @@ import type { POAMLocalDefinitions } from '@/oscal'; import Dialog from '@/volt/Dialog.vue'; import LocalDefinitionsForm from '@/components/poam/LocalDefinitionsForm.vue'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import type { AxiosError } from 'axios'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesObservationsView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesObservationsView.vue index 58c54c9f..77737c23 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesObservationsView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesObservationsView.vue @@ -153,7 +153,7 @@ import ObservationCreateForm from '@/components/poam/ObservationCreateForm.vue'; import ObservationEditForm from '@/components/poam/ObservationEditForm.vue'; import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesOverviewView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesOverviewView.vue index 54bc84bf..0d974007 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesOverviewView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesOverviewView.vue @@ -254,7 +254,7 @@ import { useDataApi } from '@/composables/axios'; import type { AxiosError } from 'axios'; import type { ErrorResponse, ErrorBody } from '@/stores/types'; import decamelizeKeys from 'decamelize-keys'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import type { Metadata } from '@/oscal'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesPoamItemsView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesPoamItemsView.vue index 5a0d7f14..acd45238 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesPoamItemsView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesPoamItemsView.vue @@ -203,7 +203,7 @@ import PoamItemEditForm from '@/components/poam/PoamItemEditForm.vue'; import PoamItemAttachModal from '@/components/poam/PoamItemAttachModal.vue'; import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesRisksView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesRisksView.vue index 8cc9b6e4..135b82f0 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesRisksView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesRisksView.vue @@ -184,7 +184,7 @@ import RiskCreateForm from '@/components/risk/RiskCreateForm.vue'; import RiskEditForm from '@/components/risk/RiskEditForm.vue'; import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesSystemIdView.vue b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesSystemIdView.vue index 790d81f5..c3c983cf 100644 --- a/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesSystemIdView.vue +++ b/src/views/plan-of-actions-and-milestones/PlanOfActionAndMilestonesSystemIdView.vue @@ -113,7 +113,7 @@ import type { SystemID } from '@/oscal'; import Dialog from '@/volt/Dialog.vue'; import SystemIdForm from '@/components/poam/SystemIdForm.vue'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '../../utils/get-poam-id-from-route'; +import { getIdFromRoute } from '../../utils/get-id-from-route'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/system-security-plans/SystemSecurityPlanControlImplementationView.vue b/src/views/system-security-plans/SystemSecurityPlanControlImplementationView.vue index 64d241fe..5f857416 100644 --- a/src/views/system-security-plans/SystemSecurityPlanControlImplementationView.vue +++ b/src/views/system-security-plans/SystemSecurityPlanControlImplementationView.vue @@ -446,7 +446,7 @@ import StatementCreateForm from '@/components/system-security-plans/StatementCre import StatementByComponent from '@/views/system-security-plans/partials/StatementByComponent.vue'; import ByComponentEditForm from '@/components/system-security-plans/ByComponentEditForm.vue'; import { useDataApi, decamelizeKeys } from '@/composables/axios'; -import { getIdFromRoute } from '@/utils/get-poam-id-from-route'; +import { getIdFromRoute } from '@/utils/get-id-from-route'; import { useDeleteConfirmationDialog } from '@/utils/delete-dialog'; import { usePermissions } from '@/composables/usePermissions'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; diff --git a/src/views/system-security-plans/SystemSecurityPlanEditorView.vue b/src/views/system-security-plans/SystemSecurityPlanEditorView.vue index c8028b8a..eaeafe62 100644 --- a/src/views/system-security-plans/SystemSecurityPlanEditorView.vue +++ b/src/views/system-security-plans/SystemSecurityPlanEditorView.vue @@ -69,6 +69,15 @@ > Leverage + + Inherited Capabilities + +
+
+

+ Inherited Capabilities +

+ +
+

+ Loading inherited capabilities... +

+
+ +
+

+ No inherited capabilities yet. +

+
+ +
+
+
+

+ {{ control.controlId + }} + · Statement {{ control.statementId }} +

+ + Covered by {{ control.inheritedFrom.offeringTitle }} v{{ + control.inheritedFrom.offeringVersion + }} + + + {{ control.satisfaction }} + +
+ +
+

+ This capability is {{ control.status }} and may no longer reflect + the upstream offering. +

+
+ + View drift risk + + + + Re-attest + + +
+
+ +
+
+ Outstanding responsibilities +
+
+
+ + {{ posture(control, responsibility.responsibilityUuid) }} + + + {{ responsibility.description }} + +
+
+
+
+
+
+
+ + + diff --git a/src/views/system-security-plans/SystemSecurityPlanLeverageView.vue b/src/views/system-security-plans/SystemSecurityPlanLeverageView.vue index 5405c5c5..46e868f3 100644 --- a/src/views/system-security-plans/SystemSecurityPlanLeverageView.vue +++ b/src/views/system-security-plans/SystemSecurityPlanLeverageView.vue @@ -93,7 +93,7 @@ import PermissionGate from '@/components/auth/PermissionGate.vue'; import SubscribeOfferingWizard from '@/components/system-security-plans/SubscribeOfferingWizard.vue'; import { RESOURCES, ACTIONS } from '@/constants/permissions'; import { useDataApi } from '@/composables/axios'; -import { getIdFromRoute } from '@/utils/get-poam-id-from-route'; +import { getIdFromRoute } from '@/utils/get-id-from-route'; import type { CatalogOffering } from '@/types/ssp-export-offerings'; import type { SSPLeverageLink } from '@/types/ssp-leverage'; diff --git a/src/views/system-security-plans/__tests__/SystemSecurityPlanInheritedCapabilitiesView.spec.ts b/src/views/system-security-plans/__tests__/SystemSecurityPlanInheritedCapabilitiesView.spec.ts new file mode 100644 index 00000000..df057ed0 --- /dev/null +++ b/src/views/system-security-plans/__tests__/SystemSecurityPlanInheritedCapabilitiesView.spec.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import SystemSecurityPlanInheritedCapabilitiesView from '../SystemSecurityPlanInheritedCapabilitiesView.vue'; +import type { LeveragedControl } from '@/types/ssp-leverage'; + +const { postMock, toastAddMock, confirmRequireMock, permState, controlsData } = + vi.hoisted(() => ({ + postMock: vi.fn(), + toastAddMock: vi.fn(), + confirmRequireMock: vi.fn(), + permState: { canUpdateSsp: true }, + controlsData: { current: [] as LeveragedControl[] }, + })); + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { id: 'ssp-downstream-1' } }), +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: toastAddMock }), +})); + +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ require: confirmRequireMock }), +})); + +vi.mock('@/composables/usePermissions', () => ({ + usePermissions: () => ({ + can: () => permState.canUpdateSsp, + permissionTooltip: () => '', + }), +})); + +const fetchedUrls: string[] = []; +const executeMock = vi.fn(); +vi.mock('@/composables/axios', async () => { + const { ref } = await import('vue'); + return { + useDataApi: (url: string) => { + fetchedUrls.push(url); + return { + data: ref(controlsData.current), + isLoading: ref(false), + execute: executeMock, + }; + }, + useAuthenticatedInstance: () => ({ post: postMock }), + }; +}); + +const stubs = { + RouterLink: { + props: ['to'], + template: '', + }, + Badge: { + props: ['severity'], + template: '', + }, + SecondaryButton: { + emits: ['click'], + template: '', + }, +}; + +function makeControl( + overrides: Partial = {}, +): LeveragedControl { + return { + id: 'link-1', + controlId: 'ac-2', + statementId: 'ac-2_smt.a', + inheritedFrom: { + upstreamSspId: 'ssp-upstream-1', + offeringId: 'offering-1', + offeringTitle: 'FedRAMP AC-2 Baseline', + offeringVersion: 1, + }, + satisfaction: 'full', + status: 'active', + outstandingResponsibilities: [], + responsibilityPosture: {}, + ...overrides, + }; +} + +function findButton(wrapper: ReturnType, text: string) { + const button = wrapper.findAll('button').find((b) => b.text() === text); + if (!button) throw new Error(`Button with text "${text}" not found`); + return button; +} + +function mountView() { + return mount(SystemSecurityPlanInheritedCapabilitiesView, { + global: { stubs }, + }); +} + +describe('SystemSecurityPlanInheritedCapabilitiesView', () => { + beforeEach(() => { + vi.clearAllMocks(); + permState.canUpdateSsp = true; + controlsData.current = []; + fetchedUrls.length = 0; + postMock.mockResolvedValue({ data: { data: {} } }); + executeMock.mockResolvedValue(undefined); + }); + + it('renders an empty state when there are no inherited capabilities', () => { + const wrapper = mountView(); + expect(wrapper.text()).toContain('No inherited capabilities yet.'); + }); + + it('renders the covered-by badge, satisfaction, and no catalog controls are fetched', () => { + controlsData.current = [makeControl()]; + const wrapper = mountView(); + expect(wrapper.text()).toContain('ac-2'); + expect(wrapper.text()).toContain('Statement ac-2_smt.a'); + expect(wrapper.text()).toContain('Covered by FedRAMP AC-2 Baseline v1'); + expect(wrapper.text()).toContain('full'); + + expect(fetchedUrls).toEqual([ + '/api/oscal/system-security-plans/ssp-downstream-1/leveraged-controls', + ]); + for (const url of fetchedUrls) { + expect(url).not.toContain('/catalogs'); + expect(url).not.toContain('/profiles'); + } + }); + + it('renders outstanding responsibilities with posture', () => { + controlsData.current = [ + makeControl({ + satisfaction: 'partial', + outstandingResponsibilities: [ + { responsibilityUuid: 'r-1', description: 'Rotate credentials' }, + ], + responsibilityPosture: { 'r-1': 'not-satisfied' }, + }), + ]; + const wrapper = mountView(); + expect(wrapper.text()).toContain('partial'); + expect(wrapper.text()).toContain('Rotate credentials'); + expect(wrapper.text()).toContain('not-satisfied'); + }); + + it('shows a warning and Re-attest for a drifted link, plus a link to the drift risk', async () => { + controlsData.current = [ + makeControl({ status: 'drifted', driftRiskId: 'risk-1' }), + ]; + const wrapper = mountView(); + expect(wrapper.text()).toContain( + 'This capability is drifted and may no longer reflect the upstream offering.', + ); + expect(wrapper.text()).toContain('View drift risk'); + expect(wrapper.findAll('button').map((b) => b.text())).toContain( + 'Re-attest', + ); + }); + + it('shows a warning for a revoked link without a Re-attest button', () => { + controlsData.current = [makeControl({ status: 'revoked' })]; + const wrapper = mountView(); + expect(wrapper.text()).toContain( + 'This capability is revoked and may no longer reflect the upstream offering.', + ); + expect(wrapper.findAll('button').map((b) => b.text())).not.toContain( + 'Re-attest', + ); + }); + + it('does not show a drift-risk link when driftRiskId is absent', () => { + controlsData.current = [makeControl({ status: 'drifted' })]; + const wrapper = mountView(); + expect(wrapper.text()).not.toContain('View drift risk'); + }); + + it('re-attests a drifted link and refreshes the projection', async () => { + controlsData.current = [ + makeControl({ status: 'drifted', driftRiskId: 'risk-1' }), + ]; + const wrapper = mountView(); + + await findButton(wrapper, 'Re-attest').trigger('click'); + expect(confirmRequireMock).toHaveBeenCalled(); + + const { accept } = confirmRequireMock.mock.calls[0][0]; + await accept(); + await flushPromises(); + + expect(postMock).toHaveBeenCalledWith( + '/api/oscal/system-security-plans/ssp-downstream-1/leveraged-controls/link-1/attest', + ); + expect(executeMock).toHaveBeenCalled(); + expect(toastAddMock).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }), + ); + }); + + it('shows an error toast when re-attest fails', async () => { + postMock.mockRejectedValueOnce(new Error('network down')); + controlsData.current = [ + makeControl({ status: 'drifted', driftRiskId: 'risk-1' }), + ]; + const wrapper = mountView(); + + await findButton(wrapper, 'Re-attest').trigger('click'); + const { accept } = confirmRequireMock.mock.calls[0][0]; + await accept(); + await flushPromises(); + + expect(toastAddMock).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ); + }); + + it('hides Re-attest without ssp:update', () => { + permState.canUpdateSsp = false; + controlsData.current = [ + makeControl({ status: 'drifted', driftRiskId: 'risk-1' }), + ]; + const wrapper = mountView(); + expect(wrapper.findAll('button').map((b) => b.text())).not.toContain( + 'Re-attest', + ); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index c8d60ccd..725b6997 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -7,3 +7,47 @@ if (typeof URL.createObjectURL !== 'function') { if (typeof URL.revokeObjectURL !== 'function') { URL.revokeObjectURL = () => {}; } + +// Some Node/jsdom version combinations leave globalThis.localStorage missing or +// non-functional — e.g. Node's own experimental native localStorage resolves to undefined +// without a --localstorage-file flag, and that flag can't reliably reach jsdom's per-test +// sandboxed global scope across Node versions/CI. Providing our own minimal, always-working +// in-memory Storage here avoids depending on host Node version behavior entirely. +if ( + typeof globalThis.localStorage === 'undefined' || + typeof globalThis.localStorage.clear !== 'function' +) { + class MemoryStorage implements Storage { + private store = new Map(); + + get length() { + return this.store.size; + } + + clear(): void { + this.store.clear(); + } + + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.store.delete(key); + } + + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + } + + Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + writable: true, + configurable: true, + }); +}