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() { let resolve!: (value: T) => void; const promise = new Promise(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(); 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(); 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); }); });