diff --git a/src/components/OrgProfileWizard.js b/src/components/OrgProfileWizard.js index 3b1a46d..4036690 100644 --- a/src/components/OrgProfileWizard.js +++ b/src/components/OrgProfileWizard.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { X, Building2, Server, Shield, Gem, ChevronLeft, ChevronRight } from 'lucide-react'; import toast from 'react-hot-toast'; import useOrgProfileStore, { EMPTY_PROFILE } from '../stores/orgProfileStore'; +import { INFRA_PRESET_LABELS } from '../utils/infraPresets'; /** * Optional org-profile mini-wizard. Five questions, every one skippable, @@ -13,12 +14,9 @@ import useOrgProfileStore, { EMPTY_PROFILE } from '../stores/orgProfileStore'; const SIZE_BANDS = ['1–49', '50–249', '250–999', '1,000–4,999', '5,000+']; // Cloud, email, and chat selections drive DETERMINISTIC procedure tailoring -// (canned substitutions, no AI) — see utils/stackTailorMaps.js. -const INFRA_PRESETS = [ - 'AWS', 'Azure', 'Google Cloud', 'On-premises data center', 'SaaS-heavy', - 'Kubernetes / containers', 'OT / ICS', 'Remote-first endpoints', - 'Microsoft 365', 'Google Workspace', 'Slack', 'Microsoft Teams' -]; +// (canned substitutions, no AI) — see utils/stackTailorMaps.js. The preset +// list lives in utils/infraPresets.js so the assessment wizard's Environment +// step can read the same source of truth; the persisted token stays the label. // Naming a specific EDR product here enables an exact SentinelOne→product // swap in tailored procedures; the generic 'EDR' chip neutralizes instead. @@ -147,7 +145,7 @@ const OrgProfileWizard = ({ onClose }) => { title: 'Key IT infrastructure?', body: ( patch({ infrastructure })} placeholder="Add your own and press Enter…" diff --git a/src/components/PlatformAddendumBadges.js b/src/components/PlatformAddendumBadges.js new file mode 100644 index 0000000..bff2b63 --- /dev/null +++ b/src/components/PlatformAddendumBadges.js @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'; +import { addendumRowDescriptor } from '../utils/platformBank'; + +/** + * Read-mode provenance chrome for an observation's platform addenda — + * one summary line plus one row per entry (plan PR-7). Pure presentation + * over addendumRowDescriptor(): the rows, the summary counts, and the + * picker's attached list all read the same derivation, so they cannot + * disagree. Renders from the per-observation entries alone — never from + * the assessment-level platforms array, whose emptiness is ambiguous by + * contract. + * + * Badge states are the production-drivable set only: "Updated upstream" + * (attach-time fingerprint differs from the current corpus — the rendered + * text below already shows the current version; the badge is the consent + * surface, resolved by the picker's adopt action) and "Unresolved" (no + * current corpus record — the expanded placeholder carries the identity). + * Rows are collapsed behind the summary by default: attractor + * subcategories legitimately carry 100+ addenda and the chrome must not + * bury the procedure text. + */ +const CHIP = 'px-1.5 py-0.5 rounded text-xs'; +const DRIFT_CHIP = `${CHIP} bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300`; +const UNRESOLVED_CHIP = `${CHIP} bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300`; + +const PlatformAddendumBadges = ({ entries }) => { + const [open, setOpen] = useState(false); + const list = Array.isArray(entries) ? entries : []; + if (list.length === 0) return null; + const rows = list.map(addendumRowDescriptor); + const driftedCount = rows.filter((r) => r.drifted).length; + const unresolvedCount = rows.filter((r) => r.unresolved).length; + + return ( +
+ + {open && ( + + )} +
+ ); +}; + +export default PlatformAddendumBadges; diff --git a/src/components/PlatformAddendumBadges.test.js b/src/components/PlatformAddendumBadges.test.js new file mode 100644 index 0000000..3ed67d8 --- /dev/null +++ b/src/components/PlatformAddendumBadges.test.js @@ -0,0 +1,145 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import PlatformAddendumBadges from './PlatformAddendumBadges'; +import bank from '../data/platformProcedures.json'; +import { buildPlatformRef, getPlatformProcedures } from '../utils/platformBank'; +import { exportAllDataJSON } from '../utils/dataExport'; +import { importCompleteDatabase } from '../utils/dataImport'; + +/** + * Read-mode addendum provenance chrome (plan PR-7). The hard badge rule + * binds every state to a production-drivable fixture: + * - drift: a contentHash-divergent reference rendered through the + * component's production derivation (platformRefDrift); + * - unresolved: a corpus-less reference driven through the PRODUCTION + * importCompleteDatabase restore path (the ISC-862 pattern) and + * rendered exactly as restored. + * Assertions map each rendered label to the ref it claims — not merely + * "a badge rendered". + */ + +const gwsOffer = getPlatformProcedures('PR.DS-10', ['google-workspace'])[0]; +const m365Offer = getPlatformProcedures('PR.DS-10', ['microsoft-365'])[0]; +const freshRef = () => buildPlatformRef(gwsOffer.record, gwsOffer.corpusId); + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('PlatformAddendumBadges', () => { + test('renders nothing without entries', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + const { container: c2 } = render(); + expect(c2).toBeEmptyDOMElement(); + }); + + test('a fresh reference: summary with count, NO drift or unresolved chip; row carries label + upstream source link', () => { + render(); + expect(screen.getByText('1 platform check')).toBeInTheDocument(); + expect(screen.queryByText(/updated upstream/i)).toBeNull(); + expect(screen.queryByText(/unresolved/i)).toBeNull(); + fireEvent.click(screen.getByText('1 platform check')); + expect(screen.getByText(gwsOffer.policyId)).toBeInTheDocument(); + expect(screen.getByText('Google Workspace')).toBeInTheDocument(); + const link = screen.getByTitle('View the upstream source of this platform check'); + expect(link).toHaveAttribute('href', gwsOffer.record.attribution.sourceUrl); + }); + + test('drift badge: a contentHash-divergent ref shows "Updated upstream" in summary and on ITS row, link intact', () => { + const drifted = { ...freshRef(), contentHash: '0000000000000000' }; + const stable = buildPlatformRef(m365Offer.record, m365Offer.corpusId); + render(); + expect(screen.getByText('2 platform checks')).toBeInTheDocument(); + expect(screen.getByText('1 updated upstream')).toBeInTheDocument(); + fireEvent.click(screen.getByText('2 platform checks')); + const rows = screen.getAllByTestId('platform-addendum-row'); + const driftedRow = rows.find((r) => r.textContent.includes(drifted.policyId)); + const stableRow = rows.find((r) => r.textContent.includes(stable.policyId)); + expect(driftedRow).toHaveTextContent('Updated upstream'); + expect(stableRow).not.toHaveTextContent('Updated upstream'); + expect(within(driftedRow).getByRole('link')).toHaveAttribute( + 'href', gwsOffer.record.attribution.sourceUrl); + }); + + test('unresolved badge through the PRODUCTION restore path: a corpus-less ref imported verbatim badges "Unresolved" with NO link', () => { + const FOREIGN = { + corpusId: 'foreign-corpus', + corpusVersion: 'ffffffffffffffff', + policyId: 'gone.policy.1.1v1', + contentHash: '0123456789abcdef' + }; + const data = { + users: [{ id: 'u1', name: 'Auditor One' }], + controls: [], + requirements: [{ id: 'PR.DS-01 Ex1', frameworkId: 'nist-csf-2.0' }], + frameworks: [{ id: 'nist-csf-2.0', name: 'NIST CSF 2.0', isDefault: true }], + artifacts: [], findings: [], metrics: [], + assessments: [{ + id: 'a1', + name: 'Restored', + observations: { + 'PR.DS-01 Ex1': { + testProcedures: 'Trunk.', + platformProcedures: [FOREIGN], + quarters: {} + } + } + }] + }; + const setters = { setAssessments: jest.fn() }; + const storeOf = (state) => ({ getState: () => state }); + const stores = { + userStore: storeOf({ users: data.users, setUsers: jest.fn() }), + controlsStore: storeOf({ controls: data.controls, setControls: jest.fn() }), + assessmentsStore: storeOf({ assessments: data.assessments, setAssessments: setters.setAssessments }), + requirementsStore: storeOf({ requirements: data.requirements, setRequirements: jest.fn() }), + frameworksStore: storeOf({ frameworks: data.frameworks, setFrameworks: jest.fn() }), + artifactStore: storeOf({ artifacts: data.artifacts, setArtifacts: jest.fn() }), + findingsStore: storeOf({ findings: data.findings, setFindings: jest.fn() }), + metricsStore: storeOf({ metrics: data.metrics, setMetrics: jest.fn() }), + orgProfileStore: storeOf({ profile: null, cloudConsent: false, setProfileState: jest.fn() }) + }; + const parsed = JSON.parse(JSON.stringify(exportAllDataJSON(stores))); + importCompleteDatabase(parsed, stores, { backupFirst: false }); + const restored = setters.setAssessments.mock.calls[0][0]; + const entries = restored[0].observations['PR.DS-01 Ex1'].platformProcedures; + expect(entries).toEqual([FOREIGN]); // verbatim — the stale identity is preserved, never healed + + render(); + expect(screen.getByText('1 unresolved')).toBeInTheDocument(); + fireEvent.click(screen.getByText('1 platform check')); + const row = screen.getByTestId('platform-addendum-row'); + expect(row).toHaveTextContent(FOREIGN.policyId); + expect(row).toHaveTextContent('Unresolved'); + expect(within(row).queryByRole('link')).toBeNull(); // no dead href + }); + + test('rows collapsed by default; the toggle expands one row per entry in entry order', () => { + const refs = getPlatformProcedures('PR.DS-10', ['google-workspace']) + .slice(0, 3) + .map((o) => buildPlatformRef(o.record, o.corpusId)); + render(); + expect(screen.queryAllByTestId('platform-addendum-row')).toHaveLength(0); + fireEvent.click(screen.getByText('3 platform checks')); + const rows = screen.getAllByTestId('platform-addendum-row'); + expect(rows).toHaveLength(refs.length); + rows.forEach((row, i) => { + expect(within(row).getByText(refs[i].policyId)).toBeInTheDocument(); + }); + }); + + test('mixed corpus record license surface: the M365 record used here really carries a sourceUrl (fixture honesty)', () => { + expect(bank.procedures[m365Offer.policyId].attribution.sourceUrl).toBeTruthy(); + }); + + test('a fork row shows NEITHER chip — its text is user-owned and the drift remedy (adopt) refuses forks', () => { + const fork = { ...freshRef(), contentHash: '0000000000000000', text: 'User text.', modified: true }; + render(); + expect(screen.queryByText(/updated upstream/i)).toBeNull(); + expect(screen.queryByText(/unresolved/i)).toBeNull(); + fireEvent.click(screen.getByText('1 platform check')); + const row = screen.getByTestId('platform-addendum-row'); + expect(row).toHaveTextContent(fork.policyId); + }); +}); diff --git a/src/components/PlatformCheckPicker.js b/src/components/PlatformCheckPicker.js new file mode 100644 index 0000000..d5039aa --- /dev/null +++ b/src/components/PlatformCheckPicker.js @@ -0,0 +1,243 @@ +import React, { useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ExternalLink, Plus, RefreshCw, Trash2, X } from 'lucide-react'; +import { + addendumRowDescriptor, + getPlatformProcedures, + getPlatformRecord, + platformRecordContentHash, + platformRefSourceUrl +} from '../utils/platformBank'; +import { subcategoryFromItemId } from '../utils/procedureBank'; +import { availablePlatforms } from '../utils/environmentStep'; + +/** + * Post-create management of one observation's platform checks (plan PR-7) + * — the surface that closes PR-6's one-way door. Pure presentation: every + * click dispatches ONE operation to the page handler, which routes it + * through the pure producer (pickerObservationUpdate) — no composition + * rule lives here. + * + * Doctrine inherited from the wizard: offers are unranked (committed-map + * order), grouped by platform with counts visible, and nothing is trimmed + * or thresholded — the user is the only exclusion actor. + * + * Consent surfaces: + * - Adopt shows an in-dialog confirm naming exactly what changes: the + * check's rendered text already shows the current upstream version + * (rendering always does); adopting records that version as the one + * you accepted, replacing the stored fingerprint (old vs new shown, + * with the upstream source link). Nothing is adopted implicitly. + * - Removing a customized (forked) check shows an in-dialog confirm with + * a preview of the text that will be discarded. + * Unresolved checks offer Remove only: there is no upstream record to + * adopt, so no re-attach affordance renders. + */ +const CHIP = 'px-1.5 py-0.5 rounded text-xs'; +const DRIFT_CHIP = `${CHIP} bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300`; +const UNRESOLVED_CHIP = `${CHIP} bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300`; + +const PlatformCheckPicker = ({ itemId, observation, canAttach, onOperation, onClose }) => { + const [confirming, setConfirming] = useState(null); // {type:'adopt'|'removeFork', entry} + + React.useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose?.(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const entries = useMemo( + () => (Array.isArray(observation?.platformProcedures) ? observation.platformProcedures : []), + [observation] + ); + const attachedRows = useMemo(() => entries.map(addendumRowDescriptor), [entries]); + + const subcategoryId = subcategoryFromItemId(itemId); + const offerGroups = useMemo(() => { + const attachedKeys = new Set(entries.map((e) => `${e.corpusId}:${e.policyId}`)); + return availablePlatforms() + .map((platform) => { + const offers = getPlatformProcedures(subcategoryId, [platform.id]) + .filter((offer) => !attachedKeys.has(`${offer.corpusId}:${offer.policyId}`)); + return { platform, offers }; + }) + .filter((group) => group.offers.length > 0); + }, [subcategoryId, entries]); + + const entryFor = (row) => + entries.find((e) => e.corpusId === row.corpusId && e.policyId === row.policyId); + + const dispatch = (operation) => { + setConfirming(null); + onOperation(operation); + }; + + const modal = ( +
+
+
+

+ Platform checks for {subcategoryId} +

+ +
+ +
+ {confirming?.type === 'adopt' && (() => { + const entry = confirming.entry; + const record = getPlatformRecord(entry.corpusId, entry.policyId); + const newHash = record ? platformRecordContentHash(record) : null; + const sourceUrl = platformRefSourceUrl(entry); + return ( +
+

+ Adopt the upstream update for {entry.policyId}? +

+

+ The text shown and exported for this check already comes from the + current upstream version. Adopting replaces the fingerprint recorded + when you attached it with the current one, marking this version as + the one you accepted. +

+

+ attached version: {entry.contentHash} +
+ current version: {newHash} +

+ {sourceUrl && ( + + Review the upstream source + + )} +
+ + +
+
+ ); + })()} + + {confirming?.type === 'removeFork' && ( +
+

+ Remove this customized check and discard your edits? +

+

+ You edited the text of {confirming.entry.policyId}. Removing it + deletes your edited text, which starts with: +

+

+ {String(confirming.entry.text).slice(0, 160)} + {String(confirming.entry.text).length > 160 ? '…' : ''} +

+
+ + +
+
+ )} + +
+

+ Attached ({attachedRows.length}) +

+ {attachedRows.length === 0 ? ( +

No platform checks attached.

+ ) : ( +
    + {attachedRows.map((row) => { + const entry = entryFor(row); + return ( +
  • + {row.policyId} + {row.platformText && {row.platformText}} + {row.drifted && Updated upstream} + {row.unresolved && Unresolved} + {row.fork && customized} + + {row.drifted && ( + + )} + +
  • + ); + })} +
+ )} +
+ +
+

Available

+ {!canAttach ? ( +

+ Platform checks extend the community procedure. Use "Insert + community procedure" first, then add checks here. +

+ ) : offerGroups.length === 0 ? ( +

+ Every available platform check for this subcategory is attached. +

+ ) : ( + offerGroups.map(({ platform, offers }) => ( +
+
+ + {platform.label} ({offers.length} available) + + +
+
    + {offers.map((offer) => ( +
  • + {offer.policyId} + + +
  • + ))} +
+
+ )) + )} +
+
+
+
+ ); + + return createPortal(modal, document.body); +}; + +export default PlatformCheckPicker; diff --git a/src/components/PlatformCheckPicker.test.js b/src/components/PlatformCheckPicker.test.js new file mode 100644 index 0000000..f1e2ea2 --- /dev/null +++ b/src/components/PlatformCheckPicker.test.js @@ -0,0 +1,173 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import PlatformCheckPicker from './PlatformCheckPicker'; +import { + forkPlatformProcedure, + getPlatformProcedures, + platformRecordContentHash +} from '../utils/platformBank'; +import { composeAttachObservation } from '../utils/procedureTailor'; +import { getBankProcedure } from '../utils/procedureBank'; + +/** + * The post-create platform-check picker (plan PR-7). Presentation-only + * contract tests: every affordance dispatches ONE operation object to the + * page handler; the negative surfaces (no adopt on unresolved, no writes + * on open/close) are asserted explicitly — an affordance that renders for + * a state the producer refuses would be UI theater. + */ + +const ITEM_ID = 'PR.AA-05 Ex1'; +const offers = () => getPlatformProcedures('PR.AA-05', ['google-workspace', 'microsoft-365']); + +const observationWith = (attachedOffers) => + composeAttachObservation(getBankProcedure(ITEM_ID), attachedOffers); + +const setup = (observation, { canAttach = true } = {}) => { + const onOperation = jest.fn(); + const onClose = jest.fn(); + render( + + ); + return { onOperation, onClose }; +}; + +describe('PlatformCheckPicker', () => { + test('opening and closing dispatches NO operation — the picker never writes on its own', () => { + const { onOperation, onClose } = setup(observationWith(offers().slice(0, 2))); + fireEvent.click(screen.getByLabelText('Close')); + expect(onOperation).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + test('Escape closes the dialog without dispatching; the dialog role is labeled', () => { + const { onOperation, onClose } = setup(observationWith(offers().slice(0, 1))); + expect(screen.getByRole('dialog', { name: `Platform checks for PR.AA-05` })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + expect(onOperation).not.toHaveBeenCalled(); + }); + + test('attached rows render in entry order with Remove; a plain ref removes with ONE dispatch, no confirm', () => { + const obs = observationWith(offers().slice(0, 2)); + const { onOperation } = setup(obs); + const rows = screen.getAllByTestId('attached-check-row'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent(obs.platformProcedures[0].policyId); + fireEvent.click(within(rows[0]).getByTitle('Remove this platform check')); + expect(onOperation).toHaveBeenCalledWith({ op: 'remove', ref: obs.platformProcedures[0] }); + }); + + test('offered list: platform-grouped with counts, committed-map order, attached policies excluded', () => { + const all = offers(); + const gws = all.filter((o) => o.record.platform === 'google-workspace'); + const attached = [gws[0]]; + setup(observationWith(attached)); + const gwsGroup = screen.getByTestId('offer-group-google-workspace'); + expect(gwsGroup).toHaveTextContent(`Google Workspace (${gws.length - 1} available)`); + const offered = within(gwsGroup).getAllByTestId('offer-row'); + expect(offered).toHaveLength(gws.length - 1); // attached policy excluded + offered.forEach((row, i) => { + // committed-map order, minus the attached first policy + expect(within(row).getByText(gws.slice(1)[i].policyId)).toBeInTheDocument(); + }); + expect(within(gwsGroup).queryByText(gws[0].policyId)).toBeNull(); + }); + + test('Attach dispatches {op:add} with the exact offer; Attach all dispatches {op:addAll} with the group', () => { + const all = offers(); + const gws = all.filter((o) => o.record.platform === 'google-workspace'); + const { onOperation } = setup(observationWith([])); + const gwsGroup = screen.getByTestId('offer-group-google-workspace'); + const firstOfferRow = within(gwsGroup).getAllByTestId('offer-row')[0]; + fireEvent.click(within(firstOfferRow).getByTitle('Attach this platform check')); + expect(onOperation).toHaveBeenLastCalledWith({ op: 'add', offer: expect.objectContaining({ policyId: gws[0].policyId }) }); + fireEvent.click(screen.getByText(`Attach all ${gws.length}`)); + expect(onOperation).toHaveBeenLastCalledWith({ + op: 'addAll', + offers: expect.arrayContaining([expect.objectContaining({ policyId: gws[0].policyId })]) + }); + }); + + test('canAttach=false: no offer rows, the attach-community-first explanation renders instead', () => { + setup({ testProcedures: 'Hand-written.', platformProcedures: [] }, { canAttach: false }); + expect(screen.queryAllByTestId('offer-row')).toHaveLength(0); + expect(screen.getByText(/use "insert community procedure" first/i)).toBeInTheDocument(); + }); + + test('adopt: ONLY a drifted row offers "Adopt update"; the confirm names adoption and shows old vs new fingerprints + source link', () => { + const base = observationWith(offers().slice(0, 2)); + const drifted = { ...base.platformProcedures[0], contentHash: '0000000000000000' }; + const obs = { ...base, platformProcedures: [drifted, base.platformProcedures[1]] }; + const { onOperation } = setup(obs); + const rows = screen.getAllByTestId('attached-check-row'); + expect(rows[0]).toHaveTextContent('Updated upstream'); + expect(rows[1]).not.toHaveTextContent('Adopt update'); + fireEvent.click(screen.getByText('Adopt update')); + const confirm = screen.getByTestId('adopt-confirm'); + expect(confirm).toHaveTextContent(`Adopt the upstream update for ${drifted.policyId}?`); + expect(confirm).toHaveTextContent('attached version: 0000000000000000'); + const currentHash = platformRecordContentHash( + offers().find((o) => o.policyId === drifted.policyId).record); + expect(confirm).toHaveTextContent(`current version: ${currentHash}`); + expect(within(confirm).getByRole('link')).toHaveAttribute( + 'href', + offers().find((o) => o.policyId === drifted.policyId).record.attribution.sourceUrl); + expect(onOperation).not.toHaveBeenCalled(); // nothing adopted before the confirm + fireEvent.click(screen.getByText('Adopt upstream update')); + expect(onOperation).toHaveBeenCalledWith({ op: 'adopt', ref: drifted }); + }); + + test('adopt confirm Cancel dispatches nothing', () => { + const base = observationWith(offers().slice(0, 1)); + const drifted = { ...base.platformProcedures[0], contentHash: '0000000000000000' }; + const { onOperation } = setup({ ...base, platformProcedures: [drifted] }); + fireEvent.click(screen.getByText('Adopt update')); + fireEvent.click(screen.getByText('Cancel')); + expect(onOperation).not.toHaveBeenCalled(); + }); + + test('NEGATIVE: an unresolved row offers Remove only — no adopt affordance, no source link', () => { + const FOREIGN = { + corpusId: 'foreign-corpus', + corpusVersion: 'ffffffffffffffff', + policyId: 'gone.policy.1.1v1', + contentHash: '0123456789abcdef' + }; + const base = observationWith([]); + const { onOperation } = setup({ ...base, platformProcedures: [FOREIGN] }); + const row = screen.getByTestId('attached-check-row'); + expect(row).toHaveTextContent('Unresolved'); + expect(row).not.toHaveTextContent('Adopt update'); + expect(within(row).queryByRole('link')).toBeNull(); + fireEvent.click(within(row).getByTitle('Remove this platform check')); + expect(onOperation).toHaveBeenCalledWith({ op: 'remove', ref: FOREIGN }); + }); + + test('a customized (forked) row removes through the in-dialog confirm showing the text to be discarded', () => { + const base = observationWith(offers().slice(0, 1)); + const fork = forkPlatformProcedure(base.platformProcedures[0], 'My hardened variant of this check.'); + const { onOperation } = setup({ ...base, platformProcedures: [fork] }); + const row = screen.getByTestId('attached-check-row'); + expect(row).toHaveTextContent('customized'); + fireEvent.click(within(row).getByTitle('Remove this platform check')); + expect(onOperation).not.toHaveBeenCalled(); // confirm first + const confirm = screen.getByTestId('remove-fork-confirm'); + expect(confirm).toHaveTextContent('discard your edits'); + expect(confirm).toHaveTextContent('My hardened variant of this check.'); + fireEvent.click(screen.getByText('Remove and discard edits')); + expect(onOperation).toHaveBeenCalledWith({ op: 'remove', ref: fork }); + }); + + test('everything attached: the offered section states it plainly, zero offer rows', () => { + setup(observationWith(offers())); + expect(screen.queryAllByTestId('offer-row')).toHaveLength(0); + expect(screen.getByText(/every available platform check for this subcategory is attached/i)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Assessments.js b/src/pages/Assessments.js index bea0746..e017683 100644 --- a/src/pages/Assessments.js +++ b/src/pages/Assessments.js @@ -3,7 +3,7 @@ import { Plus, Edit, Save, Trash2, X, CheckCircle, XCircle, Download, Upload, ClipboardList, FileSearch, ChevronRight, Copy, Loader2, Bot, Sparkles, User, Settings, - BookOpen, ExternalLink, RotateCcw + BookOpen, ExternalLink, RotateCcw, Server } from 'lucide-react'; import toast from 'react-hot-toast'; import Markdown from '../components/Markdown'; @@ -28,13 +28,17 @@ import useAIStore from '../stores/aiStore'; import useUIStore from '../stores/uiStore'; import { formatInlineMarkdown, stripMarkdown } from '../utils/markdownText'; import { bankCoverage, getBankProcedure, canResetToCommunity, resetToCommunityUpdate, sourceUrlFor } from '../utils/procedureBank'; -import { expandProcedureText } from '../utils/platformBank'; -import { canUseProfileWithProvider, buildTailorPrompt, tailoredProvenance, deriveStackTargets, describeStackPlan, bankAttachObservation, deterministicTailorUpdate } from '../utils/procedureTailor'; +import { expandProcedureText, derivePlatformsFromObservations } from '../utils/platformBank'; +import { canUseProfileWithProvider, buildTailorPrompt, tailoredProvenance, deriveStackTargets, describeStackPlan, bankAttachObservation, wizardAttachObservation, deterministicTailorUpdate, pickerObservationUpdate, pickerAvailability } from '../utils/procedureTailor'; +import { buildEnvironmentMatrix, buildAttachPlan, cellKey, toggleCell, setColumn, columnFullySelected, columnTotal, countAttached, attachedCountForItem, availablePlatforms } from '../utils/environmentStep'; +import { platformIdsFromInfrastructure } from '../utils/infraPresets'; import { getScoringScale, scoreBand, CMMI_LEVELS } from '../utils/scoringScale'; import { SYSTEM_NAME_MAX_LENGTH } from '../utils/externalLinks'; import { belongsToAssessment, isUnassigned } from '../utils/assessmentScope'; import ExternalLinksEditor from '../components/ExternalLinksEditor'; import ProcedureSourceBadge from '../components/ProcedureSourceBadge'; +import PlatformAddendumBadges from '../components/PlatformAddendumBadges'; +import PlatformCheckPicker from '../components/PlatformCheckPicker'; import useOrgProfileStore from '../stores/orgProfileStore'; import OrgProfileWizard from '../components/OrgProfileWizard'; @@ -72,6 +76,8 @@ const Assessments = () => { const deleteAssessment = useAssessmentsStore((state) => state.deleteAssessment); const getObservation = useAssessmentsStore((state) => state.getObservation); const updateObservation = useAssessmentsStore((state) => state.updateObservation); + const getAssessment = useAssessmentsStore((state) => state.getAssessment); + const updateAssessment = useAssessmentsStore((state) => state.updateAssessment); const updateQuarterlyObservation = useAssessmentsStore((state) => state.updateQuarterlyObservation); const addToScope = useAssessmentsStore((state) => state.addToScope); const removeFromScope = useAssessmentsStore((state) => state.removeFromScope); @@ -114,9 +120,15 @@ const Assessments = () => { const [showExportPasswordDialog, setShowExportPasswordDialog] = useState(false); const [showSingleExportPasswordDialog, setShowSingleExportPasswordDialog] = useState(false); + // Post-create platform-check picker (plan PR-7) + const [showPlatformPicker, setShowPlatformPicker] = useState(false); + const allPlatformIds = useMemo(() => availablePlatforms().map((p) => p.id), []); + // The dialog is per-subcategory state — close it when the item changes. + useEffect(() => { setShowPlatformPicker(false); }, [selectedItemId]); + // New assessment wizard state const [showNewModal, setShowNewModal] = useState(false); - const [wizardStep, setWizardStep] = useState(1); // 1: Basic + Scope, 2: Test Procedures, 3: Users + const [wizardStep, setWizardStep] = useState(1); // 1: Basic + Scope, 2: Environment, 3: Test Procedures, 4: Users const [newAssessment, setNewAssessment] = useState({ name: '', description: '', @@ -152,6 +164,33 @@ const Assessments = () => { const [generationProgress, setGenerationProgress] = useState({ done: 0, total: 0 }); const cancelGenerationRef = useRef(false); + // Wizard Environment step (plan PR-6): ephemeral chip + cell state. The + // chips seed from the saved org profile (pure read, never written back — + // ratified); the assessment persists only the DERIVED platform set of + // what actually attaches. All rules live in utils/environmentStep.js. + const [envPlatforms, setEnvPlatforms] = useState([]); + const [envSelections, setEnvSelections] = useState({}); + + // Seed the platform chips each time the wizard opens. Open-time snapshot + // on purpose: the profile store hydrates synchronously from localStorage + // long before the modal can open, and a profile edited mid-wizard applies + // on the next open (the reset link re-derives on demand). + React.useEffect(() => { + if (showNewModal) { + setEnvPlatforms(platformIdsFromInfrastructure(orgProfile?.infrastructure)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showNewModal]); + + const envMatrix = useMemo( + () => buildEnvironmentMatrix(Array.from(selectedScopeItems), envPlatforms), + [selectedScopeItems, envPlatforms] + ); + const envAttachedCount = useMemo( + () => countAttached(envMatrix, envSelections), + [envMatrix, envSelections] + ); + // Wizard Users step (issue #290): people in scope for the assessment. // Rows are { name, email, role } while editing; on create they become // user-directory entries plus { userId, role } pairs on the assessment. @@ -649,6 +688,8 @@ Format as a numbered list. Be specific and actionable.`; setScopePreset(null); setScopeFilterText(''); setUseBankProcedures(true); + setEnvPlatforms([]); + setEnvSelections({}); setShowBankPreview(false); setTailorWithProfile(false); setAdaptStackRefs(false); @@ -686,7 +727,14 @@ Format as a numbered list. Be specific and actionable.`; } } - const created = createAssessment({ ...newAssessment, users: scopedUsers }); + // Environment step (plan PR-6): which platform checks each item attaches + // (checked cells only — the user is the only exclusion actor) and the + // DERIVED platform set the assessment record persists. + const attachPlan = buildAttachPlan( + Array.from(selectedScopeItems), envSelections, envPlatforms, useBankProcedures + ); + + const created = createAssessment({ ...newAssessment, users: scopedUsers, platforms: attachPlan.platforms }); // Add selected items to scope for (const itemId of selectedScopeItems) { @@ -700,7 +748,10 @@ Format as a numbered list. Be specific and actionable.`; // the case study's "Alma Security" and/or re-aim tool/platform // references at the org's stack (canned maps, no AI). The producer // stamps tailored provenance so share export can swap to pristine. - updateObservation(created.id, itemId, bankAttachObservation(bankEntry, orgProfile, { + // Platform checks selected in the Environment step ride as + // references through the composed producer; with none selected the + // update is byte-identical to the plain community attach. + updateObservation(created.id, itemId, wizardAttachObservation(bankEntry, attachPlan.offersByItem[itemId], orgProfile, { substituteName: tailorWithProfile, adaptStack: adaptStackRefs })); @@ -714,7 +765,7 @@ Format as a numbered list. Be specific and actionable.`; setCurrentAssessmentId(created.id); setView('scope'); toast.success(`Assessment "${created.name}" created with ${selectedScopeItems.size} items`); - }, [newAssessment, createAssessment, selectedScopeItems, useBankProcedures, tailorWithProfile, adaptStackRefs, orgProfile, generatedProcedures, wizardUsers, addToScope, updateObservation, setCurrentAssessmentId, resetWizard]); + }, [newAssessment, createAssessment, selectedScopeItems, useBankProcedures, envSelections, envPlatforms, tailorWithProfile, adaptStackRefs, orgProfile, generatedProcedures, wizardUsers, addToScope, updateObservation, setCurrentAssessmentId, resetWizard]); const handleSelectAssessment = useCallback((assessment) => { setCurrentAssessmentId(assessment.id); @@ -775,6 +826,32 @@ Format as a numbered list. Be specific and actionable.`; updateObservation(currentAssessmentId, selectedItemId, { [field]: value }); }, [currentAssessmentId, selectedItemId, updateObservation]); + // Recompute the assessment's derived platform set from the live + // observations — the SINGLE derivation call site, run after every + // composition-changing write (ratified: platforms is recomputed, never + // incrementally mutated). + const syncDerivedPlatforms = useCallback(() => { + if (!currentAssessmentId) return; + const assessment = getAssessment(currentAssessmentId); + if (!assessment) return; + updateAssessment(currentAssessmentId, { + platforms: derivePlatformsFromObservations(assessment.observations) + }); + }, [currentAssessmentId, getAssessment, updateAssessment]); + + // Dispatch one picker operation (add / addAll / remove / adopt) through + // the pure producer. The observation is re-read from the store at + // dispatch time so rapid consecutive operations never build on a stale + // snapshot. A null update means nothing would change — no write. + const handlePlatformCheckOperation = useCallback((operation) => { + if (!currentAssessmentId || !selectedItemId) return; + const fresh = getObservation(currentAssessmentId, selectedItemId); + const update = pickerObservationUpdate(fresh, selectedItemId, operation); + if (!update) return; + updateObservation(currentAssessmentId, selectedItemId, update); + syncDerivedPlatforms(); + }, [currentAssessmentId, selectedItemId, getObservation, updateObservation, syncDerivedPlatforms]); + // Attach (or re-attach) the community procedure for the selected item. // The pure producer stamps pristine provenance (so the store's // modified-flag heuristic doesn't mark a deliberate attach as a @@ -795,8 +872,9 @@ Format as a numbered list. Be specific and actionable.`; return; } updateObservation(currentAssessmentId, selectedItemId, update); + syncDerivedPlatforms(); toast.success(`Community procedure for ${update.procedureSource.bankId} attached`); - }, [currentAssessmentId, selectedItemId, getObservation, updateObservation]); + }, [currentAssessmentId, selectedItemId, getObservation, updateObservation, syncDerivedPlatforms]); // Deterministic tailoring of the selected item's procedure: canned // name + tool/platform substitutions from the org profile. No AI, no @@ -1624,6 +1702,16 @@ Format as a numbered list. Be specific and actionable.`; : (<> Insert community procedure)} )} + {editMode && pickerAvailability(currentObservation, selectedItemId, allPlatformIds).canOpen && ( + + )} {!editMode && sourceUrlFor(currentObservation.procedureSource) && ( + {!editMode && currentObservation.platformProcedures?.length > 0 && ( + + )} {editMode ? (