95 lines
5.7 KiB
TypeScript
95 lines
5.7 KiB
TypeScript
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import type { ReportProjection } from '../../src/reporting/report-projection';
|
|
import { renderReportFiles, reportEngineVersions, type ReportRun } from '../../src/reporting/report-run';
|
|
import { buildReportProjection } from '../../src/reporting/report-projection';
|
|
import {
|
|
FileSystemReportArchive, REPORT_ARCHIVE_FILES, reportArchiveProjectDirectory, resolveReportArchiveRoot
|
|
} from '../../server/reporting/report-archive';
|
|
|
|
function dummyRun(projectId = 'dummy-project'): ReportRun {
|
|
const projection: ReportProjection = buildReportProjection({
|
|
generatedAt: '2026-09-30T08:00:00Z', project: { id: projectId, name: 'Dummy Customer' },
|
|
results: [], domains: [], domainOf: () => 'unclassified'
|
|
});
|
|
return {
|
|
projection, revision: 3, versions: reportEngineVersions(), files: renderReportFiles(projection),
|
|
scope: { projectId, generatedAt: projection.generatedAt, assessmentIds: ['dummy-assessment'] }
|
|
};
|
|
}
|
|
|
|
let root: string;
|
|
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'assessor-report-archive-')); });
|
|
afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }); });
|
|
|
|
describe('Report archive', () => {
|
|
it('archives one run write-once with a verifiable manifest and no partial folder', async () => {
|
|
const archive = new FileSystemReportArchive(root);
|
|
const run = dummyRun();
|
|
const manifest = await archive.write(run, new Date('2026-09-30T08:00:01.234Z'));
|
|
expect(manifest.runId).toMatch(/^20260930T080001Z-[0-9a-f]{8}$/);
|
|
expect(manifest).toMatchObject({
|
|
version: 1, handling: 'internal-confidential', projectId: 'dummy-project', repositoryRevision: 3,
|
|
scope: { assessmentIds: ['dummy-assessment'] }, versions: reportEngineVersions()
|
|
});
|
|
const projectDirectory = join(root, reportArchiveProjectDirectory('dummy-project'));
|
|
expect(await readdir(projectDirectory)).toEqual([manifest.runId]);
|
|
expect((await readdir(join(projectDirectory, manifest.runId))).sort()).toEqual(Object.values(REPORT_ARCHIVE_FILES).sort());
|
|
const archived = await archive.read('dummy-project', manifest.runId);
|
|
expect(archived.management).toBe(run.files.management);
|
|
expect(archived.technical).toBe(run.files.technical);
|
|
expect(renderReportFiles(archived.projection)).toEqual(run.files);
|
|
const file = join(projectDirectory, manifest.runId, REPORT_ARCHIVE_FILES.management);
|
|
expect((await stat(file)).mode & 0o222).toBe(0);
|
|
await expect(writeFile(file, 'changed')).rejects.toThrow();
|
|
});
|
|
|
|
it('detects a changed archived file instead of trusting it', async () => {
|
|
const archive = new FileSystemReportArchive(root);
|
|
const manifest = await archive.write(dummyRun(), new Date('2026-09-30T08:00:00Z'));
|
|
const file = join(root, reportArchiveProjectDirectory('dummy-project'), manifest.runId, REPORT_ARCHIVE_FILES.technical);
|
|
await rm(file, { force: true });
|
|
await writeFile(file, 'tampered');
|
|
await expect(archive.read('dummy-project', manifest.runId)).rejects.toThrow(/Manifest/);
|
|
await expect(archive.read('dummy-project', '../escape')).rejects.toThrow(/Lauf-ID/);
|
|
});
|
|
|
|
it('keeps separate runs and removes every run of a client together with the client only', async () => {
|
|
const archive = new FileSystemReportArchive(root);
|
|
await archive.write(dummyRun(), new Date('2026-09-30T08:00:00Z'));
|
|
await archive.write(dummyRun(), new Date('2026-09-30T08:00:00Z'));
|
|
await archive.write(dummyRun('other-dummy-project'), new Date('2026-09-30T08:00:00Z'));
|
|
expect(await readdir(join(root, reportArchiveProjectDirectory('dummy-project')))).toHaveLength(2);
|
|
await archive.deleteProject('dummy-project');
|
|
await archive.deleteProject('dummy-project');
|
|
expect(await readdir(root)).toEqual([reportArchiveProjectDirectory('other-dummy-project')]);
|
|
});
|
|
|
|
it('never places the archive in the repository, the served app or a OneDrive folder', () => {
|
|
const repository = join(root, 'repo');
|
|
expect(resolveReportArchiveRoot({ ASSESSOR_REPORT_ARCHIVE_DIR: join(root, 'archive') }, [repository]))
|
|
.toBe(join(root, 'archive'));
|
|
expect(() => resolveReportArchiveRoot({ ASSESSOR_REPORT_ARCHIVE_DIR: join(repository, 'reports') }, [repository]))
|
|
.toThrow(/Repository/);
|
|
expect(() => resolveReportArchiveRoot({ ASSESSOR_REPORT_ARCHIVE_DIR: 'relative/archive' }, [repository])).toThrow(/absolut/);
|
|
expect(() => resolveReportArchiveRoot({ ASSESSOR_REPORT_ARCHIVE_DIR: join(root, 'OneDrive - Dummy', 'archive') }, [repository]))
|
|
.toThrow(/OneDrive/);
|
|
expect(() => resolveReportArchiveRoot({ ASSESSOR_REPORT_ARCHIVE_DIR: join(root, 'sync', 'archive'), OneDrive: join(root, 'sync') }, [repository]))
|
|
.toThrow(/OneDrive/);
|
|
expect(resolveReportArchiveRoot({ LOCALAPPDATA: 'C:\\Users\\dummy\\AppData\\Local' }, [], 'win32', 'C:\\Users\\dummy'))
|
|
.toMatch(/AppData[\\/]Local[\\/]ASSESSOR[\\/]report-archive$/);
|
|
expect(resolveReportArchiveRoot({}, [], 'linux', '/home/dummy')).toMatch(/\.local[\\/]share[\\/]assessor[\\/]report-archive$/);
|
|
});
|
|
|
|
it('writes nothing outside its own root', async () => {
|
|
const archive = new FileSystemReportArchive(join(root, 'archive'));
|
|
await archive.write(dummyRun(), new Date('2026-09-30T08:00:00Z'));
|
|
expect(await readdir(root)).toEqual(['archive']);
|
|
expect(JSON.parse(await readFile(join(root, 'archive', reportArchiveProjectDirectory('dummy-project'),
|
|
(await readdir(join(root, 'archive', reportArchiveProjectDirectory('dummy-project'))))[0], REPORT_ARCHIVE_FILES.manifest), 'utf8')).projectId)
|
|
.toBe('dummy-project');
|
|
});
|
|
});
|