197 lines
12 KiB
TypeScript
197 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|