449 lines
30 KiB
TypeScript
449 lines
30 KiB
TypeScript
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { Server } from 'node:http';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import * as XLSX from 'xlsx';
|
|
import { createAssessorHttpServer } from '../../server/assessor-http-server';
|
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
|
import type { ImportBatchPreview } from '../../src/domain/import-workflow';
|
|
import type { ImportBatchCompleteResponse } from '../../src/api/assessor-api-contract';
|
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
|
import type { CanonicalAssessmentModel, FindingReview } from '../../src/domain/canonical-model';
|
|
import { confirmEntryMappings, recognitionOverridesFromResult } from '../../src/features/import-workflow/import-workflow';
|
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
|
|
|
describe('batch import HTTP workflow', () => {
|
|
let directory: string;
|
|
let server: Server;
|
|
let baseUrl: string;
|
|
let currentTime: Date;
|
|
|
|
beforeEach(async () => {
|
|
currentTime = new Date('2026-07-17T08:00:00.000Z');
|
|
directory = await mkdtemp(join(tmpdir(), 'assessor-batch-'));
|
|
await writeFile(join(directory, 'index.html'), '<main>ASSESSOR</main>', 'utf8');
|
|
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, now: () => currentTime });
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
await rm(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
const mutate = (path: string, body: unknown) => fetch(`${baseUrl}${path}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' }, body: JSON.stringify(body)
|
|
});
|
|
it('automatically persists reference provenance on import and guards explicit idempotent reprocessing', async () => {
|
|
const bytes = Buffer.from('Rule ID;Title;Severity;Status;MITRE ATT&CK;MITRE D3FEND;Description\nSYN-REF;Synthetic reference;High;Failed;T1059.001;D3-CH;Consider T1003');
|
|
const file = { fileName: 'synthetic-references.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const response = await mutate('/api/v1/import-batches/complete', { batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }] });
|
|
expect(response.status).toBe(200);
|
|
const complete = await response.json() as ImportBatchCompleteResponse;
|
|
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
expect(model.referenceRuns).toHaveLength(1);
|
|
expect(model.referenceRuns![0].results[0].references.map(ref => ref.id)).toEqual(['T1059.001', 'D3-CH', 'T1003']);
|
|
expect(model.findingDefinitions[0].frameworkReferences).toEqual([]);
|
|
const path = `/api/v1/projects/${complete.projectId}/references`;
|
|
expect(await (await fetch(`${baseUrl}${path}`)).json()).toMatchObject({ totalScans: 1, indexedScans: 1, pendingScans: 0 });
|
|
expect((await fetch(`${baseUrl}${path}/recognize`, { method: 'POST', body: '{}' })).status).toBe(403);
|
|
expect(await (await mutate(`${path}/recognize`, {})).json()).toMatchObject({ processedScans: 0, unchangedScans: 1 });
|
|
expect((await mutate('/api/v1/projects/foreign-client/references/recognize', {})).status).toBe(404);
|
|
const reloaded = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
expect(reloaded).toEqual(model);
|
|
});
|
|
it('creates project-owned Defender device scopes and derives grouped snapshot membership on the server', async () => {
|
|
const bytes = Buffer.from([
|
|
'Device ID,Device Name,IP Address,Site,Sensor,CVE,CVSS Score,Severity,Component,Status',
|
|
'DEV-A,PLC-A,192.0.2.10,Synthetic site,SENSOR-1,CVE-2026-1001,9.1,Critical,Component A,Open',
|
|
'DEV-B,PLC-B,192.0.2.20,Synthetic site,SENSOR-1,CVE-2026-1001,9.1,Critical,Component A,Open',
|
|
'DEV-A,PLC-A,192.0.2.10,Synthetic site,SENSOR-1,CVE-2026-1002,7.5,High,Component B,Open'
|
|
].join('\n'));
|
|
const file = { fileName: 'defender-cves.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ',' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const complete = await (await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
|
})).json() as ImportBatchCompleteResponse;
|
|
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
const cve1 = model.findingOccurrences.find(item => item.sourceIdentity.canonicalRuleId === 'CVE-2026-1001')!;
|
|
const path = `/api/v1/projects/${complete.projectId}/risk-acceptances/scoped`;
|
|
const pairBody = {
|
|
target: { kind: 'device-finding', occurrenceId: cve1.id, deviceKey: 'device-id:dev-a' },
|
|
command: riskCommand(), expectedRevision: 0
|
|
};
|
|
const noHeader = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pairBody) });
|
|
expect(noHeader.status).toBe(403);
|
|
expect((await mutate(path, { ...pairBody, target: { kind: 'device-snapshot', deviceKey: 'device-id:dev-a', scanRunIds: [] } })).status).toBe(400);
|
|
const pairResponse = await mutate(path, pairBody);
|
|
expect(pairResponse.status).toBe(200);
|
|
await expect(pairResponse.json()).resolves.toMatchObject({ acceptance: {
|
|
target: { kind: 'device-finding', device: { key: 'device-id:dev-a' }, members: [{ cve: 'CVE-2026-1001' }] },
|
|
revision: 1
|
|
} });
|
|
|
|
const groupedResponse = await mutate(path, {
|
|
target: { kind: 'device-snapshot', scanRunIds: [complete.scanRunId], deviceKey: 'device-id:dev-a' },
|
|
command: riskCommand(), expectedRevision: 0
|
|
});
|
|
expect(groupedResponse.status).toBe(200);
|
|
const grouped = await groupedResponse.json() as { acceptance: { id: string; revision: number; target: { members: Array<{ cve: string }> } } };
|
|
expect(grouped.acceptance.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
|
expect((await mutate(path, { acceptanceId: grouped.acceptance.id, command: riskCommand('draft'), expectedRevision: 0 })).status).toBe(409);
|
|
await expect((await fetch(`${baseUrl}${path}`)).json()).resolves.toHaveLength(2);
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects/${complete.projectId}/export`)).json()).resolves.toMatchObject({
|
|
scopedRiskAcceptances: [expect.objectContaining({ projectId: complete.projectId }), expect.objectContaining({ projectId: complete.projectId })]
|
|
});
|
|
});
|
|
|
|
it('records risk acceptance through the protected API and preserves it on reload and ordinary review updates', async () => {
|
|
const bytes = Buffer.from('Rule ID;Title;Severity;Status\nSYN-1;Synthetic risk;High;Failed');
|
|
const file = { fileName: 'synthetic.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const complete = await (await mutate('/api/v1/import-batches/complete', { batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }] })).json() as ImportBatchCompleteResponse;
|
|
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
const occurrenceId = model.findingOccurrences[0].id;
|
|
const path = `/api/v1/projects/${complete.projectId}/risk-acceptances`;
|
|
const body = { occurrenceId, expectedRevision: 0, command: riskCommand() };
|
|
const noHeader = await fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
expect(noHeader.status).toBe(403);
|
|
expect((await mutate(path, { ...body, command: { ...body.command, details: {} } })).status).toBe(400);
|
|
const response = await mutate(path, body);
|
|
expect(response.status).toBe(200);
|
|
const { review } = await response.json() as { review: FindingReview };
|
|
expect(review.riskAcceptance?.revisions[0]).toMatchObject({ action: 'accept', targetOccurrenceId: occurrenceId, approvalIdentityVerified: false });
|
|
expect(Date.parse(review.riskAcceptance!.revisions[0].recordedAt)).toBeGreaterThan(0);
|
|
expect((await mutate(path, body)).status).toBe(409);
|
|
expect((await mutate(`/api/v1/projects/other/risk-acceptances`, { ...body, expectedRevision: 1 })).status).toBe(404);
|
|
expect((await mutate('/api/v1/reviews', { review: { ...review, riskAcceptance: undefined, revision: 2 }, expectedRevision: 1 })).status).toBe(400);
|
|
expect((await mutate('/api/v1/reviews', { review: { ...review, comment: 'Synthetic comment', revision: 2 }, expectedRevision: 1 })).status).toBe(200);
|
|
const after = await (await fetch(`${baseUrl}/api/v1/assessments/${complete.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
expect(after.findingOccurrences).toEqual(model.findingOccurrences);
|
|
expect(after.reviews[0].riskAcceptance).toEqual(review.riskAcceptance);
|
|
});
|
|
|
|
it('previews files independently, marks duplicates and remembers completed hashes', async () => {
|
|
const bytes = Buffer.from('Rule ID;Title;Severity\nCSV-1;Server import;High', 'utf8');
|
|
const file = { fileName: 'findings.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const previewResponse = await mutate('/api/v1/import-batches/preview', { files: [file, { ...file, fileName: 'copy.csv' }] });
|
|
expect(previewResponse.status).toBe(200);
|
|
const preview = await previewResponse.json() as ImportBatchPreview;
|
|
expect(preview.entries).toHaveLength(2);
|
|
expect(preview.entries[0].dataset?.sheets[0].analysis.records.length).toBeGreaterThan(0);
|
|
expect(preview.entries[1]).toMatchObject({ status: 'duplicate', selected: false, duplicateOf: 'findings.csv' });
|
|
expect(preview.entries[0].sha256).toBe(preview.entries[1].sha256);
|
|
|
|
const completed = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ entryId: preview.entries[0].id, sha256: preview.entries[0].sha256, fileName: 'findings.csv', sizeBytes: bytes.length, lastModified: 0 }]
|
|
});
|
|
expect(completed.status).toBe(200);
|
|
const completion = await completed.json() as ImportBatchCompleteResponse;
|
|
expect(completion).toMatchObject({ fingerprints: [{ importCount: 1 }], repositoryRevision: 1, importedFiles: 1, importedOccurrences: 1 });
|
|
const stored = await fetch(`${baseUrl}/api/v1/assessments/${completion.assessmentId}/model`);
|
|
await expect(stored.json()).resolves.toMatchObject({
|
|
project: { id: completion.projectId },
|
|
scanRuns: [{ id: completion.scanRunId }],
|
|
importFiles: [{ sha256: preview.entries[0].sha256 }],
|
|
findingOccurrences: [expect.objectContaining({ sourceIdentity: expect.objectContaining({ originalRuleId: 'CSV-1' }) })]
|
|
});
|
|
|
|
const repeated = await mutate('/api/v1/import-batches/preview', { files: [file] });
|
|
await expect(repeated.json()).resolves.toMatchObject({ entries: [{ status: 'duplicate', selected: false, duplicateOf: 'findings.csv' }] });
|
|
});
|
|
|
|
it('imports into an existing tenant without replacing its name', async () => {
|
|
const project = {
|
|
id: 'project-test', name: 'Test', description: 'Mandant',
|
|
createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString(), attributes: {}
|
|
};
|
|
const saved = await mutate('/api/v1/projects', project);
|
|
expect(saved.status).toBe(200);
|
|
const bytes = Buffer.from('Rule ID;Title;Severity\nPK-1;Purple Knight finding;High', 'utf8');
|
|
const file = { fileName: 'purple-knight.csv', sizeBytes: bytes.length, lastModified: currentTime.getTime(), bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file], targetProjectId: project.id })).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const completed = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
targetProjectId: project.id,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
|
});
|
|
expect(completed.status).toBe(200);
|
|
const completedBody = await completed.json() as ImportBatchCompleteResponse;
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}`)).json()).resolves.toMatchObject({ id: project.id, name: 'Test' });
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).json()).resolves.toMatchObject({
|
|
project: { name: 'Test' }, importFiles: [{ fileName: 'purple-knight.csv' }]
|
|
});
|
|
|
|
const changedBytes = Buffer.from('Rule ID;Title;Severity\nPK-2;Changed Purple Knight finding;High', 'utf8');
|
|
const possibleDuplicate = await mutate('/api/v1/import-batches/preview', {
|
|
targetProjectId: project.id,
|
|
files: [{ ...file, fileName: 'purple-knight-copy.csv', sizeBytes: changedBytes.length, bytesBase64: changedBytes.toString('base64') }]
|
|
});
|
|
await expect(possibleDuplicate.json()).resolves.toMatchObject({
|
|
entries: [{ status: 'warning', selected: true, possibleDuplicateOf: expect.stringContaining('Purple') }]
|
|
});
|
|
|
|
const deleted = await fetch(`${baseUrl}/api/v1/projects/${project.id}/scan-runs/${completedBody.scanRunId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' },
|
|
body: JSON.stringify({ projectId: project.id, scanRunId: completedBody.scanRunId, cascade: true })
|
|
});
|
|
expect(deleted.status).toBe(200);
|
|
await expect(deleted.json()).resolves.toMatchObject({ deleted: true });
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/import-files`)).json()).resolves.toEqual([]);
|
|
});
|
|
it('stores repeated source uploads as separate scan runs inside one selected timeslot', async () => {
|
|
const project = {
|
|
id: 'timeslot-project', name: 'Synthetic timeslot project',
|
|
createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString(), attributes: {}
|
|
};
|
|
const timeslot = {
|
|
id: 'timeslot-assessment', projectId: project.id, name: '22.07.2026',
|
|
effectiveAt: '2026-07-22T12:00:00.000Z', createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString()
|
|
};
|
|
expect((await mutate('/api/v1/projects', project)).status).toBe(200);
|
|
expect((await mutate('/api/v1/assessments', timeslot)).status).toBe(200);
|
|
|
|
const upload = async (fileName: string, ruleId: string) => {
|
|
const bytes = Buffer.from(`Rule ID;Title;Severity\n${ruleId};${ruleId} title;High`, 'utf8');
|
|
const file = { fileName, sizeBytes: bytes.length, lastModified: currentTime.getTime(), bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', {
|
|
files: [file], targetProjectId: project.id, targetAssessmentId: timeslot.id
|
|
})).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const response = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
targetProjectId: project.id,
|
|
targetAssessmentId: timeslot.id,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
|
});
|
|
expect(response.status).toBe(200);
|
|
};
|
|
|
|
await upload('source-one.csv', 'SOURCE-1');
|
|
await upload('source-two.csv', 'SOURCE-2');
|
|
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/assessment-history`)).json()).resolves.toMatchObject({
|
|
assessments: [{ id: timeslot.id, effectiveAt: timeslot.effectiveAt }],
|
|
scanRuns: [expect.objectContaining({ assessmentId: timeslot.id, sequence: 1 }), expect.objectContaining({ assessmentId: timeslot.id, sequence: 2 })]
|
|
});
|
|
await expect((await fetch(`${baseUrl}/api/v1/assessments/${timeslot.id}/model`)).json()).resolves.toMatchObject({
|
|
assessment: { id: timeslot.id, effectiveAt: timeslot.effectiveAt },
|
|
scanRuns: [expect.objectContaining({ sequence: 1 }), expect.objectContaining({ sequence: 2 })],
|
|
importFiles: [{ fileName: 'source-one.csv' }, { fileName: 'source-two.csv' }]
|
|
});
|
|
});
|
|
|
|
it('previews and completes a Maester Excel import whose native error text contains U+0000', async () => {
|
|
const project = {
|
|
id: 'maester-null-project', name: 'Maester null project',
|
|
createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString(), attributes: {}
|
|
};
|
|
const timeslot = {
|
|
id: 'maester-null-assessment', projectId: project.id, name: '20.09.2026',
|
|
createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString()
|
|
};
|
|
expect((await mutate('/api/v1/projects', project)).status).toBe(200);
|
|
expect((await mutate('/api/v1/assessments', timeslot)).status).toBe(200);
|
|
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([
|
|
['ID', 'Title', 'Severity', 'Status', 'Result'],
|
|
['MT.9912', 'Synthetic Graph error', 'High', 'Error', 'HttpStatusCode=401 ErrorMessage=\u0000Synthetic denial']
|
|
]), 'Tests');
|
|
const bytes = Buffer.from(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }));
|
|
const file = { fileName: 'Maester.xlsx', sizeBytes: bytes.length, lastModified: currentTime.getTime(), bytesBase64: bytes.toString('base64') };
|
|
const previewResponse = await mutate('/api/v1/import-batches/preview', {
|
|
files: [file], targetProjectId: project.id, targetAssessmentId: timeslot.id
|
|
});
|
|
expect(previewResponse.status).toBe(200);
|
|
const preview = await previewResponse.json() as ImportBatchPreview;
|
|
expect(preview.entries[0]).toMatchObject({ toolId: 'maester', recordCount: 1 });
|
|
const entry = confirmEntryMappings(preview.entries[0], importProfiles);
|
|
const completedResponse = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
targetProjectId: project.id,
|
|
targetAssessmentId: timeslot.id,
|
|
entries: [{
|
|
entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified,
|
|
recognitionOverrides: recognitionOverridesFromResult(entry.dataset?.recognition, entry.dataset?.mapping)
|
|
}]
|
|
});
|
|
expect(completedResponse.status).toBe(200);
|
|
const completed = await completedResponse.json() as ImportBatchCompleteResponse;
|
|
const model = await (await fetch(`${baseUrl}/api/v1/assessments/${completed.assessmentId}/model`)).json() as CanonicalAssessmentModel;
|
|
expect(model.evidence[0].value).toBe('HttpStatusCode=401 ErrorMessage=\\u0000Synthetic denial');
|
|
expect(model.diagnostics).toContainEqual(expect.objectContaining({ code: 'unicode-null-escaped' }));
|
|
});
|
|
it('keeps a malformed file as a controlled row instead of failing the whole preview', async () => {
|
|
const valid = Buffer.from('Name\nFinding', 'utf8');
|
|
const response = await mutate('/api/v1/import-batches/preview', { files: [
|
|
{ fileName: 'valid.csv', sizeBytes: valid.length, lastModified: 0, bytesBase64: valid.toString('base64') },
|
|
{ fileName: 'invalid.exe', sizeBytes: 3, lastModified: 0, bytesBase64: Buffer.from('bad').toString('base64') }
|
|
] });
|
|
expect(response.status).toBe(200);
|
|
await expect(response.json()).resolves.toMatchObject({ entries: [expect.objectContaining({ dataset: expect.any(Object) }), { status: 'error', selected: false }] });
|
|
});
|
|
it('reimports a scan into the same timeslot and keeps the superseded run auditable', async () => {
|
|
const project = {
|
|
id: 'reimport-project', name: 'Synthetic reimport project',
|
|
createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString(), attributes: {}
|
|
};
|
|
const timeslot = {
|
|
id: 'reimport-assessment', projectId: project.id, name: '22.07.2026',
|
|
effectiveAt: '2026-07-22T12:00:00.000Z', createdAt: currentTime.toISOString(), updatedAt: currentTime.toISOString()
|
|
};
|
|
expect((await mutate('/api/v1/projects', project)).status).toBe(200);
|
|
expect((await mutate('/api/v1/assessments', timeslot)).status).toBe(200);
|
|
|
|
const importSource = async (ruleId: string, replacesScanRunId?: string) => {
|
|
const bytes = Buffer.from(`Rule ID;Title;Severity\n${ruleId};${ruleId} title;High`, 'utf8');
|
|
const file = { fileName: 'generic.csv', sizeBytes: bytes.length, lastModified: currentTime.getTime(), bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const target = { targetProjectId: project.id, targetAssessmentId: timeslot.id, replacesScanRunId };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file], ...target })).json() as ImportBatchPreview;
|
|
expect(preview.replacesScanRunId).toBe(replacesScanRunId);
|
|
const entry = preview.entries[0];
|
|
const response = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }],
|
|
...target
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const completed = await response.json() as ImportBatchCompleteResponse;
|
|
expect(completed.replacedScanRunId).toBe(replacesScanRunId);
|
|
return completed;
|
|
};
|
|
|
|
const original = await importSource('OLD-1');
|
|
currentTime = new Date('2026-07-17T09:00:00.000Z');
|
|
const changedBytes = Buffer.from('Rule ID;Title;Severity\nNEW-1;Different scan;High', 'utf8');
|
|
const rejected = await mutate('/api/v1/import-batches/preview', {
|
|
files: [{ fileName: 'generic.csv', sizeBytes: changedBytes.length, lastModified: currentTime.getTime(), bytesBase64: changedBytes.toString('base64'), delimiter: ';' }],
|
|
targetProjectId: project.id, targetAssessmentId: timeslot.id, replacesScanRunId: original.scanRunId
|
|
});
|
|
expect(rejected.status).toBe(409);
|
|
await expect(rejected.json()).resolves.toMatchObject({ error: { code: 'reimport_source_mismatch' } });
|
|
|
|
const replacement = await importSource('OLD-1', original.scanRunId);
|
|
|
|
const activeModel = await (await fetch(`${baseUrl}/api/v1/assessments/${timeslot.id}/model`)).json();
|
|
expect(activeModel).toMatchObject({
|
|
scanRuns: [{ id: replacement.scanRunId, supersedesScanRunId: original.scanRunId }],
|
|
findingOccurrences: [expect.objectContaining({ sourceIdentity: expect.objectContaining({ originalRuleId: 'OLD-1' }) })]
|
|
});
|
|
expect(activeModel.scanRuns).toHaveLength(1);
|
|
expect(activeModel.findingOccurrences).toHaveLength(1);
|
|
|
|
const exported = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).json();
|
|
expect(exported.scanRuns).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: original.scanRunId, supersededByScanRunId: replacement.scanRunId }),
|
|
expect.objectContaining({ id: replacement.scanRunId, supersedesScanRunId: original.scanRunId })
|
|
]));
|
|
expect(exported.findingOccurrences).toHaveLength(2);
|
|
});
|
|
it('rejects a changed completion selection without creating partial repository data', async () => {
|
|
const bytes = Buffer.from('Rule ID;Title\nSAFE-1;Original', 'utf8');
|
|
const response = await mutate('/api/v1/import-batches/preview', { files: [{
|
|
fileName: 'safe.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';'
|
|
}] });
|
|
const preview = await response.json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
const changed = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: '0'.repeat(64), fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
|
});
|
|
expect(changed.status).toBe(400);
|
|
await expect(changed.json()).resolves.toMatchObject({ error: { code: 'import_batch_selection_mismatch' } });
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects`)).json()).resolves.toEqual([]);
|
|
});
|
|
it('rejects XML entity declarations with a structured per-file resource diagnostic', async () => {
|
|
const bytes = Buffer.from('<!DOCTYPE x [<!ENTITY e SYSTEM "file:///secret">]><x>&e;</x>', 'utf8');
|
|
const file = { fileName: 'entity.xml', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64') };
|
|
const previewResponse = await mutate('/api/v1/import-batches/preview', { files: [file] });
|
|
expect(previewResponse.status).toBe(200);
|
|
await expect(previewResponse.json()).resolves.toMatchObject({
|
|
entries: [{ status: 'error', selected: false, errorMessages: [expect.stringContaining('xml-doctype-rejected')] }]
|
|
});
|
|
|
|
const directResponse = await mutate('/api/v1/import-sources/parse', file);
|
|
expect(directResponse.status).toBe(422);
|
|
await expect(directResponse.json()).resolves.toMatchObject({ error: { code: 'xml-doctype-rejected' } });
|
|
});
|
|
it('requires explicit mapping confirmation and persists the validated profile decision', async () => {
|
|
const bytes = Buffer.from('Alpha;Beta\none;two', 'utf8');
|
|
const file = { fileName: 'CIS-CAT-SYNTHETIC.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';' };
|
|
const preview = await (await mutate('/api/v1/import-batches/preview', { files: [file] })).json() as ImportBatchPreview;
|
|
const entry = preview.entries[0];
|
|
expect(entry).toMatchObject({ toolId: 'cis-cat', mappingsToConfirm: expect.any(Number) });
|
|
expect(entry.mappingsToConfirm).toBeGreaterThan(0);
|
|
|
|
const selection = { entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified };
|
|
const rejected = await mutate('/api/v1/import-batches/complete', { batchId: preview.batchId, entries: [selection] });
|
|
expect(rejected.status).toBe(409);
|
|
await expect(rejected.json()).resolves.toMatchObject({ error: { code: 'import_mapping_confirmation_required' } });
|
|
|
|
const invalid = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ ...selection, recognitionOverrides: { sourceTool: { toolId: 'unregistered', toolName: 'Unregistered' } } }]
|
|
});
|
|
expect(invalid.status).toBe(400);
|
|
await expect(invalid.json()).resolves.toMatchObject({ error: { code: 'invalid_recognition_overrides' } });
|
|
const invalidCandidate = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ ...selection, recognitionOverrides: { candidateFieldRoles: [{ decisionId: 'missing', role: 'title' }] } }]
|
|
});
|
|
expect(invalidCandidate.status).toBe(400);
|
|
await expect(invalidCandidate.json()).resolves.toMatchObject({ error: { code: 'invalid_recognition_overrides' } });
|
|
const recognition = entry.dataset!.recognition!;
|
|
const recognitionOverrides = {
|
|
sourceTool: recognition.source.requiresConfirmation
|
|
? { toolId: recognition.source.selected.toolId, toolName: recognition.source.selected.toolName }
|
|
: undefined,
|
|
tableSelections: Object.fromEntries(recognition.tables.filter(item => item.requiresConfirmation).map(item => [item.tableId, item.selected])),
|
|
fieldRoles: recognition.fields.filter(item => item.requiresConfirmation).map(item => ({
|
|
tableId: item.tableId,
|
|
columnIndex: item.columnIndex,
|
|
role: item.proposedRole ?? item.appliedRole
|
|
}))
|
|
};
|
|
const completed = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ ...selection, recognitionOverrides }]
|
|
});
|
|
expect(completed.status).toBe(200);
|
|
const result = await completed.json() as ImportBatchCompleteResponse;
|
|
await expect((await fetch(`${baseUrl}/api/v1/assessments/${result.assessmentId}/model`)).json()).resolves.toMatchObject({
|
|
importFiles: [{ detectedTool: { id: 'cis-cat' }, processedWith: { profileId: 'cis-cat' } }]
|
|
});
|
|
});
|
|
it('expires previews and leaves the repository unchanged', async () => {
|
|
const bytes = Buffer.from('Rule ID;Title\nTTL-1;Expires', 'utf8');
|
|
const response = await mutate('/api/v1/import-batches/preview', { files: [{
|
|
fileName: 'expires.csv', sizeBytes: bytes.length, lastModified: 0, bytesBase64: bytes.toString('base64'), delimiter: ';'
|
|
}] });
|
|
const preview = await response.json() as ImportBatchPreview;
|
|
currentTime = new Date('2026-07-17T08:16:00.000Z');
|
|
const entry = preview.entries[0];
|
|
const completion = await mutate('/api/v1/import-batches/complete', {
|
|
batchId: preview.batchId,
|
|
entries: [{ entryId: entry.id, sha256: entry.sha256, fileName: entry.fileName, sizeBytes: entry.sizeBytes, lastModified: entry.lastModified }]
|
|
});
|
|
expect(completion.status).toBe(409);
|
|
await expect(completion.json()).resolves.toMatchObject({ error: { code: 'import_batch_expired' } });
|
|
await expect((await fetch(`${baseUrl}/api/v1/projects`)).json()).resolves.toEqual([]);
|
|
});
|
|
});
|