228 lines
19 KiB
TypeScript
228 lines
19 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, useState } from 'react';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { useApp } from '../../src/app/AppProvider';
|
|
import { initialAppState } from '../../src/app/app-state';
|
|
import { RelatedFindingsProvider, useRelatedViewRestore } from '../../src/features/related-findings/RelatedFindings';
|
|
import { RiskRegister } from '../../src/features/dashboard/RiskRegister';
|
|
import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance';
|
|
import { defaultReview } from '../../src/domain/review';
|
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
|
import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView';
|
|
import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
|
import { useWorkPackages } from '../../src/remediation/use-work-packages';
|
|
import { workRecordKey } from '../../src/remediation/work-package-index';
|
|
|
|
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
|
|
vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() }));
|
|
vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => undefined }));
|
|
vi.mock('../../src/features/finding-reader/FindingContext', () => ({ FindingContext: () => null, FindingContextBadges: () => null, FindingContextSection: () => null }));
|
|
vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({ SearchFilterBar: () => null, SearchTextControl: () => null }));
|
|
vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null }));
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
|
|
|
describe.each(['light', 'dark'])('same-change rings (%s)', theme => {
|
|
it('focuses the exact group in Pathfinder, keeps the ring size independent of zoom, and restores graph selection and camera', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
|
|
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
|
document.documentElement.dataset.theme = theme;
|
|
const sheet = makeSheet('Synthetic graph', [['Name', 'Status', 'Severity', 'Category'],
|
|
['Declared SMB', 'Failed', 'High', 'Synthetic'], ['Observed SMB', 'Passed', 'High', 'Synthetic'], ['Unrelated', 'Failed', 'Low', 'Other']], '', 'synthetic.csv', { isProjectSheet: true });
|
|
const records = sheet.analysis.records;
|
|
records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-graph-${index}`; });
|
|
const pkg = { tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) }] };
|
|
vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never);
|
|
const initial = { ...initialAppState, dataset: { sheets: [sheet], profileId: 'generic' }, activeSheet: sheet,
|
|
activeSheetId: sheet.id, workScopeId: 'synthetic-graph', activeProfile: genericProfile, titleColumn: 'Name', statusFilter: 'all', selectedRecordId: records[2].id } as unknown as ReturnType<typeof useApp>;
|
|
function Harness() {
|
|
const [state, setState] = useState(initial);
|
|
vi.mocked(useApp).mockReturnValue({ ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })),
|
|
applyViewBookmark: saved => setState(s => ({ ...s, ...saved })), resetFilters: vi.fn(), setFacetFilter: vi.fn() });
|
|
return <RelatedFindingsProvider><ObsidianGraphView active onOpenFinding={vi.fn()}/></RelatedFindingsProvider>;
|
|
}
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<Harness/>));
|
|
const graph = host.querySelector<SVGSVGElement>('.obsidian-graph-surface')!;
|
|
await act(async () => graph.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 800, clientX: 100, clientY: 100 })));
|
|
const unrelated = host.querySelector<SVGGElement>('[aria-label="Unrelated"]')!;
|
|
await act(async () => unrelated.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })));
|
|
const beforeCamera = graph.querySelector('g[transform]')!.getAttribute('transform');
|
|
const zoom = Number(beforeCamera!.match(/scale\(([^)]+)/)![1]);
|
|
const counterZoom = Number(graph.querySelector('.graph-related-ring')!.getAttribute('transform')!.match(/scale\(([^)]+)/)![1]);
|
|
expect(zoom * counterZoom).toBeCloseTo(1);
|
|
expect(unrelated.getAttribute('aria-pressed')).toBe('true');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.graph-related-ring .related-finding-ring')!.click());
|
|
expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2);
|
|
expect(host.querySelectorAll('.graph-related-ring .related-finding-ring[aria-pressed="true"]')).toHaveLength(2);
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('.graph-related-ring .related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2);
|
|
expect(graph.querySelector('g[transform]')!.getAttribute('transform')).toBe(beforeCamera);
|
|
expect(host.querySelector('[aria-label="Unrelated"]')!.getAttribute('aria-pressed')).toBe('true');
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
it('opens the complete group from Accepted Risks and restores the register without changing its decision or count', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
|
|
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value() {} });
|
|
document.documentElement.dataset.theme = theme;
|
|
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Declared SMB', 'Failed', 'High'], ['Observed SMB', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true });
|
|
const records = sheet.analysis.records;
|
|
records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; });
|
|
const today = new Date().toISOString().slice(0,10);
|
|
const risk = appendRiskAcceptanceReview(undefined, records[0].findingOccurrenceId!, riskCommand('accept', today), `${today}T10:00:00.000Z`);
|
|
const reviews = { [records[0].id]: { ...defaultReview(), riskAcceptance: risk.riskAcceptance } };
|
|
const pkg = { id: 'package', code: 'WP-01', title: 'Synthetic', clusterLabel: 'Synthetic', facts: { tasks: 1, findings: 2 },
|
|
tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.map(workRecordKey) }] };
|
|
vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], packageFor: () => pkg,
|
|
unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never);
|
|
const app = { ...initialAppState, workScopeId: 'synthetic-risk', dataset: { sheets: [sheet], profileId: 'generic', fileName: 'Synthetic' },
|
|
activeSheet: sheet, activeSheetId: sheet.id, activeProfile: genericProfile, reviews, view: 'records', statusFilter: 'all', selectedRecordId: records[0].id,
|
|
setSelectedRecord: vi.fn(), applyViewBookmark: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), updateReview: vi.fn() } as unknown as ReturnType<typeof useApp>;
|
|
vi.mocked(useApp).mockReturnValue(app);
|
|
const original = JSON.stringify(reviews);
|
|
function Views() {
|
|
const [work, setWork] = useState(false);
|
|
useRelatedViewRestore(() => () => setWork(work));
|
|
return <><div hidden={work} data-testid="risk-view"><RiskRegister dataset={app.dataset!} reviews={reviews} onOpenFinding={() => setWork(true)}/></div>
|
|
{work && <DatasetViewer/>}</>;
|
|
}
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<RelatedFindingsProvider><Views/></RelatedFindingsProvider>));
|
|
expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1);
|
|
expect(host.querySelector('.client-risk-register-navigation header')!.textContent).toContain('1 recorded');
|
|
const details = host.querySelector<HTMLDetailsElement>('.risk-register-detail-group')!;
|
|
details.open = true;
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.risk-entry-with-group .related-finding-ring')!.click());
|
|
expect(host.querySelector<HTMLElement>('[data-testid="risk-view"]')!.hidden).toBe(true);
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(2);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
expect(host.querySelector<HTMLElement>('[data-testid="risk-view"]')!.hidden).toBe(false);
|
|
expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1);
|
|
expect(details.open).toBe(true);
|
|
expect(JSON.stringify(reviews)).toBe(original);
|
|
expect(app.updateReview).not.toHaveBeenCalled();
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
it('isolates every exact group member, fills their rings, and restores filters, selected finding, view and scroll without editing reviews', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
|
|
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
|
vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: function(this: HTMLElement, options: ScrollToOptions | number = {}) { this.scrollTop = typeof options === 'number' ? options : options.top ?? 0; } });
|
|
document.documentElement.dataset.theme = theme;
|
|
const sheet = makeSheet('Synthetic', [['Name','Status','Severity','Scope','Technical Identity'],
|
|
['SMB declaration','Failed','High','synthetic','smb-a'], ['SMB observation','Passed','Low','synthetic','smb-b'],
|
|
['SMB declaration','Failed','High','synthetic','unrelated'], ['Other change','Failed','Low','synthetic','other']], '', 'synthetic.csv');
|
|
sheet.isProjectSheet = true;
|
|
const records = sheet.analysis.records;
|
|
const sourceSheet = { ...sheet, id: 'source', isProjectSheet: false, analysis: { ...sheet.analysis, records: [{ ...records[0], id: 'source-alias' }] } };
|
|
const workPackage = { id: 'package', code: 'WP-01', clusterLabel: 'Synthetic', title: 'Synthetic', facts: { tasks: 2, findings: 3 },
|
|
tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) },
|
|
{ id: 'unrelated', redundant: false, findingKeys: [workRecordKey(records[2])] }], findingKeys: records.slice(0,3).map(workRecordKey) };
|
|
const index = { packages: [workPackage], packageFor: (r: typeof records[0]) => workPackage.findingKeys.includes(workRecordKey(r)) ? workPackage : undefined,
|
|
unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', kind: 'category', values: [], valuesFor: () => [] } };
|
|
vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index } as never);
|
|
const original = JSON.stringify(records);
|
|
const style = document.createElement('style');
|
|
style.textContent = readFileSync('src/styles/components.css', 'utf8') + readFileSync('src/styles/legacy-views.css', 'utf8');
|
|
document.head.append(style);
|
|
const reviews = { [records[0].id]: { include: true, decision: 'open', note: '' } };
|
|
let app: ReturnType<typeof useApp>;
|
|
const initial = { ...initialAppState, theme, dataset: { sheets: [sheet, sourceSheet], fileName: 'Synthetic', profileId: 'generic' },
|
|
activeSheetId: sheet.id, activeSheet: sheet, activeProfile: genericProfile, workScopeId: 'synthetic',
|
|
view: 'records', search: 'SMB declaration', statusFilter: 'all', actionOnly: true, reviews,
|
|
pathFindingIds: [records[0].id], selectedRecordId: records[0].id } as unknown as ReturnType<typeof useApp>;
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
function Harness() {
|
|
const [state, setState] = useState(initial);
|
|
app = { ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })),
|
|
setActiveSheet: id => setState(s => ({ ...s, activeSheetId: id, activeSheet: id === 'source' ? sourceSheet : sheet, selectedRecordId: id === 'source' ? 'source-alias' : records[0].id })),
|
|
setView: view => setState(s => ({ ...s, view })), applyViewBookmark: saved => setState(s => ({ ...s, ...saved })),
|
|
setFacetFilter: (id, value) => setState(s => {
|
|
const facetFilters = { ...s.facetFilters };
|
|
if (value) facetFilters[id] = value; else delete facetFilters[id];
|
|
return { ...s, facetFilters };
|
|
}),
|
|
updateReview: vi.fn(), resetFilters: () => setState(s => ({ ...s, search: '', actionOnly: false, pathFindingIds: [], facetFilters: {} })) };
|
|
vi.mocked(useApp).mockImplementation(() => app);
|
|
return <RelatedFindingsProvider><DatasetViewer/></RelatedFindingsProvider>;
|
|
}
|
|
try {
|
|
await act(async () => root.render(<Harness/>));
|
|
const list = host.querySelector<HTMLElement>('.record-list')!;
|
|
list.scrollTop = 184; list.scrollLeft = 17;
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(1);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(2);
|
|
expect(host.querySelectorAll('.record-list .related-finding-ring[aria-pressed="true"]')).toHaveLength(2);
|
|
const activeRing = host.querySelector<HTMLElement>('.record-card.active')!.parentElement!.querySelector<HTMLElement>('.related-finding-ring')!;
|
|
expect(Number(getComputedStyle(activeRing).zIndex)).toBeGreaterThan(Number(getComputedStyle(host.querySelector('.record-card.active')!).zIndex));
|
|
const rings = host.querySelectorAll<HTMLElement>('.record-list .related-finding-ring');
|
|
expect(rings[0].style.getPropertyValue('--related-color')).toBe(rings[1].style.getPropertyValue('--related-color'));
|
|
await act(async () => app!.setSelectedRecord(records[1].id));
|
|
for (const ring of host.querySelectorAll<HTMLElement>('.record-list .related-finding-ring')) {
|
|
expect(ring.getAttribute('aria-pressed')).toBe('true');
|
|
expect(ring.style.getPropertyValue('--related-color')).toBe(rings[0].style.getPropertyValue('--related-color'));
|
|
expect(getComputedStyle(ring).zIndex).toBe('2');
|
|
}
|
|
expect(host.querySelector('.record-card.active')!.parentElement!.querySelector('.related-finding-ring[aria-pressed="true"]')).not.toBeNull();
|
|
await act(async () => app!.setView('columns'));
|
|
expect(host.querySelectorAll('.data-tile')).toHaveLength(2);
|
|
expect(host.querySelectorAll('.data-tile .related-finding-ring[aria-pressed="true"]')).toHaveLength(2);
|
|
await act(async () => app!.setView('table'));
|
|
expect(host.querySelectorAll('tbody .related-finding-ring')).toHaveLength(2);
|
|
await act(async () => app!.setView('raw'));
|
|
expect(host.querySelectorAll('.related-raw-record .related-finding-ring')).toHaveLength(2);
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('.related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(app!.view).toBe('records'); expect(app!.selectedRecordId).toBe(initial.selectedRecordId);
|
|
expect(app!.search).toBe(initial.search); expect(app!.statusFilter).toBe(initial.statusFilter);
|
|
expect(app!.actionOnly).toBe(true); expect(app!.pathFindingIds).toEqual(initial.pathFindingIds);
|
|
expect(app!.facetFilters).toEqual(initial.facetFilters); expect(app!.reviews).toBe(reviews);
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(1);
|
|
expect(host.querySelector<HTMLElement>('.record-list')!.scrollTop).toBe(184);
|
|
expect(host.querySelector<HTMLElement>('.record-list')!.scrollLeft).toBe(17);
|
|
expect(JSON.stringify(records)).toBe(original);
|
|
await act(async () => app!.resetFilters());
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(4);
|
|
expect(host.querySelectorAll('.record-list .related-finding-ring')).toHaveLength(2);
|
|
const unrelated = host.querySelector(`[data-record-id="${records[2].id}"]`)!;
|
|
expect(unrelated.querySelector('.related-finding-ring')).toBeNull();
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(2);
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
await act(async () => app!.setActiveSheet('source'));
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(1);
|
|
expect(host.querySelector('.record-list .related-finding-ring')).not.toBeNull();
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(2);
|
|
const peerLink = host.querySelector<HTMLAnchorElement>(`[data-work-package-finding="${records[1].id}"]`)!;
|
|
expect(peerLink).not.toBeNull();
|
|
await act(async () => peerLink.click());
|
|
expect(app!.selectedRecordId).toBe(records[1].id);
|
|
expect(host.querySelector('[data-work-package-hidden]')).toBeNull();
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
expect(app!.activeSheetId).toBe('source');
|
|
expect(app!.selectedRecordId).toBe('source-alias');
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(1);
|
|
await act(async () => app!.setActiveSheet(sheet.id));
|
|
await act(async () => host.querySelector<HTMLButtonElement>('.record-list .related-finding-ring')!.click());
|
|
vi.mocked(useWorkPackages).mockReturnValue({ status: 'off' } as never);
|
|
await act(async () => root.render(<Harness/>));
|
|
expect(host.querySelector('.related-finding-ring')).toBeNull();
|
|
expect(host.querySelectorAll('.record-card')).toHaveLength(4);
|
|
} finally { await act(async () => root.unmount()); host.remove(); style.remove(); }
|
|
});
|
|
});
|