108 lines
7.5 KiB
TypeScript
108 lines
7.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
import type { ProjectAssessmentHistoryResponse } from '../../src/api/assessor-api-contract';
|
|
import { DataHub } from '../../src/features/project-import/DataHub';
|
|
import { DatahubMetricsCache, type TimeslotMetricsScope } from '../../src/features/project-import/datahub-metrics-cache';
|
|
|
|
const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(),
|
|
listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn() }));
|
|
vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class {
|
|
health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles;
|
|
listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus;
|
|
getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets;
|
|
} }));
|
|
vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null }));
|
|
const date = '2026-01-01T00:00:00Z';
|
|
const totals = (all: number) => ({ all, deviation: all / 2, passed: all / 2, notApplicable: 0, notRun: 0, error: 0 });
|
|
const history = (id: string): ProjectAssessmentHistoryResponse => ({
|
|
version: 1, projectId: id,
|
|
assessments: [{ id: `${id}-assessment`, projectId: id, name: 'Synthetic timeslot', createdAt: date, updatedAt: date, effectiveAt: date }],
|
|
scanRuns: [{ id: `${id}-run`, assessmentId: `${id}-assessment`, sequence: 1, label: 'Synthetic scan', importedAt: date, tool: { id: 'dummy', name: 'Synthetic scanner' } }]
|
|
});
|
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); });
|
|
|
|
it('preloads during overview loading, shares pending requests, reuses counts on selection and revisits, and refreshes changed data', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
let revision = 700;
|
|
api.health.mockImplementation(async () => ({ status: 'ok', repositoryRevision: revision }));
|
|
api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'], techniques: [{ id: 'T1234', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } });
|
|
api.listProjects.mockResolvedValue(['alpha', 'beta'].map(id => ({ id, name: id, attributes: {}, createdAt: date, updatedAt: date })));
|
|
api.listProjectImportFiles.mockResolvedValue([]);
|
|
api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] });
|
|
api.queryUnifiedResultFacets.mockResolvedValue({ total: 2, facets: [] });
|
|
const betaHistory = deferred<ProjectAssessmentHistoryResponse>();
|
|
api.listProjectAssessmentHistory.mockImplementation((id: string) => id === 'beta' ? betaHistory.promise : Promise.resolve(history(id)));
|
|
const alphaMetrics = deferred<ReturnType<typeof totals>>();
|
|
api.getTimeslotResultMetrics.mockImplementation((id: string) => id === 'alpha' ? alphaMetrics.promise : Promise.resolve(totals(40)));
|
|
const host = document.createElement('div'); document.body.append(host);
|
|
let root = createRoot(host);
|
|
const mount = () => <DataHub backendStatus="connected" onProjectSelected={() => undefined}/>;
|
|
const select = (id: string) => host.querySelector<HTMLButtonElement>(`[aria-label="Mandant ${id} auswählen"]`)!.click();
|
|
const readout = () => host.querySelector('.donut-metric-card[aria-label^="Results All:"]')?.getAttribute('aria-label');
|
|
try {
|
|
await act(async () => root.render(mount()));
|
|
// One slow client's metadata must not postpone another client's summaries.
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledExactlyOnceWith('alpha', ['alpha-run'], ['alpha-assessment']);
|
|
expect(host.querySelector('.datahub-tenant-select')).toBeNull();
|
|
await act(async () => betaHistory.resolve(history('beta')));
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2);
|
|
await act(async () => select('alpha'));
|
|
expect(readout()).toBe('Results All: Loading…');
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2);
|
|
await act(async () => select('beta'));
|
|
expect(readout()).toBe('Results All: 40, 100%');
|
|
await act(async () => alphaMetrics.resolve(totals(20)));
|
|
expect(readout()).toBe('Results All: 40, 100%'); // Late alpha must not overwrite beta.
|
|
await act(async () => select('alpha'));
|
|
expect(readout()).toBe('Results All: 20, 100%');
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2);
|
|
await act(async () => root.unmount()); root = createRoot(host);
|
|
await act(async () => root.render(mount()));
|
|
await act(async () => select('alpha'));
|
|
expect(readout()).toBe('Results All: 20, 100%');
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2);
|
|
revision++;
|
|
api.getTimeslotResultMetrics.mockImplementation(async (id: string) => totals(id === 'alpha' ? 22 : 44));
|
|
await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed')));
|
|
expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(4);
|
|
expect(readout()).toBe('Results All: 22, 100%');
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
const scope = (projectId: string, revision = 1): TimeslotMetricsScope => ({ projectId, revision, scanRunIds: ['b','a'], assessmentIds: ['slot'] });
|
|
it('bounds concurrent preloads, prioritizes selection and keys counts by exact revision and scope', async () => {
|
|
const pending = [deferred<ReturnType<typeof totals>>(), deferred<ReturnType<typeof totals>>()];
|
|
const fetch = vi.fn().mockImplementationOnce(() => pending[0].promise).mockImplementationOnce(() => pending[1].promise).mockResolvedValue(totals(8));
|
|
const cache = new DatahubMetricsCache(fetch, 2);
|
|
cache.prefetch(scope('one')); cache.prefetch(scope('two')); cache.prefetch(scope('three')); cache.prefetch(scope('four'));
|
|
await Promise.resolve(); expect(fetch).toHaveBeenCalledTimes(2);
|
|
const chosen = cache.load(scope('four'));
|
|
pending[0].resolve(totals(2)); await chosen;
|
|
expect(fetch.mock.calls[2][0].projectId).toBe('four');
|
|
pending[1].resolve(totals(4)); await cache.load(scope('three'));
|
|
await cache.load(scope('five'));
|
|
expect(cache.peek(scope('one'))).toBeUndefined(); // Bounded completed cache.
|
|
const count = fetch.mock.calls.length;
|
|
await cache.load({ ...scope('five'), scanRunIds: ['a','b'] });
|
|
expect(fetch).toHaveBeenCalledTimes(count);
|
|
await cache.load(scope('five', 2));
|
|
await cache.load({ ...scope('five', 2), assessmentIds: ['different-slot'] });
|
|
expect(fetch).toHaveBeenCalledTimes(count + 2);
|
|
});
|
|
|
|
it('does not cache failures or let invalidated in-flight requests restore old values', async () => {
|
|
const stale = deferred<ReturnType<typeof totals>>();
|
|
const fetch = vi.fn().mockRejectedValueOnce(new Error('Synthetic unavailable')).mockImplementationOnce(() => stale.promise).mockResolvedValue(totals(10));
|
|
const cache = new DatahubMetricsCache(fetch);
|
|
await expect(cache.load(scope('one'))).rejects.toThrow('Synthetic unavailable');
|
|
await Promise.resolve();
|
|
const previous = cache.load(scope('one'));
|
|
cache.invalidate();
|
|
await cache.load(scope('one'));
|
|
stale.resolve(totals(2)); await previous;
|
|
expect(cache.peek(scope('one'))).toEqual(totals(10));
|
|
});
|