117 lines
7.1 KiB
TypeScript
117 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { loadCanonicalProjectWorkspace } from '../../src/features/project-import/load-canonical-project-workspace';
|
|
import { reportReadPortFrom, type ReportReadMethods } from '../../src/reporting/report-read-port';
|
|
import {
|
|
assembleConsistentReportInput, createReportRun, renderReportFiles, REPORT_ENGINE_VERSION, ReportStateChangedError
|
|
} from '../../src/reporting/report-run';
|
|
import { buildReportProjection } from '../../src/reporting/report-projection';
|
|
import { ReportContextRequiredError } from '../../src/reporting/report-context';
|
|
import { reportContext } from './context-report-fixture';
|
|
|
|
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({
|
|
loadCanonicalProjectWorkspace: vi.fn()
|
|
}));
|
|
|
|
const scope = { projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z' };
|
|
|
|
function dummyRepository(): ReportReadMethods & { saveReview: () => Promise<void> } {
|
|
const sheet = makeSheet('Dummy', [['Title', 'Source: Scan'], ['Synthetic check', 'PingCastle']],
|
|
'', 'dummy.csv', { isProjectSheet: true });
|
|
vi.mocked(loadCanonicalProjectWorkspace).mockResolvedValue({
|
|
models: [], assessmentIds: ['dummy-assessment'],
|
|
dataset: { fileName: 'Dummy', sheets: [sheet], scanTool: 'PingCastle' } as never
|
|
});
|
|
const result = {
|
|
resultProjectionId: 'dummy-result', sourceKind: 'scanner-finding', sourceResultId: 'dummy-source',
|
|
projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'dummy-run', collectionRunId: 'dummy-run',
|
|
title: 'Synthetic hardening check', sourceTool: 'PingCastle', sourceRuleId: 'DUMMY-RULE-7',
|
|
severityNormalized: 'medium', outcomeNormalized: 'fail', isDeviation: true, categories: [], frameworkReferences: []
|
|
} as unknown as UnifiedResultProjection;
|
|
return {
|
|
getProject: vi.fn(async () => ({ id: 'dummy-project', name: 'Dummy Customer', attributes: {}, createdAt: '', updatedAt: '' })),
|
|
listAssessments: vi.fn(async () => []),
|
|
loadAssessmentModel: vi.fn(async () => null),
|
|
queryUnifiedResults: vi.fn(async (query?: { sourceKind?: string[] }) => {
|
|
const items = query?.sourceKind?.includes('scanner-finding') ? [result] : [];
|
|
return { items, total: items.length, offset: 0, limit: 500 };
|
|
}),
|
|
listPolicyEvaluations: vi.fn(async () => []),
|
|
listMicrosoftSecurityBaselineAnalyses: vi.fn(async () => []),
|
|
getEnvironmentSnapshot: vi.fn(async () => ({ observations: [] })),
|
|
listReviews: vi.fn(async () => []),
|
|
listProjectProcessingRuns: vi.fn(async () => []),
|
|
listGpoPolicyAnalyses: vi.fn(async () => []),
|
|
listPolicySnapshotAggregations: vi.fn(async () => []),
|
|
saveReview: vi.fn(async () => undefined)
|
|
} as unknown as ReportReadMethods & { saveReview: () => Promise<void> };
|
|
}
|
|
|
|
describe('Report Engine run', () => {
|
|
it('requires published Context for the new management run and validates scope and revision before rendering', async () => {
|
|
const repository = dummyRepository();
|
|
const port = reportReadPortFrom(repository, async () => 9);
|
|
await expect(createReportRun(port, scope, { requireContext: true })).rejects.toBeInstanceOf(ReportContextRequiredError);
|
|
await expect(createReportRun(port, scope, { requireContext: true, readContext: async () => ({ ...reportContext(), assessmentIds: ['wrong-slot'] }) }))
|
|
.rejects.toBeInstanceOf(ReportContextRequiredError);
|
|
await expect(createReportRun(port, scope, { requireContext: true, readContext: async () => reportContext(8) }))
|
|
.rejects.toBeInstanceOf(ReportStateChangedError);
|
|
const readContext = vi.fn(async () => reportContext());
|
|
const run = await createReportRun(port, scope, { requireContext: true, readContext });
|
|
expect(run.files.management).toContain('Context-Heatmap');
|
|
expect(run.files.management).not.toContain('secret-result-one');
|
|
expect(run.files.technical).toContain('secret-result-one');
|
|
expect(renderReportFiles(JSON.parse(JSON.stringify(run.projection)))).toEqual(run.files);
|
|
expect(repository.saveReview).not.toHaveBeenCalled();
|
|
const technicalOnly = await createReportRun(port, scope, { requireContext: false });
|
|
expect(technicalOnly.files.management).toBe('');
|
|
expect(technicalOnly.files.technical.length).toBeGreaterThan(0);
|
|
const retry = vi.fn().mockResolvedValueOnce(reportContext(8)).mockResolvedValue(reportContext(9));
|
|
const consistent = await createReportRun(port, scope, { readContext: retry });
|
|
expect(consistent.projection.context?.repositoryRevision).toBe(consistent.revision);
|
|
expect(retry).toHaveBeenCalledTimes(2);
|
|
});
|
|
it('exposes only the read methods, so a writing method cannot be reached through the port', () => {
|
|
const repository = dummyRepository();
|
|
const port = reportReadPortFrom(repository, async () => 1);
|
|
expect((port as unknown as Record<string, unknown>).saveReview).toBeUndefined();
|
|
expect(Object.isFrozen(port)).toBe(true);
|
|
expect(Object.keys(port).sort()).toEqual([
|
|
'currentRevision', 'getEnvironmentSnapshot', 'getProject', 'listAssessments', 'listGpoPolicyAnalyses',
|
|
'listMicrosoftSecurityBaselineAnalyses', 'listPolicyEvaluations', 'listPolicySnapshotAggregations',
|
|
'listProjectProcessingRuns', 'listReferenceRuns', 'listReviews', 'loadAssessmentModel', 'queryUnifiedResults'
|
|
]);
|
|
});
|
|
|
|
it('repeats the read when the committed revision changed during assembly and returns one consistent state', async () => {
|
|
const revisions = [4, 5, 5, 5];
|
|
const repository = dummyRepository();
|
|
const port = reportReadPortFrom(repository, async () => revisions.shift() ?? 5);
|
|
const { input, revision } = await assembleConsistentReportInput(port, scope);
|
|
expect(revision).toBe(5);
|
|
expect(input.project.name).toBe('Dummy Customer');
|
|
expect(repository.getProject).toHaveBeenCalledTimes(2);
|
|
expect(repository.saveReview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a report instead of mixing states when the data keeps changing', async () => {
|
|
let revision = 0;
|
|
const port = reportReadPortFrom(dummyRepository(), async () => revision++);
|
|
await expect(assembleConsistentReportInput(port, scope)).rejects.toBeInstanceOf(ReportStateChangedError);
|
|
});
|
|
|
|
it('renders byte-identical files again from the archived projection JSON', async () => {
|
|
const run = await createReportRun(reportReadPortFrom(dummyRepository(), async () => 9), scope, { readContext: async () => reportContext() });
|
|
expect(run.revision).toBe(9);
|
|
expect(run.versions.engine).toBe(REPORT_ENGINE_VERSION);
|
|
const restored = JSON.parse(JSON.stringify(run.projection, null, 2));
|
|
expect(renderReportFiles(restored)).toEqual(run.files);
|
|
const rebuilt = buildReportProjection((await assembleConsistentReportInput(reportReadPortFrom(dummyRepository(), async () => 9), scope)).input);
|
|
rebuilt.context = reportContext(); rebuilt.managementContextRequired = true;
|
|
expect(run.files).toEqual(renderReportFiles(rebuilt));
|
|
expect(run.files.management).not.toContain('DUMMY-RULE-7');
|
|
expect(run.files.technical).toContain('DUMMY-RULE-7');
|
|
});
|
|
});
|