Assessor_2.0/tests/compliance/bsi-catalog-service.test.ts

103 lines
7.0 KiB
TypeScript

import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BSI_KERNEL_CLASS } from '../../src/compliance/bsi-grundschutz';
import { BSI_CATALOG_SOURCE_URL, BSI_CROSSWALK_SOURCE_URL, BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
// `Update from BSI` (Phase 13F, user decision 2026-10-08): synthetic OSCAL in the published shape; ids and prose are invented.
const sourceCatalog = (version: string, ids = ['KONF.1.1', 'KONF.2.1']) => JSON.stringify({ catalog: {
metadata: { title: 'Synthetischer Katalog', version },
groups: [{ id: 'KONF', title: 'Konfiguration', controls: ids.map(id => ({ id, class: BSI_KERNEL_CLASS, title: `Titel ${id}`,
parts: [{ name: 'statement', prose: `Konfiguration MUSS ${id} umsetzen.` }] })) }]
} });
const sourceCrosswalk = (version = '9.9-synthetic', targets = ['KONF.1.1', 'KONF.3.1']) => JSON.stringify({ 'mapping-collection': {
metadata: { title: 'Synthetische Zuordnung', version },
mappings: [{ 'target-resource': { href: 'BSI-Stand-der-Technik-Kernel-catalog.json' },
maps: [{ uuid: 'm1', relationship: 'subset-of', sources: [{ 'id-ref': '8.9' }], targets: targets.map(target => ({ 'id-ref': target })) }] }]
} });
const NEWER = '2099-01-01T00:00:00+00:00';
let directory: string;
let storage: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-'));
storage = join(directory, 'bsi', 'active-catalog.json');
});
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
function fetcher(files: Record<string, string | Response>) {
return vi.fn(async (url: string | URL | Request) => {
const body = files[String(url)];
return body instanceof Response ? body : new Response(body, { status: 200 });
}) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
}
describe('BSI catalog service', () => {
it('starts with the bundled snapshots', async () => {
const service = new BsiCatalogService(storage, fetcher({}));
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
expect(await service.current()).toMatchObject({ version: 1, origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version,
crosswalkVersion: '1.0.1-qa', requirementCount: 901, missingTargets: ['BER.5.1.2', 'BER.6.8', 'DET.3.1.13', 'GEB.5.4'] });
});
it('stores a newer official state privately, activates it at once and reports it unchanged on the next update', async () => {
const fetch = fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() });
const service = new BsiCatalogService(storage, fetch, () => new Date('2026-10-08T12:00:00Z'));
const result = await service.refresh();
expect(result).toMatchObject({ updated: true, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: NEWER,
crosswalkVersion: '9.9-synthetic', requirementCount: 2, crosswalkEntryCount: 1, missingTargets: ['KONF.3.1'] });
// Only the fixed official URLs, without following a redirect.
expect(fetch.mock.calls.map(call => [String(call[0]), (call[1] as RequestInit).redirect])).toEqual([
[BSI_CATALOG_SOURCE_URL, 'error'], [BSI_CROSSWALK_SOURCE_URL, 'error']]);
expect((await service.catalogs()).catalog.requirements.map(item => item.id)).toEqual(['KONF.1.1', 'KONF.2.1']);
expect((await service.catalogs()).catalog.sourceUrl).toBe(BSI_CATALOG_SOURCE_URL);
if (process.platform !== 'win32') expect((await stat(storage)).mode & 0o777).toBe(0o600);
// A restarted server reads the stored state.
expect(await new BsiCatalogService(storage, fetcher({})).current()).toMatchObject({ origin: 'bsi', catalogVersion: NEWER });
expect(await service.refresh()).toMatchObject({ updated: false, catalogVersion: NEWER });
});
it('never steps back behind the active catalog', async () => {
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog('2000-01-01T00:00:00+00:00'), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
expect(await service.refresh()).toMatchObject({ updated: false, origin: 'bundled' });
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('fails explicitly and keeps the active catalog when BSI is unreachable, unavailable, too large or invalid', async () => {
const cases: Array<[Record<string, string | Response> | null, string]> = [
[null, 'bsi_unreachable'],
[{ [BSI_CATALOG_SOURCE_URL]: new Response('', { status: 404 }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_unavailable'],
[{ [BSI_CATALOG_SOURCE_URL]: new Response('{}', { headers: { 'content-length': String(64 * 1024 * 1024) } }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_too_large'],
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER, []), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_invalid'],
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: '{"mapping-collection":{}}' }, 'bsi_source_invalid']
];
for (const [files, code] of cases) {
const download = files ? fetcher(files) : vi.fn(async () => { throw new TypeError("offline"); }) as unknown as typeof globalThis.fetch;
const service = new BsiCatalogService(storage, download);
await expect(service.refresh()).rejects.toMatchObject({ code });
expect(await service.current()).toMatchObject({ origin: 'bundled' });
}
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('returns to the bundled snapshots on reset', async () => {
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
await service.refresh();
expect(await service.reset()).toMatchObject({ origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version });
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('refuses a damaged stored state and lets a stored state older than the bundled one give way to it', async () => {
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
await service.refresh();
const stored = JSON.parse(await readFile(storage, 'utf8'));
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, framework: 'other' } }));
await expect(new BsiCatalogService(storage, fetcher({})).current()).rejects.toMatchObject({ code: 'bsi_storage_invalid' });
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, version: '2000-01-01T00:00:00+00:00' } }));
expect(await new BsiCatalogService(storage, fetcher({})).catalogs()).toBe(bundledBsiCatalogs);
});
});