88 lines
5.3 KiB
TypeScript
88 lines
5.3 KiB
TypeScript
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<BsiCatalogService, 'current' | 'refresh' | 'reset'>, bsiSearchIndex?: object) => {
|
|
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService, bsiSearchIndex: bsiSearchIndex as never });
|
|
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}/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'), '<main>synthetic app</main>');
|
|
service.current.mockResolvedValue(bundled);
|
|
service.refresh.mockResolvedValue({ ...updated, updated: true });
|
|
service.reset.mockResolvedValue(bundled);
|
|
});
|
|
afterEach(async () => {
|
|
if (server) await new Promise<void>((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<void>((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<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
|
await start(service as never);
|
|
expect((await call('/search-index')).status).toBe(503);
|
|
});
|
|
});
|