Assessor_2.0/tests/reporting/report-run.test.ts

122 lines
7.5 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 () => []),
listImportArtifacts: vi.fn(async () => []),
listGpoBackupInstances: 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');
// Result IDs prove the Context counts in the archived projection; the technical HTML aggregates.
expect(run.projection.context?.clusters[0].resultIds).toContain('secret-result-one');
expect(run.files.technical).not.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', 'listGpoBackupInstances', 'listGpoPolicyAnalyses',
'listImportArtifacts', '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(JSON.stringify(run.projection)).toContain('DUMMY-RULE-7');
expect(run.files.technical).toContain('Technical Report');
});
});