Assessor_2.0/tests/features/client-information-browser....

152 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { ClientInformationBrowser } from '../../src/features/dashboard/ClientInformationBrowser';
import { CloudInformationTile } from '../../src/features/dashboard/CloudInformationTile';
import { clientInformationFixture } from '../client-information-fixture';
import { cloudInformationFixture } from '../cloud-information-fixture';
import { selectClientInformation, type ClientInformationResult } from '../../src/domain/client-information';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
let root: Root | undefined;
let host: HTMLDivElement;
afterEach(async () => {
if (root) await act(async () => root!.unmount());
root = undefined; host?.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals();
});
function mount() {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
host = document.createElement('div'); document.body.append(host); root = createRoot(host); return root;
}
async function click(label: string) {
const button = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label || item.getAttribute('aria-label') === label);
expect(button, label).toBeTruthy();
await act(async () => button!.click());
}
async function input(label: string, value: string) {
const element = document.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`)!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
});
}
describe('Client Info: selective Client Information access', () => {
it.each(['dark', 'light'])('shows concrete facts for all four cloud areas and their unchanged Results in %s', async theme => {
document.documentElement.dataset.theme = theme;
const snapshot = cloudInformationFixture(); const before = JSON.stringify(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'queryClientInformation').mockImplementation(async scope => selectClientInformation(snapshot, scope, 7));
await act(async () => mount().render(<CloudInformationTile snapshot={snapshot} scope={{ projectId: 'client-1', scanRunIds: ['run-1'] }} />));
expect(host.querySelectorAll('dt')).toHaveLength(4);
for (const value of ['FIDO2: enabled', 'Permanent: 2 reported assignments',
'Admin requests: disabled', 'Guests: restricted role']) expect(host.textContent).toContain(value);
expect(host.textContent).not.toContain('Source evidence available');
await click('Open Cloud Information results'); await click('Details: CISA.MS.AAD.7.4');
const details = document.querySelector('[aria-label="Client Information details"]')!;
expect(details.textContent).toContain('Reported permanent assignments: 2 (reported subset)');
expect(details.textContent).toContain('Synthetic admin');
expect(JSON.stringify(snapshot)).toBe(before);
});
it.each(['dark', 'light'])('opens on demand in %s, filters and resets without altering Work or optional-engine choices', async theme => {
document.documentElement.dataset.theme = theme; localStorage.clear(); resetEnginePreferencesForTests();
setEngineEnabled('context', false); setEngineEnabled('report', false);
const snapshot = clientInformationFixture();
const query = vi.spyOn(AssessorApiClient.prototype, 'queryClientInformation').mockImplementation(async scope => selectClientInformation(snapshot, scope, 7));
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Synthetic check', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true });
const setFacetFilter = vi.fn(); const resetFilters = vi.fn();
vi.mocked(useApp).mockReturnValue({ dataset: { fileName: 'Synthetic', sheets: [sheet], scanTool: 'Generic' }, activeSheet: sheet,
activeProfile: genericProfile, reviews: {}, targetProjectId: 'client-1', activeScanRunIds: ['run-1'], activeAssessmentIds: ['slot-1'],
facetFilters: {}, setFacetFilter, resetFilters, setStatusFilter: vi.fn(), setPathFindingIds: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn()
} as unknown as ReturnType<typeof useApp>);
await act(async () => mount().render(<DashboardPanel onOpenWork={vi.fn()} />));
expect(query).not.toHaveBeenCalled();
const tile = host.querySelector('[aria-label="Cloud Information"]')!;
expect(tile).not.toBeNull();
expect(tile.querySelectorAll('dt')).toHaveLength(4);
expect(tile.textContent).toContain('FIDO2: enabled');
expect(tile.textContent).toContain('Not reported');
await click('Open Cloud Information results');
expect(query).toHaveBeenLastCalledWith(expect.objectContaining({ projectId: 'client-1', assessmentId: 'slot-1', scanRunIds: ['run-1'],
areas: ['cloud-access', 'cloud-roles', 'cloud-apps', 'cloud-sharing'] }));
expect(document.querySelector('.floating-data-window')!.textContent).toContain('1 matching observations');
await click('Details: MFA source');
expect(document.querySelector('.floating-data-window')!.textContent).toContain('FIDO2');
await click('Reset');
expect(document.querySelector('.floating-data-window')!.textContent).toContain('1 matching observations');
await click('Close Cloud Information window');
expect(document.querySelector('[aria-label="Open Client Information results"]')).toBeNull();
// The standalone data browser is retained; only its Dashboard trigger was removed.
await act(async () => root!.render(<ClientInformationBrowser scope={{ projectId: 'client-1', assessmentId: 'slot-1', scanRunIds: ['run-1'] }} />));
expect(query).toHaveBeenLastCalledWith(expect.objectContaining({ projectId: 'client-1', assessmentId: 'slot-1', scanRunIds: ['run-1'] }));
expect(document.body.textContent).toContain('6 matching observations');
await click('Details: MFA source');
expect(document.body.textContent).toContain('Reported test evidence.');
expect(document.body.textContent).toContain('FIDO2');
await input('Client Information property', 'domain.fqdn');
await click('Apply');
expect(query).toHaveBeenLastCalledWith(expect.objectContaining({ properties: ['domain.fqdn'] }));
expect(document.body.textContent).toContain('2 matching observations');
expect(document.querySelector('[aria-label="Client Information details"]')).toBeNull();
await input('Client Information property', 'missing.property'); await click('Apply');
expect(document.body.textContent).toContain('Missing information does not mean');
await click('Reset');
expect(document.body.textContent).toContain('6 matching observations');
expect(setFacetFilter).not.toHaveBeenCalled(); expect(resetFilters).not.toHaveBeenCalled();
});
it('always shows four cloud areas without evidence and distinguishes unassessed evidence from observed configuration', async () => {
const snapshot = clientInformationFixture();
const scope = { projectId: 'client-1', scanRunIds: ['run-1'] };
const cloud = snapshot.observations.find(item => item.property.startsWith('cloud.'))!;
cloud.state = 'not-assessed';
const original = JSON.stringify(snapshot);
await act(async () => mount().render(<CloudInformationTile snapshot={snapshot} scope={scope} />));
expect(host.textContent).not.toContain('FIDO2: enabled');
expect([...host.querySelectorAll('dd')].every(item => item.textContent === 'Not reported')).toBe(true);
expect(JSON.stringify(snapshot)).toBe(original);
await act(async () => root!.render(<CloudInformationTile snapshot={{ ...snapshot, observations: snapshot.observations.filter(item => item !== cloud) }} scope={scope} />));
expect(host.querySelectorAll('dd')).toHaveLength(4);
expect([...host.querySelectorAll('dd')].every(item => item.textContent === 'Not reported')).toBe(true);
await act(async () => root!.render(<CloudInformationTile snapshot={undefined} scope={scope} />));
expect(host.querySelectorAll('dd')).toHaveLength(4);
await act(async () => root!.render(<CloudInformationTile snapshot={undefined} />));
await click('Open Cloud Information results');
expect(document.querySelector('.floating-data-window')!.textContent).toContain('No retained Client Information');
});
it('paginates, retries failures and suppresses late responses from a previous scope', async () => {
const snapshot = clientInformationFixture();
snapshot.observations = Array.from({ length: 55 }, (_, index) => ({ ...snapshot.observations[0], id: String(index).padStart(3, '0'), label: `Synthetic ${index}` }));
const query = vi.spyOn(AssessorApiClient.prototype, 'queryClientInformation').mockImplementation(async scope => selectClientInformation(snapshot, scope, 8));
const scope = { projectId: 'client-1', scanRunIds: ['run-1'] };
await act(async () => mount().render(<ClientInformationBrowser scope={scope} />));
await click('Next observations');
expect(query).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 50 }));
expect(document.querySelector('.client-information-table tbody')!.children).toHaveLength(5);
expect(document.querySelector<HTMLButtonElement>('[aria-label="Client Information pages"] button:last-child')!.disabled).toBe(true);
query.mockRejectedValueOnce(new Error('synthetic failure'));
await click('Previous observations');
expect(document.querySelector('[role="alert"]')!.textContent).toContain('could not be loaded');
await click('Retry');
expect(document.querySelector('.client-information-table tbody')!.children).toHaveLength(50);
let oldResolve!: (result: ClientInformationResult) => void;
query.mockImplementationOnce(() => new Promise(resolve => { oldResolve = resolve; }));
await click('Reset');
await act(async () => root!.render(<ClientInformationBrowser scope={{ projectId: 'new-client', scanRunIds: ['new-run'] }} />));
await act(async () => oldResolve({ ...selectClientInformation(snapshot, scope, 1), totalObservations: 999 }));
expect(document.body.textContent).not.toContain('999 matching');
expect(query).toHaveBeenLastCalledWith(expect.objectContaining({ projectId: 'new-client', scanRunIds: ['new-run'] }));
});
});