Assessor_2.0/tests/features/workspace-load-performance....

197 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
import { POLICY_EVALUATION_ENGINE, type PolicyEvaluationResult, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import type { StoredMicrosoftSecurityBaselineAnalysis } from '../../src/domain/microsoft-security-baseline-analysis';
import { loadCanonicalProjectWorkspace, type CanonicalProjectWorkspace } from '../../src/features/project-import/load-canonical-project-workspace';
import {
offerWorkspaceLoad, takeWorkspaceLoad, workspaceScopeKey, WORKSPACE_HANDOFF_MAX_AGE_MS
} from '../../src/features/project-import/workspace-load-handoff';
import { analyzeRows } from '../../src/analysis/schema-detector';
import { stableRecordId } from '../../src/shared/identifiers';
import { clean } from '../../src/shared/text';
const stamp = '2026-09-06T00:00:00Z';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(next => { resolve = next; });
return { promise, resolve };
}
function emptyModel(): CanonicalAssessmentModel {
return { schemaVersion: 1, project: { id: 'project', name: 'Synthetic' }, assessment: { id: 'slot', projectId: 'project', name: 'Synthetic' },
scanRuns: [], importFiles: [], sourceTables: [], findingDefinitions: [], findingOccurrences: [], evidence: [], reviews: [], diagnostics: []
} as unknown as CanonicalAssessmentModel;
}
function policyResult(index: number): PolicyEvaluationResult {
return { id: `result-${index}`, baselineId: `baseline-${index}`, baselineAnalysisId: `analysis-${index}`, baselineLabel: `Synthetic baseline ${index}`,
baselineState: 'explicit-recommendation', customerState: 'configured', classification: 'confirmed_configured_deviation',
comparator: { comparator: { kind: 'equality' }, relation: 'different', baseline: { kind: 'integer', value: '1' }, actual: { kind: 'integer', value: '0' } },
identity: { id: `identity-${index}`, scope: 'computer',
target: { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE', key: 'Software\\Policies\\Synthetic', valueName: `Mode${index}` },
admx: { status: 'matched', catalogId: `catalog-${index}`, reference: { technicalId: `namespace#Mode${index}` } } },
observationIds: [`observation-${index}`], backupInstanceIds: ['backup'], diagnosticCodes: [] } as unknown as PolicyEvaluationResult;
}
function baselineAnalysis(index: number): StoredMicrosoftSecurityBaselineAnalysis {
return { analysis: { id: `analysis-${index}`, baselineId: `baseline-${index}`, admxCatalog: { id: `catalog-${index}`,
policies: [{ technicalId: `namespace#Mode${index}`, sourcePath: 'Templates/Synthetic.admx', explainTextResourceId: 'Explain' }],
languageResources: [{ admxFileName: 'Synthetic.admx', locale: 'en-US', strings: { Explain: `Synthetic explanation ${index}.` } }]
} } } as unknown as StoredMicrosoftSecurityBaselineAnalysis;
}
function policyObservation(index: number) {
return { sourceKind: 'policy-observation', sourceResultId: `observation-${index}`, projectId: 'project', assessmentId: 'slot',
title: `Mode${index}`, importedAt: stamp, policyScope: 'computer', policyDeliveryMechanism: 'registry-policy',
policyValues: [{ kind: 'registry-policy', key: 'Software\\Policies\\Synthetic', valueName: `Mode${index}`, operation: 'set-value', typeCode: 4, data: { kind: 'uint32', value: 0 } }] };
}
afterEach(() => { vi.useRealTimers(); });
describe('workspace load concurrency', () => {
it('requests the GPO results of an explicit timeslot while the scanner model is still loading', async () => {
const model = deferred<CanonicalAssessmentModel>();
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([emptyModel().assessment]),
loadAssessmentModel: vi.fn(() => model.promise),
queryUnifiedResults: vi.fn().mockResolvedValue({ items: [], total: 0, limit: 10_000, offset: 0 })
};
const loading = loadCanonicalProjectWorkspace(reader, 'project', [], ['slot']);
await vi.waitFor(() => expect(reader.queryUnifiedResults).toHaveBeenCalledWith(expect.objectContaining({ assessmentIds: ['slot'] })));
expect(reader.loadAssessmentModel).toHaveBeenCalledTimes(1);
model.resolve(emptyModel());
await expect(loading).resolves.toMatchObject({ assessmentIds: ['slot'] });
});
it('reports a failed GPO request even when it settles before the scanner model', async () => {
const model = deferred<CanonicalAssessmentModel>();
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([emptyModel().assessment]),
loadAssessmentModel: vi.fn(() => model.promise),
queryUnifiedResults: vi.fn().mockRejectedValue(new Error('synthetic policy failure'))
};
const loading = loadCanonicalProjectWorkspace(reader, 'project', [], ['slot']);
await vi.waitFor(() => expect(reader.queryUnifiedResults).toHaveBeenCalled());
model.resolve(emptyModel());
await expect(loading).rejects.toThrow('synthetic policy failure');
});
it('loads every referenced baseline once with at most three requests in flight and keeps per-baseline failures', async () => {
const indices = [1, 2, 3, 4, 5, 6];
let inFlight = 0;
let maximumInFlight = 0;
const list = vi.fn(async (baselineId: string) => {
inFlight += 1;
maximumInFlight = Math.max(maximumInFlight, inFlight);
await new Promise(resolve => setTimeout(resolve, 5));
inFlight -= 1;
const index = Number(baselineId.split('-')[1]);
if (index === 4) throw new Error('synthetic unavailable');
return { records: [baselineAnalysis(index)] };
});
const evaluation = { results: indices.map(policyResult),
run: { id: 'run', completedAt: stamp, evaluator: POLICY_EVALUATION_ENGINE, baselineAnalysisIds: indices.map(index => `analysis-${index}`) } } as StoredPolicyEvaluation;
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([emptyModel().assessment]),
loadAssessmentModel: vi.fn().mockResolvedValue(emptyModel()),
listPolicyEvaluations: vi.fn().mockResolvedValue({ records: [evaluation] }),
listMicrosoftSecurityBaselineAnalyses: list,
queryUnifiedResults: vi.fn().mockResolvedValue({ total: indices.length, limit: 10_000, offset: 0, items: indices.map(policyObservation) })
};
const workspace = await loadCanonicalProjectWorkspace(reader, 'project', [], ['slot']);
expect(list.mock.calls.map(([id]) => id).sort()).toEqual(indices.map(index => `baseline-${index}`));
expect(maximumInFlight).toBe(3);
const [headers, ...rows] = workspace.dataset!.sheets[0].rows;
const descriptions = new Map(rows.map(row => [row[headers.indexOf('Title')], row[headers.indexOf('Description')]]));
expect(descriptions.get('Mode4')).toBe('Baseline description could not be loaded. The saved comparison is retained.');
for (const index of [1, 2, 3, 5, 6]) expect(descriptions.get(`Mode${index}`)).toBe(`Synthetic explanation ${index}.`);
});
});
describe('Data Hub to Open workspace handoff', () => {
const workspace = { models: [], dataset: null, assessmentIds: ['slot'] } as CanonicalProjectWorkspace;
it('hands the identical scope over exactly once, regardless of ID order and duplicates', async () => {
const load = Promise.resolve(workspace);
offerWorkspaceLoad(workspaceScopeKey('project', ['run-b', 'run-a'], ['slot']), load, 1_000);
expect(takeWorkspaceLoad(workspaceScopeKey('project', ['run-a', 'run-b', 'run-a'], ['slot']), 2_000)).toBe(load);
expect(takeWorkspaceLoad(workspaceScopeKey('project', ['run-a', 'run-b'], ['slot']), 2_000)).toBeUndefined();
});
it('never hands over a different scope, a missing versus empty assessment scope, or an expired load', () => {
const load = Promise.resolve(workspace);
offerWorkspaceLoad(workspaceScopeKey('project', ['run-a'], ['slot']), load, 1_000);
expect(takeWorkspaceLoad(workspaceScopeKey('project', ['run-b'], ['slot']), 2_000)).toBeUndefined();
offerWorkspaceLoad(workspaceScopeKey('project', ['run-a'], undefined), load, 1_000);
expect(takeWorkspaceLoad(workspaceScopeKey('project', ['run-a'], []), 2_000)).toBeUndefined();
offerWorkspaceLoad(workspaceScopeKey('project', ['run-a'], ['slot']), load, 1_000);
expect(takeWorkspaceLoad(workspaceScopeKey('project', ['run-a'], ['slot']), 1_000 + WORKSPACE_HANDOFF_MAX_AGE_MS + 1)).toBeUndefined();
});
it('discards the load when any mutating API request starts or settles after it began', async () => {
const key = workspaceScopeKey('project', ['run-a'], ['slot']);
const fetcher = vi.fn(async () => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }));
const client = new AssessorApiClient({ baseUrl: '/api', fetcher });
offerWorkspaceLoad(key, Promise.resolve(workspace));
await client.health();
expect(takeWorkspaceLoad(key)).toBeDefined();
offerWorkspaceLoad(key, Promise.resolve(workspace));
await client.saveProject({ id: 'project', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
expect(takeWorkspaceLoad(key)).toBeUndefined();
});
it('does not keep a failed load for the next open', async () => {
const key = workspaceScopeKey('project', ['run-a'], ['slot']);
const failed = Promise.reject(new Error('synthetic load failure'));
offerWorkspaceLoad(key, failed);
await failed.catch(() => undefined);
await Promise.resolve();
expect(takeWorkspaceLoad(key)).toBeUndefined();
});
});
describe('shared header work in record analysis', () => {
it('recomputes the record ID when the same headers array is mutated', () => {
const headers = ['Title', 'Status'];
const row = ['Synthetic finding', 'Failed'];
const before = stableRecordId({ sheetName: 'Sheet', headers, row });
headers[1] = 'Result';
expect(stableRecordId({ sheetName: 'Sheet', headers, row })).toBe(stableRecordId({ sheetName: 'Sheet', headers: [...headers], row }));
expect(stableRecordId({ sheetName: 'Sheet', headers, row })).not.toBe(before);
});
it('relies on German lowercasing being identical to the default mapping', () => {
// Record IDs lowercase cell values with toLowerCase() for speed; they were created
// with toLocaleLowerCase('de-DE'). Only lt, tr and az have language-specific rules.
for (const sample of ['ẞ STRASSE Straße', 'İstanbul I ı', 'ΟΔΟΣ ΣΑΣ Σ', 'DžLjNj', 'Ω K Å', 'ÄÖÜ', 'ff ffi', '𐐀𐐁', 'Ⅻ Ⓐ']) {
expect(sample.toLowerCase()).toBe(sample.toLocaleLowerCase('de-DE'));
}
expect('İI'.toLocaleLowerCase('tr-TR')).not.toBe('İI'.toLowerCase());
});
it('produces the same record ID from a precomputed cleaned row', () => {
const headers = ['Title', ' Status ', 'Details'];
const row = [' Synthetic finding ', 'FAILED', { nested: ['value'] }];
expect(stableRecordId({ sheetName: 'Sheet', headers, row, cleanedRow: row.map(clean) }))
.toBe(stableRecordId({ sheetName: 'Sheet', headers, row }));
});
it('keeps titles, meta fields and IDs when rows share one header list', () => {
const analysis = analyzeRows([
['Rule ID', 'Source: File', 'Name', 'Status', 'Priorität'],
['R-1', 'synthetic.csv', 'First synthetic finding', 'Failed', 'High'],
['R-2', 'synthetic.csv', 'Second synthetic finding', 'Passed', 'Low'],
['R-2', 'synthetic.csv', 'Second synthetic finding', 'Passed', 'Low']
], 'Sheet', 'synthetic.csv');
expect(analysis.records.map(record => record.title)).toEqual(['First synthetic finding', 'Second synthetic finding', 'Second synthetic finding']);
expect(analysis.records[0].meta.map(field => field.label)).toEqual(['Status', 'Priorität']);
expect(new Set(analysis.records.map(record => record.id)).size).toBe(3);
expect(analysis.columns.find(column => column.header === 'Status')?.filled).toBe(3);
});
});