Assessor_2.0/tests/server/assessor-http-server.test.ts

191 lines
10 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Server } from 'node:http';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
const timestamp = '2026-07-17T08:00:00.000Z';
const project = { id: 'project-1', name: 'Project 1', createdAt: timestamp, updatedAt: timestamp, attributes: {} };
const assessment = { id: 'assessment-1', projectId: project.id, name: 'Assessment 1', createdAt: timestamp, updatedAt: timestamp };
const scanRun = {
id: 'run-1', assessmentId: assessment.id, sequence: 1, label: 'Run 1',
tool: { id: 'generic', name: 'Generic' }, importedAt: timestamp
};
describe('local ASSESSOR HTTP server', () => {
let directory: string;
let server: Server;
let baseUrl: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-server-'));
await writeFile(join(directory, 'index.html'), '<!doctype html><title>ASSESSOR</title><main>Local app</main>', 'utf8');
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory });
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
await rm(directory, { recursive: true, force: true });
});
const mutate = (path: string, body: unknown, origin = baseUrl) => fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: origin },
body: JSON.stringify(body)
});
it('serves the browser app and a same-origin health endpoint with hardening headers', async () => {
const page = await fetch(`${baseUrl}/`);
expect(page.status).toBe(200);
expect(await page.text()).toContain('Local app');
expect(page.headers.get('content-security-policy')).toContain("default-src 'self'");
expect(page.headers.get('x-content-type-options')).toBe('nosniff');
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.5.12', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
});
expect(health.headers.get('access-control-allow-origin')).toBeNull();
});
it('rejects unmarked and cross-origin mutations', async () => {
const missingHeader = await fetch(`${baseUrl}/api/v1/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(project)
});
expect(missingHeader.status).toBe(403);
await expect(missingHeader.json()).resolves.toMatchObject({ error: { code: 'missing_mutation_header' } });
const foreignOrigin = await mutate('/api/v1/projects', project, 'https://untrusted.example');
expect(foreignOrigin.status).toBe(403);
await expect(foreignOrigin.json()).resolves.toMatchObject({ error: { code: 'cross_origin_rejected' } });
});
it('exposes repository operations and preserves atomic import sessions across HTTP calls', async () => {
expect((await mutate('/api/v1/projects', project)).status).toBe(200);
expect((await mutate('/api/v1/assessments', assessment)).status).toBe(200);
expect((await mutate('/api/v1/import-sessions', {
sessionId: 'session-1', projectId: project.id, assessmentId: assessment.id, scanRun
})).status).toBe(201);
expect((await mutate('/api/v1/import-sessions/session-1/stage', {
importFiles: [{
id: 'file-1', scanRunId: scanRun.id, fileName: 'input.csv', format: 'CSV', sizeBytes: 10,
importedAt: timestamp, detectedTool: scanRun.tool,
detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: { parserId: 'csv', parserVersion: '1.0.0', profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0' },
sourceArtifact: { kind: 'not-retained', reason: 'test' }
}],
reportSections: [{
version: 1, id: 'section-1', scanRunId: scanRun.id, importFileId: 'file-1', tool: scanRun.tool,
sectionKey: 'sectionDomainInformation', label: 'Domain information', ordinal: 0, state: 'observed',
tables: [], source: { objectPath: 'html/sections/domain' }, extractorVersion: '2.0.0', attributes: {}
}],
environmentObservations: [{
version: 1, id: 'observation-1', scanRunId: scanRun.id, importFileId: 'file-1', tool: scanRun.tool,
subject: { type: 'domain-information' }, property: 'domain.functional_level', label: 'Domain functional level',
originalValue: '2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' }, mappingVersion: '2.0.0', attributes: {}
}]
})).status).toBe(200);
const beforeCommit = await fetch(`${baseUrl}/api/v1/assessments/${assessment.id}/model`);
expect((await beforeCommit.json()).scanRuns).toHaveLength(0);
expect((await mutate('/api/v1/import-sessions/session-1/commit', {})).status).toBe(200);
const afterCommit = await fetch(`${baseUrl}/api/v1/assessments/${assessment.id}/model`);
expect((await afterCommit.json()).scanRuns).toHaveLength(1);
const projects = await fetch(`${baseUrl}/api/v1/projects`);
expect(await projects.json()).toEqual([project]);
const environment = await fetch(`${baseUrl}/api/v1/projects/${project.id}/environment-snapshot?scanRunIds=${scanRun.id}`);
expect(environment.status).toBe(200);
await expect(environment.json()).resolves.toMatchObject({
projectId: project.id, scanRunIds: [scanRun.id], coverage: { observed: 2 },
observations: [expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 })]
});
const environmentResults = await fetch(
`${baseUrl}/api/v1/results?projectId=${project.id}&sourceKind=environment-observation&environmentProperty=domain.functional_level&environmentState=observed`
);
expect(environmentResults.status).toBe(200);
await expect(environmentResults.json()).resolves.toEqual({ total: 0, items: [] });
const environmentResult = await fetch(`${baseUrl}/api/v1/results/environment-observation%3Aobservation-1`);
expect(environmentResult.status).toBe(404);
});
it('validates import metadata before file content reaches a future server parser', async () => {
const accepted = await mutate('/api/v1/import-sources/validate', { fileName: 'report.xlsx', sizeBytes: 2048 });
await expect(accepted.json()).resolves.toMatchObject({ accepted: true, format: 'XLSX' });
const rejected = await mutate('/api/v1/import-sources/validate', { fileName: 'payload.exe', sizeBytes: 10 });
expect(rejected.status).toBe(415);
await expect(rejected.json()).resolves.toMatchObject({ error: { code: 'unsupported_import_format' } });
});
it('deletes a confirmed assessment through the project-bound timeslot endpoint', async () => {
await mutate('/api/v1/projects', project);
await mutate('/api/v1/assessments', assessment);
const response = await fetch(`${baseUrl}/api/v1/projects/${project.id}/assessments/${assessment.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({ projectId: project.id, assessmentId: assessment.id, cascade: true })
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ deleted: true });
const projects = await fetch(`${baseUrl}/api/v1/projects`);
await expect(projects.json()).resolves.toEqual([project]);
});
it('parses an uploaded source through the backend API', async () => {
const bytes = Buffer.from('Rule ID;Title;Severity\nCSV-1;Server import;High', 'utf8');
const response = await mutate('/api/v1/import-sources/parse', {
fileName: 'findings.csv',
sizeBytes: bytes.length,
lastModified: 0,
mediaType: 'text/csv',
bytesBase64: bytes.toString('base64'),
delimiter: ';'
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
fileName: 'findings.csv',
format: 'CSV',
sheets: [expect.objectContaining({ analysis: expect.objectContaining({ records: expect.any(Array) }) })]
});
});
it('returns structured errors and an audit trail without request bodies', async () => {
await mutate('/api/v1/projects', project);
const missing = await fetch(`${baseUrl}/api/v1/unknown`);
expect(missing.status).toBe(404);
await expect(missing.json()).resolves.toMatchObject({
error: { code: 'api_route_not_found', requestId: expect.stringMatching(/^request-/) }
});
const audit = await fetch(`${baseUrl}/api/v1/audit-events`);
const events = await audit.json() as Array<Record<string, unknown>>;
expect(events.some(event => event.path === '/api/v1/projects' && event.method === 'POST' && event.status === 200)).toBe(true);
expect(events).toContainEqual(expect.objectContaining({ durationMs: expect.any(Number) }));
expect(JSON.stringify(events)).not.toContain(project.name);
});
it('exposes the unified result, facet and category read endpoints without mutation headers', async () => {
const results = await fetch(`${baseUrl}/api/v1/results?severity=high&severity=critical`);
expect(results.status).toBe(200);
await expect(results.json()).resolves.toEqual({ total: 0, items: [] });
const facets = await fetch(`${baseUrl}/api/v1/results/facets`);
expect(facets.status).toBe(200);
await expect(facets.json()).resolves.toMatchObject({ total: 0, facets: expect.any(Array) });
const categories = await fetch(`${baseUrl}/api/v1/results/categories`);
expect(categories.status).toBe(200);
await expect(categories.json()).resolves.toEqual([]);
const missing = await fetch(`${baseUrl}/api/v1/results/scanner-finding%3Amissing`);
expect(missing.status).toBe(404);
await expect(missing.json()).resolves.toMatchObject({ error: { code: 'repository_not_found' } });
});
});