Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions src/components/OrgProfileWizard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -147,7 +145,7 @@ const OrgProfileWizard = ({ onClose }) => {
title: 'Key IT infrastructure?',
body: (
<ChipPicker
presets={INFRA_PRESETS}
presets={INFRA_PRESET_LABELS}
value={draft.infrastructure}
onChange={(infrastructure) => patch({ infrastructure })}
placeholder="Add your own and press Enter…"
Expand Down
83 changes: 83 additions & 0 deletions src/components/PlatformAddendumBadges.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mb-2 text-xs" data-testid="platform-addendum-badges">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 text-gray-600 dark:text-gray-300 hover:underline"
aria-expanded={open}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{list.length === 1 ? '1 platform check' : `${list.length} platform checks`}</span>
{driftedCount > 0 && (
<span className={DRIFT_CHIP}>{driftedCount} updated upstream</span>
)}
{unresolvedCount > 0 && (
<span className={UNRESOLVED_CHIP}>{unresolvedCount} unresolved</span>
)}
</button>
{open && (
<ul className="mt-1 ml-5 space-y-1">
{rows.map((row) => (
<li
key={`${row.corpusId}:${row.policyId}`}
className="flex items-center gap-2 text-gray-600 dark:text-gray-300"
data-testid="platform-addendum-row"
>
<span className="font-mono">{row.policyId}</span>
{row.platformText && <span>{row.platformText}</span>}
{row.drifted && <span className={DRIFT_CHIP}>Updated upstream</span>}
{row.unresolved && <span className={UNRESOLVED_CHIP}>Unresolved</span>}
{row.sourceUrl && (
<a
href={row.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline"
title="View the upstream source of this platform check"
>
<ExternalLink size={11} /> Source
</a>
)}
</li>
))}
</ul>
)}
</div>
);
};

export default PlatformAddendumBadges;
145 changes: 145 additions & 0 deletions src/components/PlatformAddendumBadges.test.js
Original file line number Diff line number Diff line change
@@ -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(<PlatformAddendumBadges entries={[]} />);
expect(container).toBeEmptyDOMElement();
const { container: c2 } = render(<PlatformAddendumBadges entries={undefined} />);
expect(c2).toBeEmptyDOMElement();
});

test('a fresh reference: summary with count, NO drift or unresolved chip; row carries label + upstream source link', () => {
render(<PlatformAddendumBadges entries={[freshRef()]} />);
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(<PlatformAddendumBadges entries={[drifted, stable]} />);
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(<PlatformAddendumBadges entries={entries} />);
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(<PlatformAddendumBadges entries={refs} />);
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(<PlatformAddendumBadges entries={[fork]} />);
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);
});
});
Loading
Loading