203 lines
11 KiB
TypeScript
203 lines
11 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.8.7', 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);
|
|
const report = await fetch(`${baseUrl}/api/v1/projects/${project.id}/report-projection?assessmentIds=${assessment.id}&scanRunIds=${scanRun.id}`);
|
|
expect(report.status).toBe(200);
|
|
await expect(report.json()).resolves.toMatchObject({
|
|
version: '1.1.0', project: { id: project.id }, assessmentId: assessment.id,
|
|
environment: expect.arrayContaining([{ key: 'Domain' }, { key: 'Forest' }, { key: 'Domain Controller' }]),
|
|
coverage: { total: 0 }, diagnostics: expect.any(Array), provenance: expect.any(Array)
|
|
});
|
|
const metrics = await fetch(`${baseUrl}/api/v1/projects/${project.id}/timeslot-result-metrics?assessmentIds=${assessment.id}&scanRunIds=${scanRun.id}`);
|
|
expect(metrics.status).toBe(200);
|
|
await expect(metrics.json()).resolves.toEqual({ all: 0, deviation: 0, passed: 0, notApplicable: 0, notRun: 0, error: 0 });
|
|
const missingScope = await fetch(`${baseUrl}/api/v1/projects/${project.id}/timeslot-result-metrics?scanRunIds=${scanRun.id}`);
|
|
expect(missingScope.status).toBe(400);
|
|
});
|
|
|
|
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' } });
|
|
});
|
|
});
|