235 lines
20 KiB
TypeScript
235 lines
20 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { useApp } from '../../src/app/AppProvider';
|
|
import { installTableRowMarking } from '../../src/app/table-row-marking';
|
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
|
import type { WorkPackagesResponse } from '../../src/api/assessor-api-contract';
|
|
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { buildWorkPackages, scannerFindingKey } from '../../src/remediation/remediation-engine';
|
|
import { workRecordKey } from '../../src/remediation/work-package-index';
|
|
import { resetWorkPackageRequestsForTests } from '../../src/remediation/use-work-packages';
|
|
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
|
|
|
|
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
|
|
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic assessment selector</span> }));
|
|
vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => undefined }));
|
|
|
|
function fixture(projectId = 'synthetic-project', assessmentId = 'synthetic-assessment') {
|
|
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Source Rule ID', 'Source: Scan', 'Description', 'Remediation'],
|
|
['Synthetic admin A', 'Failed', 'High', 'MT.1014', 'Maester', 'Synthetic explanation A', 'Synthetic exact remediation A'],
|
|
['Synthetic admin B', 'Failed', 'High', 'CISA.MS.AAD.3.6', 'Maester', 'Synthetic explanation B', 'Synthetic exact remediation B'],
|
|
['Synthetic external control', 'Failed', 'Medium', 'MT.1056', 'Maester', 'Synthetic external explanation', 'Synthetic external remediation'],
|
|
['Synthetic unassigned control', 'Failed', 'Low', 'SYNTHETIC.UNASSIGNED', 'Maester', 'Synthetic unassigned explanation', 'Synthetic unassigned remediation']
|
|
], '', 'synthetic.csv', { isProjectSheet: true });
|
|
sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = `${projectId}-occ-${index}`; });
|
|
const findings = sheet.analysis.records.map(record => ({ key: scannerFindingKey(record.findingOccurrenceId!), occurrenceId: record.findingOccurrenceId,
|
|
tool: 'maester', ruleId: record.fields[3].value, title: record.title, outcome: 'fail' }));
|
|
const result = buildWorkPackages({ projectId, assessmentIds: [assessmentId], scanRunIds: ['synthetic-run'], findings,
|
|
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
|
clusters: [{ clusterId: 'cloud-privileged-access-exposure', label: { en: 'Cloud Privileged Access Exposure' } },
|
|
{ clusterId: 'cloud-external-access-exposure', label: { en: 'Cloud External Access Exposure' } }],
|
|
findings: findings.slice(0, 3).map((finding, index) => ({ occurrenceId: finding.occurrenceId!,
|
|
clusterIds: [index < 2 ? 'cloud-privileged-access-exposure' : 'cloud-external-access-exposure'], conditionIds: [] })), settings: [] }
|
|
});
|
|
const app = { ...clientFactsState(), targetProjectId: projectId, activeAssessmentIds: [assessmentId], activeSheet: sheet,
|
|
dataset: { fileName: `Synthetic ${projectId}`, sheets: [sheet], scanTool: 'Generic' },
|
|
search: 'retained Work search', statusFilter: 'passed', facetFilters: { severity: { values: ['High'] } }, setFacetFilter: vi.fn() };
|
|
return { app, result };
|
|
}
|
|
function setup() {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests();
|
|
setEngineEnabled('context', false);
|
|
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot);
|
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
|
|
return vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext');
|
|
}
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests(); });
|
|
|
|
describe.each(['light', 'dark'])('Client Info Work Packages (%s)', theme => {
|
|
it('opens packages and original finding details without marking any Work Packages table row blue', async () => {
|
|
setup(); document.documentElement.dataset.theme = theme;
|
|
const { app, result } = fixture();
|
|
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
|
|
vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
|
|
const original = JSON.stringify(app.dataset); const openWork = vi.fn();
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
const stopMarking = installTableRowMarking(document);
|
|
try {
|
|
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
|
|
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
|
|
const panel = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
|
|
const packageButton = panel.querySelector<HTMLButtonElement>('[data-work-package-select]')!;
|
|
await act(async () => packageButton.click());
|
|
expect(packageButton.getAttribute('aria-expanded')).toBe('true');
|
|
const finding = panel.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
|
|
await act(async () => finding.click());
|
|
expect(finding.getAttribute('aria-expanded')).toBe('true');
|
|
const details = panel.querySelector<HTMLElement>('.client-security-finding-detail')!;
|
|
const record = app.activeSheet.analysis.records.find(record => record.title === finding.querySelector('strong')!.textContent)!;
|
|
expect(details.textContent).toContain(record.fields[5].value);
|
|
expect(details.textContent).toContain(record.fields[6].value);
|
|
await act(async () => details.click());
|
|
expect(panel.querySelector('[data-row-marked="true"]')).toBeNull();
|
|
await act(async () => panel.querySelector<HTMLButtonElement>('[data-work-package-unassigned-summary]')!.click());
|
|
expect(panel.querySelector('[data-work-package-unassigned-details]')?.textContent).toContain('Synthetic unassigned control');
|
|
expect(panel.querySelector('[data-row-marked="true"]')).toBeNull();
|
|
// Other tables retain the established reading marker.
|
|
const table = document.createElement('table'); table.innerHTML = '<tbody><tr><td>Other table</td></tr></tbody>'; host.append(table);
|
|
table.querySelector('td')!.click(); expect(table.querySelector('tr')!.getAttribute('data-row-marked')).toBe('true');
|
|
expect(openWork).not.toHaveBeenCalled(); expect(JSON.stringify(app.dataset)).toBe(original);
|
|
} finally { stopMarking(); await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
|
|
it('keeps Security and Work Packages unchanged after all Work/Graph filters and the selected source sheet change', async () => {
|
|
setup(); document.documentElement.dataset.theme = theme;
|
|
const { app, result } = fixture();
|
|
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
|
|
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
|
|
const openWork = vi.fn(); const original = JSON.stringify(app.dataset);
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
const render = () => root.render(<DashboardPanel onOpenWork={openWork}/>);
|
|
const tab = (name: string) => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(button => button.textContent === name)!;
|
|
try {
|
|
await act(async () => render()); await act(async () => tab('Security').click());
|
|
const security = host.querySelector<HTMLElement>('[data-group-scroll="client-info-security"]')!;
|
|
await act(async () => security.querySelector<HTMLButtonElement>('.client-security-group-toggle')!.click());
|
|
expect(security.querySelectorAll('.client-security-finding-toggle')).toHaveLength(4);
|
|
expect(security.querySelector('.client-security-remediation strong')?.textContent).toBe('4');
|
|
const securityContent = security.textContent;
|
|
await act(async () => tab('Work Packages').click());
|
|
const wp = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
|
|
await act(async () => wp.querySelector<HTMLButtonElement>('[data-work-package-select]')!.click());
|
|
const wpContent = wp.textContent;
|
|
const subset = makeSheet('Selected source subset', [['Name', 'Status'], ['Only a source subset', 'Passed']], '', 'subset.csv');
|
|
const changed = { ...app, activeSheet: subset, activeProfile: { ...app.activeProfile, matchesStatus: () => false },
|
|
search: 'no matching title', statusFilter: 'passed', actionOnly: true, pathFindingIds: ['absent graph member'],
|
|
facetFilters: { 'provenance:source-scan': { values: ['Absent scanner'] }, severity: { values: ['Critical'] },
|
|
'context:cluster': { values: ['absent cluster'] }, 'report:business-impact': { values: ['absent impact'] },
|
|
'mitre:technique': { values: ['T0000'] }, arbitrary: { values: ['absent'] } } };
|
|
vi.mocked(useApp).mockReturnValue(changed as unknown as ReturnType<typeof useApp>);
|
|
await act(async () => render());
|
|
expect(wp.textContent).toBe(wpContent); expect(packages).toHaveBeenCalledTimes(1);
|
|
await act(async () => tab('Security').click());
|
|
expect(security.textContent).toBe(securityContent);
|
|
expect(security.querySelectorAll('.client-security-finding-toggle')).toHaveLength(4);
|
|
expect(openWork).not.toHaveBeenCalled(); expect(app.setFacetFilter).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(app.dataset)).toBe(original);
|
|
expect(changed.facetFilters['provenance:source-scan'].values).toEqual(['Absent scanner']);
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
|
|
it('opens the third tab lazily, retains filters and expands exact package findings and their original details locally', async () => {
|
|
const context = setup(); document.documentElement.dataset.theme = theme;
|
|
const { app, result } = fixture(); vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
|
|
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
|
|
const original = JSON.stringify({ app, result }); const openWork = vi.fn();
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
|
|
const tabs = [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
|
|
expect(tabs.map(tab => tab.textContent)).toEqual(['Overview', 'Security', 'Work Packages']);
|
|
expect(packages).not.toHaveBeenCalled();
|
|
await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })));
|
|
const panel = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
|
|
expect(panel.hidden).toBe(false); expect(document.activeElement).toBe(tabs[2]);
|
|
expect(packages).toHaveBeenCalledWith('synthetic-project', ['synthetic-assessment'], ['synthetic-run']);
|
|
expect(context).not.toHaveBeenCalled();
|
|
expect(panel.querySelector('.floating-data-window')).toBeNull();
|
|
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(result.packages.length);
|
|
expect(panel.querySelector('[aria-label="Package totals"]')?.textContent).toContain('Findings4');
|
|
const filter = panel.querySelector<HTMLSelectElement>('[aria-label="Filter Package"]')!;
|
|
const selectedPackage = result.packages[0];
|
|
await act(async () => { filter.value = selectedPackage.id; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
|
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1);
|
|
await act(async () => panel.querySelector<HTMLButtonElement>('[data-sort-column="findings"]')!.click());
|
|
await act(async () => tabs[0].click());
|
|
await act(async () => tabs[2].click());
|
|
expect(filter.value).toBe(selectedPackage.id); expect(packages).toHaveBeenCalledTimes(1);
|
|
expect(panel.querySelector('[aria-sort="descending"] button')?.textContent).toBe('Findings');
|
|
await act(async () => panel.querySelector<HTMLButtonElement>(`[data-work-package-select="${selectedPackage.id}"]`)!.click());
|
|
const packageButton = panel.querySelector<HTMLButtonElement>(`[data-work-package-select="${selectedPackage.id}"]`)!;
|
|
const detailRow = panel.querySelector<HTMLTableRowElement>(`[data-work-package-details="${selectedPackage.id}"]`)!;
|
|
expect(packageButton.getAttribute('aria-expanded')).toBe('true'); expect(detailRow.hidden).toBe(false);
|
|
expect(detailRow.previousElementSibling?.getAttribute('data-work-package-row')).toBe(selectedPackage.id);
|
|
const expected = app.activeSheet.analysis.records.filter(record => selectedPackage.findingKeys.includes(workRecordKey(record)));
|
|
expect([...detailRow.querySelectorAll('.client-security-finding-toggle strong')].map(title => title.textContent)).toEqual(expected.map(record => record.title));
|
|
const finding = detailRow.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
|
|
await act(async () => finding.click());
|
|
expect(finding.getAttribute('aria-expanded')).toBe('true');
|
|
expect(detailRow.textContent).toContain(expected[0].fields[5].value);
|
|
expect(detailRow.textContent).toContain(expected[0].fields[6].value);
|
|
await act(async () => tabs[0].click()); await act(async () => tabs[2].click());
|
|
expect(packageButton.getAttribute('aria-expanded')).toBe('true'); expect(finding.getAttribute('aria-expanded')).toBe('true');
|
|
await act(async () => packageButton.click());
|
|
expect(detailRow.hidden).toBe(true); expect(detailRow.querySelector('.client-security-finding-toggle')).toBeNull();
|
|
await act(async () => panel.querySelector<HTMLButtonElement>('[data-work-package-unassigned-summary]')!.click());
|
|
expect(panel.querySelector('[data-work-package-unassigned-details]')?.textContent).toContain('Synthetic unassigned control');
|
|
expect(filter.value).toBe('');
|
|
expect(panel.querySelector('[title*="in Work"]')).toBeNull();
|
|
expect(app.setFacetFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled();
|
|
expect(JSON.stringify({ app, result })).toBe(original);
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
|
|
it('clears the old scope while loading another client/assessment and suppresses late packages when Remediation is disabled', async () => {
|
|
const context = setup(); document.documentElement.dataset.theme = theme;
|
|
const first = fixture(), second = fixture('synthetic-other-client', 'synthetic-other-assessment');
|
|
vi.mocked(useApp).mockReturnValue(first.app as unknown as ReturnType<typeof useApp>);
|
|
let finish!: (response: WorkPackagesResponse) => void;
|
|
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValueOnce({ status: 'ready', result: first.result })
|
|
.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
const render = () => root.render(<DashboardPanel onOpenWork={() => {}}/>);
|
|
try {
|
|
await act(async () => render());
|
|
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
|
|
expect(host.querySelectorAll('[data-work-package-row]')).toHaveLength(first.result.packages.length);
|
|
vi.mocked(useApp).mockReturnValue(second.app as unknown as ReturnType<typeof useApp>);
|
|
await act(async () => render());
|
|
expect(packages).toHaveBeenLastCalledWith('synthetic-other-client', ['synthetic-other-assessment'], ['synthetic-run']);
|
|
expect(host.querySelector('[data-work-package-row]')).toBeNull();
|
|
expect(host.textContent).toContain('Loading work packages');
|
|
await act(async () => setEngineEnabled('remediation', false));
|
|
expect([...host.querySelectorAll('[role="tab"]')].map(tab => tab.textContent)).toEqual(['Overview', 'Security']);
|
|
expect(host.querySelector('[role="tabpanel"]:not([hidden])')?.getAttribute('data-group-scroll')).toBe('client-info-overview');
|
|
await act(async () => finish({ status: 'ready', result: second.result }));
|
|
expect(host.querySelector('[data-work-package-row]')).toBeNull();
|
|
expect(packages).toHaveBeenCalledTimes(2); expect(context).not.toHaveBeenCalled();
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
|
|
it('reads all exact package and same-change members from the project Work sheet even when another source sheet is selected', async () => {
|
|
setup(); document.documentElement.dataset.theme = theme;
|
|
const { app, result } = fixture();
|
|
const grouped = result.packages.find(p => p.findingKeys.length === 2)!;
|
|
grouped.tasks = [{ id: 'synthetic-same-change', findingKeys: [...grouped.findingKeys], redundant: true }];
|
|
grouped.facts.tasks = 1; grouped.facts.redundantFindings = 2;
|
|
const sourceSheet = makeSheet('Synthetic source subset', [['Name', 'Status'], ['A different source row', 'Passed']], '', 'source.csv');
|
|
app.dataset.sheets.push(sourceSheet); app.activeSheet = sourceSheet;
|
|
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
|
|
vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
|
|
const original = JSON.stringify({ app, result }); const openWork = vi.fn();
|
|
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
|
|
try {
|
|
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
|
|
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
|
|
await act(async () => host.querySelector<HTMLButtonElement>(`[data-work-package-select="${grouped.id}"]`)!.click());
|
|
const details = host.querySelector(`[data-work-package-details="${grouped.id}"]`)!;
|
|
expect([...details.querySelectorAll('.client-security-finding-toggle strong')].map(title => title.textContent).sort()).toEqual(['Synthetic admin A', 'Synthetic admin B']);
|
|
expect(details.textContent).not.toContain('A different source row');
|
|
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-package-related-summary]')!.click());
|
|
const related = host.querySelector<HTMLElement>('[data-work-package-related-summary]')!;
|
|
const relatedDetails = document.getElementById(related.getAttribute('aria-controls')!)!;
|
|
expect(relatedDetails.hidden).toBe(false);
|
|
expect(relatedDetails.querySelectorAll('.client-security-finding-toggle')).toHaveLength(2);
|
|
await act(async () => related.click()); expect(relatedDetails.hidden).toBe(true);
|
|
expect(app.setFacetFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled();
|
|
expect(JSON.stringify({ app, result })).toBe(original);
|
|
} finally { await act(async () => root.unmount()); host.remove(); }
|
|
});
|
|
});
|