import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import type { Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createAssessorHttpServer } from '../../server/assessor-http-server'; import { BsiCatalogError, type BsiCatalogService } from '../../server/compliance/bsi-catalog-service'; import type { BsiCatalogState, BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; // Data Hub `Update from BSI` routes; the service is a stub with synthetic states. const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', requirementCount: 2, crosswalkEntryCount: 1, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: [] }; const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2099-01-01T00:00:00+00:00' }; describe('BSI catalog routes', () => { let directory: string; let server: Server | undefined; let baseUrl: string; const service = { current: vi.fn(), refresh: vi.fn(), reset: vi.fn() }; const start = async (bsiCatalogService?: Pick, bsiSearchIndex?: object) => { server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService, bsiSearchIndex: bsiSearchIndex as never }); await new Promise((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}/api/v1/reference-data/bsi-grundschutz`; }; const call = (suffix = '', method = 'GET', body = '{}') => fetch(`${baseUrl}${suffix}`, method === 'GET' ? {} : { method, headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(baseUrl).origin }, body }); beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-http-')); await writeFile(join(directory, 'index.html'), '
synthetic app
'); service.current.mockResolvedValue(bundled); service.refresh.mockResolvedValue({ ...updated, updated: true }); service.reset.mockResolvedValue(bundled); }); afterEach(async () => { if (server) await new Promise((resolve, reject) => server!.close(error => error ? reject(error) : resolve())); server = undefined; await rm(directory, { recursive: true, force: true }); vi.clearAllMocks(); }); it('reads, updates and resets the active catalog', async () => { await start(service as never); const read = await call(); expect(read.status).toBe(200); expect(await read.json()).toEqual(bundled); const refreshed = await call('/refresh', 'POST'); expect(refreshed.status).toBe(200); expect(await refreshed.json()).toEqual({ ...updated, updated: true }); const reset = await call('/reset', 'POST'); expect(await reset.json()).toEqual(bundled); expect([service.current.mock.calls.length, service.refresh.mock.calls.length, service.reset.mock.calls.length]).toEqual([1, 1, 1]); }); it('reports a BSI failure as a bad gateway with its code and an unconfigured service as unavailable', async () => { service.refresh.mockRejectedValue(new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.')); await start(service as never); const failed = await call('/refresh', 'POST'); expect(failed.status).toBe(502); expect(await failed.json()).toMatchObject({ error: { code: 'bsi_unreachable', message: 'BSI could not be reached. Please try again later.' } }); await new Promise((resolve, reject) => server!.close(error => error ? reject(error) : resolve())); await start(); const unavailable = await call(); expect(unavailable.status).toBe(503); expect(await unavailable.json()).toMatchObject({ error: { code: 'bsi_catalog_unavailable' } }); }); it('reports the BSI search index and starts a build or a forced rebuild from the Data Hub', async () => { const indexStatus: BsiSearchIndexStatus = { version: 1, state: 'not-built', catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 2, indexedChunks: 0 }; const index = { status: vi.fn(async () => indexStatus), startBuild: vi.fn(async () => ({ ...indexStatus, state: 'building' as const })) }; await start(service as never, index); const read = await call('/search-index'); expect(read.status).toBe(200); expect(await read.json()).toEqual(indexStatus); const build = await call('/search-index/build', 'POST'); expect(build.status).toBe(202); expect(await build.json()).toMatchObject({ state: 'building' }); await call('/search-index/build', 'POST', JSON.stringify({ force: true })); expect(index.startBuild.mock.calls).toEqual([[false], [true]]); await new Promise((resolve, reject) => server!.close(error => error ? reject(error) : resolve())); await start(service as never); expect((await call('/search-index')).status).toBe(503); }); });