103 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
});
|